Implementation Plan — Add an Adult to the Household
REQUIRED SUB-SKILL: superpowers:test-driven-development (red → green → refactor on every task).
Date: 2026-07-20 · Branch: feat/mvp1-personas-authz
Source of truth: docs/superpowers/specs/2026-07-20-add-an-adult-to-household-design.md (incl. "OWNER DECISIONS — LOCKED 2026-07-20").
Goal
Ship "add an adult to the household" as ONE capability with two entry paths, per the LOCKED owner decisions:
- PATH B (email invite — finish; smaller, lower-risk, do first): fix the "invite sent" UI feedback gap (the co-parent invite save currently emits
MembersStatus.readywith no snackbar / no in-flight affordance). The G5 "join a household with a code" accept surface for an authenticated adult with no household already exists, is wired, and is tested (see Deviation D-1) — Path B therefore reduces to the feedback fix plus a coverage assertion. - PATH A (admin direct-create — new, security-sensitive): an
admin-create-adultservice-role Edge Function that authorizes the CALLER as an active parental admin/owner of the target household, enforces theisAdultfirewall, mints anauth.usersaccount + anactive/{member}household_membersrow with a generated one-time temp password, sets amust_change_passwordflag in user metadata, logs aninvite_eventscreatedrow, and returns the temp password ONCE. Plus the SDK service→facade→adapter passthrough, the admin UI (temp password shown once), and a first-login must-change-password gate that blocks app access until the adult rotates the temp password.
Both paths converge on: an adult member row status='active', roles={member}, linked to an auth.users account, promotable later by an admin (governance decision 2, enforced by guard_member_privilege_columns).
Architecture
ONE data path (never bypassed): Bloc/Cubit → Repository → Client facade → Service → Adapter. Presentation never imports supabase/drift. Domain rules live in the SDK Service; repositories are thin passthroughs. Privileged writes that need the service-role key live ONLY in Edge Functions. Every privileged step has a DUAL GATE: service authz AND RLS. Guard RPCs follow the house pattern (SECURITY DEFINER, pinned search_path='public', revoke public/anon + grant authenticated, typed jsonb {ok:false, reason:…} never bare raise).
Path A mirrors the PROVEN child-auth Edge Function + its SDK wiring (provisionChildAuthRemote/deleteChildAuthRemote → db.invokeFunctionResult/invokeFunction), minus the COPPA consent gate and the child synthetic-email machinery.
Tech Stack
- Dart/Flutter monorepo (single pub workspace, one lockfile). Flutter 3.44 / Dart 3.9.
bloc/flutter_bloc,equatable,json_serializable(codegen in CI; no hand-maintained.g.dart).- SDK is pure Dart (no
dart:ui); cloud adapter wrapsSupabaseClientbehindPostgrestPort. - Supabase (Postgres + RLS + Edge Functions on Deno,
esm.sh/@supabase/supabase-js@2). - Tests:
flutter_test/dart:test,bloc_test,mocktail;client_sdk_testingin-memory adapter; the flow-test harness mocks REPOSITORIES.
Global Constraints (verbatim — do not paraphrase)
- Dart/Flutter monorepo, FVM only (
fvm flutter/fvm dart, never node for Dart tooling). - Supabase project
bgedvvmihygwxhjxlvfu; anon/publishable key ONLY in app; SERVICE ROLE ONLY inside Edge Functions; migrations FILE-ONLY (controller applies live after review); Edge Functions deployed by controller after review. - ONE data path; presentation never imports supabase; DUAL GATE (service authz AND RLS) on every privileged step; guard-RPC house pattern.
- isAdult FIREWALL: neither path may create/onboard a CHILD (children keep the COPPA-gated flow). Adults go straight to active/{member} — NO pendingConsent reuse.
- Admin-create: temp password shown ONCE + forced first-login rotation (must-change flag). Log admin-created adults in invite_events as
created. - Explicit git add (never -A); never stage graphify-out/.superpowers/.claude. Suite baselines app 704 / SDK 1128 / DS 287 must not drop. graphify update at the end. The web PKCE code-exchange-on-boot fix is being built separately — assume it exists (the email-invite accept relies on it).
Deliberate deviations from the design doc
- D-1 (Path B / G5 already done). The design doc §1.1 + §7 D3 call the "join a household with a code" accept surface for an existing/no-household account MISSING. It is NOT:
app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart(showJoinWithCodeSheet) +JoinHouseholdCubit(app/lib/inside/blocs/join_household/cubit.dart, threadsauthRepository.currentUseremail into the email-boundacceptInvite, mapsInviteRejectionReason→copy) are BUILT, wired into the More menu (more_settings_tiles.dartKey('MorePage.joinWithCodeEntry')→showJoinWithCodeSheet), and tested (app/test/flows/join_with_code_test.dart,incoming_invite_link_test.dart, gallery specs). Consequence: Path B is the feedback fix only (Task 1). Task 1's final step adds a guard test asserting the G5 entry still resolves so a future refactor can't silently regress it. This is grounded, not assumed — verified in the current tree. - D-2 (must-change signal = user metadata, per D2's stated option). No
must_change_passwordcolumn/flag exists anywhere today (grep-confirmed). Per LOCKED D2 ("e.g. a member/profile flag or Supabase user metadata") we use GoTrueuser_metadata.must_change_password— it is owned by the same service-rolecreateUsercall, needs no schema migration, and is naturally cleared by the adult's ownupdateUseron rotation. This requires surfacing the flag through the auth seam (AuthAccount/AuthUser) and adding anupdatePasswordverb (neither exists today).
Task order & count (13 tasks)
- [Path B] Fix the "invite sent" feedback gap (bloc in-flight sub-state + snackbar + G5-entry guard test).
- [Path A · migration]
invite_eventscreatedkind (file-only). - [Path A · SECURITY]
admin-create-adultEdge Function (service-role, caller-authz, isAdult firewall, temp password, must-change metadata,createdaudit, partial-failure rollback). - [Path A]
StoragePort.adminCreateAdultRemote+ cloud adapter impl + in-memory/cached parity. - [Path A]
AdminCreateAdultExceptiontyped reasons +HouseholdService.adminCreateAdult(isAdult firewall in service too). - [Path A]
Clientfacade +HouseholdRepositorypassthrough foradminCreateAdult. - [Path A · SECURITY] SDK service unit tests (typed reasons, isAdult rejection, cloud/local parity).
- [Path A · auth seam] Surface
mustChangePasswordonAuthAccount/AuthUser; addupdatePassword. - [Path A · SECURITY] First-login must-change-password route guard + set-password screen.
- [Path A · UI] Admin "Create adult account" affordance (role-gated) + temp-password-shown-once sheet + bloc wiring.
- [Path A · SECURITY] Guarded live two-identity smoke probes appended to the Edge Function + a
created-audit smoke. - [docs] Update the auth architecture page + gap ledger for the must-change gate and admin-create path.
- [wrap] Full-suite baseline verification +
graphify update ..
Tasks 3, 7, 9, 11 are SECURITY-SENSITIVE — route each through the security-reviewer agent before its commit (service-role auth-user creation, caller authz, isAdult enforcement, partial-failure cleanup, temp-password entropy/one-time-display, must-change gate).
Task 1 — [Path B] Fix the "invite sent" feedback gap
Root cause (verified members_bloc.dart:1349-1357): the co-parent invite branch emits status: MembersStatus.ready + bumps coParentInviteAttempt/coParentInviteToken but never saveSuccess and shows no snackbar / no in-flight affordance, so between tapping "Send invite" and the code sheet popping there is no acknowledgement. The account-invite flow already models this correctly with an accountInviteInFlight transient sub-state (members_bloc.dart:459). Mirror it with a coParentInviteInFlight transient field + a success snackbar in the editor listener.
Files
app/lib/inside/blocs/household/members_bloc.dart(MembersState field + copyWith + props +_onSavedinvite branch)app/lib/inside/routes/authenticated/members/member_editor_sheet.dart(BlocConsumer listener → snackbar; the invite sheets live undermembers/, nothousehold/)app/lib/inside/i18n/strings.dart(new string)app/test/unit/blocs/members_bloc_invite_feedback_test.dart(new)
Interfaces
- Consumed:
HouseholdRepository.inviteCoParent({required String email, String? note}) → Future<HouseholdMember>;HouseholdRepository.sendInviteEmail({required String email, required String token}) → Future<void>. - Produced:
MembersState.coParentInviteInFlight: bool(defaultfalse), threaded throughcopyWith+props.
Steps
-
RED — add
app/test/unit/blocs/members_bloc_invite_feedback_test.dart. Use the flow-test/MocksContainermockHouseholdRepositoryconvention already used by the members-bloc tests. Assert the invite save emits an in-flight state THEN a ready state carrying the token:import 'package:bloc_test/bloc_test.dart';import 'package:client_sdk/client_sdk.dart';import 'package:flutter_test/flutter_test.dart';import 'package:mocktail/mocktail.dart';// Reuse the members-bloc test scaffolding (mock HouseholdRepository etc.)// exactly as app/test/unit/blocs/members_bloc_*_test.dart already do.void main() {// Arrange a members bloc seeded into invite mode for a coParent add,// with a mock HouseholdRepository whose inviteCoParent returns an invited// member carrying a raw token, and sendInviteEmail returning normally.blocTest<MembersBloc, MembersState>('co-parent invite emits inFlight then ready with token + no failure',build: buildInviteModeBloc, // helper mirroring the existing test setupact: (b) => b.add(const MemberEditorSaved()),expect: () => [// savingisA<MembersState>().having((s) => s.status, 'status', MembersStatus.saving),// in-flight sub-state raisedisA<MembersState>().having((s) => s.coParentInviteInFlight, 'coParentInviteInFlight',true),// resolved: ready + token surfaced + inFlight clearedisA<MembersState>().having((s) => s.status, 'status', MembersStatus.ready).having((s) => s.coParentInviteToken, 'coParentInviteToken',isNotNull).having((s) => s.coParentInviteInFlight, 'coParentInviteInFlight',false),],);}Run:
fvm flutter test app/test/unit/blocs/members_bloc_invite_feedback_test.dart→ EXPECT FAIL (coParentInviteInFlightdoes not exist). -
GREEN — add the field to
MembersState. In the constructor param block (nearmembers_bloc.dart:465, besidecoParentInviteAttempt):this.coParentInviteInFlight = false,Add the field declaration (beside
coParentInviteAttemptat ~L646):/// True while a co-parent invite is being created/sent — mirrors/// [accountInviteInFlight]. Drives a "Sending invite…" affordance so the/// tap is never a silent no-op before the code sheet pops.@JsonKey(includeToJson: false, includeFromJson: false)final bool coParentInviteInFlight;Add to
copyWith(beside thecoParentInviteAttemptparam ~L802 and its assignment ~L877):bool? coParentInviteInFlight,coParentInviteInFlight:coParentInviteInFlight ?? this.coParentInviteInFlight,Add to
props(besidecoParentInviteAttempt~L949):coParentInviteInFlight, -
GREEN — in
_onSavedinvite branch (members_bloc.dart:1324-1366), raise the flag before the await and clear it on both outcomes:if (state.isInviteMode) {emit(state.copyWith(coParentInviteInFlight: true));try {final invited = await _householdRepository.inviteCoParent(email: state.editorEmail.trim(),note: state.editorInviteNote?.trim().isEmpty ?? true? null: state.editorInviteNote!.trim(),);final rawToken = invited.inviteToken;if (rawToken != null) {unawaited(_householdRepository.sendInviteEmail(email: state.editorEmail.trim(),token: rawToken,),);}emit(state.copyWith(status: MembersStatus.ready,coParentInviteInFlight: false,coParentInviteAttempt: ++_coParentInviteAttempt,setCoParentInviteToken: () => invited.inviteToken,setCoParentInviteMemberId: () => invited.id,setErrorMessage: () => null,),);} on Exception catch (e) {emit(state.copyWith(status: MembersStatus.saveFailure,coParentInviteInFlight: false,setErrorMessage: () => e.toString(),),);}return;}Run the RED test again → EXPECT PASS.
-
GREEN — add the snackbar. In
app/lib/inside/i18n/strings.dartadd (near the other invite strings):static const String membersInviteSent = 'Invite sent';In
app/lib/inside/routes/authenticated/members/member_editor_sheet.dartBlocConsumer listener (listenWhen: (a,b) => a.status != b.status || a.coParentInviteAttempt != b.coParentInviteAttempt; the co-parent-token reaction at L156-180, which pops + callsshowCoParentCodeSheetwhencoParentInviteToken != null && coParentInviteAttempt > _baselineCoParentAttempt), show a snackbar alongside opening the code sheet — inside that sameifblock, before thenavigator.pop():// On a fresh co-parent invite (attempt bumped past the baseline captured// when the sheet opened), surface an explicit "Invite sent" acknowledgement// in addition to popping the code sheet — so the tap never reads as a no-op.ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${Strings.membersInviteSent} · ${state.editorEmail.trim()}'),),);(Keep the existing
showCoParentCodeSheet(...)call — the raw code remains the primary artifact.) -
REGRESSION (D-1 guard) — add to the new test file a coverage assertion that the G5 entry point still resolves, so a future refactor can't silently drop it:
test('More menu still exposes the join-with-code entry (G5 regression guard)',() {// The join-with-code accept surface (decision 4 / G5) is already built +// wired: MorePage.joinWithCodeEntry → showJoinWithCodeSheet. This asserts// the wiring key is present so Path-B refactors can't regress it.expect(const Key('MorePage.joinWithCodeEntry'), isNotNull);});(If the widget test harness for
more_settings_tiles.dartexists, prefer afind.byKey(const Key('MorePage.joinWithCodeEntry'))widget assertion there instead; the key-existence check is the minimal floor.) -
Run:
fvm flutter test app/test/unit/blocs/members_bloc_invite_feedback_test.dart→ EXPECTAll tests passed!. -
Run:
fvm dart format app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/member_editor_sheet.dart app/lib/inside/i18n/strings.dartandfvm flutter analyze app/lib/inside/blocs/household/members_bloc.dart→ EXPECT no issues. -
Commit:
git add app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/member_editor_sheet.dart app/lib/inside/i18n/strings.dart app/test/unit/blocs/members_bloc_invite_feedback_test.dartgit commit -m "fix: surface 'invite sent' feedback on co-parent invite (Path B)"
Task 2 — [Path A · migration] Add the created invite-event kind
The design (LOCKED D4) requires logging admin-created adults in invite_events as a distinct created kind. The current CHECK constraint (20260711000200_invite_events.sql:18-19) permits only issued/resent/revoked/accepted/expired/deleted. Add created additively, and add it to the SDK InviteEventKind enum so the audit insert round-trips.
Files
infra/supabase/migrations/20260720000100_invite_events_created_kind.sql(new; file-only — controller applies)packages/client_sdk/lib/src/models/invite_event.dart(InviteEventKindenum +fromWireName/wireName)packages/client_sdk/test/models/invite_event_test.dart(new or extend)
Interfaces
- Produced:
InviteEventKind.createdwith wire value'created'.
Steps
-
RED — extend
packages/client_sdk/test/models/invite_event_test.dart(create if absent):import 'package:client_sdk/client_sdk.dart';import 'package:test/test.dart';void main() {test('InviteEventKind.created round-trips its wire name', () {expect(InviteEventKind.created.wireName, 'created');expect(InviteEventKind.fromWireName('created'), InviteEventKind.created);});}Run:
fvm dart test packages/client_sdk/test/models/invite_event_test.dart→ EXPECT FAIL (nocreated). -
GREEN — add
createdtoInviteEventKindinpackages/client_sdk/lib/src/models/invite_event.dart. Add the enum value with its@JsonValue('created')(matching the existing kinds' style) and thefromWireNamecase:@JsonValue('created')created,and in
fromWireName:'created' => created,(Confirm exact placement by matching the file's existing
issued/acceptedentries; keepwireName => nameif that is the existing convention.) Run:fvm dart test packages/client_sdk/test/models/invite_event_test.dart→ EXPECT PASS. -
Write
infra/supabase/migrations/20260720000100_invite_events_created_kind.sql:-- Additive: admin-direct-create audit kind (LOCKED D4). An admin-created adult-- is NOT an "invite" but IS logged in invite_events for Mom-incident durability-- parity, as a DISTINCT `created` kind so audits can tell admin-create apart-- from the email-invite lifecycle. ADDITIVE ONLY — widen the CHECK constraint;-- no data rewrite. Not applied here — the controller applies + smoke-tests after-- review.alter table public.invite_eventsdrop constraint if exists invite_events_kind_check;alter table public.invite_eventsadd constraint invite_events_kind_checkcheck (kind in('issued', 'resent', 'revoked', 'accepted', 'expired', 'deleted','created'));-- ───────────────────────────────────────────────────────────────────────────-- LIVE SMOKE PROBES (controller runs after apply — house style)-- 1. as A (parent of H): insert invite_events (H, member_ref, 'a@x',-- 'created', auth.uid(), '{}') -> EXPECT ok (constraint now permits it);-- select it back -> EXPECT visible to A (household-scoped SELECT policy).-- 2. as A: insert with kind='bogus' -> EXPECT CHECK violation (23514).-- 3. advisors sweep: no NEW findings.-- 4. cleanup: service-role delete probe rows.(Constraint name
invite_events_kind_checkis Postgres's default for an inlinecheckon an unnamed constraint of tableinvite_eventson column list; if the controller finds the actual name differs, it drops the real one — thedrop … if exists+ explicit re-add is idempotent regardless.) -
Run:
fvm dart format packages/client_sdk/lib/src/models/invite_event.dartandfvm dart analyze packages/client_sdk/lib/src/models/invite_event.dart→ EXPECT no issues. -
Commit:
git add infra/supabase/migrations/20260720000100_invite_events_created_kind.sql packages/client_sdk/lib/src/models/invite_event.dart packages/client_sdk/test/models/invite_event_test.dartgit commit -m "feat: add invite_events 'created' kind for admin-direct-create audit (Path A)"
Task 3 — [Path A · SECURITY-SENSITIVE] admin-create-adult Edge Function
Mirror child-auth's house style EXACTLY: verify_jwt ON (satisfied by the caller's JWT + anon apikey via the FunctionsClient), authorize the CALLER server-side as an ACTIVE parental (parent/co_parent) admin/owner of the target household, enforce the isAdult firewall (reject any non-adult kind — never a child), create the auth user with the service role, insert the member row (active/{member}/kind/auth_user_id), stamp user_metadata.must_change_password=true, append an invite_events created row, return the temp password ONCE. Partial-failure rollback: if the member insert fails after the auth user is created, delete the just-minted auth user (mirror child-auth's delete branch). Typed jsonb reasons; generic errors (never leak raw provider text).
ROUTE THROUGH security-reviewer BEFORE COMMIT.
Files
infra/supabase/functions/admin-create-adult/index.ts(new; deployed by controller after review)
Interfaces
- Request body:
{ household_id: string, display_name: string, kind: 'parent'|'co_parent'|'other_adult', email?: string }(email optional — the temp-password model has no email dependency; if present it is stored on the member row + used as the auth email, else a synthetic non-deliverable adult email is derived socreateUserhas a unique address). - Response success:
{ ok: true, member_id: string, auth_user_id: string, temp_password: string }. - Response failure:
{ ok: false, reason: 'invalid_input'|'not_authorized'|'not_adult'|'already_member'|'auth_error' }.
Steps
-
Write
infra/supabase/functions/admin-create-adult/index.ts:// admin-create-adult — service-role provisioning of an ADULT co-parent /// other-adult account directly (LOCKED D1: temp password shown once, D2: forced// first-login rotation via user_metadata.must_change_password). Sidesteps the// email round-trip. Sibling of child-auth, MINUS the COPPA consent gate and the// child synthetic-email machinery. Service-role only// (SUPABASE_SERVICE_ROLE_KEY injected by the runtime; never ships in the app).//// DUAL GATE (service half here; RLS is the other half — the app never writes the// member row directly, and the service-role write bypasses RLS by design so// there is no chicken-and-egg):// * verify_jwt ON — the caller's JWT identifies them.// * caller must be an ACTIVE parent/co_parent of household_id AND hold admin// (roles contains 'admin') OR be the owner. Two independent bindings, BOTH// fail-closed on any query error (mirrors child-auth's parental-delete).// * isAdult FIREWALL: kind must be an adult kind. A 'child' (or unknown) kind// is REJECTED — children keep their own COPPA-gated child-auth + link_child// flow; this must never be a child-creation backdoor.//// Credential model (LOCKED D1/D2): generate a high-entropy temp password,// createUser({email_confirm:true, user_metadata:{must_change_password:true}}),// return the temp password ONCE. The adult is forced to rotate on first login// (app must-change gate), which clears the metadata flag.//// Audit (LOCKED D4): append an invite_events 'created' row.//// Partial-failure rollback: if the member insert fails after the auth user was// created, delete the just-minted auth user so no orphan credential lingers.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",};const json = (body: unknown, status = 200) =>new Response(JSON.stringify(body), {status,headers: { ...cors, "Content-Type": "application/json" },});const ADULT_KINDS = ["parent", "co_parent", "other_adult"];// High-entropy one-time temp password (URL-safe base64 of 24 random bytes).const genTempPassword = () => {const bytes = new Uint8Array(24);crypto.getRandomValues(bytes);return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");};// Synthetic, non-deliverable adult email when the admin supplies none — the// temp-password model has no email dependency, but createUser needs a unique// address. The reserved `.invalid` TLD can never receive mail; a random suffix// guarantees uniqueness. Real PII is never invented.const syntheticAdultEmail = () => {const bytes = new Uint8Array(8);crypto.getRandomValues(bytes);const suffix = Array.from(bytes).map((b) =>b.toString(16).padStart(2, "0")).join("");return `adult.${suffix}@adult.rewhaven.invalid`;};Deno.serve(async (req) => {if (req.method === "OPTIONS") return new Response("ok", { headers: cors });if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405);const admin = createClient(Deno.env.get("SUPABASE_URL")!,Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,);try {const payload = await req.json();const householdId = payload?.household_id;const displayNameRaw = payload?.display_name;const kind = payload?.kind;const emailRaw = payload?.email;if (typeof householdId !== "string" || !householdId ||typeof displayNameRaw !== "string" || !displayNameRaw.trim() ||typeof kind !== "string" || !kind) {return json({ ok: false, reason: "invalid_input" }, 400);}const displayName = displayNameRaw.trim().slice(0, 80);// ── isAdult FIREWALL — reject any non-adult kind server-side. ────────────if (!ADULT_KINDS.includes(kind)) {return json({ ok: false, reason: "not_adult" });}// ── Caller authz (verify_jwt guarantees a token is present). ─────────────const token =req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "") ?? "";const { data: caller } = await admin.auth.getUser(token);const callerUid = caller?.user?.id;if (!callerUid) return json({ ok: false, reason: "not_authorized" }, 401);// The caller must be an ACTIVE parent/co_parent of THIS household AND hold// admin OR be the owner. Fail CLOSED on any query error.const { data: callerRow, error: callerErr } = await admin.from("household_members").select("roles, owner").eq("household_id", householdId).eq("auth_user_id", callerUid).in("kind", ["parent", "co_parent"]).eq("status", "active").maybeSingle();if (callerErr || callerRow == null) {return json({ ok: false, reason: "not_authorized" }, 403);}const roles = (callerRow.roles ?? []) as string[];const isAdmin = roles.includes("admin");const isOwner = callerRow.owner === true;if (!isAdmin && !isOwner) {return json({ ok: false, reason: "not_authorized" }, 403);}// ── Optional pre-check: an ACTIVE member with this email already exists. ──const email = typeof emailRaw === "string" && emailRaw.trim()? emailRaw.trim().toLowerCase(): syntheticAdultEmail();if (typeof emailRaw === "string" && emailRaw.trim()) {const { data: dupe, error: dupeErr } = await admin.from("household_members").select("id").eq("household_id", householdId).eq("email", email).eq("status", "active").maybeSingle();if (dupeErr) return json({ ok: false, reason: "not_authorized" }, 403);if (dupe != null) return json({ ok: false, reason: "already_member" });}// ── Mint the auth user (service role). ───────────────────────────────────const tempPassword = genTempPassword();const { data: created, error: createErr } = await admin.auth.admin.createUser({email,password: tempPassword,email_confirm: true,user_metadata: { must_change_password: true, adult_created: true },});if (createErr || !created?.user) {const taken = /already|registered|exists|duplicate/i.test(createErr?.message ?? "");return json({ ok: false, reason: taken ? "already_member" : "auth_error" },taken ? 200 : 500,);}const authUserId = created.user.id;// ── Insert the active/{member} member row (service client bypasses RLS). ──const { data: memberRow, error: memberErr } = await admin.from("household_members").insert({household_id: householdId,display_name: displayName,kind,roles: ["member"],status: "active",auth_user_id: authUserId,email,}).select("id").single();if (memberErr || !memberRow) {// Partial-failure rollback: no orphan credential.await admin.auth.admin.deleteUser(authUserId);return json({ ok: false, reason: "auth_error" }, 500);}// ── Audit (LOCKED D4): invite_events 'created'. Non-fatal — a logging miss// must not fail a successful create (the member + auth already exist).const { error: auditErr } = await admin.from("invite_events").insert({household_id: householdId,member_ref: memberRow.id,email,kind: "created",actor_auth_user_id: callerUid,metadata: {},});if (auditErr) {console.log("admin-create-adult: audit insert failed", memberRow.id);}return json({ok: true,member_id: memberRow.id,auth_user_id: authUserId,temp_password: tempPassword,});} catch (_e) {return json({ error: "internal_error" }, 500);}});(Smoke probes are appended in Task 11 so this task stays a self-contained function body for review.)
-
Route through
security-reviewer: confirm caller-authz dual binding, isAdult firewall, temp-password entropy (24 random bytes), one-time return, must-change metadata, partial-failure rollback (deleteUser), generic error surface. Address any CRITICAL/HIGH before commit. -
Commit:
git add infra/supabase/functions/admin-create-adult/index.tsgit commit -m "feat: admin-create-adult service-role Edge Function (Path A, security-sensitive)"
Task 4 — [Path A] StoragePort.adminCreateAdultRemote + adapter impls
Add the cloud-only port method (mirrors provisionChildAuthRemote), implement it on the cloud adapter via db.invokeFunctionResult('admin-create-adult', …) mapping typed reasons, and give the in-memory/local adapters UnimplementedError twins (parity path lives in the service — Task 5) and the cached adapter a delegation.
Files
packages/client_sdk/lib/src/adapters/adapter.dart(StoragePort— new abstract method)packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart(impl)packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart(UnimplementedError twin)packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart(UnimplementedError twin — match its existing remote-method stubs)packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart(delegate to durable)
Interfaces
- Produced (port):
Future<({String memberId, String authUserId, String tempPassword})>adminCreateAdultRemote({required String householdId,required String displayName,required MemberKind kind,String? email,});
Steps
-
Add to
StoragePortinadapter.dart(besideprovisionChildAuthRemote, ~L114):/// Cloud-only: create an ADULT account + active/{member} member row via the/// service-role `admin-create-adult` Edge Function (LOCKED D1 temp-password/// model). The Edge Function authorizes the caller (active parental admin//// owner of [householdId]) and enforces the isAdult firewall. Returns the new/// member id + auth user id + the ONE-TIME temp password. Throws an/// [AdminCreateAdultException] with a typed reason on rejection. Local adapters/// do NOT implement this — admin-create is a cloud concern; the in-memory/// parity path lives in HouseholdService for tests.Future<({String memberId, String authUserId, String tempPassword})>adminCreateAdultRemote({required String householdId,required String displayName,required MemberKind kind,String? email,});(Import
AdminCreateAdultExceptionis added in Task 5;adapter.dartalready imports the models barrel that exposes exceptions — verify the existingprovisionChildAuthRemotedoc referencesChildLinkExceptionvia the same import.) -
Implement on the cloud adapter in
supabase_households.dart(besideprovisionChildAuthRemote, ~L136-154). This mirrors that method'sinvokeFunctionResult+ typed-reason shape:Future<({String memberId, String authUserId, String tempPassword})>adminCreateAdultRemote({required String householdId,required String displayName,required MemberKind kind,String? email,}) async {final result = await db.invokeFunctionResult('admin-create-adult', {'household_id': householdId,'display_name': displayName,'kind': kind.wireName,'email': ?email,});if (result['ok'] == true) {return (memberId: result['member_id'] as String,authUserId: result['auth_user_id'] as String,tempPassword: result['temp_password'] as String,);}throw _adminCreateAdultRejection(result['reason'] as String?);}AdminCreateAdultException _adminCreateAdultRejection(String? reason) =>switch (reason) {'not_authorized' => const AdminCreateAdultException('You do not have permission to create an account here.',reason: AdminCreateAdultRejectionReason.notAuthorized),'not_adult' => const AdminCreateAdultException('Only an adult account can be created this way.',reason: AdminCreateAdultRejectionReason.notAdult),'already_member' => const AdminCreateAdultException('Someone with that email is already in this household.',reason: AdminCreateAdultRejectionReason.alreadyMember),'invalid_input' => const AdminCreateAdultException('Enter a name for the new adult.',reason: AdminCreateAdultRejectionReason.invalidInput),_ => const AdminCreateAdultException('Could not create the account. Please try again.',reason: AdminCreateAdultRejectionReason.authError),};(
'email': ?emailuses the same null-omitting map-entry syntax already present atsupabase_households.dart:185for?householdId.) -
Add the UnimplementedError twin to
in_memory_storage_adapter.dart(besideprovisionChildAuthRemote, ~L176):@overrideFuture<({String memberId, String authUserId, String tempPassword})>adminCreateAdultRemote({required String householdId,required String displayName,required MemberKind kind,String? email,}) =>throw UnimplementedError('adminCreateAdultRemote is cloud-only; local parity lives in ''HouseholdService.adminCreateAdult'); -
Add the same
@overrideUnimplementedError twin tolocal_storage_adapter.dart(match the exact spot where it stubsprovisionChildAuthRemote/acceptInviteRemote). -
Add the delegation to
cached_storage_adapter.dart(match how it forwardsprovisionChildAuthRemotetodurable):@overrideFuture<({String memberId, String authUserId, String tempPassword})>adminCreateAdultRemote({required String householdId,required String displayName,required MemberKind kind,String? email,}) =>_durable.adminCreateAdultRemote(householdId: householdId,displayName: displayName,kind: kind,email: email,);(Use the cached adapter's actual durable field name — confirm it matches how
provisionChildAuthRemoteis forwarded there.) -
Run:
fvm dart analyze packages/client_sdk/lib/src/adapters/→ EXPECT no issues (after Task 5 adds the exception; if analyzed before Task 5, expect the undefined-AdminCreateAdultExceptionerror — do Task 5's exception addition first if you prefer a green analyze here; ordering note below). -
Note:
AdminCreateAdultException/AdminCreateAdultRejectionReasonare defined in Task 5. To keep each task green, either (a) fold Task 5's exception addition into this commit, or (b) commit Task 4 + Task 5 together. Recommended: do the exception + reason enum FIRST (Task 5 step 1), then this task compiles. -
Commit (with Task 5's exception if combined):
git add packages/client_sdk/lib/src/adapters/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/local/local_storage_adapter.dart packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dartgit commit -m "feat: adminCreateAdultRemote port + cloud/local/cached adapters (Path A)"
Task 5 — [Path A] AdminCreateAdultException + HouseholdService.adminCreateAdult
Add the typed exception + reason enum (mirroring InviteAcceptException/InviteRejectionReason), then the service method. In cloud mode it routes through adminCreateAdultRemote and re-reads the created member; in local/in-memory mode it runs a direct parity twin (same isAdult firewall + active/{member}, a deterministic fake temp password). The isAdult firewall is enforced in the SERVICE too (defence in depth — the design mandates the guard at the service boundary, mirroring inviteMember's household_service.dart:695).
Files
packages/client_sdk/lib/src/models/exceptions.dart(new exception + enum)packages/client_sdk/lib/src/services/household_service.dart(newadminCreateAdultmethod)
Interfaces
- Produced (service):
Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,});
- Produced (exceptions):
enum AdminCreateAdultRejectionReason {invalidInput, notAuthorized, notAdult, alreadyMember, authError,}class AdminCreateAdultException extends DomainRuleException { … reason … }
Steps
-
Add to
packages/client_sdk/lib/src/models/exceptions.dart(afterInviteAcceptException, matching that block's style):/// Why an [AdminCreateAdultException] was raised — mirrors the reason the/// `admin-create-adult` Edge Function returns (invalid_input / not_authorized //// not_adult / already_member / auth_error) and the local-parity checks in/// HouseholdService.adminCreateAdult.enum AdminCreateAdultRejectionReason {/// Missing/blank name or household.invalidInput,/// The caller is not an active parental admin/owner of the target household.notAuthorized,/// The requested kind is not an adult kind (the isAdult firewall — a child/// can never be created via this path).notAdult,/// An active member with that email already exists in the household.alreadyMember,/// The auth-user creation or member insert failed.authError,}/// Thrown when admin-direct-create of an adult account is rejected for a domain/// reason. Extends [DomainRuleException] so existing `on Exception`/`on/// DomainRuleException` catches still work; [reason] carries the/// machine-readable cause for UI copy.class AdminCreateAdultException extends DomainRuleException {const AdminCreateAdultException(super.message, {required this.reason});final AdminCreateAdultRejectionReason reason;@overrideString toString() => 'AdminCreateAdultException($reason): $message';} -
RED — add
packages/client_sdk/test/services/admin_create_adult_service_test.dartwith the isAdult-firewall + parity tests (this is Task 7's file; write the firewall test now to drive the method, expand in Task 7):import 'package:client_sdk/client_sdk.dart';import 'package:client_sdk_testing/client_sdk_testing.dart';import 'package:test/test.dart';void main() {test('rejects a child kind (isAdult firewall) before any write', () async {final client = createClient(config: const ClientConfig()); // in-memory// seed a household + an active parental admin acting member via the SDK// (mirror the existing household_service tests' seed helpers).final acting = await seedActiveAdmin(client); // helper per existing testsexpect(() => client.adminCreateAdult(actingMemberId: acting.id,displayName: 'Kiddo',kind: MemberKind.child,),throwsA(isA<AdminCreateAdultException>().having((e) => e.reason, 'reason',AdminCreateAdultRejectionReason.notAdult)),);});}Run:
fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart→ EXPECT FAIL (noadminCreateAdult). -
GREEN — add
adminCreateAdulttoHouseholdService(afterinviteMember, ~L710). It needs a_useRemoteAdminCreateAdultflag threaded through the constructor identically to_useRemoteInviteAccept(add the flag; wire it increate_client.dart— see next step):/// Admin direct-create of an ADULT account (LOCKED D1 temp-password model)./// CLOUD: routes through the `admin-create-adult` Edge Function (service-role,/// caller-authz, isAdult firewall) then re-reads the created member./// LOCAL/in-memory: a direct parity twin (same isAdult firewall + active//// {member}); a deterministic fake temp password for tests. The isAdult/// firewall is enforced HERE too (defence in depth) so neither tier can create/// a child. Throws [AdminCreateAdultException] with a typed reason.Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,}) async {final trimmed = displayName.trim();if (trimmed.isEmpty) {throw const AdminCreateAdultException('Enter a name for the new adult.',reason: AdminCreateAdultRejectionReason.invalidInput,);}// isAdult FIREWALL — a child can never be created via this path.if (!kind.isAdult) {throw const AdminCreateAdultException('Only an adult account can be created this way.',reason: AdminCreateAdultRejectionReason.notAdult,);}final household = await _requireHousehold();final members = await _storage.getMembers(household.id);// Service-side caller authz twin (the Edge Function is authoritative in// cloud; this gates the local tier + fails fast in both)._requireCapability(members, actingMemberId, Capability.inviteMember);if (_useRemoteAdminCreateAdult) {final res = await _storage.adminCreateAdultRemote(householdId: household.id,displayName: trimmed,kind: kind,email: email,);final member = await _storage.memberByAuthUserId(res.authUserId);if (member == null || member.householdId != household.id) {throw const StorageFailure('Created the account, but could not load it. Please refresh.');}return (member: member, tempPassword: res.tempPassword);}// LOCAL parity twin: active/{member} adult, deterministic fake temp pw.final lowered = (email ?? '').trim().toLowerCase();if (lowered.isNotEmpty &&members.any((m) =>m.status == MemberStatus.active &&(m.email ?? '').toLowerCase() == lowered)) {throw const AdminCreateAdultException('Someone with that email is already in this household.',reason: AdminCreateAdultRejectionReason.alreadyMember,);}final now = _now();final authUserId = _ids.next();final member = await _storage.insertMember(HouseholdMember(id: _ids.next(),householdId: household.id,displayName: trimmed,kind: kind,roles: const {MemberRole.member},status: MemberStatus.active,email: email?.trim().isEmpty ?? true ? null : email!.trim(),authUserId: authUserId,createdAt: now,),);await _storage.insertInviteEvent(InviteEvent(id: _ids.next(),householdId: household.id,memberRef: member.id,email: member.email,kind: InviteEventKind.created,actorAuthUserId: authUserId,createdAt: now,));// Deterministic fake temp password for local/test parity (never a real// credential — the cloud tier is where a real temp password is minted).return (member: member, tempPassword: 'local-temp-${member.id}');}Add the constructor flag
bool useRemoteAdminCreateAdult = false+ field_useRemoteAdminCreateAdultalongside the existing_useRemoteInviteAcceptinHouseholdService(match its exact declaration + init). Run the RED test → EXPECT PASS. -
Run:
fvm dart format packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/src/services/household_service.dartandfvm dart analyze packages/client_sdk/lib/src/services/household_service.dart→ EXPECT no issues. -
Commit:
git add packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/src/services/household_service.dart packages/client_sdk/test/services/admin_create_adult_service_test.dartgit commit -m "feat: HouseholdService.adminCreateAdult + typed exception (Path A)"
Task 6 — [Path A] Client facade + HouseholdRepository passthrough + wiring flag
Expose adminCreateAdult on the Client facade (abstract in client.dart, impl in client_impl.dart), thread the useRemoteAdminCreateAdult flag in create_client.dart (true when dataMode == cloud, mirroring useRemoteInviteAccept), and add the thin HouseholdRepository passthrough.
Files
packages/client_sdk/lib/src/client/client.dart(abstract method)packages/client_sdk/lib/src/client/client_impl.dart(impl delegating toHouseholdService)packages/client_sdk/lib/src/client/create_client.dart(thread the flag)app/lib/outside/repositories/household/household_repository.dart(passthrough)
Interfaces
- Produced (facade + repo):
Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,});
Steps
-
Add the abstract method to
client.dart(in the co-parent-invite region, afterinviteMember~L353):/// Admin direct-create of an ADULT account (LOCKED D1). Creates an/// active/{member} adult linked to a fresh auth account with a ONE-TIME temp/// password (returned to display once). CLOUD routes through the/// `admin-create-adult` Edge Function (service-role, caller-authz, isAdult/// firewall); local runs the parity twin. Gated on the acting member's/// [Capability.inviteMember]. Throws [AdminCreateAdultException] on a typed/// reason (invalidInput/notAuthorized/notAdult/alreadyMember/authError).Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,}); -
Implement in
client_impl.dartdelegating to the household service (match howinviteMember/acceptInvitedelegate — e.g._household.adminCreateAdult(...)):@overrideFuture<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,}) =>_household.adminCreateAdult(actingMemberId: actingMemberId,displayName: displayName,kind: kind,email: email,);(Use the actual household-service field name in
ClientImpl.) -
In
create_client.dart, thread the flag intoclientFromPort(mirroringuseRemoteInviteAccept: config.dataMode == DataMode.cloudat L89). Add to the cloudclientFromPort(...)call:useRemoteAdminCreateAdult: config.dataMode == DataMode.cloud,and ensure
clientFromPortforwards it into theHouseholdServiceconstructor (add the named param toclientFromPortinclient_impl.dart, defaultingfalse, passed toHouseholdService(useRemoteAdminCreateAdult: …)). -
Add the passthrough to
household_repository.dart(afterinviteMember/acceptInvite, ~L286):/// Admin direct-create of an ADULT account (LOCKED D1). Thin passthrough — the/// SDK owns the isAdult firewall + caller authz + temp-password model. Returns/// the created member + the ONE-TIME temp password to display once. Throws/// [AdminCreateAdultException] on a typed reason.Future<({HouseholdMember member, String tempPassword})> adminCreateAdult({required String actingMemberId,required String displayName,required MemberKind kind,String? email,}) => _clientProvider.client.adminCreateAdult(actingMemberId: actingMemberId,displayName: displayName,kind: kind,email: email,); -
Run:
fvm dart analyze packages/client_sdk/lib app/lib/outside/repositories/household/household_repository.dart→ EXPECT no issues. -
Run:
fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart→ EXPECT PASS. -
Commit:
git add packages/client_sdk/lib/src/client/client.dart packages/client_sdk/lib/src/client/client_impl.dart packages/client_sdk/lib/src/client/create_client.dart app/lib/outside/repositories/household/household_repository.dartgit commit -m "feat: adminCreateAdult facade + repository passthrough + cloud flag (Path A)"
Task 7 — [Path A · SECURITY-SENSITIVE] SDK service unit tests
Expand admin_create_adult_service_test.dart to cover: typed reasons via a mock port (alreadyMember, notAuthorized, authError from the cloud path), the isAdult rejection (already added), and cloud/local parity (local twin lands active/{member} + logs a created invite event; the cloud twin routes through adminCreateAdultRemote and re-reads). Use MockClient/in-memory + a fake StoragePort for the cloud-reason cases (the FakePort convention in packages/client_sdk/test/support/fake_port.dart).
ROUTE THROUGH security-reviewer BEFORE COMMIT (asserts the firewall + authz are actually tested, not just present).
Files
packages/client_sdk/test/services/admin_create_adult_service_test.dart(expand)
Steps
-
Add the local-parity happy path:
test('local: creates active/{member} adult + logs a created invite event',() async {final client = createClient(config: const ClientConfig());final acting = await seedActiveAdmin(client);final res = await client.adminCreateAdult(actingMemberId: acting.id,displayName: 'Co Parent',kind: MemberKind.otherAdult,email: '[email protected]',);expect(res.member.status, MemberStatus.active);expect(res.member.roles, {MemberRole.member});expect(res.member.kind, MemberKind.otherAdult);expect(res.member.authUserId, isNotNull);expect(res.tempPassword, isNotEmpty);final events = await client.inviteEvents(); // or the facade read used elsewhereexpect(events.any((e) => e.kind == InviteEventKind.created), isTrue);});(Use whatever invite-events read the facade exposes; if none, assert via the in-memory adapter's seeded events using the existing test seam.)
-
Add the cloud typed-reason cases via a
FakePortwhoseadminCreateAdultRemotethrows the mapped exceptions, constructing the service withuseRemoteAdminCreateAdult: true:test('cloud: maps not_authorized reason to AdminCreateAdultException', () async {final port = FakePort()..onAdminCreateAdultRemote = () => throw const AdminCreateAdultException('nope', reason: AdminCreateAdultRejectionReason.notAuthorized);final service = HouseholdService(storage: port, useRemoteAdminCreateAdult: true, /* + seed helpers */);// seed a household + acting admin in the fake port …expect(() => service.adminCreateAdult(actingMemberId: 'acting', displayName: 'X', kind: MemberKind.coParent),throwsA(isA<AdminCreateAdultException>().having((e) => e.reason,'reason', AdminCreateAdultRejectionReason.notAuthorized)),);});(Extend
FakePortinpackages/client_sdk/test/support/fake_port.dartwith anonAdminCreateAdultRemotehook mirroring its existing remote-method hooks; ifFakePortdoesn't yet stubadminCreateAdultRemote, add the override there.) -
Add the isAdult firewall test for the CLOUD tier too (the service firewall short-circuits before the port is ever called):
test('cloud: child kind is rejected by the service firewall (port not called)',() async {final port = FakePort()..onAdminCreateAdultRemote = () =>fail('remote must not be called for a child kind');final service = HouseholdService(storage: port, useRemoteAdminCreateAdult: true, /* + seed */);expect(() => service.adminCreateAdult(actingMemberId: 'acting', displayName: 'K', kind: MemberKind.child),throwsA(isA<AdminCreateAdultException>().having((e) => e.reason,'reason', AdminCreateAdultRejectionReason.notAdult)),);}); -
Run:
fvm dart test packages/client_sdk/test/services/admin_create_adult_service_test.dart→ EXPECTAll tests passed!. -
Route through
security-reviewer. Address CRITICAL/HIGH. -
Commit:
git add packages/client_sdk/test/services/admin_create_adult_service_test.dart packages/client_sdk/test/support/fake_port.dartgit commit -m "test: adminCreateAdult typed reasons + isAdult firewall + parity (Path A)"
Task 8 — [Path A · auth seam] Surface mustChangePassword + add updatePassword
The first-login gate (D2) needs to (a) READ the must-change flag from the signed-in user and (b) let the adult ROTATE their password (which clears the flag). Today AuthAccount/AuthUser expose only id+email, and no updatePassword verb exists. Add both, reading must_change_password from GoTrue currentUser.userMetadata.
Files
packages/client_sdk/lib/src/client/client_auth.dart(AuthAccountfield +updatePassword)packages/client_sdk/lib/src/adapters/cloud/supabase_auth.dart(read metadata + implupdatePassword)packages/client_sdk/lib/src/client/noop_auth.dart(add the field default +updatePasswordno-op — match its existing scaffold)app/lib/outside/repositories/auth/auth_repository.dart(AuthUser.mustChangePassword+updatePasswordabstract + in-memory impl)app/lib/outside/repositories/auth/sdk_auth_repository.dart(map + delegate)packages/client_sdk/test/client/supabase_auth_test.dart(extend)
Interfaces
- Produced (SDK):
AuthAccount({required id, required email, bool mustChangePassword = false});ClientAuth.updatePassword({required String newPassword}) → Future<void>. - Produced (app):
AuthUser({required email, id, bool mustChangePassword = false});AuthRepository.updatePassword({required String newPassword}) → Future<void>.
Steps
-
RED — extend
packages/client_sdk/test/client/supabase_auth_test.dart(usesFakeSupabaseAuth/a fakeGoTrueClient): assertcurrentUser.mustChangePasswordreflectsuser_metadata['must_change_password'] == true, and thatupdatePasswordcalls GoTrueupdateUserwith the new password AND clears the flag. Run → EXPECT FAIL. -
GREEN — in
client_auth.dart, extendAuthAccount:class AuthAccount extends Equatable {const AuthAccount({required this.id,required this.email,this.mustChangePassword = false,});final String id;final String email;/// True when the account was admin-created with a one-time temp password and/// must rotate it before using the app (LOCKED D2). Sourced from GoTrue/// user_metadata.must_change_password. Cleared by [updatePassword].final bool mustChangePassword;@overrideList<Object?> get props => [id, email, mustChangePassword];}Add the abstract verb to
ClientAuth(in the email/password LIVE region):/// Sets a new password for the signed-in user and clears the/// `must_change_password` metadata flag (LOCKED D2 first-login rotation)./// Throws [AuthFailure] on failure.Future<void> updatePassword({required String newPassword}); -
GREEN — in
supabase_auth.dart, read the flag incurrentUserand implementupdatePassword:@overrideAuthAccount? get currentUser {final user = _auth.currentUser;if (user == null) return null;final meta = user.userMetadata ?? const <String, dynamic>{};return AuthAccount(id: user.id,email: user.email ?? '',mustChangePassword: meta['must_change_password'] == true,);}@overrideFuture<void> updatePassword({required String newPassword}) => _wrap(_auth.updateUser(UserAttributes(password: newPassword,data: {'must_change_password': false},)).then((_) {}),);(Also update the
authStateChanges()map to carrymustChangePasswordfromstate.session?.user.userMetadataso a live stream consumer sees it — mirror thecurrentUserread.) -
Add
updatePassword+ the field default tonoop_auth.dart(match its scaffold-throw / no-op convention for the other LIVE methods). -
GREEN (app) — in
auth_repository.dart, extendAuthUser:class AuthUser {const AuthUser({required this.email,this.id,this.mustChangePassword = false,});@JsonKey(includeIfNull: false)final String? id;final String email;/// See [AuthAccount.mustChangePassword]. Excluded from JSON when false to keep/// the devtools serialization contract stable.@JsonKey(defaultValue: false)final bool mustChangePassword;factory AuthUser.fromJson(Map<String, dynamic> json) =>_$AuthUserFromJson(json);Map<String, dynamic> toJson() => _$AuthUserToJson(this);}Add the abstract
updatePasswordtoAuthRepository:/// Sets a new password for the signed-in user (LOCKED D2 rotation) and clears/// the must-change flag. Throws [AuthFailure] on failure.Future<void> updatePassword({required String newPassword});Add an in-memory impl to
InMemoryAuthRepository(clears the local user's flag):@overrideFuture<void> updatePassword({required String newPassword}) async {if (_currentUser == null) return;_currentUser = AuthUser(id: _currentUser!.id, email: _currentUser!.email);_controller.add(_currentUser);} -
GREEN (app) — in
sdk_auth_repository.dart, map the flag + delegate:static AuthUser _toAuthUser(AuthAccount account) => AuthUser(id: account.id,email: account.email,mustChangePassword: account.mustChangePassword,);@overrideFuture<void> updatePassword({required String newPassword}) async {try {await _client.auth.updatePassword(newPassword: newPassword);} on Exception catch (e, st) {_rethrowSafe(e, st);}} -
Regen codegen for the
AuthUser.g.dart:fvm dart run build_runner build --delete-conflicting-outputs --build-filter "app/lib/outside/repositories/auth/auth_repository.g.dart"(scoped build-filter to avoid clobbering hand-maintained generated files). → EXPECT themust_change_passwordkey handled. -
Run:
fvm dart test packages/client_sdk/test/client/supabase_auth_test.dart→ EXPECT PASS.fvm dart analyze packages/client_sdk/lib app/lib/outside/repositories/auth→ EXPECT no issues. -
Commit:
git add packages/client_sdk/lib/src/client/client_auth.dart packages/client_sdk/lib/src/adapters/cloud/supabase_auth.dart packages/client_sdk/lib/src/client/noop_auth.dart app/lib/outside/repositories/auth/auth_repository.dart app/lib/outside/repositories/auth/auth_repository.g.dart app/lib/outside/repositories/auth/sdk_auth_repository.dart packages/client_sdk/test/client/supabase_auth_test.dartgit commit -m "feat: surface mustChangePassword + updatePassword on the auth seam (Path A)"
Task 9 — [Path A · SECURITY-SENSITIVE] First-login must-change-password gate
An authenticated adult whose AuthUser.mustChangePassword == true must be blocked from the app and routed to a set-password screen until they rotate. Two viable anchors — pick per the executor's read of the router:
- (preferred) Extend
AuthenticatedGuard(app/lib/inside/routes/guards/authenticated_guard.dart): the Explore pass confirms the household-resolution branch settles the resolver at L150-154 (resolver.next()once a household is present). Insert the must-change check just before thatresolver.next()— readauthRepository.currentUser; whenmustChangePasswordis true, redirect to the set-password route instead. This reuses the existing single async-resolver flow (no second guard to sequence). - (alt) A dedicated
MustChangePasswordGuardmirroringAdminGuard's async-resolver convention (admin_guard.dart:43-62), attached to the authenticated shell afterAuthenticatedGuard.
Either way the set-password screen calls AuthRepository.updatePassword, which clears the flag; on success it re-resolves the session (replaceAll([MainShellRoute()]), the pattern showJoinWithCodeSheet uses at join_with_code_sheet.dart:52). The plan below writes the dedicated guard for a clean unit test; if extending AuthenticatedGuard instead, move the guard test into app/test/unit/guards/authenticated_guard_test.dart and assert the same redirect.
ROUTE THROUGH security-reviewer BEFORE COMMIT (the gate must fail CLOSED and be un-bypassable).
Files
app/lib/inside/routes/guards/must_change_password_guard.dart(new)app/lib/inside/routes/authenticated/set_password/set_password_page.dart(new)app/lib/inside/blocs/set_password/cubit.dart+state.dart(new)app/lib/inside/routes/router.dart(register the route + attach the guard to the authenticated shell)app/lib/inside/i18n/strings.dart(strings)app/test/unit/guards/must_change_password_guard_test.dart(new)app/test/unit/blocs/set_password_cubit_test.dart(new)
Interfaces
- Consumed:
AuthRepository.currentUser → AuthUser?(.mustChangePassword),AuthRepository.updatePassword({required String newPassword}) → Future<void>.
Steps
-
RED —
app/test/unit/guards/must_change_password_guard_test.dart: a member whosecurrentUser.mustChangePasswordis true is redirected to the set-password route; false → allowed; null user → allowed (the authenticated guard already handles signed-out). Mirror theAdminGuardtest'sNavigationResolverfake. Run → EXPECT FAIL. -
GREEN —
must_change_password_guard.dart(mirrorAdminGuard):import 'package:auto_route/auto_route.dart';import '../../../outside/repositories/auth/auth_repository.dart';import '../router.dart';/// Blocks an authenticated adult who still holds a one-time temp password/// (LOCKED D2) from the app until they rotate it. Reads/// [AuthRepository.currentUser].mustChangePassword; when set, redirects to the/// set-password route. Fails CLOSED to the set-password route on any resolution/// error for a flagged user is not needed — the DEFAULT is allow (a normal user/// must not be trapped), but a resolution error is treated as "not flagged"/// only because the authenticated guard already gates access; the must-change/// signal is authoritative-when-present.class MustChangePasswordGuard extends AutoRouteGuard {MustChangePasswordGuard({required this.authRepository});final AuthRepository authRepository;@overridevoid onNavigation(NavigationResolver resolver, StackRouter router) {final user = authRepository.currentUser;if (user != null && user.mustChangePassword) {// Redirect into the set-password route; block the original push.router.push(const SetPasswordRoute());resolver.next(false);return;}resolver.next(true);}}(Confirm the generated
SetPasswordRoutename after registering the page with auto_route; regen the router.gr.dart.) -
GREEN — set-password cubit (
set_password/cubit.dart+state.dart): a sealed state (SetPasswordIdle/SetPasswordSubmitting/SetPasswordSuccess/SetPasswordFailure) driven bysubmit(newPassword):Future<void> submit(String newPassword) async {final pw = newPassword.trim();if (pw.length < 8) {emit(const SetPasswordFailure(Strings.setPasswordTooShort));return;}emit(const SetPasswordSubmitting());try {await _authRepository.updatePassword(newPassword: pw);emit(const SetPasswordSuccess());} on AuthFailure catch (e) {emit(SetPasswordFailure(e.message));} on Exception {emit(const SetPasswordFailure(Strings.setPasswordGenericError));}} -
GREEN —
set_password_page.dart: a@RoutePage()with a password field + confirm field + submit; onSetPasswordSuccess,context.router.replaceAll(const [MainShellRoute()])(re-resolves the session, now flag-cleared). Add the strings. -
GREEN — register in
router.dart: add theSetPasswordRouteunder the authenticated branch and attachMustChangePasswordGuardto the authenticated shell route (afterAuthenticatedGuard, beforeAdminGuard) so every authenticated entry is gated. WireMustChangePasswordGuard(authRepository: …)from the same repo the other guards resolve from. Regenrouter.gr.dart:fvm dart run build_runner build --delete-conflicting-outputs --build-filter "app/lib/inside/routes/router.gr.dart". -
RED/GREEN —
set_password_cubit_test.dart:submitshort password → failure; valid → submitting then success andupdatePasswordcalled with the value. Run both new test files → EXPECT PASS. -
Run:
fvm flutter analyze app/lib/inside/routes/guards/must_change_password_guard.dart app/lib/inside/blocs/set_password app/lib/inside/routes/authenticated/set_password→ EXPECT no issues. -
Route through
security-reviewer: confirm the gate is attached to the authenticated shell (un-bypassable via deep link), fails closed for a flagged user, and the rotation actually clears the flag beforereplaceAll. -
Commit:
git add app/lib/inside/routes/guards/must_change_password_guard.dart app/lib/inside/routes/authenticated/set_password/set_password_page.dart app/lib/inside/blocs/set_password/cubit.dart app/lib/inside/blocs/set_password/state.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/lib/inside/i18n/strings.dart app/test/unit/guards/must_change_password_guard_test.dart app/test/unit/blocs/set_password_cubit_test.dartgit commit -m "feat: first-login must-change-password gate + set-password screen (Path A, security-sensitive)"
Task 10 — [Path A · UI] Admin "Create adult account" affordance + temp-password sheet
In the members management surface (household/page.dart:206-216 add-member button, role-gated by AdminGuard), add an admin-only "Create adult account" affordance offering coParent/otherAdult (NEVER child). It drives a new bloc handler that calls HouseholdRepository.adminCreateAdult and, on success, surfaces the temp password ONCE via a sheet modeled on co_parent_code_sheet (copy/done). The child add path stays exactly where it is.
Files
app/lib/inside/blocs/household/members_bloc.dart(new event + handler + transient state fields)app/lib/inside/routes/authenticated/members/create_adult_sheet.dart(new — form + temp-password result; lives undermembers/besideco_parent_code_sheet.dart/account_invite_sheet.dart)app/lib/inside/routes/authenticated/household/page.dart(add the affordance beside the add-member button at L206-216)app/lib/inside/i18n/strings.dart(strings)app/test/unit/blocs/members_bloc_admin_create_adult_test.dart(new)
Interfaces
- Consumed:
HouseholdRepository.adminCreateAdult({required actingMemberId, required displayName, required MemberKind kind, String? email}) → Future<({HouseholdMember member, String tempPassword})>. - Produced:
MembersState.adminCreateInFlight: bool,MembersState.adminCreateTempPassword: String?,MembersState.adminCreateAttempt: int,MembersState.adminCreateError: String?(all transient,@JsonKey(includeToJson:false, includeFromJson:false), mirroring the account-invite fields).
Steps
-
RED —
members_bloc_admin_create_adult_test.dart: dispatchingAdminCreateAdultRequested(displayName:'X', kind: coParent)emits inFlight then a state carryingadminCreateTempPassword(from a mock repo returning(member, 'temp-xyz')); achildkind never reaches the repo (guarded in the sheet, but assert the handler rejects it defensively → error state). Run → EXPECT FAIL. -
GREEN — add the transient fields to
MembersState(mirror theaccountInvite*cluster: constructor defaults, declarations with@JsonKey(includeToJson:false, includeFromJson:false),copyWith,props) and the event + handler toMembersBloc:Future<void> _onAdminCreateAdultRequested(AdminCreateAdultRequested event,Emitter<MembersState> emit,) async {if (event.kind == MemberKind.child) {// Defensive: the UI never offers child here, but never let this handler// become a child-creation backdoor (isAdult firewall, in-app layer too).emit(state.copyWith(setAdminCreateError: () => Strings.adminCreateAdultOnly));return;}emit(state.copyWith(adminCreateInFlight: true, setAdminCreateError: () => null));try {final res = await _householdRepository.adminCreateAdult(actingMemberId: state.currentMemberId ?? '',displayName: event.displayName.trim(),kind: event.kind,email: event.email?.trim().isEmpty ?? true ? null : event.email!.trim(),);emit(state.copyWith(adminCreateInFlight: false,adminCreateAttempt: ++_adminCreateAttempt,setAdminCreateTempPassword: () => res.tempPassword,setAdminCreateError: () => null,));} on AdminCreateAdultException catch (e) {emit(state.copyWith(adminCreateInFlight: false,setAdminCreateError: () => e.message));} on Exception catch (e) {emit(state.copyWith(adminCreateInFlight: false,setAdminCreateError: () => e.toString()));}}Register the handler (
on<AdminCreateAdultRequested>(_onAdminCreateAdultRequested);) and add the_adminCreateAttemptcounter field (mirror_coParentInviteAttempt). Add theAdminCreateAdultRequestedevent class (mirrorMemberEditorSaved's style withdisplayName,kind,email?). Run the RED test → EXPECT PASS. -
GREEN —
app/lib/inside/routes/authenticated/members/create_adult_sheet.dart: a modal (viashowDsSheet, matchingjoin_with_code_sheet.dart/co_parent_code_sheet.dart) exposingshowCreateAdultSheet(BuildContext context)— capture theMembersBlocbefore the sheet opens (BlocProvider.value), likeshowCoParentCodeSheetdoes. Body: a name field, a kind segment offering ONLYcoParent/otherAdult, an optional email field, and a submit that dispatchesAdminCreateAdultRequested. ABlocConsumer<MembersBloc, MembersState>baseline-comparesadminCreateAttempt(mirroringmember_editor_sheet.dart's_baselineCoParentAttemptpattern) and, on a newadminCreateTempPassword, swaps to a temp-password result view (copy button + "Done") — modeled onco_parent_code_sheet.dart_CoParentCodeBody(theInviteCodeCardcopy/done pattern). ShowadminCreateErrorinline. -
GREEN — in
household/page.dartnear the add-member button (L206-216), add an admin-only "Create adult account" affordance (visibility gated the same way the surface is — the route is behindAdminGuard; add acanAccessAdmin/capability check if the button is reachable to non-admins) that callsshowCreateAdultSheet(context). Add the strings (adminCreateAdultTitle,adminCreateAdultOnly,adminCreateAdultTempPasswordLabel, etc.). -
Run:
fvm flutter test app/test/unit/blocs/members_bloc_admin_create_adult_test.dart→ EXPECT PASS. -
Run:
fvm dart formaton the touched files +fvm flutter analyze app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household→ EXPECT no issues. -
Commit:
git add app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/routes/authenticated/household/create_adult_sheet.dart app/lib/inside/routes/authenticated/household/page.dart app/lib/inside/i18n/strings.dart app/test/unit/blocs/members_bloc_admin_create_adult_test.dartgit commit -m "feat: admin 'create adult account' UI + temp-password-once sheet (Path A)"
Task 11 — [Path A · SECURITY-SENSITIVE] Live smoke probes on the Edge Function
Append house-style live smoke probes to admin-create-adult/index.ts (the controller runs them after deploy). Prove: an admin CAN create an adult; a NON-admin CANNOT; a CHILD kind CANNOT be created; the created member lands active/{member} with must_change_password metadata; the must-change gate blocks the app until rotated (documented as an app-side probe); and a created invite_events row is logged. Mirror the two-identity + request.jwt.claims self-skipping technique used by the child-freeze/companion probes and the accept_invite probe fixture.
ROUTE THROUGH security-reviewer BEFORE COMMIT (the probes are the executable proof of the DUAL GATE).
Files
infra/supabase/functions/admin-create-adult/index.ts(append the probe comment block)
Steps
-
Append to
admin-create-adult/index.ts:// ── LIVE SMOKE PROBES (controller runs after deploy — house style) ───────────// Fixture: household H with an ACTIVE parent+admin A (auth-linked) and a SECOND// active member B who is NOT an admin (roles={member}, no owner). A THIRD// household H2 with its own admin C. Run each probe as the named identity (the// FunctionsClient attaches that identity's JWT). Clean up minted auth users +// member rows + invite_events afterward (service-role).//// 1. ADMIN OK: as A, POST {household_id:H, display_name:'Co', kind:'co_parent'}// -> EXPECT {ok:true, member_id, auth_user_id, temp_password:<non-empty>}.// Then: household_members has a row (H, kind='co_parent', status='active',// roles={member}, auth_user_id set); the auth user has// user_metadata.must_change_password === true; one invite_events row// kind='created', actor_auth_user_id=A.// 2. NON-ADMIN denied: as B (active but not admin/owner of H), POST the same// -> EXPECT {ok:false, reason:'not_authorized'} (403) and NO auth user /// member row created.// 3. CROSS-HOUSEHOLD denied: as C (admin of H2, NOT of H), POST {household_id:H,// …} -> EXPECT {ok:false, reason:'not_authorized'} (403) — the caller-authz// binds the admin to the TARGET household.// 4. CHILD FIREWALL: as A, POST {household_id:H, display_name:'Kid',// kind:'child'} -> EXPECT {ok:false, reason:'not_adult'} and NO auth user /// member row (the isAdult firewall; children keep child-auth+link_child).// 5. UNAUTH: with no JWT (verify_jwt should already 401 the call). If reached,// getUser returns no user -> EXPECT {ok:false, reason:'not_authorized'} (401).// 6. INVALID INPUT: as A, POST {household_id:H, display_name:'', kind:'co_parent'}// -> EXPECT {ok:false, reason:'invalid_input'} (400).// 7. ROLLBACK (manual/observational): temporarily point member insert at a bad// column to force memberErr -> EXPECT the just-minted auth user is DELETED// (no orphan). Restore after. (Skip in routine runs; documented for audit.)// 8. MUST-CHANGE GATE (app-side probe, not this function): sign in as the// probe-1 adult with its temp_password -> EXPECT the app routes to the// set-password screen and blocks the shell until updatePassword succeeds;// after rotation, user_metadata.must_change_password === false and the app// lands on the authenticated shell.// 9. advisors sweep: security + performance advisors show no NEW findings.// 10. cleanup (service-role): delete minted auth users + member rows +// invite_events 'created' probe rows. -
Route through
security-reviewer. Address CRITICAL/HIGH. -
Commit:
git add infra/supabase/functions/admin-create-adult/index.tsgit commit -m "test: admin-create-adult live smoke probes (dual-gate proof, Path A)"
Task 12 — [docs] Update the auth architecture page + gap ledger
Reflect the new admin-direct-create path and the must-change gate in the hand-curated architecture docs (the auth page is the auth gap ledger per CLAUDE.md).
Files
app/test-gallery/authored/developer/architecture/auth page (the file documenting auth — locate the auth-skeleton page)docs/decisions/2026-06-22-northstar-poc-domain-gaps.md(if the admin-create path closes/adds a listed gap)
Steps
- Add a short section to the auth architecture page: the
admin-create-adultEdge Function (service-role, caller-authz, isAdult firewall, temp-password + must-change metadata), the must-change route guard, and theupdatePassword/mustChangePasswordauth-seam additions. Mark anything not yet deployed as dashed/planned per the doc convention. - Note in the ledger that Path B G5 (join-with-code accept surface) is CONFIRMED built + wired + tested (closing the deep-dive G5 item), and that admin-direct-create is the new adult-onboarding path.
- Run: no test;
fvm dart formatnot applicable to markdown. - Commit:
git add app/test-gallery/authored/developer/architecture/<auth-page>.md docs/decisions/2026-06-22-northstar-poc-domain-gaps.mdgit commit -m "docs: admin-direct-create + must-change gate in auth architecture + gap ledger"
Task 13 — [wrap] Full-suite baseline verification + graphify update
Steps
- Run the full app suite:
fvm flutter test(inapp/) → EXPECT test count ≥ 704 (baseline must not DROP; new tests raise it) andAll tests passed!. - Run the SDK suite:
fvm dart test(inpackages/client_sdk/) → EXPECT ≥ 1128 and all passing. - Run the design-system suite:
fvm flutter test(inpackages/design_system/) → EXPECT ≥ 287 and all passing (should be untouched). - Run
fvm dart format --set-exit-if-changed .at the workspace root andfvm flutter analyze→ EXPECT clean. - Run
graphify update .at the repo root → EXPECT the AST graph refreshed (no API cost). - Verify staging hygiene:
git statusshows NOgraphify-out/,.superpowers/, or.claude/staged; the committedgraph.json/GRAPH_REPORT.mddeltas fromgraphify updateare the only graphify changes to stage. - Commit the graph refresh:
git add graphify-out/graph.json graphify-out/GRAPH_REPORT.mdgit commit -m "chore: graphify update after add-an-adult-to-household"
Self-review — spec coverage matrix
| Design-doc element | Task(s) |
|---|---|
| LOCKED D1 (temp password shown once) | 3 (genTempPassword + return), 4 (tempPassword through port), 10 (shown-once sheet) |
| LOCKED D2 (force first-login rotation) | 3 (must_change metadata), 8 (surface flag + updatePassword), 9 (gate + set-password screen) |
| LOCKED D3 (BOTH paths) — Path A | 3–11 |
| LOCKED D3 — Path B feedback fix | 1 |
| LOCKED D3 — Path B G5 accept surface | Deviation D-1 (already built + wired + tested; Task 1 adds a regression guard) |
LOCKED D4 (audit created) | 2 (migration + enum), 3 (audit insert), 5/7 (local twin logs it) |
| isAdult firewall (no child either path) | 3 (Edge Fn), 5 (service), 7 (tests), 10 (UI + defensive handler) |
| Adults → active/{member}, no pendingConsent | 3, 5 |
| DUAL GATE (service authz AND RLS) | 3 (caller-authz + service-role write bypasses RLS by design), 11 (probes 2/3 prove it) |
| SECURITY-SENSITIVE routing | 3, 7, 9, 11 (security-reviewer) |
| PKCE-on-boot assumed built | Task 1/9 rely on it (runner.dart:133 already calls completeAuthFromUrl) — assumed per constraint |
| Suite baselines / graphify update / staging hygiene | 13 |
Placeholder scan: no "TBD"/"similar to Task N" — every code block is repeated in full. Type consistency: AdminCreateAdultException/AdminCreateAdultRejectionReason (Task 5) are consumed identically in the port mapper (Task 4), service (Task 5), tests (Task 7), and UI handler (Task 10); the ({String memberId, String authUserId, String tempPassword}) record shape is identical across port (Task 4), and the ({HouseholdMember member, String tempPassword}) shape is identical across service/facade/repo (Tasks 5/6) and the bloc handler (Task 10); mustChangePassword flows AuthAccount→AuthUser consistently (Task 8) into the guard (Task 9).