diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c7ff7a7 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,33 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-development", + "runtimeExecutable": "flutter", + "runtimeArgs": [ + "run", + "-d", + "chrome", + "--web-port", + "5959", + "--target", + "lib/main_development.dart" + ], + "port": 5959 + }, + { + "name": "design-preview", + "runtimeExecutable": "flutter", + "runtimeArgs": [ + "run", + "-d", + "chrome", + "--web-port", + "5960", + "--target", + "lib/main_design_preview.dart" + ], + "port": 5960 + } + ] +} diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..e679891 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "magic-yeti" + } +} diff --git a/README.md b/README.md index 67aade4..940fb71 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,48 @@ flutter gen-l10n --arb-dir="lib/l10n/arb" Alternatively, run `flutter run` and code generation will take place automatically. +--- + +## Firebase Backend πŸ”₯ + +Magic Yeti's backend lives alongside the Flutter app in this repo: + +- `functions/` β€” TypeScript Cloud Functions (e.g. `validatePin`, a callable that + checks a user's PIN server-side against their salted private credentials). +- `firestore.rules` β€” Firestore security rules governing direct client reads + and writes (friends, requests, profiles, private credentials, etc.). + +### Running backend tests + +```sh +# Build and unit test the Cloud Functions +cd functions && npm run build && npm test && cd .. + +# Run the firebase_database_repository package tests +cd packages/firebase_database_repository && flutter test && cd .. + +# Run Firestore security rules tests against the emulator +firebase emulators:exec --only firestore "npm --prefix functions run test:rules" +``` + +If the globally-installed `firebase` CLI has trouble with `emulators:exec`, pin +a known-good version instead: + +```sh +npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules" +``` + +### Deploying + +```sh +firebase deploy --only firestore:rules,functions +``` + +Deploys are run manually by a maintainer β€” see +`docs/superpowers/plans/2026-07-03-friends-INDEX.md` for the pre-deploy gate +(diffing versioned rules against the console's current production rules +before the first deploy). + [coverage_badge]: coverage_badge.svg [flutter_localizations_link]: https://api.flutter.dev/flutter/flutter_localizations/flutter_localizations-library.html [internationalization_link]: https://flutter.dev/docs/development/accessibility-and-localization/internationalization diff --git a/docs/friends_feature_plan.md b/docs/friends_feature_plan.md index 59ca292..4a8467f 100644 --- a/docs/friends_feature_plan.md +++ b/docs/friends_feature_plan.md @@ -1,384 +1,86 @@ # Friends List Feature β€” Implementation Plan -## Overview - -Enable authenticated users to add friends via a short friend code, manage friend requests, and select friends as players when setting up a game. When a game ends, match history syncs to every authenticated player's profile β€” not just the host's. - ---- - -## Current State - -### What Exists -- **Friend models:** `FriendModel`, `FriendRequestModel` in `firebase_database_repository` -- **Friend BLoCs:** `SearchBloc`, `FriendBloc`, `FriendRequestBloc` -- **Friend UI:** `FriendsListPage` (friends tab + requests tab), `SearchUserPage`, `FriendRequestsPage` -- **Firebase methods:** `addFriendRequest`, `acceptFriendRequest`, `declineFriendRequest`, `removeFriend`, `getFriends`, `getFriendRequests`, `searchUsers` -- **Onboarding:** Page exists with username/firstName/lastName/bio fields, but the `isNewUser` β†’ `onboardingRequired` routing isn't wired up in `AppBloc` - -### What's Missing -- Friend code (short unique ID) β€” search is currently by username/email -- 4-digit numeric PIN per user -- Friend selection on `CustomizePlayerPage` -- Post-game sync to all authenticated players' match histories -- Onboarding trigger from `AppBloc` (the `onboardingRequired` status exists but isn't emitted) - ---- - -## Data Model Changes - -### 1. `UserProfileModel` β€” Add Fields - -**File:** `packages/firebase_database_repository/lib/models/user_profile_model.dart` - -```dart -class UserProfileModel { - // existing fields... - final String? friendCode; // e.g. "YETI-A3F9" β€” generated once, immutable - final String? pin; // 4-digit numeric string, e.g. "0742" -} -``` - -**Firebase path:** `users/{userId}/friendCode`, `users/{userId}/pin` - -**Friend code format:** `YETI-XXXX` where X is alphanumeric uppercase (A-Z, 0-9). This gives 36^4 = ~1.7M combinations. Generated at account creation, stored in Firestore, indexed for lookup. - -### 2. `Player` Model β€” No Structural Changes - -The existing `firebaseId` field on `Player` already handles linking a player slot to an authenticated user. No changes needed. - -### 3. Firebase Indexes - -Add a Firestore index on `users.friendCode` for efficient friend code lookup. - ---- - -## Implementation Phases - -### Phase 1: Friend Code & PIN Infrastructure - -#### 1.1 Update `UserProfileModel` -- Add `friendCode` and `pin` fields -- Update `fromJson`/`toJson` serialization -- Run `build_runner` for codegen if using `json_serializable` - -#### 1.2 Friend Code Generation -**File:** `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` - -Add method: -```dart -Future generateUniqueFriendCode() async { - // Generate random "YETI-XXXX" code - // Query Firestore to ensure uniqueness - // Return the unique code -} -``` - -Called once during onboarding/profile setup. The code is permanent and never changes. - -#### 1.3 Search by Friend Code -**File:** `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` - -Add/replace method: -```dart -Future searchByFriendCode(String code) async { - // Query users collection where friendCode == code - // Return matching user profile or null -} -``` - -Update the existing `SearchBloc` to use friend code search instead of (or in addition to) username/email search. - -#### 1.4 PIN Storage -- PIN is set during onboarding (Phase 2) -- Stored as a field on the user profile in Firestore -- PIN is hashed before storage (use a simple hash β€” this isn't high-security, it's game identity verification) -- Validation method: `Future validatePin(String userId, String pin)` - -> **Security note:** The PIN is a convenience check ("is this really you?"), not a security boundary. Hashing is good practice but the threat model is low β€” these are friends playing a card game together on one device. - ---- - -### Phase 2: Onboarding Updates - -#### 2.1 Fix Onboarding Trigger in `AppBloc` -**File:** `lib/app/bloc/app_bloc.dart` - -In `_onUserChanged`, add logic: -```dart -if (event.user != User.anonymous && event.user.isNewUser) { - emit(state.copyWith(status: AppStatus.onboardingRequired)); - return; -} -``` - -This ensures new users hit the onboarding flow before reaching the home page. - -#### 2.2 Add PIN Field to Onboarding Form -**File:** `lib/onboarding/view/onboarding_form.dart` - -Add a 4-digit numeric PIN input field: -- Numeric keyboard only -- 4 digit max length -- Required field (must be set to complete onboarding) -- Show explanation: "This PIN confirms your identity when friends add you to a game" - -#### 2.3 Update `OnboardingBloc` -**File:** `lib/onboarding/bloc/onboarding_bloc.dart` - -- Add `OnboardingPinChanged` event -- Add `pin` field to state -- On `OnboardingSubmitted`: - 1. Generate friend code via `generateUniqueFriendCode()` - 2. Hash the PIN - 3. Save profile with friendCode + hashedPin - 4. Set `isNewUser = false` - -#### 2.4 Add PIN to Profile Page -**File:** `lib/profile/view/profile_page.dart` - -- Display the user's friend code prominently (read-only, with copy button) -- Allow PIN change (enter current PIN to set new PIN) -- Show friend code in a shareable format - -#### 2.5 Add `Pin` Form Input -**File:** `packages/form_inputs/lib/src/pin.dart` - -Create a `Pin` formz input class: -- Valid: exactly 4 numeric digits -- Invalid: empty, non-numeric, wrong length - ---- - -### Phase 3: Friend Search & Request Flow Updates - -#### 3.1 Update Search UI -**File:** `lib/friends_list/search_user/search_user_page.dart` - -Replace the current search with friend-code-based search: -- Single text field with placeholder "Enter friend code (e.g. YETI-A3F9)" -- Format the input automatically (uppercase, add dash) -- Show matched user profile with "Add Friend" button -- Show "No user found" if code doesn't match - -#### 3.2 Update `SearchBloc` -**File:** `lib/friends_list/search_user/bloc/search_bloc.dart` - -- Replace `searchUsers(query)` with `searchByFriendCode(code)` -- Keep the request flow: `addFriendRequest(senderId, senderName, receiverId)` - -#### 3.3 Friend Request Flow (Unchanged) -The existing accept/decline/pending flow works as-is. No changes needed to: -- `FriendRequestBloc` -- `FriendRequestsPage` -- `FriendModel` / `FriendRequestModel` -- `addFriendRequest` / `acceptFriendRequest` / `declineFriendRequest` - ---- - -### Phase 4: Friend Selection on Customize Player Page - -This is the core UX change β€” letting users pick a friend as a player. - -#### 4.1 Update `CustomizePlayerPage` UI -**File:** `lib/player/view/customize_player_page.dart` - -Add a "Select Friend" section above or alongside the name input: - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ [Select Friend] or [Type Name] β”‚ ← toggle/tabs -β”‚ β”‚ -β”‚ If "Select Friend": β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Friend A β”‚ ← tap to select β”‚ -β”‚ β”‚ Friend B β”‚ β”‚ -β”‚ β”‚ Friend C β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ [Enter 4-digit PIN: ____] β”‚ ← appears after selection -β”‚ β”‚ -β”‚ Commander: [search/select] β”‚ -β”‚ Partner: [toggle + search] β”‚ -β”‚ [Save] β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` - -**Behavior:** -- Show list of accepted friends (from `FriendBloc`) -- Tapping a friend auto-populates the name field (read-only when friend selected) -- PIN dialog appears β€” must enter correct PIN to confirm -- Commander/partner selection remains manual -- On save: `firebaseId` is set to the friend's Firebase UID -- A "Clear" button to deselect friend and go back to manual name entry - -#### 4.2 Update `PlayerCustomizationBloc` -**File:** `lib/player/view/bloc/player_customization_bloc.dart` - -Add events/state: -- `SelectFriendEvent(FriendModel friend)` β€” sets selected friend -- `ClearFriendEvent` β€” clears friend selection -- `ValidatePinEvent(String pin)` β€” validates PIN against friend's stored hash -- State additions: `selectedFriend`, `pinValidated`, `pinError` - -#### 4.3 PIN Validation Flow -When a friend is selected: -1. Show PIN input dialog/field -2. Call `firebase_database_repository.validatePin(friendUserId, enteredPin)` -3. If valid: lock in the friend, set `firebaseId`, auto-fill name -4. If invalid: show error, allow retry -5. User can still cancel and type a name manually instead - -#### 4.4 Update `PlayerBloc.UpdatePlayerInfoEvent` -**File:** `lib/player/bloc/player_bloc.dart` - -The existing event already accepts `firebaseId`. Ensure it passes through correctly when a friend is selected. No structural changes needed β€” just verify the data flow. - ---- - -### Phase 5: Post-Game Sync - -This is the key data flow change β€” fan out game results to all authenticated players. - -#### 5.1 Update `GameBloc` Game Finish Flow -**File:** `lib/game/bloc/game_bloc.dart` - -Current flow in `_onGameFinish`: -1. Create `GameModel` -2. Call `saveGameStats(gameModel)` β€” saves to `games/` collection -3. Save to `users/{hostId}/matches/{gameId}` - -New flow: -1. Create `GameModel` (unchanged) -2. Call `saveGameStats(gameModel)` (unchanged) -3. **For each player with a non-null `firebaseId`:** - - Save to `users/{player.firebaseId}/matches/{gameId}` - - This includes the host AND any friends linked to player slots - -#### 5.2 Update `FirebaseDatabaseRepository.saveGameStats` -**File:** `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` - -Modify to accept a list of user IDs to sync to: -```dart -Future saveGameStats(GameModel game, {List playerFirebaseIds = const []}) async { - // Save to games/ collection (existing) - // Save to users/{id}/matches/ for EACH id in playerFirebaseIds -} -``` - -Or add a separate method: -```dart -Future syncGameToPlayers(GameModel game, List firebaseIds) async { - for (final id in firebaseIds) { - await _firestore.collection('users').doc(id).collection('matches').doc(game.id).set(game.toJson()); - } -} -``` - -#### 5.3 Edge Cases -- **Host is always synced** (existing behavior, unchanged) -- **String-only players** (no `firebaseId`) are skipped β€” no sync target -- **Duplicate firebaseIds** (same user in two slots?) β€” deduplicate before syncing -- **Game save failure** for one player shouldn't block others β€” use `Future.wait` with error handling per player - ---- - -### Phase 6: Stats Integration - -#### 6.1 Update Stats Queries -**File:** `lib/stats_overview/stats_overview_bloc/stats_overview_bloc.dart` - -Currently stats are calculated from the host's match history. Since friends now have their own match history copies, their stats pages will automatically work β€” no changes needed to the stats calculation logic itself. - -#### 6.2 Friend Stats View (Future Enhancement) -This is out of scope for the initial implementation but worth noting: eventually you could view a friend's stats from your friends list. This would require reading `users/{friendId}/matches/` β€” which is the same data structure already used for the current user's stats. - ---- - -## Firebase Database Schema (Updated) - -``` -Firestore Root: -β”œβ”€β”€ users/ -β”‚ └── {userId}/ -β”‚ β”œβ”€β”€ username: "josh" -β”‚ β”œβ”€β”€ email: "josh@example.com" -β”‚ β”œβ”€β”€ firstName: "Josh" -β”‚ β”œβ”€β”€ lastName: "S" -β”‚ β”œβ”€β”€ bio: "..." -β”‚ β”œβ”€β”€ imageUrl: "..." -β”‚ β”œβ”€β”€ isNewUser: false -β”‚ β”œβ”€β”€ isAnonymous: false -β”‚ β”œβ”€β”€ friendCode: "YETI-A3F9" ← NEW -β”‚ β”œβ”€β”€ pin: "hashed_pin_value" ← NEW -β”‚ β”œβ”€β”€ matches/ (subcollection) -β”‚ β”‚ └── {gameId}/ (GameModel) -β”‚ └── friends/ (subcollection or field) -β”‚ -β”œβ”€β”€ games/ -β”‚ └── {docId}/ (GameModel) -β”‚ -β”œβ”€β”€ friends/ -β”‚ └── {userId}/ -β”‚ └── {friendId}/ (FriendModel) -β”‚ -└── friendRequests/ - └── {requestId}/ - β”œβ”€β”€ senderId, receiverId, senderName - β”œβ”€β”€ status: "pending" - └── timestamp -``` - ---- - -## File Change Summary - -| File | Change | -|------|--------| -| `packages/firebase_database_repository/lib/models/user_profile_model.dart` | Add `friendCode`, `pin` fields | -| `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` | Add `generateUniqueFriendCode`, `searchByFriendCode`, `validatePin`, update `saveGameStats` | -| `packages/form_inputs/lib/src/pin.dart` | **New file** β€” `Pin` formz input | -| `lib/app/bloc/app_bloc.dart` | Wire up `onboardingRequired` status for new users | -| `lib/onboarding/view/onboarding_form.dart` | Add PIN input field | -| `lib/onboarding/bloc/onboarding_bloc.dart` | Add PIN event/state, generate friend code on submit | -| `lib/profile/view/profile_page.dart` | Display friend code, allow PIN change | -| `lib/profile/bloc/profile_bloc.dart` | Add PIN change logic | -| `lib/friends_list/search_user/search_user_page.dart` | Switch to friend code search UI | -| `lib/friends_list/search_user/bloc/search_bloc.dart` | Use `searchByFriendCode` instead of `searchUsers` | -| `lib/player/view/customize_player_page.dart` | Add friend selection UI with PIN verification | -| `lib/player/view/bloc/player_customization_bloc.dart` | Add friend selection + PIN validation events/state | -| `lib/game/bloc/game_bloc.dart` | Fan out game results to all players with `firebaseId` | -| `lib/l10n/arb/app_en.arb` | Add localization strings for new UI elements | -| `lib/l10n/arb/app_es.arb` | Spanish translations | - ---- - -## Implementation Order - -``` -Phase 1: Friend Code & PIN Infrastructure (data layer) - └─ Phase 2: Onboarding Updates (first user touchpoint) - └─ Phase 3: Friend Search Updates (discovery) - └─ Phase 4: Player Selection (core feature) - └─ Phase 5: Post-Game Sync (data flow) - └─ Phase 6: Stats (verification & polish) -``` - -Each phase is independently testable. Phase 1-2 can ship without 3-6, giving existing users friend codes early. - ---- - -## Testing Strategy - -- **Unit tests:** BLoC tests for each new/modified bloc (onboarding, player customization, game finish sync) -- **Repository tests:** Mock Firestore for `generateUniqueFriendCode`, `searchByFriendCode`, `validatePin`, `syncGameToPlayers` -- **Widget tests:** Customize player page with friend selection, onboarding form with PIN -- **Integration:** Full flow β€” sign up β†’ onboarding (get code + PIN) β†’ add friend β†’ create game β†’ select friend β†’ play β†’ game over β†’ verify both players have match in history - ---- - -## Decisions - -1. **Friend code format:** `YETI-XXXX` (alphanumeric uppercase, 36^4 = ~1.7M combinations) -2. **PIN hashing:** SHA-256 β€” sufficient for a game identity check, not a password -3. **Existing users without friend codes:** Generate code automatically on launch, prompt for PIN setup via a banner/dialog -4. **Friend code in `FriendModel`:** Store it on the model β€” it's immutable once generated, avoids extra reads +This document describes the friends feature as it ships today. For the design +rationale and phased build-out, see +[`docs/superpowers/specs/2026-07-03-friends-feature-design.md`](superpowers/specs/2026-07-03-friends-feature-design.md). +For plan-by-plan status and deploy gates, see +[`docs/superpowers/plans/2026-07-03-friends-INDEX.md`](superpowers/plans/2026-07-03-friends-INDEX.md). + +## What it does + +- **Friend codes.** Every user gets a unique `YETI-XXXX` code at onboarding. + `searchByFriendCode` is a block-aware callable β€” it returns "not found" if + either party has blocked the other, otherwise a public profile summary plus + relationship status. +- **Friend requests.** Requests use deterministic ids + (`friendRequests/{senderId}_{receiverId}`), which lets Firestore rules + enforce the request lifecycle without queries. A mutual request auto-accepts. + Declining sets `status: declined` and **retains** the doc so a sender can't + silently re-send β€” the client checks for a prior declined doc and no-ops + (UI still shows "sent"). Accept/remove are client batch writes gated by + rules on the deterministic path. +- **Blocking.** Blocking removes any existing friendship (both edges deleted), + clears pending requests in both directions, and hides the blocked user from + friend-code search both ways. The block record itself is one-directional + (owned by the blocker) and is the actual security boundary; the edge/request + cleanup is a single atomic batch commit β€” it either fully applies or fully + fails, so there's no partial-cleanup state to self-heal from. +- **PIN-gated player linking.** Selecting a friend as a player requires their + 4-digit PIN on every link β€” there's no trusted-device state. PINs are salted + SHA-256 hashes stored in a private, owner-only `credentials` subdocument; + only the `validatePin` callable (Admin SDK) can read them. The callable + requires the caller to already be friends with the target, and enforces + **5 failed attempts β†’ 15-minute lockout** per callerβ†’target pair. Legacy + (pre-migration) accounts fall back to the old unsalted-hash `pin` field + via the same callable until login-time migration moves it. +- **Server-side game fan-out.** Match history sync to linked players is a + Firestore trigger (`onGameCreated`), not a client write β€” the client no + longer writes cross-user `matches` docs, and rules deny it outright. The + trigger reads each player's `firebaseId`, dedupes, and copies the game into + every linked user's match history (host included), keyed by game id so it's + idempotent. +- **Game-code import for non-friends.** Players who aren't friends (or don't + want to link) can still share a game's short room code; the other person + enters it to pull the game into their own match history via a direct, + owner-only write. No friendship or rules changes needed for this path. +- **Legacy-user onboarding gate.** Any account where the profile is missing or + incomplete (`username` empty, no PIN, or `onboardingComplete` false) is + routed back into the same 4-step onboarding wizard, pre-filled, to complete + only what's missing. Anonymous sessions bypass the gate; offline launches + fail open to `authenticated` so the gate can't lock out a user who's offline. +- **Account-deletion cleanup.** Deleting an account triggers server-side + cleanup (`onUserDeleted`) of the user's own data and social-graph + references β€” their profile, friend list, friendship edges pointing at them, + friend requests, and blocks of them. Games and other players' match + histories persist untouched. + +## Security model + +- **Private, salted PIN hashes behind a friends-only, rate-limited callable.** + PIN hashes live in an owner-only, function-readable subdocument; the + `validatePin` callable is the only path to compare a submission, requires + caller/target to already be friends, and locks out after 5 failures for + 15 minutes. +- **Firestore rules enforce the entire social graph.** Friend requests, + friendships, and blocks are all governed by rules using deterministic + document ids β€” the client can't create, accept, or bypass these states + outside what rules allow. +- **Blocking is bidirectional in effect.** A block removes the friendship both + ways, hides both users from each other's search, and is designed to fail + closed; the underlying block doc is one-directional but the enforced + behavior isn't. +- **Cross-user writes only happen via trigger, never the client.** Match + history fan-out to linked players is written exclusively by the + `onGameCreated` Cloud Function; rules deny any client-side cross-user + `matches` write. +- **Accepted residual risks:** + - *Users-list wire read:* the `users` collection remains list-readable to + any signed-in user at the Firestore wire level, so a raw-SDK client could + still find a blocked user by querying `friendCode` directly, even though + the official app's callable hides them. Request-create denial and edge + gating still hold regardless. + - *Block-status wire leak:* a blocked user's client sees "request sent" or + "not found" rather than an explicit block message, but a rules denial + still surfaces as a raw `permission-denied` at the wire level β€” a + technically savvy blocked user could infer they've been blocked by + inspecting error codes. Accepted at this threat model. diff --git a/docs/superpowers/plans/2026-07-03-friends-INDEX.md b/docs/superpowers/plans/2026-07-03-friends-INDEX.md new file mode 100644 index 0000000..b3b7bb4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-friends-INDEX.md @@ -0,0 +1,209 @@ +# Friends Feature β€” Plan Index + +Spec: docs/superpowers/specs/2026-07-03-friends-feature-design.md +Branch: feat/friends-hardening + +| Plan | Scope (spec phases) | Status | +|---|---|---| +| A `2026-07-03-friends-a-backend-foundation.md` | Functions + rules + private PIN (1) | complete | +| B `2026-07-03-friends-b-gate-and-sync.md` | Legacy gate + game fan-out (2–3) | complete | +| C `2026-07-03-friends-c-social-graph-blocking.md` | Social graph rules + blocking (4–5) | complete | +| D `2026-07-03-friends-d-profile-cleanup.md` | Profile page + cleanup (6–7) | complete | + +**POST-DEPLOY GOTCHA (discovered 2026-07-05, first real production deploy):** if a +multi-function deploy partially fails (one function's build breaks, aborting the +whole operation), any 2nd-gen callable functions that DID finish creating their +underlying Cloud Run service can be left without the public-invoker IAM binding β€” +they "exist" and every subsequent deploy sees no source change and treats them as +an update (skipping re-applying that binding), so they silently reject ALL traffic +forever with a raw Google-frontend 403 HTML page instead of Firebase's normal JSON +error. This is indistinguishable from a working deploy in `firebase deploy` output. +**Verify any 2nd-gen callable after deploying** with: +`curl -X POST -d '{"data":{}}'` β€” expect +`{"error":{"status":"UNAUTHENTICATED",...}}` (HTTP 401). A raw HTML "403 Forbidden" +page means the fix is `firebase functions:delete --force` followed by a +fresh deploy, forcing a genuine create instead of an update. + +**NATIVE CRASH ON FRIEND SEARCH (found 2026-07-05, root-caused via Console.app crash +report):** the IAM gotcha above was real but was NOT the cause of the on-device +friend-search freeze/crash reported the same day β€” that symptom is a **native +SIGABRT inside the FirebaseFunctions/FirebaseAuth iOS SDKs themselves**, unrelated +to any app or Cloud Functions code. Root cause: Swift 6.3 (shipped in Xcode 26.4) +has a compiler regression that miscompiles `async let` teardown in optimized +builds, corrupting the Swift Concurrency runtime's task-local stack the moment +`HTTPSCallable.call()` (used by `searchByFriendCode`) tears down its internal +concurrent auth/App Check token fetches β€” same pattern affects any `FirebaseAuth` +call. Upstream: firebase/firebase-ios-sdk#15974, fixed via PR #15991 (Task-based +rewrite replacing the `async let`), targeted for firebase-ios-sdk 12.12.0 β€” far +ahead of this repo's currently pinned 11.8.0. Confirmed match: this machine runs +exactly Xcode 26.4 / Swift 6.3. +Fix applied in `ios/Podfile`'s `post_install` hook: force +`SWIFT_OPTIMIZATION_LEVEL = -Onone` on the `FirebaseFunctions` and `FirebaseAuth` +pod targets (all configs), sidestepping the optimizer bug regardless of which +Xcode build configuration CocoaPods compiles them under. This is a toolchain +workaround, not an app code change β€” remove it once the podspecs are bumped to a +firebase-ios-sdk release containing #15991 (12.12.0+), which requires bumping the +FlutterFire plugin majors (`cloud_functions`, `firebase_auth`, `firebase_core`, +etc.) since this repo currently pins `firebase_core: ^3.8.1` β†’ SDK 11.8.0. + +**DEPLOY GATE:** before the first `firebase deploy --only firestore:rules`, export +the project's CURRENT production rules from the Firebase console and diff them +against `firestore.rules` β€” the console rules were never versioned and may contain +grants this repo doesn't know about. Deployment is run by Josh, not by an agent. + +`firebase deploy --only functions` MUST happen before any app release built from +this branch reaches users. The client's PIN validation now calls the `validatePin` +callable exclusively (there is no client-side fallback) β€” without the function +deployed, every link attempt fails with an "unavailable" error, not a graceful +degrade. + +Pre-update app versions validate PINs by reading the profile's legacy `pin` field +directly (there was no callable before this feature). Login-time migration +(`migrateLegacyPin`) deletes that field once the account signs in on an +up-to-date client. Practically: once a friend's account migrates, hosts still +running an old app version will read the (now-deleted) legacy field as empty and +report "Incorrect PIN" even when the PIN is right. This is a **named, accepted +breakage** β€” to be paired with the existing force-upgrade mechanism when Plan B +ships, so old clients are pushed to update before they can hit this path. + +**Resolved in Plan B:** +- `hasPin` self-healing β€” `migrateLegacyPin` now repairs a wiped flag when the + private credentials doc exists. +- `PinNotSet` result variant β€” `failed-precondition` surfaces distinct "friend + has no PIN" copy instead of "check your connection". +- TRANSITIONAL strategy header added to `firestore.rules`. +- Completeness gate: `AppBloc` routes on `UserProfileModel.isComplete` + (username + PIN + onboardingComplete); legacy users re-enter the pre-filled + onboarding wizard. +- Game fan-out is fully server-side (`onGameCreated` trigger); cross-user + `matches` writes are now DENIED by rules; the game-over `firebaseId` + overwrite bug is fixed (guard + UI exclusion + "I'm not playing" option). + +**Still open (deploy-time policy, Josh's call):** the force-upgrade decision β€” +the maintenance/force-upgrade mechanism exists via `app_config_repository`; +whether to trip it for this release (pairing with the rules+functions deploy so +old clients can't hit the migrated-PIN and denied-fan-out paths) is decided at +release time, not in code. + +**DEPLOY GATE (updated for Plan B):** the Plan B rules tightening (cross-user +`matches` writes denied) and the app's removal of client-side fan-out MUST +deploy together with the `onGameCreated` function: deploying rules without the +function (or shipping the app without deploying either) silently stops ALL +match-history sync β€” including the host's own copy (the client no longer writes +any matches doc at game end); game saves would succeed while nobody receives +copies. +Order: `firebase deploy --only functions` β†’ `firebase deploy --only +firestore:rules` β†’ app release. + +**Resolved in Plan C:** +- Trigger injection closed: `games` create requires `hostId == request.auth.uid`; + `onGameCreated` rejects path-hostile ids; malformed-input tests added. + (Friendship-gated fan-out was considered and NOT adopted: hostId is now + authenticated, players are chosen on the host's device, and gating on edges + would break the guest/game-code flow β€” accepted residual: a host can list a + linked friend who later unfriends them; the game still syncs, which matches + the "players in the game get the game" product rule.) +- Deterministic friendRequests ids (`{sender}_{receiver}`), declined docs + retained as permanent suppression markers (pending-only deletes β€” the + delete-and-recreate dodge is rules-blocked), block-gated creates, edge + writes gated on pending requests with `userId == doc key` integrity. +- Full blocking: owner-managed `users/{uid}/blocks`, block-aware + `searchByFriendCode` callable (block-hiding both directions, fail-closed + friend-edge direction), client batch block/unblock, blocked-users screen, + friends-list block action. + Accepted residual: search block-hiding binds the official client only β€” + the `users` collection remains list-readable to any signed-in user at the + wire level, so a raw-SDK user can still find a blocker by friendCode + query. Request-create denial and edge gating still hold. Plan D + candidate: restrict users `list` (requires moving + generateUniqueFriendCode's uniqueness query server-side). +- **Legacy data note (Josh, deploy-time):** pending requests created before + this deploy (random doc ids) can be DECLINED but not accepted (accept shows + "sent from an older version β€” ask them to re-send"). Optional one-time + cleanup: delete `friendRequests` docs where the doc id doesn't match + `{senderId}_{receiverId}` β€” or just let them drain via decline. + +**Resolved in Plan D:** +- `onUserDeleted` auth trigger cleans the deleted user's doc tree, friendship + edges both directions, requests (any status), and blocks of them β€” games and + other players' match copies persist. Accepted residual: v1 auth triggers + don't retry, so a mid-flight crash can orphan a few edges/requests β€” they + fail closed (every rules guard on the missing uid denies) and are cosmetic. + +**DEPLOY GATE (updated for Plan D review):** deploy order now starts with +`firebase deploy --only firestore:indexes` β€” before functions. The +`onUserDeleted` collection-group queries (`collectionGroup('friendList')`, +`collectionGroup('blocks')`, both filtered on `userId`) hard-require the +COLLECTION_GROUP field overrides in `firestore.indexes.json`; Firestore's +automatic indexes are collection-scope only and do not cover them. **The +emulator cannot validate this** β€” it does not enforce indexes, so +`test:rules` (and any emulator-based test) will pass even if the production +indexes are missing or still building, and cleanup steps 3-6 of +`onUserDeleted` will silently throw `FAILED_PRECONDITION` in production. +Index builds on existing data take time, so deploy indexes first and wait +for the build to reach READY before deploying functions. +BEFORE the first index deploy: export current production indexes +(`firebase firestore:indexes`) and merge them into `firestore.indexes.json` +β€” the file is declarative and ships `"indexes": []`, so the CLI will offer +to DELETE any console-created composite indexes it doesn't know about +(same never-versioned risk as the rules gate above). Note also that the +fieldOverrides drop Firestore's automatic DESCENDING/array-contains +single-field indexes on `friendList.userId` and `blocks.userId`; nothing +queries those today, but future queries on those fields need the file +extended. +Order: `firebase deploy --only firestore:indexes` β†’ `firebase deploy --only +functions` β†’ `firebase deploy --only firestore:rules` β†’ app release. +- Game-over debt: `GameOverState.props` fixed; save failures now block + navigation and surface a snackbar with retry (buttons disabled while + saving, double-submit guarded by a status check β€” a `droppable()` + transformer was tried and reverted: its cancellation semantics hang + `bloc.close()`); disowned self-linked slots unlink on save; dropdown keyed; + label overflow fixed. +- Profile page rebuilt on `UserProfileModel`: real submit that carries + pin/hasPin/friendCode through the full-doc set (Fix-2-class regression + impossible by construction), PIN change with no old-PIN prompt, friend-code + copy (share deferred β€” `share_plus` not a dependency), email read-only. +- `includeIfNull: false` adopted on all five firebase_database_repository + models β€” CLAUDE.md's stated convention is now true for this package. +- Search-card accept honesty (reverse-pending wins over declined + suppression); anonymous users get intentional sign-in copy on the friend + section and search page; `removeFriend` edge deletes are atomic. +- l10n sweep of branch-introduced strings; `docs/friends_feature_plan.md` + rewritten as the as-shipped feature README. + +**Open follow-ups (post-branch):** +- Restrict `users` collection `list` reads (requires moving + `generateUniqueFriendCode`'s uniqueness query server-side) β€” closes the + raw-SDK friendCode-search bypass of block hiding. +- `search_user_page` renders raw exception text on search errors + (pre-existing pattern). +- The force-upgrade deploy decision (see deploy gates above). +- Distinct "username can't be empty" copy on the profile submit gate + (currently the generic save-failed snackbar). +- Refresh `state.profile` after a PIN save on the profile page (closes a + narrow stale-profile window when login migration failed and the user + changes PIN then saves profile fields in one session). +- CI is red on main for pre-existing repo-wide reasons (formatting + + spell-check gate the analyze/test jobs) β€” fix in a dedicated change. +- Consolidate the duplicate delete-account paths (`ProfileBloc`'s + `ProfileDeleted` handler is test-only/unused by the page; the page and + `AppBloc` route through `AppUserAccountDeleted`, whose `deleteAccount` call + is unawaited and surfaces failures nowhere). +- Add logging/analytics to the swallowed `catch` blocks in `GameOverBloc` and + `ProfileBloc` so failures are observable instead of silently dropped. + +**SEARCH BY NAME (post-branch feature, 2026-07-05):** the search box now +auto-detects friend-code-shaped input (`YETI-XXXX`) vs. everything else, +routing the latter to a new block-aware `searchByUsername` callable +(case-insensitive prefix match, 2-char minimum, 10-result cap). Requires: +- A new `usernameLower` field on `UserProfileModel`, derived and force-synced + by `FirebaseDatabaseRepository.updateUserProfile` on every write β€” never + set it directly. `firestore.rules` validates any client-written + `usernameLower` matches `username.lower()`. +- **DEPLOY GATE:** this needs `firebase deploy --only functions` (new + `searchByUsername`) AND `firebase deploy --only firestore:rules` (the + `usernameLower` validation) before release. No index changes β€” a + single-field range query gets Firestore's automatic index for free. + Existing profiles won't have `usernameLower` until their next profile + save, so name search only finds users who have saved their profile since + this deploy; friend-code search is unaffected. diff --git a/docs/superpowers/plans/2026-07-03-friends-a-backend-foundation.md b/docs/superpowers/plans/2026-07-03-friends-a-backend-foundation.md new file mode 100644 index 0000000..5a51f76 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-friends-a-backend-foundation.md @@ -0,0 +1,2100 @@ +# Friends Plan A: Backend Foundation 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:** Stand up the Firebase backend (Cloud Functions + versioned Firestore rules + emulator), move PIN hashes into an owner-only private doc, and switch client PIN validation to a rate-limited callable. + +**Architecture:** Spec phase 1 of `docs/superpowers/specs/2026-07-03-friends-feature-design.md`. PIN hashes move from the world-readable `users/{uid}` doc to `users/{uid}/private/credentials` (owner-only rules), lazily migrated on login. A `validatePin` callable β€” the one job rules can't do β€” compares the secret server-side with a 5-failuresβ†’15-minute lockout per callerβ†’target pair. Rules ship as a **transitional v1**: they lock down `private/**` and `pinAttempts/**` hard, but intentionally keep today's cross-user friend/match writes legal until Plans B/C move those flows. + +**Tech Stack:** Firebase Cloud Functions v2 (TypeScript, Node 20, `firebase-functions` ^6, `firebase-admin` ^13, jest + ts-jest), `@firebase/rules-unit-testing` for rules tests, Flutter client (`cloud_functions` ^5.2.0, `fake_cloud_firestore` ^3.1.0 for repo tests, existing `bloc_test`/`mocktail` patterns). + +## Global Constraints + +- Firebase project ID: `magic-yeti` (from `firebase.json`); no GoogleService config changes. +- PIN is exactly 4 numeric digits everywhere; lockout policy is exactly **5 failed attempts β†’ 15-minute lockout** per callerβ†’target pair. +- Salted hash format: `pinHash = sha256(salt + pin)` hex, `salt` = 16 random bytes hex; legacy format `sha256(pin)` with `salt: null` must keep validating until Plan D retires it. +- Only friends of the target may call `validatePin`; anonymous callers always rejected. +- v1 rules must NOT break existing client flows: friend-edge batch writes and host game fan-out stay legal until Plans B/C. +- Dart: `very_good_analysis` lints; models `@JsonSerializable(explicitToJson: true)`; run `dart run build_runner build --delete-conflicting-outputs` after model changes. +- All new user-facing strings added to BOTH `lib/l10n/arb/app_en.arb` and `app_es.arb`, then `flutter gen-l10n --arb-dir="lib/l10n/arb"`. +- Commit after every task; messages follow `feat:`/`test:`/`chore:` conventions seen in `git log`. +- Do not deploy to production from a task; Task 11 stages deployment as an explicit manual gate. + +--- + +### Task 1: Firebase infra scaffold (functions package, emulator config, .firebaserc) + +**Files:** +- Create: `.firebaserc` +- Modify: `firebase.json` +- Create: `functions/package.json` +- Create: `functions/tsconfig.json` +- Create: `functions/.gitignore` +- Create: `functions/src/index.ts` + +**Interfaces:** +- Consumes: nothing (first task) +- Produces: `functions/` npm package with `npm run build`, `npm test`, `npm run test:rules` scripts; `firebase emulators:start` config with auth:9099, firestore:8080, functions:5001. Later tasks add files under `functions/src/` and export them from `functions/src/index.ts`. + +- [ ] **Step 1: Create `.firebaserc`** + +```json +{ + "projects": { + "default": "magic-yeti" + } +} +``` + +- [ ] **Step 2: Rewrite `firebase.json`** (preserve the existing `flutter` block exactly; add `firestore`, `functions`, `emulators`): + +```json +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "magic-yeti", + "appId": "1:370172089725:android:8e686daa66153c82ad6814", + "fileOutput": "android/app/google-services.json" + } + }, + "ios": { + "default": { + "projectId": "magic-yeti", + "appId": "1:370172089725:ios:bf69e58694170008ad6814", + "uploadDebugSymbols": false, + "fileOutput": "ios/Runner/GoogleService-Info.plist" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "magic-yeti", + "configurations": { + "android": "1:370172089725:android:8e686daa66153c82ad6814", + "ios": "1:370172089725:ios:bf69e58694170008ad6814" + } + } + } + } + }, + "firestore": { + "rules": "firestore.rules" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] + } + ], + "emulators": { + "auth": { "port": 9099 }, + "firestore": { "port": 8080 }, + "functions": { "port": 5001 }, + "ui": { "enabled": true } + } +} +``` + +Note: `firestore.rules` does not exist yet β€” Task 2 creates it TDD-style. Create an empty placeholder now so emulator commands don't fail: + +```bash +cat > firestore.rules <<'EOF' +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + allow read, write: if request.auth != null; + } + } +} +EOF +``` + +- [ ] **Step 3: Create `functions/package.json`** + +```json +{ + "name": "functions", + "private": true, + "engines": { "node": "20" }, + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "test": "jest --testPathIgnorePatterns test/rules", + "test:rules": "jest test/rules", + "serve": "npm run build && firebase emulators:start --only functions,firestore,auth" + }, + "dependencies": { + "firebase-admin": "^13.0.0", + "firebase-functions": "^6.1.0" + }, + "devDependencies": { + "@firebase/rules-unit-testing": "^4.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "firebase-functions-test": "^3.3.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.5.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": ["/test"] + } +} +``` + +- [ ] **Step 4: Create `functions/tsconfig.json`** + +```json +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2022", + "lib": ["es2022"], + "outDir": "lib", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 5: Create `functions/.gitignore`** + +``` +node_modules/ +lib/ +*.log +``` + +- [ ] **Step 6: Create `functions/src/index.ts`** (empty export barrel for now) + +```typescript +// Cloud Functions entry point. Each function lives in its own module and +// is re-exported here so the Firebase CLI discovers it. +export {}; +``` + +- [ ] **Step 7: Verify the scaffold builds** + +Run: `cd functions && npm install && npm run build` +Expected: exits 0, `functions/lib/index.js` exists. + +Run: `npx firebase-tools --version || firebase --version` +Expected: a version prints. If the CLI is missing, install with `npm install -g firebase-tools` (or use `npx firebase-tools` for all later `firebase` commands). + +- [ ] **Step 8: Commit** + +```bash +git add .firebaserc firebase.json firestore.rules functions/ +git commit -m "chore: scaffold Cloud Functions package and Firebase emulator config" +``` + +--- + +### Task 2: Firestore rules v1 (TDD via rules-unit-testing) + +**Files:** +- Create: `functions/test/rules/firestore-rules.test.ts` +- Modify: `firestore.rules` (replace the Task 1 placeholder) + +**Interfaces:** +- Consumes: emulator config from Task 1. +- Produces: v1 rules contract relied on by all later plans β€” `users/{uid}/private/**` owner-only, `pinAttempts/**` no client access, `users/{uid}` writes owner-only, `users/{uid}/matches/{id}` reads owner-only (writes transitional-open), `games` update/delete host-only, `friendRequests` reads scoped to participants. + +- [ ] **Step 1: Write the failing rules tests** β€” `functions/test/rules/firestore-rules.test.ts`: + +```typescript +import { + assertFails, + assertSucceeds, + initializeTestEnvironment, + RulesTestEnvironment, +} from '@firebase/rules-unit-testing'; +import { readFileSync } from 'fs'; +import { doc, getDoc, setDoc, updateDoc, deleteDoc } from 'firebase/firestore'; + +let env: RulesTestEnvironment; + +beforeAll(async () => { + env = await initializeTestEnvironment({ + projectId: 'magic-yeti-rules-test', + firestore: { rules: readFileSync('../firestore.rules', 'utf8') }, + }); +}); + +afterAll(async () => { + await env.cleanup(); +}); + +beforeEach(async () => { + await env.clearFirestore(); +}); + +const alice = () => env.authenticatedContext('alice').firestore(); +const bob = () => env.authenticatedContext('bob').firestore(); +const anon = () => env.unauthenticatedContext().firestore(); + +describe('private credentials', () => { + test('owner can read and write own credentials', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/private/credentials'), { + pinHash: 'h', + salt: 's', + }), + ); + await assertSucceeds( + getDoc(doc(alice(), 'users/alice/private/credentials')), + ); + }); + + test('another signed-in user cannot read or write credentials', async () => { + await assertFails(getDoc(doc(bob(), 'users/alice/private/credentials'))); + await assertFails( + setDoc(doc(bob(), 'users/alice/private/credentials'), { pinHash: 'x' }), + ); + }); +}); + +describe('pinAttempts', () => { + test('no client may read or write pinAttempts', async () => { + await assertFails(getDoc(doc(alice(), 'pinAttempts/alice_bob'))); + await assertFails( + setDoc(doc(alice(), 'pinAttempts/alice_bob'), { failCount: 0 }), + ); + }); +}); + +describe('users', () => { + test('any signed-in user can read a profile; anon cannot', async () => { + await assertSucceeds(getDoc(doc(bob(), 'users/alice'))); + await assertFails(getDoc(doc(anon(), 'users/alice'))); + }); + + test('only the owner can write their profile doc', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice'), { username: 'alice' }), + ); + await assertFails(setDoc(doc(bob(), 'users/alice'), { username: 'evil' })); + }); +}); + +describe('matches', () => { + test('owner can read own matches; others cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/alice/matches/g1'), { id: 'g1' }); + }); + await assertSucceeds(getDoc(doc(alice(), 'users/alice/matches/g1'))); + await assertFails(getDoc(doc(bob(), 'users/alice/matches/g1'))); + }); + + test('TRANSITIONAL: another signed-in user may write matches (host fan-out until Plan B)', async () => { + await assertSucceeds( + setDoc(doc(bob(), 'users/alice/matches/g2'), { id: 'g2' }), + ); + }); +}); + +describe('games', () => { + test('signed-in users can create and read games', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'games/g1'), { hostId: 'alice', roomId: 'AB2C' }), + ); + await assertSucceeds(getDoc(doc(bob(), 'games/g1'))); + }); + + test('only the host can update or delete a game', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'games/g1'), { hostId: 'alice' }); + }); + await assertSucceeds(updateDoc(doc(alice(), 'games/g1'), { x: 1 })); + await assertFails(updateDoc(doc(bob(), 'games/g1'), { x: 2 })); + await assertFails(deleteDoc(doc(bob(), 'games/g1'))); + }); +}); + +describe('friends (TRANSITIONAL until Plan C)', () => { + test('owner reads own friend list; non-participant cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friends/alice/friendList/bob'), { + userId: 'bob', + }); + }); + await assertSucceeds(getDoc(doc(alice(), 'friends/alice/friendList/bob'))); + await assertFails(getDoc(doc(bob(), 'friends/alice/friendList/bob'))); + }); + + test('TRANSITIONAL: signed-in users may write friend edges (accept batch until Plan C)', async () => { + await assertSucceeds( + setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'bob' }), + ); + }); +}); + +describe('friendRequests', () => { + test('participants can read; strangers cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/r1'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }); + }); + await assertSucceeds(getDoc(doc(alice(), 'friendRequests/r1'))); + await assertSucceeds(getDoc(doc(bob(), 'friendRequests/r1'))); + await assertFails( + getDoc(doc(env.authenticatedContext('carol').firestore(), 'friendRequests/r1')), + ); + }); + + test('sender may create a request as themselves only', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'friendRequests/r2'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }), + ); + await assertFails( + setDoc(doc(alice(), 'friendRequests/r3'), { + senderId: 'bob', + receiverId: 'carol', + status: 'pending', + }), + ); + }); +}); +``` + +Also add the `firebase` web SDK needed by the test file: in `functions/package.json` devDependencies add `"firebase": "^10.12.0"`, then `cd functions && npm install`. + +- [ ] **Step 2: Run rules tests to verify they fail against the placeholder rules** + +Run: `firebase emulators:exec --only firestore "npm --prefix functions run test:rules"` +Expected: FAIL β€” private-credentials isolation and pinAttempts tests fail (placeholder allows all signed-in access). + +- [ ] **Step 3: Write the real `firestore.rules`** (replace placeholder entirely): + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + function signedIn() { + return request.auth != null; + } + function isOwner(uid) { + return signedIn() && request.auth.uid == uid; + } + + match /users/{uid} { + allow read: if signedIn(); + allow write: if isOwner(uid); + + match /private/{document=**} { + allow read, write: if isOwner(uid); + } + + match /matches/{gameId} { + allow read: if isOwner(uid); + // TRANSITIONAL (Plan B removes): host client fan-out writes cross-user. + allow write: if signedIn(); + } + } + + // Server-only rate limiting state. No client access, ever. + match /pinAttempts/{attemptId} { + allow read, write: if false; + } + + match /games/{gameId} { + allow read, create: if signedIn(); + allow update, delete: if signedIn() && resource.data.hostId == request.auth.uid; + } + + match /friends/{uid}/friendList/{friendId} { + allow read: if isOwner(uid); + // TRANSITIONAL (Plan C tightens): accept/remove batch writes cross-user. + allow write: if signedIn(); + } + + match /friendRequests/{requestId} { + allow read: if signedIn() && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + allow create: if signedIn() && + request.resource.data.senderId == request.auth.uid; + // TRANSITIONAL (Plan C tightens to deterministic-ID lifecycle rules). + allow update, delete: if signedIn() && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + } + } +} +``` + +- [ ] **Step 4: Run rules tests to verify they pass** + +Run: `firebase emulators:exec --only firestore "npm --prefix functions run test:rules"` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add firestore.rules functions/test/rules/firestore-rules.test.ts functions/package.json functions/package-lock.json +git commit -m "feat: versioned Firestore rules v1 with private-credentials lockdown and rules tests" +``` + +--- + +### Task 3: Pure PIN logic module in functions (TDD) + +**Files:** +- Create: `functions/src/pin-logic.ts` +- Create: `functions/test/pin-logic.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (used by Task 4): + - `hashPin(pin: string): string` β€” sha256 hex of pin (legacy format) + - `saltedPinHash(pin: string, salt: string): string` β€” sha256 hex of `salt + pin` + - `checkPin(stored: StoredCredentials, pin: string): boolean` where `StoredCredentials = { pinHash: string; salt: string | null }` + - `MAX_ATTEMPTS = 5`, `LOCKOUT_MS = 15 * 60 * 1000` + - `evaluateAttempt(state: AttemptState | null, nowMillis: number): { lockedOut: boolean; lockedUntilMillis: number | null }` where `AttemptState = { failCount: number; lockedUntilMillis: number | null }` + - `recordFailure(state: AttemptState | null, nowMillis: number): AttemptState` β€” increments; sets `lockedUntilMillis = now + LOCKOUT_MS` when the new count reaches `MAX_ATTEMPTS`; a failure after an expired lockout starts a fresh count of 1. + +- [ ] **Step 1: Write the failing test** β€” `functions/test/pin-logic.test.ts`: + +```typescript +import { + hashPin, + saltedPinHash, + checkPin, + evaluateAttempt, + recordFailure, + MAX_ATTEMPTS, + LOCKOUT_MS, +} from '../src/pin-logic'; + +describe('hashing', () => { + test('hashPin matches known sha256 of "0742"', () => { + // echo -n 0742 | shasum -a 256 + expect(hashPin('0742')).toBe( + 'a2f8d2eed38aea1c4e9a90af0a0ad9fc833b5f923afc5c7709745e56f766c87c', + ); + }); + + test('salted hash differs from unsalted and is stable', () => { + const salted = saltedPinHash('0742', 'abc123'); + expect(salted).not.toBe(hashPin('0742')); + expect(saltedPinHash('0742', 'abc123')).toBe(salted); + }); +}); + +describe('checkPin', () => { + test('legacy credentials (salt null) validate with plain hash', () => { + expect(checkPin({ pinHash: hashPin('1234'), salt: null }, '1234')).toBe(true); + expect(checkPin({ pinHash: hashPin('1234'), salt: null }, '4321')).toBe(false); + }); + + test('salted credentials validate with salted hash', () => { + const stored = { pinHash: saltedPinHash('1234', 's4lt'), salt: 's4lt' }; + expect(checkPin(stored, '1234')).toBe(true); + expect(checkPin(stored, '0000')).toBe(false); + }); +}); + +describe('lockout state machine', () => { + const NOW = 1_000_000; + + test('null state is not locked out', () => { + expect(evaluateAttempt(null, NOW)).toEqual({ + lockedOut: false, + lockedUntilMillis: null, + }); + }); + + test('recordFailure increments and locks at MAX_ATTEMPTS', () => { + let state = recordFailure(null, NOW); // 1 + for (let i = 1; i < MAX_ATTEMPTS - 1; i++) state = recordFailure(state, NOW); + expect(state.failCount).toBe(MAX_ATTEMPTS - 1); + expect(state.lockedUntilMillis).toBeNull(); + + state = recordFailure(state, NOW); // 5th failure + expect(state.failCount).toBe(MAX_ATTEMPTS); + expect(state.lockedUntilMillis).toBe(NOW + LOCKOUT_MS); + expect(evaluateAttempt(state, NOW + 1).lockedOut).toBe(true); + }); + + test('lockout expires and a new failure starts a fresh count', () => { + const locked = { failCount: 5, lockedUntilMillis: NOW + LOCKOUT_MS }; + const after = NOW + LOCKOUT_MS + 1; + expect(evaluateAttempt(locked, after).lockedOut).toBe(false); + expect(recordFailure(locked, after)).toEqual({ + failCount: 1, + lockedUntilMillis: null, + }); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd functions && npm test` +Expected: FAIL β€” `Cannot find module '../src/pin-logic'`. + +- [ ] **Step 3: Implement `functions/src/pin-logic.ts`** + +```typescript +import { createHash } from 'crypto'; + +export const MAX_ATTEMPTS = 5; +export const LOCKOUT_MS = 15 * 60 * 1000; + +export interface StoredCredentials { + pinHash: string; + salt: string | null; +} + +export interface AttemptState { + failCount: number; + lockedUntilMillis: number | null; +} + +export function hashPin(pin: string): string { + return createHash('sha256').update(pin).digest('hex'); +} + +export function saltedPinHash(pin: string, salt: string): string { + return createHash('sha256').update(salt + pin).digest('hex'); +} + +export function checkPin(stored: StoredCredentials, pin: string): boolean { + const expected = + stored.salt === null ? hashPin(pin) : saltedPinHash(pin, stored.salt); + return stored.pinHash === expected; +} + +export function evaluateAttempt( + state: AttemptState | null, + nowMillis: number, +): { lockedOut: boolean; lockedUntilMillis: number | null } { + if ( + state?.lockedUntilMillis != null && + state.lockedUntilMillis > nowMillis + ) { + return { lockedOut: true, lockedUntilMillis: state.lockedUntilMillis }; + } + return { lockedOut: false, lockedUntilMillis: null }; +} + +export function recordFailure( + state: AttemptState | null, + nowMillis: number, +): AttemptState { + const lockoutExpired = + state?.lockedUntilMillis != null && state.lockedUntilMillis <= nowMillis; + const previousCount = state === null || lockoutExpired ? 0 : state.failCount; + const failCount = previousCount + 1; + return { + failCount, + lockedUntilMillis: failCount >= MAX_ATTEMPTS ? nowMillis + LOCKOUT_MS : null, + }; +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd functions && npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add functions/src/pin-logic.ts functions/test/pin-logic.test.ts +git commit -m "feat: pure PIN hashing and lockout logic for Cloud Functions" +``` + +--- + +### Task 4: `validatePin` callable (emulator-integration TDD) + +**Files:** +- Create: `functions/src/validate-pin.ts` +- Modify: `functions/src/index.ts` +- Create: `functions/test/rules/validate-pin.integration.test.ts` (lives under `test/rules/` so it runs inside `emulators:exec` with Firestore available) + +**Interfaces:** +- Consumes: Task 3's `checkPin`, `evaluateAttempt`, `recordFailure`, `MAX_ATTEMPTS`. +- Produces the callable contract the Dart client (Task 7) depends on: + - Callable name: **`validatePin`**; request `{ targetUserId: string, pin: string }` + - Success: `{ valid: true }` or `{ valid: false, attemptsRemaining: number }` + - Errors: `unauthenticated`; `permission-denied` (anonymous caller or caller not on target's friend list); `invalid-argument` (pin not 4 digits / missing target); `failed-precondition` (target has no PIN); `resource-exhausted` with `details.lockedUntilMillis` (locked out) + - Firestore side effects: reads `users/{target}/private/credentials` falling back to `users/{target}.pin`; transactionally maintains `pinAttempts/{callerUid}_{targetUid}` `{ failCount, lockedUntilMillis, updatedAt }`; deletes the attempts doc on success. + +- [ ] **Step 1: Write the failing integration test** β€” `functions/test/rules/validate-pin.integration.test.ts`: + +```typescript +/** + * Runs inside `firebase emulators:exec --only firestore` via the test:rules + * script. Uses firebase-functions-test in online mode against the emulator. + */ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; +import { hashPin, saltedPinHash, MAX_ATTEMPTS } from '../../src/pin-logic'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// Import AFTER functionsTest() so admin.initializeApp inside the module +// picks up emulator env. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { validatePin } = require('../../src/validate-pin'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(validatePin); + +const callerAuth = { + uid: 'caller', + token: { firebase: { sign_in_provider: 'password' } }, +}; + +async function seedFriendshipAndPin(opts: { salted: boolean }) { + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + if (opts.salted) { + await db.doc('users/target/private/credentials').set({ + pinHash: saltedPinHash('0742', 'somesalt'), + salt: 'somesalt', + }); + } else { + await db.doc('users/target').set({ pin: hashPin('0742') }); + } +} + +afterAll(async () => { + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all( + collections.map((c) => db.recursiveDelete(c)), + ); +}); + +test('correct PIN against salted credentials returns valid', async () => { + await seedFriendshipAndPin({ salted: true }); + const result = await wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: callerAuth, + }); + expect(result).toEqual({ valid: true }); +}); + +test('correct PIN against legacy profile field returns valid (fallback)', async () => { + await seedFriendshipAndPin({ salted: false }); + const result = await wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: callerAuth, + }); + expect(result).toEqual({ valid: true }); +}); + +test('wrong PIN decrements attempts and locks out after MAX_ATTEMPTS', async () => { + await seedFriendshipAndPin({ salted: true }); + for (let i = 1; i < MAX_ATTEMPTS; i++) { + const r = await wrapped({ + data: { targetUserId: 'target', pin: '9999' }, + auth: callerAuth, + }); + expect(r.valid).toBe(false); + expect(r.attemptsRemaining).toBe(MAX_ATTEMPTS - i); + } + // 5th failure locks + const fifth = await wrapped({ + data: { targetUserId: 'target', pin: '9999' }, + auth: callerAuth, + }); + expect(fifth.valid).toBe(false); + expect(fifth.attemptsRemaining).toBe(0); + + // 6th attempt β€” even with the CORRECT pin β€” is locked out + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'resource-exhausted' }); +}); + +test('success clears the attempt counter', async () => { + await seedFriendshipAndPin({ salted: true }); + await wrapped({ data: { targetUserId: 'target', pin: '9999' }, auth: callerAuth }); + await wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }); + const attempts = await db.doc('pinAttempts/caller_target').get(); + expect(attempts.exists).toBe(false); +}); + +test('non-friend caller is rejected', async () => { + await db.doc('users/target/private/credentials').set({ + pinHash: saltedPinHash('0742', 's'), + salt: 's', + }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'permission-denied' }); +}); + +test('anonymous caller is rejected', async () => { + await seedFriendshipAndPin({ salted: true }); + await expect( + wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: { + uid: 'caller', + token: { firebase: { sign_in_provider: 'anonymous' } }, + }, + }), + ).rejects.toMatchObject({ code: 'permission-denied' }); +}); + +test('missing auth is rejected', async () => { + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' } }), + ).rejects.toMatchObject({ code: 'unauthenticated' }); +}); + +test('malformed pin is rejected', async () => { + await seedFriendshipAndPin({ salted: true }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '12' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'invalid-argument' }); +}); + +test('target without any PIN yields failed-precondition', async () => { + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'failed-precondition' }); +}); +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd functions && npm run build && cd .. && firebase emulators:exec --only firestore "npm --prefix functions run test:rules"` +Expected: FAIL β€” `Cannot find module '../../src/validate-pin'`. + +- [ ] **Step 3: Implement `functions/src/validate-pin.ts`** + +```typescript +import * as admin from 'firebase-admin'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { + AttemptState, + checkPin, + evaluateAttempt, + MAX_ATTEMPTS, + recordFailure, + StoredCredentials, +} from './pin-logic'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +interface ValidatePinRequest { + targetUserId?: string; + pin?: string; +} + +export const validatePin = onCall(async (request) => { + const auth = request.auth; + if (!auth) { + throw new HttpsError('unauthenticated', 'Sign in required.'); + } + if (auth.token?.firebase?.sign_in_provider === 'anonymous') { + throw new HttpsError('permission-denied', 'Anonymous users cannot validate PINs.'); + } + + const { targetUserId, pin } = request.data ?? {}; + if (typeof targetUserId !== 'string' || targetUserId.length === 0) { + throw new HttpsError('invalid-argument', 'targetUserId is required.'); + } + if (typeof pin !== 'string' || !/^\d{4}$/.test(pin)) { + throw new HttpsError('invalid-argument', 'pin must be exactly 4 digits.'); + } + + const db = admin.firestore(); + const callerUid = auth.uid; + + // Only friends of the target may attempt validation. + const friendEdge = await db + .doc(`friends/${targetUserId}/friendList/${callerUid}`) + .get(); + if (!friendEdge.exists) { + throw new HttpsError('permission-denied', 'Caller is not a friend of the target.'); + } + + // Load stored credentials: private doc first, legacy profile field fallback. + let stored: StoredCredentials | null = null; + const credentials = await db + .doc(`users/${targetUserId}/private/credentials`) + .get(); + if (credentials.exists) { + const data = credentials.data()!; + stored = { + pinHash: data.pinHash as string, + salt: (data.salt as string | null) ?? null, + }; + } else { + const profile = await db.doc(`users/${targetUserId}`).get(); + const legacyHash = profile.data()?.pin as string | undefined; + if (legacyHash != null && legacyHash.length > 0) { + stored = { pinHash: legacyHash, salt: null }; + } + } + if (stored === null) { + throw new HttpsError('failed-precondition', 'Target user has no PIN set.'); + } + + const attemptsRef = db.doc(`pinAttempts/${callerUid}_${targetUserId}`); + + return db.runTransaction(async (tx) => { + const now = Date.now(); + const attemptsSnap = await tx.get(attemptsRef); + const state: AttemptState | null = attemptsSnap.exists + ? { + failCount: attemptsSnap.data()!.failCount as number, + lockedUntilMillis: + (attemptsSnap.data()!.lockedUntilMillis as number | null) ?? null, + } + : null; + + const lock = evaluateAttempt(state, now); + if (lock.lockedOut) { + throw new HttpsError('resource-exhausted', 'Too many failed attempts.', { + lockedUntilMillis: lock.lockedUntilMillis, + }); + } + + if (checkPin(stored!, pin)) { + if (attemptsSnap.exists) { + tx.delete(attemptsRef); + } + return { valid: true }; + } + + const next = recordFailure(state, now); + tx.set(attemptsRef, { + failCount: next.failCount, + lockedUntilMillis: next.lockedUntilMillis, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + return { + valid: false, + attemptsRemaining: Math.max(0, MAX_ATTEMPTS - next.failCount), + }; + }); +}); +``` + +- [ ] **Step 4: Export from `functions/src/index.ts`** (replace file contents): + +```typescript +// Cloud Functions entry point. Each function lives in its own module and +// is re-exported here so the Firebase CLI discovers it. +export { validatePin } from './validate-pin'; +``` + +- [ ] **Step 5: Build and run integration tests** + +Run: `cd functions && npm run build && cd .. && firebase emulators:exec --only firestore "npm --prefix functions run test:rules"` +Expected: PASS β€” all validate-pin integration tests and the Task 2 rules tests. + +- [ ] **Step 6: Commit** + +```bash +git add functions/src/validate-pin.ts functions/src/index.ts functions/test/rules/validate-pin.integration.test.ts +git commit -m "feat: rate-limited validatePin callable with friend gating and legacy fallback" +``` + +--- + +### Task 5: Dart models β€” `PinValidationResult` + `UserProfileModel.hasPin` + +**Files:** +- Create: `packages/firebase_database_repository/lib/models/pin_validation_result.dart` +- Modify: `packages/firebase_database_repository/lib/models/user_profile_model.dart` +- Modify: `packages/firebase_database_repository/lib/models/models.dart` (add export) +- Create: `packages/firebase_database_repository/test/models/pin_validation_result_test.dart` +- Create: `packages/firebase_database_repository/test/models/user_profile_model_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces (used by Tasks 6–10 and Plans B–D): + - `sealed class PinValidationResult` with subtypes `PinValid()`, `PinInvalid({required int attemptsRemaining})`, `PinLockedOut({required DateTime lockedUntil})`, `PinCheckUnavailable()` β€” all `const`, all `Equatable`. + - `UserProfileModel.hasPin` (`bool`, default `false`, serialized as `hasPin`). + - `UserProfileModel.isComplete` getter: `onboardingComplete && (username?.isNotEmpty ?? false) && (hasPin || (pin?.isNotEmpty ?? false))`. + +- [ ] **Step 1: Write failing model tests** β€” `packages/firebase_database_repository/test/models/pin_validation_result_test.dart`: + +```dart +import 'package:firebase_database_repository/models/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('PinValidationResult', () { + test('value equality', () { + expect(const PinValid(), const PinValid()); + expect( + const PinInvalid(attemptsRemaining: 3), + const PinInvalid(attemptsRemaining: 3), + ); + expect( + const PinInvalid(attemptsRemaining: 3), + isNot(const PinInvalid(attemptsRemaining: 2)), + ); + expect( + PinLockedOut(lockedUntil: DateTime.utc(2026, 7, 3)), + PinLockedOut(lockedUntil: DateTime.utc(2026, 7, 3)), + ); + expect(const PinCheckUnavailable(), const PinCheckUnavailable()); + }); + + test('subtypes are exhaustively switchable', () { + String describe(PinValidationResult r) => switch (r) { + PinValid() => 'valid', + PinInvalid(:final attemptsRemaining) => 'invalid:$attemptsRemaining', + PinLockedOut() => 'locked', + PinCheckUnavailable() => 'unavailable', + }; + expect(describe(const PinValid()), 'valid'); + expect(describe(const PinInvalid(attemptsRemaining: 2)), 'invalid:2'); + }); + }); +} +``` + +And `packages/firebase_database_repository/test/models/user_profile_model_test.dart`: + +```dart +import 'package:firebase_database_repository/models/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('UserProfileModel', () { + test('hasPin defaults false and round-trips through json', () { + const model = UserProfileModel(id: 'u1', hasPin: true); + final decoded = UserProfileModel.fromJson(model.toJson()); + expect(decoded.hasPin, isTrue); + expect(UserProfileModel.fromJson(const {'id': 'u2'}).hasPin, isFalse); + }); + + group('isComplete', () { + test('true when onboarded with username and hasPin', () { + const m = UserProfileModel( + id: 'u', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ); + expect(m.isComplete, isTrue); + }); + + test('legacy unmigrated pin field counts as having a PIN', () { + const m = UserProfileModel( + id: 'u', + username: 'josh', + pin: 'somelegacyhash', + onboardingComplete: true, + ); + expect(m.isComplete, isTrue); + }); + + test('false when missing username, PIN, or onboarding flag', () { + const base = UserProfileModel( + id: 'u', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ); + expect(base.copyWith(username: '').isComplete, isFalse); + expect( + const UserProfileModel( + id: 'u', + username: 'josh', + onboardingComplete: true, + ).isComplete, + isFalse, + ); + expect(base.copyWith(onboardingComplete: false).isComplete, isFalse); + }); + }); + }); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/firebase_database_repository && flutter test test/models` +Expected: FAIL β€” `PinValidationResult` undefined, `hasPin` undefined. + +- [ ] **Step 3: Implement.** Create `packages/firebase_database_repository/lib/models/pin_validation_result.dart`: + +```dart +import 'package:equatable/equatable.dart'; + +/// {@template pin_validation_result} +/// Result of validating a friend's PIN via the `validatePin` callable. +/// {@endtemplate} +sealed class PinValidationResult extends Equatable { + /// {@macro pin_validation_result} + const PinValidationResult(); + + @override + List get props => []; +} + +/// The PIN was correct. +final class PinValid extends PinValidationResult { + /// Creates a valid result. + const PinValid(); +} + +/// The PIN was wrong; [attemptsRemaining] tries left before lockout. +final class PinInvalid extends PinValidationResult { + /// Creates an invalid result. + const PinInvalid({required this.attemptsRemaining}); + + /// Attempts left before a lockout is applied. + final int attemptsRemaining; + + @override + List get props => [attemptsRemaining]; +} + +/// Too many failed attempts; retry after [lockedUntil]. +final class PinLockedOut extends PinValidationResult { + /// Creates a locked-out result. + const PinLockedOut({required this.lockedUntil}); + + /// When the lockout expires. + final DateTime lockedUntil; + + @override + List get props => [lockedUntil]; +} + +/// The check could not be performed (offline or server error). +final class PinCheckUnavailable extends PinValidationResult { + /// Creates an unavailable result. + const PinCheckUnavailable(); +} +``` + +In `user_profile_model.dart`: add the field, constructor param (`this.hasPin = false`), `copyWith` support, `props` entry, and the getter: + +```dart + /// Whether the user has a PIN set (hash lives in the private + /// credentials subcollection, so only this flag is public). + final bool hasPin; + + /// Whether the profile satisfies the friends-feature requirements: + /// onboarded, has a username, and has a PIN (new flag or legacy field). + bool get isComplete => + onboardingComplete && + (username?.isNotEmpty ?? false) && + (hasPin || (pin?.isNotEmpty ?? false)); +``` + +Add `export 'pin_validation_result.dart';` to `packages/firebase_database_repository/lib/models/models.dart`. + +- [ ] **Step 4: Regenerate JSON code and run tests** + +Run: `cd packages/firebase_database_repository && dart run build_runner build --delete-conflicting-outputs && flutter test test/models` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/firebase_database_repository/lib packages/firebase_database_repository/test +git commit -m "feat: PinValidationResult model and UserProfileModel.hasPin/isComplete" +``` + +--- + +### Task 6: Repository β€” salted `setPin`, `migrateLegacyPin`, updated `hasPin` + +**Files:** +- Modify: `packages/firebase_database_repository/pubspec.yaml` (add `fake_cloud_firestore: ^3.1.0` to dev_dependencies) +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` +- Create: `packages/firebase_database_repository/test/src/pin_storage_test.dart` + +**Interfaces:** +- Consumes: Task 5 models. +- Produces (used by Tasks 8–10 and Plan B): + - `static String generateSalt()` β€” 16 random bytes hex (32 chars) + - `static String saltedPinHash(String pin, String salt)` β€” sha256 hex of `salt + pin` + - `Future setPin(String userId, String pin)` β€” writes `users/{uid}/private/credentials` `{pinHash, salt, updatedAt}`, merges `{'hasPin': true}` into `users/{uid}`, deletes legacy `pin` field + - `Future migrateLegacyPin(String userId)` β€” no-op unless `users/{uid}.pin` is a non-empty string and no private credentials doc exists; then copies it as `{pinHash: legacy, salt: null}`, sets `hasPin: true`, deletes the field + - `Future hasPin(String userId)` β€” true when profile `hasPin == true` OR legacy `pin` non-empty + - Existing `static String hashPin(String pin)` unchanged (legacy format). + +- [ ] **Step 1: Add dev dependency.** In `packages/firebase_database_repository/pubspec.yaml` dev_dependencies add `fake_cloud_firestore: ^3.1.0`, then run `cd packages/firebase_database_repository && flutter pub get`. + +- [ ] **Step 2: Write failing tests** β€” `packages/firebase_database_repository/test/src/pin_storage_test.dart`: + +```dart +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + group('salt helpers', () { + test('generateSalt returns 32 hex chars and is random', () { + final a = FirebaseDatabaseRepository.generateSalt(); + final b = FirebaseDatabaseRepository.generateSalt(); + expect(a, matches(RegExp(r'^[0-9a-f]{32}$'))); + expect(a, isNot(b)); + }); + + test('saltedPinHash is deterministic and salt-sensitive', () { + final h1 = FirebaseDatabaseRepository.saltedPinHash('0742', 'aa'); + expect(h1, FirebaseDatabaseRepository.saltedPinHash('0742', 'aa')); + expect(h1, isNot(FirebaseDatabaseRepository.saltedPinHash('0742', 'bb'))); + expect(h1, isNot(FirebaseDatabaseRepository.hashPin('0742'))); + }); + }); + + group('setPin', () { + test('writes salted credentials, sets hasPin, removes legacy field', + () async { + await firestore + .collection('users') + .doc('u1') + .set({'username': 'josh', 'pin': 'legacyhash'}); + + await repository.setPin('u1', '0742'); + + final creds = await firestore + .doc('users/u1/private/credentials') + .get(); + final salt = creds.data()!['salt'] as String; + expect( + creds.data()!['pinHash'], + FirebaseDatabaseRepository.saltedPinHash('0742', salt), + ); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + expect(profile.data()!.containsKey('pin'), isFalse); + expect(profile.data()!['username'], 'josh'); + }); + }); + + group('migrateLegacyPin', () { + test('moves legacy hash into private credentials with null salt', + () async { + final legacyHash = FirebaseDatabaseRepository.hashPin('0742'); + await firestore.collection('users').doc('u1').set({'pin': legacyHash}); + + await repository.migrateLegacyPin('u1'); + + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.data()!['pinHash'], legacyHash); + expect(creds.data()!['salt'], isNull); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + expect(profile.data()!.containsKey('pin'), isFalse); + }); + + test('no-ops when there is nothing to migrate', () async { + await firestore.collection('users').doc('u1').set({'username': 'j'}); + await repository.migrateLegacyPin('u1'); + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.exists, isFalse); + }); + + test('does not overwrite already-migrated credentials', () async { + await firestore.doc('users/u1/private/credentials').set({ + 'pinHash': 'saltedHash', + 'salt': 'realsalt', + }); + await firestore + .collection('users') + .doc('u1') + .set({'pin': 'staleLegacy'}); + + await repository.migrateLegacyPin('u1'); + + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.data()!['pinHash'], 'saltedHash'); + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!.containsKey('pin'), isFalse); + expect(profile.data()!['hasPin'], isTrue); + }); + }); + + group('hasPin', () { + test('true for hasPin flag, true for legacy field, false otherwise', + () async { + await firestore.collection('users').doc('a').set({'hasPin': true}); + await firestore.collection('users').doc('b').set({'pin': 'hash'}); + await firestore.collection('users').doc('c').set({'username': 'x'}); + expect(await repository.hasPin('a'), isTrue); + expect(await repository.hasPin('b'), isTrue); + expect(await repository.hasPin('c'), isFalse); + }); + }); +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `cd packages/firebase_database_repository && flutter test test/src/pin_storage_test.dart` +Expected: FAIL β€” `generateSalt`/`saltedPinHash`/`migrateLegacyPin` undefined; `setPin` writes to the wrong place. + +- [ ] **Step 4: Implement in `firebase_database_repository.dart`.** Replace the existing `setPin` (currently at the bottom of the class, writing `{'pin': hashPin(pin)}` to the profile) and the existing `hasPin`, and add the new members: + +```dart + /// Generates a random 16-byte hex salt for PIN hashing. + static String generateSalt() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + /// Hashes a PIN with a salt: sha256(salt + pin). + static String saltedPinHash(String pin, String salt) { + final bytes = utf8.encode(salt + pin); + return sha256.convert(bytes).toString(); + } + + DocumentReference> _credentialsDoc(String userId) => + _firebase.doc('users/$userId/private/credentials'); + + /// Sets the user's PIN: salted hash into the private credentials doc, + /// `hasPin` flag onto the profile, legacy `pin` field removed. + Future setPin(String userId, String pin) async { + try { + final salt = generateSalt(); + final batch = _firebase.batch() + ..set(_credentialsDoc(userId), { + 'pinHash': saltedPinHash(pin, salt), + 'salt': salt, + 'updatedAt': FieldValue.serverTimestamp(), + }) + ..set( + _firebase.collection('users').doc(userId), + {'hasPin': true, 'pin': FieldValue.delete()}, + SetOptions(merge: true), + ); + await batch.commit(); + } catch (e) { + throw Exception('Failed to set PIN: $e'); + } + } + + /// Moves a legacy profile-doc PIN hash into the private credentials + /// doc. Safe to call on every login; no-ops when nothing to migrate. + Future migrateLegacyPin(String userId) async { + try { + final profileRef = _firebase.collection('users').doc(userId); + final profile = await profileRef.get(); + if (!profile.exists) return; + final legacyHash = profile.data()?['pin'] as String?; + if (legacyHash == null || legacyHash.isEmpty) return; + + final credentials = await _credentialsDoc(userId).get(); + final batch = _firebase.batch(); + if (!credentials.exists) { + batch.set(_credentialsDoc(userId), { + 'pinHash': legacyHash, + 'salt': null, + 'updatedAt': FieldValue.serverTimestamp(), + }); + } + batch.set( + profileRef, + {'hasPin': true, 'pin': FieldValue.delete()}, + SetOptions(merge: true), + ); + await batch.commit(); + } catch (_) { + // Migration is best-effort on login; the callable's legacy + // fallback keeps validation working until it succeeds. + } + } + + /// Checks whether a user has set their PIN (new flag or legacy field). + Future hasPin(String userId) async { + try { + final doc = await _firebase.collection('users').doc(userId).get(); + if (!doc.exists) return false; + final data = doc.data()!; + final legacy = data['pin'] as String?; + return data['hasPin'] == true || (legacy != null && legacy.isNotEmpty); + } catch (e) { + return false; + } + } +``` + +Note: the class constructor is `const`; adding these members keeps it const-compatible (no new instance fields yet β€” Task 7 adds the functions field). + +- [ ] **Step 5: Run tests to verify pass** + +Run: `cd packages/firebase_database_repository && flutter test` +Expected: PASS (new tests plus all pre-existing package tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/firebase_database_repository +git commit -m "feat: salted PIN storage in private credentials with lazy legacy migration" +``` + +--- + +### Task 7: Repository β€” `validatePin` becomes the callable + +**Files:** +- Modify: `packages/firebase_database_repository/pubspec.yaml` (add `cloud_functions: ^5.2.0` to dependencies) +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (constructor + `validatePin`) +- Modify: `lib/main_development.dart:21`, `lib/main_staging.dart:21`, `lib/main_production.dart:21` +- Modify: root `pubspec.yaml` (add `cloud_functions: ^5.2.0`) +- Create: `packages/firebase_database_repository/test/src/validate_pin_test.dart` + +**Interfaces:** +- Consumes: Task 4's callable contract, Task 5's `PinValidationResult`. +- Produces (used by Task 8): + - Constructor: `FirebaseDatabaseRepository({required FirebaseFirestore firebase, FirebaseFunctions? functions})` β€” `functions` optional so existing tests/constructions keep working; resolved lazily via `functions ?? FirebaseFunctions.instance`. + - `Future validatePin({required String targetUserId, required String pin})` β€” **named parameters, new signature** (old positional `validatePin(userId, pin) β†’ bool` is deleted). + +- [ ] **Step 1: Add dependencies.** `cloud_functions: ^5.2.0` in BOTH `packages/firebase_database_repository/pubspec.yaml` dependencies and the root `pubspec.yaml` dependencies. Run `flutter pub get` in both places. + +- [ ] **Step 2: Write failing tests** β€” `packages/firebase_database_repository/test/src/validate_pin_test.dart`: + +```dart +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFunctions extends Mock implements FirebaseFunctions {} + +class _MockCallable extends Mock implements HttpsCallable {} + +class _MockResult extends Mock implements HttpsCallableResult {} + +void main() { + late _MockFunctions functions; + late _MockCallable callable; + late FirebaseDatabaseRepository repository; + + setUp(() { + functions = _MockFunctions(); + callable = _MockCallable(); + when(() => functions.httpsCallable('validatePin')).thenReturn(callable); + repository = FirebaseDatabaseRepository( + firebase: FakeFirebaseFirestore(), + functions: functions, + ); + }); + + void stubResult(Map data) { + final result = _MockResult(); + when(() => result.data).thenReturn(data); + when(() => callable.call(any())).thenAnswer((_) async => result); + } + + test('valid response maps to PinValid', () async { + stubResult({'valid': true}); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ); + expect(result, const PinValid()); + verify( + () => callable.call({'targetUserId': 'friend1', 'pin': '0742'}), + ).called(1); + }); + + test('invalid response maps to PinInvalid with attemptsRemaining', () async { + stubResult({'valid': false, 'attemptsRemaining': 3}); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '9999', + ); + expect(result, const PinInvalid(attemptsRemaining: 3)); + }); + + test('resource-exhausted maps to PinLockedOut with lockedUntil', () async { + final until = DateTime.now().add(const Duration(minutes: 15)); + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException( + code: 'resource-exhausted', + message: 'locked', + details: {'lockedUntilMillis': until.millisecondsSinceEpoch}, + ), + ); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ); + expect(result, isA()); + expect( + (result as PinLockedOut).lockedUntil.millisecondsSinceEpoch, + until.millisecondsSinceEpoch, + ); + }); + + test('unavailable/internal errors map to PinCheckUnavailable', () async { + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException(code: 'unavailable', message: 'offline'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinCheckUnavailable(), + ); + }); + + test('permission-denied and failed-precondition surface as unavailable', + () async { + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException(code: 'failed-precondition', message: 'no pin'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinCheckUnavailable(), + ); + }); +} +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `cd packages/firebase_database_repository && flutter test test/src/validate_pin_test.dart` +Expected: FAIL β€” constructor has no `functions` parameter; `validatePin` has the old positional signature. + +- [ ] **Step 4: Implement.** In `firebase_database_repository.dart`: + +Add import: `import 'package:cloud_functions/cloud_functions.dart';` + +Change the constructor (drops `const` β€” a lazily-resolved field is added): + +```dart + FirebaseDatabaseRepository({ + required FirebaseFirestore firebase, + FirebaseFunctions? functions, + }) : _firebase = firebase, + _functionsOverride = functions; + + final FirebaseFirestore _firebase; + final FirebaseFunctions? _functionsOverride; + + FirebaseFunctions get _functions => + _functionsOverride ?? FirebaseFunctions.instance; +``` + +Replace the old `validatePin` entirely: + +```dart + /// Validates a friend's PIN via the `validatePin` Cloud Function. + /// + /// The hash never reaches this client; the server enforces the + /// 5-failures / 15-minute lockout and the friends-only precondition. + Future validatePin({ + required String targetUserId, + required String pin, + }) async { + try { + final result = await _functions + .httpsCallable('validatePin') + .call({'targetUserId': targetUserId, 'pin': pin}); + final data = Map.from(result.data as Map); + if (data['valid'] == true) return const PinValid(); + return PinInvalid( + attemptsRemaining: (data['attemptsRemaining'] as num?)?.toInt() ?? 0, + ); + } on FirebaseFunctionsException catch (e) { + if (e.code == 'resource-exhausted') { + final details = e.details; + final millis = details is Map + ? (details['lockedUntilMillis'] as num?)?.toInt() + : null; + return PinLockedOut( + lockedUntil: millis != null + ? DateTime.fromMillisecondsSinceEpoch(millis) + : DateTime.now().add(const Duration(minutes: 15)), + ); + } + return const PinCheckUnavailable(); + } catch (_) { + return const PinCheckUnavailable(); + } + } +``` + +Update the three `main_*.dart` files. Each currently constructs `FirebaseDatabaseRepository(firebase: ...)` at line ~21 β€” add the functions argument so the flavor entrypoints are explicit: + +```dart + final firebaseDatabaseRepository = FirebaseDatabaseRepository( + firebase: FirebaseFirestore.instance, + functions: FirebaseFunctions.instance, + ); +``` + +with import `package:cloud_functions/cloud_functions.dart` added to each file. (Match the existing argument β€” read the file first; if it passes a different Firestore expression, keep it and only add `functions:`.) + +- [ ] **Step 5: Run all package tests + analyze** + +Run: `cd packages/firebase_database_repository && flutter test && cd ../.. && flutter analyze lib/main_development.dart lib/main_staging.dart lib/main_production.dart` +Expected: package tests PASS; analyze reports no NEW errors in the three entrypoints. `flutter analyze` at the repo root will now flag the old `validatePin(userId, pin)` call in `player_customization_bloc.dart` β€” that is expected and fixed in Task 8; do not fix it here. + +- [ ] **Step 6: Commit** + +```bash +git add packages/firebase_database_repository pubspec.yaml pubspec.lock lib/main_development.dart lib/main_staging.dart lib/main_production.dart +git commit -m "feat: validatePin repository call routes through the Cloud Function" +``` + +--- + +### Task 8: PlayerCustomizationBloc + PIN dialog β€” typed error states with lockout and offline + +**Files:** +- Modify: `lib/player/view/bloc/player_customization_state.dart` +- Modify: `lib/player/view/bloc/player_customization_bloc.dart:216-236` (`_onValidatePin`) +- Modify: `lib/player/view/customize_player_page.dart:295-424` (`_showPinDialog`) +- Modify: `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb` +- Test: `test/player/view/bloc/player_customization_bloc_test.dart` (add group; file may already exist β€” extend it, or create if missing) + +**Interfaces:** +- Consumes: Task 7's `validatePin({targetUserId, pin}) β†’ PinValidationResult`. +- Produces: + - `enum PinFlowError { none, incorrect, lockedOut, unavailable }` declared in `player_customization_state.dart` + - State fields: `PinFlowError pinFlowError` (default `.none`), `int pinAttemptsRemaining` (default `0`), `DateTime? pinLockedUntil` β€” **replacing** `String pinError`. `pinValidated` stays. + - `copyWith` gains the three fields; `pinLockedUntil` uses a nullable-preserving setter: `DateTime? Function()? pinLockedUntil`. + +- [ ] **Step 1: Write failing bloc tests.** In the player customization bloc test file, add (adjusting the bloc constructor arguments to match the existing test file's setup β€” reuse its mocks): + +```dart + group('ValidatePin', () { + blocTest( + 'emits pinValidated on PinValid', + build: () { + when( + () => firebaseDatabaseRepository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinValid()); + return buildBloc(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + expect: () => [ + isA() + .having((s) => s.pinValidated, 'pinValidated', true) + .having((s) => s.pinFlowError, 'pinFlowError', PinFlowError.none), + ], + ); + + blocTest( + 'emits incorrect with attemptsRemaining on PinInvalid', + build: () { + when( + () => firebaseDatabaseRepository.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer((_) async => const PinInvalid(attemptsRemaining: 2)); + return buildBloc(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + expect: () => [ + isA() + .having((s) => s.pinValidated, 'pinValidated', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.incorrect, + ) + .having((s) => s.pinAttemptsRemaining, 'attempts', 2), + ], + ); + + blocTest( + 'emits lockedOut with expiry on PinLockedOut', + build: () { + when( + () => firebaseDatabaseRepository.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer( + (_) async => PinLockedOut(lockedUntil: DateTime(2026, 7, 3, 12)), + ); + return buildBloc(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + expect: () => [ + isA() + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.lockedOut, + ) + .having( + (s) => s.pinLockedUntil, + 'lockedUntil', + DateTime(2026, 7, 3, 12), + ), + ], + ); + + blocTest( + 'emits unavailable on PinCheckUnavailable', + build: () { + when( + () => firebaseDatabaseRepository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinCheckUnavailable()); + return buildBloc(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + expect: () => [ + isA().having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.unavailable, + ), + ], + ); + }); +``` + +If the test file does not exist, create it with the standard shape: mocktail mocks for every constructor dependency of `PlayerCustomizationBloc` (open `player_customization_bloc.dart:1-40` to read the constructor), a `buildBloc()` helper, and `registerFallbackValue` calls as needed. + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/player/view/bloc/player_customization_bloc_test.dart` +Expected: FAIL β€” `PinFlowError` undefined; repository stub signature mismatch. + +- [ ] **Step 3: Implement state changes** in `player_customization_state.dart`: add at top level: + +```dart +/// Errors surfaced by the friend-PIN validation flow. +enum PinFlowError { + /// No error. + none, + + /// The PIN was wrong. + incorrect, + + /// Too many failed attempts; locked until [PlayerCustomizationState.pinLockedUntil]. + lockedOut, + + /// The check could not run (offline or server error). + unavailable, +} +``` + +Replace `this.pinError = ''` / `final String pinError` with: + +```dart + this.pinFlowError = PinFlowError.none, + this.pinAttemptsRemaining = 0, + this.pinLockedUntil, +``` + +```dart + final PinFlowError pinFlowError; + final int pinAttemptsRemaining; + final DateTime? pinLockedUntil; +``` + +Update `props` (replace `pinError` with the three new fields) and `copyWith` (replace the `pinError` parameter with `PinFlowError? pinFlowError`, `int? pinAttemptsRemaining`, `DateTime? Function()? pinLockedUntil`; apply `pinLockedUntil: pinLockedUntil != null ? pinLockedUntil() : this.pinLockedUntil`). + +- [ ] **Step 4: Implement bloc changes.** Replace `_onValidatePin` (currently lines 216-236) with: + +```dart + Future _onValidatePin( + ValidatePin event, + Emitter emit, + ) async { + final result = await _firebaseDatabaseRepository.validatePin( + targetUserId: event.friendUserId, + pin: event.pin, + ); + switch (result) { + case PinValid(): + emit( + state.copyWith( + pinValidated: true, + pinFlowError: PinFlowError.none, + pinLockedUntil: () => null, + ), + ); + case PinInvalid(:final attemptsRemaining): + emit( + state.copyWith( + pinValidated: false, + pinFlowError: PinFlowError.incorrect, + pinAttemptsRemaining: attemptsRemaining, + pinLockedUntil: () => null, + ), + ); + case PinLockedOut(:final lockedUntil): + emit( + state.copyWith( + pinValidated: false, + pinFlowError: PinFlowError.lockedOut, + pinLockedUntil: () => lockedUntil, + ), + ); + case PinCheckUnavailable(): + emit( + state.copyWith( + pinValidated: false, + pinFlowError: PinFlowError.unavailable, + pinLockedUntil: () => null, + ), + ); + } + } +``` + +Also update the `SelectFriend` handler at line 206: `pinError: ''` becomes `pinFlowError: PinFlowError.none`. Run `grep -n "pinError" lib/` and update every remaining reference the same way. + +- [ ] **Step 5: Add l10n strings.** In `lib/l10n/arb/app_en.arb`: + +```json + "pinIncorrectError": "Incorrect PIN. {count, plural, =1{1 attempt} other{{count} attempts}} remaining.", + "@pinIncorrectError": { + "placeholders": { "count": { "type": "int" } } + }, + "pinLockedOutError": "Too many attempts. Try again in {minutes} min.", + "@pinLockedOutError": { + "placeholders": { "minutes": { "type": "int" } } + }, + "pinUnavailableError": "Couldn't verify the PIN. Check your connection and try again.", +``` + +In `lib/l10n/arb/app_es.arb`: + +```json + "pinIncorrectError": "PIN incorrecto. {count, plural, =1{Queda 1 intento} other{Quedan {count} intentos}}.", + "pinLockedOutError": "Demasiados intentos. IntΓ©ntalo de nuevo en {minutes} min.", + "pinUnavailableError": "No se pudo verificar el PIN. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.", +``` + +Run: `flutter gen-l10n --arb-dir="lib/l10n/arb"` + +- [ ] **Step 6: Update the dialog** in `customize_player_page.dart`. In `_showPinDialog`: + - `listenWhen` (lines 308-310): replace `previous.pinError != current.pinError` with `previous.pinFlowError != current.pinFlowError`. + - `buildWhen` (lines 339-340): replace `previous.pinError != current.pinError` with `previous.pinFlowError != current.pinFlowError || previous.pinAttemptsRemaining != current.pinAttemptsRemaining || previous.pinLockedUntil != current.pinLockedUntil`. + - `errorText` (lines 380-382): replace with a mapping helper placed above `_showPinDialog`: + +```dart + String? _pinErrorText(BuildContext context, PlayerCustomizationState state) { + final l10n = context.l10n; + return switch (state.pinFlowError) { + PinFlowError.none => null, + PinFlowError.incorrect => + l10n.pinIncorrectError(state.pinAttemptsRemaining), + PinFlowError.lockedOut => l10n.pinLockedOutError( + state.pinLockedUntil == null + ? 15 + : state.pinLockedUntil! + .difference(DateTime.now()) + .inMinutes + .clamp(1, 15), + ), + PinFlowError.unavailable => l10n.pinUnavailableError, + }; + } +``` + + used as `errorText: _pinErrorText(context, state)`. + - Disable the Verify button during lockout: wrap the `FilledButton` (lines 402-414) in a `BlocBuilder` (buildWhen on `pinFlowError`) and set `onPressed` to `null` when `state.pinFlowError == PinFlowError.lockedOut`, otherwise keep the existing 4-digit gate. + +- [ ] **Step 7: Run tests + analyze** + +Run: `flutter test test/player && flutter analyze` +Expected: bloc tests PASS; analyze clean except pre-existing `app_ui`/gallery issues. + +- [ ] **Step 8: Commit** + +```bash +git add lib/player lib/l10n test/player +git commit -m "feat: typed PIN error states with lockout and offline handling in player customization" +``` + +--- + +### Task 9: Onboarding β€” `hasExistingPin` flag + PIN writes go to private credentials + +**Files:** +- Modify: `lib/onboarding/bloc/onboarding_bloc.dart` +- Modify: `lib/onboarding/bloc/onboarding_state.dart` +- Modify: `lib/onboarding/view/onboarding_form.dart:261-268` (`_PinStep`) +- Test: `test/onboarding/bloc/onboarding_bloc_test.dart` (extend or create following existing test conventions) + +**Interfaces:** +- Consumes: Task 6's `setPin`, Task 5's `hasPin` model field. +- Produces: `OnboardingState.hasExistingPin` (`bool`, replaces `String? existingPinHash`); submitted profiles carry `hasPin: true` and never a `pin` value. + +- [ ] **Step 1: Write failing bloc tests** (extend the onboarding bloc test file, reusing its existing mocks and helpers): + +```dart + group('PIN handling', () { + test('empty legacy pin string does NOT count as an existing PIN', () { + final bloc = OnboardingBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + existingProfile: const UserProfileModel(id: 'u1', pin: ''), + ); + expect(bloc.state.hasExistingPin, isFalse); + addTearDown(bloc.close); + }); + + test('hasPin flag counts as an existing PIN', () { + final bloc = OnboardingBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + existingProfile: const UserProfileModel(id: 'u1', hasPin: true), + ); + expect(bloc.state.hasExistingPin, isTrue); + addTearDown(bloc.close); + }); + + blocTest( + 'submit with a new PIN calls setPin and writes hasPin without pin', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => null); + when(() => firebaseDatabaseRepository.generateUniqueFriendCode()) + .thenAnswer((_) async => 'YETI-A3F9'); + when(() => firebaseDatabaseRepository.setPin('u1', '0742')) + .thenAnswer((_) async {}); + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => OnboardingState( + username: const Username.dirty('josh'), + pin: const Pin.dirty('0742'), + ), + act: (bloc) => bloc.add(const OnboardingSubmitted('u1')), + verify: (_) { + verify(() => firebaseDatabaseRepository.setPin('u1', '0742')) + .called(1); + final profile = verify( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + captureAny(), + ), + ).captured.single as UserProfileModel; + expect(profile.hasPin, isTrue); + expect(profile.pin, isNull); + expect(profile.onboardingComplete, isTrue); + }, + ); + }); +``` + +(Adjust `updateUserProfile` verification to the real method name/signature in the bloc β€” read `onboarding_bloc.dart:106-153` first; if the submit handler passes the model positionally or the method has a different name, mirror it exactly in the test.) + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/onboarding` +Expected: FAIL β€” `hasExistingPin` undefined. + +- [ ] **Step 3: Implement.** + +`onboarding_state.dart`: replace `final String? existingPinHash` with `final bool hasExistingPin` (default `false`); update constructor, `copyWith`, `props`, and `isStepValid` case 1 to: + +```dart + case 1: + return pin.isValid || hasExistingPin; +``` + +`onboarding_bloc.dart` constructor (lines 15-26): replace `existingPinHash: existingProfile?.pin` with: + +```dart + hasExistingPin: (existingProfile?.hasPin ?? false) || + (existingProfile?.pin?.isNotEmpty ?? false), +``` + +`_onSubmitted` (lines 106-153): remove the `pinHash` computation and the `pin:` argument from the saved `UserProfileModel`; instead pass `hasPin: state.pin.value.isNotEmpty || state.hasExistingPin` and, when `state.pin.value.isNotEmpty`, call `await _firebaseDatabaseRepository.setPin(event.userId, state.pin.value);` immediately after the profile save. + +`onboarding_form.dart` `_PinStep` (lines 256-311): replace both `state.existingPinHash != current.existingPinHash` in `buildWhen` and `final hasExistingPin = state.existingPinHash != null` with the `hasExistingPin` bool field (`previous.hasExistingPin != current.hasExistingPin`, `final hasExistingPin = state.hasExistingPin`). + +- [ ] **Step 4: Run tests + analyze** + +Run: `flutter test test/onboarding && flutter analyze` +Expected: PASS; no new analyzer issues. + +- [ ] **Step 5: Commit** + +```bash +git add lib/onboarding test/onboarding +git commit -m "feat: onboarding PIN writes to private credentials and closes empty-PIN loophole" +``` + +--- + +### Task 10: AppBloc β€” run lazy PIN migration during profile load + +**Files:** +- Modify: `lib/app/bloc/app_bloc.dart:100-139` (`_onUserChanged`) +- Test: `test/app/bloc/app_bloc_test.dart` (extend) + +**Interfaces:** +- Consumes: Task 6's `migrateLegacyPin`. +- Produces: guarantee relied on by Plan B β€” by the time a profile is evaluated for gating, the user's own legacy `pin` field has been migrated (or migration was attempted). + +- [ ] **Step 1: Write the failing test** (extend the AppBloc test file with its existing mock setup): + +```dart + blocTest( + 'migrates legacy PIN before evaluating the profile', + setUp: () { + when(() => firebaseDatabaseRepository.migrateLegacyPin('user1')) + .thenAnswer((_) async {}); + when(() => firebaseDatabaseRepository.getUserProfileOnce('user1')) + .thenAnswer( + (_) async => const UserProfileModel( + id: 'user1', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ), + ); + }, + build: buildBloc, + act: (bloc) => bloc.add(AppUserChanged(authenticatedUser)), + verify: (_) { + verifyInOrder([ + () => firebaseDatabaseRepository.migrateLegacyPin('user1'), + () => firebaseDatabaseRepository.getUserProfileOnce('user1'), + ]); + }, + ); +``` + +(Match the existing test file's user fixtures: `authenticatedUser` should be whatever non-anonymous `User` fixture the file already uses, with id `user1` β€” adjust ids to the fixture's actual value.) + +- [ ] **Step 2: Run to verify failure** + +Run: `flutter test test/app` +Expected: FAIL β€” `migrateLegacyPin` never called. + +- [ ] **Step 3: Implement.** In `_onUserChanged`, immediately before the `getUserProfileOnce` call (line ~126), add: + +```dart + // Lazily move any legacy profile-doc PIN hash into the private + // credentials doc before the profile is evaluated. Best-effort: + // the callable's legacy fallback covers failures. + await _firebaseDatabaseRepository.migrateLegacyPin(event.user.id); +``` + +(Inside the existing try block, before the profile fetch, so the generation-counter race guard still applies to the fetch result.) + +- [ ] **Step 4: Run tests** + +Run: `flutter test test/app` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/app test/app +git commit -m "feat: migrate legacy PIN hashes to private credentials on login" +``` + +--- + +### Task 11: Full verification + deployment staging + +**Files:** +- Create: `docs/superpowers/plans/2026-07-03-friends-INDEX.md` (plan tracker) +- Modify: `README.md` (add a "Firebase backend" section if one doesn't exist) + +**Interfaces:** +- Consumes: everything above. +- Produces: a verified, deployable Plan A; the INDEX file that Plans B–D update. + +- [ ] **Step 1: Run the full verification suite** + +```bash +flutter analyze +flutter test +cd packages/firebase_database_repository && flutter test && cd ../.. +cd functions && npm run build && npm test && cd .. +firebase emulators:exec --only firestore "npm --prefix functions run test:rules" +``` + +Expected: all PASS; analyze clean except pre-existing `app_ui`/gallery issues. Fix anything new before proceeding. + +- [ ] **Step 2: Add README section** describing the backend layout (`functions/`, `firestore.rules`), the emulator commands above, and the deploy command: + +```bash +firebase deploy --only firestore:rules,functions +``` + +- [ ] **Step 3: Create the plan INDEX** at `docs/superpowers/plans/2026-07-03-friends-INDEX.md`: + +```markdown +# Friends Feature β€” Plan Index + +Spec: docs/superpowers/specs/2026-07-03-friends-feature-design.md +Branch: feat/friends-hardening + +| Plan | Scope (spec phases) | Status | +|---|---|---| +| A `2026-07-03-friends-a-backend-foundation.md` | Functions + rules + private PIN (1) | in progress | +| B (not yet written) | Legacy gate + game fan-out (2–3) | pending | +| C (not yet written) | Social graph rules + blocking (4–5) | pending | +| D (not yet written) | Profile page + cleanup (6–7) | pending | + +**DEPLOY GATE:** before the first `firebase deploy --only firestore:rules`, export +the project's CURRENT production rules from the Firebase console and diff them +against `firestore.rules` β€” the console rules were never versioned and may contain +grants this repo doesn't know about. Deployment is run by Josh, not by an agent. +``` + +Update the Plan A row to `complete` once Step 1 passes. + +- [ ] **Step 4: Commit** + +```bash +git add README.md docs/superpowers/plans/2026-07-03-friends-INDEX.md docs/superpowers/plans/2026-07-03-friends-a-backend-foundation.md +git commit -m "chore: verify friends backend foundation; add plan index and deploy gate" +``` diff --git a/docs/superpowers/plans/2026-07-03-friends-b-gate-and-sync.md b/docs/superpowers/plans/2026-07-03-friends-b-gate-and-sync.md new file mode 100644 index 0000000..6f068cf --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-friends-b-gate-and-sync.md @@ -0,0 +1,860 @@ +# Friends Plan B: Legacy Gate & Game Sync 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:** Enforce name+PIN completeness for all users via the existing onboarding gate, and move game fan-out server-side (trigger) so cross-user `matches` writes can be denied by rules β€” plus the game-over `firebaseId` overwrite guard and the Plan-A carry-overs (PinNotSet, hasPin self-healing, rules header). + +**Architecture:** Spec phases 2–3 of `docs/superpowers/specs/2026-07-03-friends-feature-design.md`, plus the "Plan B must own" list in `docs/superpowers/plans/2026-07-03-friends-INDEX.md`. An `onGameCreated` Firestore trigger fans a saved game out to `hostId βˆͺ players[].firebaseId`; the client fan-out (`syncGameToPlayers`) is deleted and the TRANSITIONAL cross-user `matches` write rule flips to owner-only (game-code import still writes the user's OWN subcollection, so it keeps working). AppBloc gates on `UserProfileModel.isComplete` instead of bare `onboardingComplete`, backed by a `hasPin` self-heal in `migrateLegacyPin`. + +**Tech Stack:** unchanged from Plan A (firebase-functions v2 `onDocumentCreated`, `@firebase/rules-unit-testing`, Flutter BLoC, fake_cloud_firestore, mocktail/bloc_test). + +## Global Constraints + +- **Environment:** the global `firebase` binary is broken for `emulators:exec` β€” always use `npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"` from the repo root. +- **Analyzer baseline:** root `flutter analyze` has ~165 pre-existing issues (app_ui/gallery). Gate = no NEW issues. +- Fan-out recipient set is exactly `set(hostId βˆͺ players[].firebaseId)` β€” host always gets a copy even when not a player (existing behavior preserved); string-only players (null firebaseId) are skipped; duplicates deduped. +- The trigger copies the game document verbatim to `users/{id}/matches/{gameId}` where `gameId` is the `games/` doc id (idempotent; matches the old client fan-out's doc-id convention and the game-code import path). +- `users/{uid}/matches/{gameId}` rules become owner-only read AND write. The game-code import flow (user copies a game into their OWN matches) must keep passing rules tests. +- Gate semantics: `onboardingRequired` when `profile == null || !profile.isComplete`; anonymous/unauthenticated paths and the network-failure fallback to `authenticated` are UNCHANGED. +- `PinValidationResult` gains exactly one subtype: `PinNotSet` (const, no fields); `failed-precondition` maps to it; every other non-lockout error still maps to `PinCheckUnavailable`. +- Account-owner dropdown: slots with `firebaseId` set to someone OTHER than the current user are excluded; a "I'm not playing" sentinel option (`GameOverBloc.notPlayingId`) must exist so the page never dead-ends when all slots are friend-linked; the bloc must also guard (never clobber a foreign `firebaseId`) even if the UI misbehaves. +- All new user-facing strings in BOTH `lib/l10n/arb/app_en.arb` and `app_es.arb`; run `flutter gen-l10n --arb-dir="lib/l10n/arb"`. +- Commit after every task; TDD with RED evidence captured before implementation. +- Do not deploy; Task 8 updates the INDEX deploy gate instead. + +--- + +### Task 1: `PinNotSet` end-to-end (model β†’ repository β†’ bloc β†’ dialog β†’ l10n) + +**Files:** +- Modify: `packages/firebase_database_repository/lib/models/pin_validation_result.dart` +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (validatePin error mapping) +- Modify: `packages/firebase_database_repository/test/models/pin_validation_result_test.dart` +- Modify: `packages/firebase_database_repository/test/src/validate_pin_test.dart` +- Modify: `lib/player/view/bloc/player_customization_state.dart` (PinFlowError enum) +- Modify: `lib/player/view/bloc/player_customization_bloc.dart` (`_onValidatePin` switch) +- Modify: `lib/player/view/customize_player_page.dart` (`_pinErrorText`) +- Modify: `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb` +- Test: `test/player/player_customization_bloc_test.dart` + +**Interfaces:** +- Consumes: Plan A's sealed `PinValidationResult`, `PinFlowError`, callable error contract (`failed-precondition` = target has no PIN). +- Produces: `final class PinNotSet extends PinValidationResult` (const, no fields); `PinFlowError.notSet`; l10n key `pinNotSetError`. + +- [ ] **Step 1: Failing tests.** In `pin_validation_result_test.dart` extend the equality test with `expect(const PinNotSet(), const PinNotSet());` and add `PinNotSet() => 'notSet',` to the exhaustive-switch helper (the sealed switch will not compile until the subtype exists β€” that IS the red state; note the compile error as RED evidence). In `validate_pin_test.dart` replace the existing `failed-precondition surfaces as unavailable` test with: + +```dart + test('failed-precondition maps to PinNotSet', () async { + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException(code: 'failed-precondition', message: 'no pin'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinNotSet(), + ); + }); +``` + +In `test/player/player_customization_bloc_test.dart` add to the `ValidatePin` group: + +```dart + blocTest( + 'emits notSet on PinNotSet', + build: () { + when( + () => firebaseDatabaseRepository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinNotSet()); + return buildBloc(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + expect: () => [ + isA().having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.notSet, + ), + ], + ); +``` + +- [ ] **Step 2: RED.** `cd packages/firebase_database_repository && flutter test` β†’ compile errors (`PinNotSet` undefined). Then root: `flutter test test/player` β†’ same. + +- [ ] **Step 3: Implement.** + +`pin_validation_result.dart` β€” add after `PinLockedOut`: + +```dart +/// The target user has not set a PIN yet, so validation cannot run. +final class PinNotSet extends PinValidationResult { + /// Creates a not-set result. + const PinNotSet(); +} +``` + +Repository `validatePin` β€” inside the `FirebaseFunctionsException` catch, before the generic fallback: + +```dart + if (e.code == 'failed-precondition') { + return const PinNotSet(); + } +``` + +`player_customization_state.dart` β€” add enum value with doc comment: + +```dart + /// The selected friend has not set a PIN yet. + notSet, +``` + +`player_customization_bloc.dart` `_onValidatePin` β€” add the case (sealed switch forces it): + +```dart + case PinNotSet(): + emit( + state.copyWith( + pinValidated: false, + pinFlowError: PinFlowError.notSet, + pinLockedUntil: () => null, + ), + ); +``` + +`customize_player_page.dart` `_pinErrorText` β€” add: + +```dart + PinFlowError.notSet => l10n.pinNotSetError, +``` + +l10n β€” `app_en.arb`: `"pinNotSetError": "This friend hasn't set a PIN yet. Ask them to set one in their profile.",` and `app_es.arb`: `"pinNotSetError": "Este amigo aΓΊn no ha configurado un PIN. PΓ­dele que configure uno en su perfil.",` then `flutter gen-l10n --arb-dir="lib/l10n/arb"`. + +- [ ] **Step 4: GREEN.** `cd packages/firebase_database_repository && flutter test` and root `flutter test test/player && flutter analyze` (no new issues). + +- [ ] **Step 5: Commit.** + +```bash +git add packages/firebase_database_repository lib/player lib/l10n test/player +git commit -m "feat: PinNotSet result surfaces friends without a PIN distinctly from offline" +``` + +--- + +### Task 2: `migrateLegacyPin` self-heals the `hasPin` flag + +**Files:** +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (`migrateLegacyPin`) +- Modify: `packages/firebase_database_repository/test/src/pin_storage_test.dart` + +**Interfaces:** +- Consumes: Plan A's `migrateLegacyPin`, `_credentialsDoc`. +- Produces: guarantee for Task 3 β€” after `migrateLegacyPin`, a user whose credentials doc exists has `hasPin: true` on the profile even if an old-version client's full-doc write wiped the flag. + +- [ ] **Step 1: Failing tests** in `pin_storage_test.dart`, inside the `migrateLegacyPin` group: + +```dart + test('repairs a wiped hasPin flag when credentials exist', () async { + await firestore.doc('users/u1/private/credentials').set({ + 'pinHash': 'saltedHash', + 'salt': 'realsalt', + }); + // Old-version client full-doc write wiped the flag and has no pin field. + await firestore.collection('users').doc('u1').set({'username': 'j'}); + + await repository.migrateLegacyPin('u1'); + await Future.delayed(Duration.zero); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + }); + + test('does not create credentials or set hasPin for a user with none', + () async { + await firestore.collection('users').doc('u1').set({'username': 'j'}); + await repository.migrateLegacyPin('u1'); + await Future.delayed(Duration.zero); + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!.containsKey('hasPin'), isFalse); + }); +``` + +- [ ] **Step 2: RED.** `cd packages/firebase_database_repository && flutter test test/src/pin_storage_test.dart` β€” first new test fails (flag not repaired). + +- [ ] **Step 3: Implement.** In `migrateLegacyPin`, replace the early return for a missing legacy hash: + +```dart + final legacyHash = profile.data()?['pin'] as String?; + if (legacyHash == null || legacyHash.isEmpty) { + // Self-heal: an old-version client's full-doc profile write can + // wipe the hasPin flag after migration already ran. If the + // credentials doc exists but the flag is missing, repair it so + // the completeness gate does not bounce a PIN-holding user back + // into onboarding. + if (profile.data()?['hasPin'] != true) { + final credentials = await _credentialsDoc(userId).get(); + if (credentials.exists) { + unawaited( + profileRef + .set({'hasPin': true}, SetOptions(merge: true)) + .catchError((Object _) {}), + ); + } + } + return; + } +``` + +(The write is fire-and-forget for the same offline-hang reason as the batch commit; the gate's `isComplete` still passes this login via the legacy-pin OR-branch only when a legacy pin exists β€” for the wiped-flag cohort the repair lands from local cache before the next profile read in practice, and worst-case the user sees onboarding pre-filled once.) + +- [ ] **Step 4: GREEN.** `cd packages/firebase_database_repository && flutter test` (all, incl. Plan A migration tests). + +- [ ] **Step 5: Commit.** + +```bash +git add packages/firebase_database_repository +git commit -m "fix: migrateLegacyPin repairs a wiped hasPin flag from old-client profile writes" +``` + +--- + +### Task 3: AppBloc gates on `isComplete` + +**Files:** +- Modify: `lib/app/bloc/app_bloc.dart` (`_onUserChanged`, the profile evaluation) +- Modify: `test/app/bloc/app_bloc_test.dart` + +**Interfaces:** +- Consumes: `UserProfileModel.isComplete` (Plan A Task 5), Task 2's self-heal ordering guarantee (migration runs before the fetch β€” already wired in Plan A Task 10). +- Produces: the spec's legacy-enforcement behavior β€” users missing username OR PIN are routed to (pre-filled) onboarding. + +- [ ] **Step 1: Failing tests.** In `test/app/bloc/app_bloc_test.dart`, add a gate matrix group (reuse the file's existing fixtures/mocks; each case stubs `migrateLegacyPin` lenient + `getUserProfileOnce` with the profile shown and asserts the emitted state): + +```dart + group('completeness gate', () { + AppState expectFor(UserProfileModel? profile) { + when(() => firebaseDatabaseRepository.getUserProfileOnce(any())) + .thenAnswer((_) async => profile); + return const AppState.authenticated(User(id: 'user1')); + } + + blocTest( + 'complete profile β†’ authenticated', + setUp: () => expectFor( + const UserProfileModel( + id: 'user1', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(AppUserChanged(authenticatedUser)), + expect: () => [isA().having((s) => s.status, 'status', AppStatus.authenticated)], + ); + + blocTest( + 'onboarded but missing PIN β†’ onboardingRequired', + setUp: () => expectFor( + const UserProfileModel( + id: 'user1', + username: 'josh', + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(AppUserChanged(authenticatedUser)), + expect: () => [isA().having((s) => s.status, 'status', AppStatus.onboardingRequired)], + ); + + blocTest( + 'onboarded but empty username β†’ onboardingRequired', + setUp: () => expectFor( + const UserProfileModel( + id: 'user1', + username: '', + hasPin: true, + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(AppUserChanged(authenticatedUser)), + expect: () => [isA().having((s) => s.status, 'status', AppStatus.onboardingRequired)], + ); + + blocTest( + 'legacy unmigrated pin field counts as complete', + setUp: () => expectFor( + const UserProfileModel( + id: 'user1', + username: 'josh', + pin: 'legacyhash', + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(AppUserChanged(authenticatedUser)), + expect: () => [isA().having((s) => s.status, 'status', AppStatus.authenticated)], + ); + }); +``` + +(Adapt the helper shape to the file's conventions β€” the meaningful content is the four profile fixtures and expected statuses. Keep the existing tests: null-profile β†’ onboardingRequired and network-failure β†’ authenticated already exist from earlier work; if they don't, add them to this matrix.) + +- [ ] **Step 2: RED.** `flutter test test/app` β€” the missing-PIN and empty-username cases fail (gate currently only checks `onboardingComplete`). + +- [ ] **Step 3: Implement.** In `_onUserChanged`, replace the evaluation: + +```dart + if (profile == null || !profile.isComplete) { + return emit(AppState.onboardingRequired(event.user)); + } + return emit(AppState.authenticated(event.user)); +``` + +(Only the condition changes β€” `!profile.onboardingComplete` becomes `!profile.isComplete`. Generation guard, anonymous/unauthenticated paths, and the catch-fallback stay byte-identical.) + +- [ ] **Step 4: GREEN.** `flutter test test/app && flutter analyze`. + +- [ ] **Step 5: Commit.** + +```bash +git add lib/app test/app +git commit -m "feat: onboarding gate requires username and PIN via UserProfileModel.isComplete" +``` + +--- + +### Task 4: `onGameCreated` server-side fan-out trigger + +**Files:** +- Create: `functions/src/on-game-created.ts` +- Modify: `functions/src/index.ts` +- Create: `functions/test/rules/on-game-created.integration.test.ts` + +**Interfaces:** +- Consumes: `games/{docId}` document shape (`hostId: string`, `players: [{firebaseId?: string|null, ...}]`, full `GameModel` JSON with `id` already stamped by `saveGameStats`). +- Produces: server-guaranteed copies at `users/{id}/matches/{gameId}` for `set(hostId βˆͺ players[].firebaseId)`; Tasks 5–6 rely on this to remove the client fan-out. + +- [ ] **Step 1: Failing integration test** β€” `functions/test/rules/on-game-created.integration.test.ts`: + +```typescript +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { onGameCreated } = require('../../src/on-game-created'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(onGameCreated); + +afterAll(() => { + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all(collections.map((c) => db.recursiveDelete(c))); +}); + +function gameDoc(overrides: Record = {}) { + return { + id: 'g1', + hostId: 'host', + roomId: 'AB2C', + winnerId: 'p1', + players: [ + { id: 'p1', name: 'Josh', firebaseId: 'host' }, + { id: 'p2', name: 'Friend', firebaseId: 'friend1' }, + { id: 'p3', name: 'Guest', firebaseId: null }, + { id: 'p4', name: 'Dup', firebaseId: 'friend1' }, + ], + ...overrides, + }; +} + +async function fireWith(data: Record, gameId = 'g1') { + const snap = testEnv.firestore.makeDocumentSnapshot(data, `games/${gameId}`); + await wrapped({ data: snap, params: { gameId } }); +} + +test('fans out to host and linked players, deduped, skipping null ids', async () => { + await fireWith(gameDoc()); + const host = await db.doc('users/host/matches/g1').get(); + const friend = await db.doc('users/friend1/matches/g1').get(); + expect(host.exists).toBe(true); + expect(friend.exists).toBe(true); + expect(friend.data()!.roomId).toBe('AB2C'); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(2); +}); + +test('host gets a copy even when not in any player slot', async () => { + await fireWith( + gameDoc({ + players: [{ id: 'p1', name: 'Friend', firebaseId: 'friend1' }], + }), + ); + expect((await db.doc('users/host/matches/g1').get()).exists).toBe(true); +}); + +test('no linked players and empty hostId writes nothing', async () => { + await fireWith( + gameDoc({ hostId: '', players: [{ id: 'p1', name: 'X', firebaseId: null }] }), + ); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(0); +}); + +test('is idempotent: re-firing overwrites the same doc, not a new one', async () => { + await fireWith(gameDoc()); + await fireWith(gameDoc()); + const friendDocs = await db.collection('users/friend1/matches').get(); + expect(friendDocs.size).toBe(1); +}); + +test('malformed firebaseId values are skipped without throwing', async () => { + await fireWith( + gameDoc({ + players: [ + { id: 'p1', firebaseId: 42 }, + { id: 'p2', firebaseId: 'ok-user' }, + ], + }), + ); + expect((await db.doc('users/ok-user/matches/g1').get()).exists).toBe(true); +}); +``` + +- [ ] **Step 2: RED.** `cd functions && npm run build` fails or the suite fails with `Cannot find module '../../src/on-game-created'`: +`npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"` + +- [ ] **Step 3: Implement `functions/src/on-game-created.ts`:** + +```typescript +import * as admin from 'firebase-admin'; +import { onDocumentCreated } from 'firebase-functions/v2/firestore'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +/** + * Fans a newly saved game out to every linked player's match history. + * Recipients: set(hostId βˆͺ players[].firebaseId). Idempotent β€” the copy + * doc id is the games/ doc id, so retries and re-fires overwrite in place. + */ +export const onGameCreated = onDocumentCreated( + { document: 'games/{gameId}', retry: true }, + async (event) => { + const snapshot = event.data; + if (!snapshot) return; + const game = snapshot.data(); + const gameId = event.params.gameId; + + const ids = new Set(); + if (typeof game.hostId === 'string' && game.hostId.length > 0) { + ids.add(game.hostId); + } + const players = Array.isArray(game.players) ? game.players : []; + for (const player of players) { + const firebaseId = player?.firebaseId; + if (typeof firebaseId === 'string' && firebaseId.length > 0) { + ids.add(firebaseId); + } + } + if (ids.size === 0) return; + + const db = admin.firestore(); + const batch = db.batch(); + for (const id of ids) { + batch.set(db.doc(`users/${id}/matches/${gameId}`), game); + } + await batch.commit(); + }, +); +``` + +`functions/src/index.ts`: + +```typescript +export { validatePin } from './validate-pin'; +export { onGameCreated } from './on-game-created'; +``` + +- [ ] **Step 4: GREEN.** Build + run the full rules script (Task 2/4 suites must stay green): +`cd functions && npm run build && cd .. && npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"` and `cd functions && npm test` (pure suite untouched). +If `testEnv.wrap` for v2 Firestore triggers requires a different invocation shape (CloudEvent envelope), adapt the TEST harness only β€” the trigger contract (paths, recipient set, idempotency) is binding; report the adaptation. + +- [ ] **Step 5: Commit.** + +```bash +git add functions/src/on-game-created.ts functions/src/index.ts functions/test/rules/on-game-created.integration.test.ts +git commit -m "feat: onGameCreated trigger fans games out to host and linked players" +``` + +--- + +### Task 5: Tighten `matches` rules to owner-only + rules header + +**Files:** +- Modify: `firestore.rules` +- Modify: `functions/test/rules/firestore-rules.test.ts` + +**Interfaces:** +- Consumes: Task 4's trigger (Admin SDK bypasses rules, so server fan-out is unaffected). +- Produces: the spec's rules-table row `users/{uid}/matches/** β€” read owner / write owner`. + +- [ ] **Step 1: Failing tests.** In `firestore-rules.test.ts`, replace the TRANSITIONAL matches test: + +```typescript + test('cross-user match writes are denied (fan-out is server-side)', async () => { + await assertFails( + setDoc(doc(bob(), 'users/alice/matches/g2'), { id: 'g2' }), + ); + }); + + test('owner may write own matches (game-code import path)', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/matches/g3'), { id: 'g3' }), + ); + }); +``` + +- [ ] **Step 2: RED.** `npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"` β€” the denial test fails against current transitional rules. + +- [ ] **Step 3: Implement.** In `firestore.rules`: replace the matches block: + +``` + match /matches/{gameId} { + // Owner-only. Fan-out to other players happens server-side via + // the onGameCreated trigger (Admin SDK bypasses rules); the + // owner write covers the game-code import flow. + allow read, write: if isOwner(uid); + } +``` + +And add the header at the very top of the file (before `rules_version`... rules files require `rules_version` first β€” place the comment immediately AFTER the `rules_version = '2';` line): + +``` +// Magic Yeti Firestore rules β€” staged hardening. +// Blocks labeled TRANSITIONAL are deliberately permissive so the shipped +// client keeps working, and are tightened by the named follow-up plan. +// Strategy + deploy gate: docs/superpowers/plans/2026-07-03-friends-INDEX.md +``` + +- [ ] **Step 4: GREEN.** Same emulators:exec command β€” full rules suite green (including Task 4's trigger tests, which use the Admin SDK and must be unaffected). + +- [ ] **Step 5: Commit.** + +```bash +git add firestore.rules functions/test/rules/firestore-rules.test.ts +git commit -m "feat: matches writes are owner-only; rules header documents staged hardening" +``` + +--- + +### Task 6: GameOverBloc β€” overwrite guard, not-playing sentinel, preselect, drop client fan-out + +**Files:** +- Modify: `lib/life_counter/bloc/game_over_bloc.dart` +- Modify: `lib/life_counter/view/game_over_page.dart:56-64` (bloc construction gains `currentUserId`) +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (delete `syncGameToPlayers`) +- Test: `test/life_counter/bloc/game_over_bloc_test.dart` (create or extend; check `ls test/life_counter` first) + +**Interfaces:** +- Consumes: Task 4 (fan-out now server-side). +- Produces: `GameOverBloc({required List players, required String currentUserId, required FirebaseDatabaseRepository firebaseDatabaseRepository})`; `static const notPlayingId = 'game_over_not_playing';` initial `selectedPlayerId` preselected to the slot whose `firebaseId == currentUserId` (else null). Task 7's UI depends on these exact names. + +- [ ] **Step 1: Failing bloc tests** (mirror repo bloc-test conventions; mock the repository): + +```dart + group('GameOverBloc', () { + final linkedFriendSlot = basePlayer.copyWith( + // adapt: construct a Player with id 'p2', firebaseId 'friend1', placement 2 + ); + + test('preselects the slot already linked to the current user', () { + final bloc = buildBloc( + players: [hostLinkedSlot, unlinkedSlot], // hostLinkedSlot.firebaseId == 'host' + currentUserId: 'host', + ); + expect(bloc.state.selectedPlayerId, hostLinkedSlot.id); + }); + + blocTest( + 'never clobbers a slot linked to another account', + build: () => buildBloc( + players: [linkedFriendSlot, unlinkedSlot], + currentUserId: 'host', + ), + seed: () => /* state with selectedPlayerId: linkedFriendSlot.id */, + act: (bloc) => bloc.add(SendGameOverStatsEvent(gameModel: gameModel, userId: 'host')), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + final slot = saved.players.firstWhere((p) => p.id == linkedFriendSlot.id); + expect(slot.firebaseId, 'friend1'); // NOT 'host' + }, + ); + + blocTest( + 'notPlayingId assigns the host uid to no slot', + build: () => buildBloc(players: [linkedFriendSlot, unlinkedSlot], currentUserId: 'host'), + seed: () => /* state with selectedPlayerId: GameOverBloc.notPlayingId */, + act: (bloc) => bloc.add(SendGameOverStatsEvent(gameModel: gameModel, userId: 'host')), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + expect(saved.players.every((p) => p.firebaseId != 'host'), isTrue); + }, + ); + + blocTest( + 'does not call client-side fan-out', + build: () => buildBloc(players: [unlinkedSlot], currentUserId: 'host'), + seed: () => /* selectedPlayerId: unlinkedSlot.id */, + act: (bloc) => bloc.add(SendGameOverStatsEvent(gameModel: gameModel, userId: 'host')), + verify: (_) { + // syncGameToPlayers no longer exists on the repository; this test + // asserts the ONLY repository call is saveGameStats. + verify(() => firebaseDatabaseRepository.saveGameStats(any())).called(1); + verifyNoMoreInteractions(firebaseDatabaseRepository); + }, + ); + }); +``` + +(Flesh out `basePlayer`/`gameModel` fixtures from `player_repository`'s `Player` constructor β€” see `packages/player_repository/lib/models/player.dart`; a minimal Player needs id, name, playerNumber, lifePoints, color, opponents, state and placement for eliminated players. Read the file and build the smallest valid fixtures.) + +- [ ] **Step 2: RED.** `flutter test test/life_counter` β€” constructor lacks `currentUserId`, guard absent, fan-out still called. + +- [ ] **Step 3: Implement.** `game_over_bloc.dart`: + +Constructor and preselect: + +```dart + GameOverBloc({ + required List players, + required String currentUserId, + required FirebaseDatabaseRepository firebaseDatabaseRepository, + }) : _firebaseDatabaseRepository = firebaseDatabaseRepository, + super( + GameOverState( + standings: List.from(players) + ..sort((a, b) => a.placement.compareTo(b.placement)), + selectedPlayerId: players + .where((p) => p.firebaseId == currentUserId) + .map((p) => p.id) + .firstOrNull, + firstPlayerId: null, + ), + ) { +``` + +Sentinel: + +```dart + /// Dropdown sentinel meaning the current user is not one of the players. + static const notPlayingId = 'game_over_not_playing'; +``` + +Guarded assignment in `_onSendGameStatsToDatabase` (replace the `firebaseId:` lambda): + +```dart + firebaseId: () { + if (player.id != state.selectedPlayerId) return player.firebaseId; + // Never clobber a slot already linked to another account + // (PIN-linked friend); the UI excludes these, this guards it. + if (player.firebaseId != null && + player.firebaseId != event.userId) { + return player.firebaseId; + } + return event.userId; + }, +``` + +Delete the fan-out block (lines collecting `playerFirebaseIds` and the `syncGameToPlayers` call) β€” the trigger owns it; keep `saveGameStats` + the success emit. Add a one-line comment: `// Fan-out to players' match histories happens server-side (onGameCreated).` + +`game_over_page.dart` bloc construction: + +```dart + create: (context) => GameOverBloc( + players: context.read().getPlayers(), + currentUserId: context.read().state.user.id, + firebaseDatabaseRepository: context.read(), + ), +``` + +Repository: delete `syncGameToPlayers` entirely (its doc comment too). `addMatchToPlayerHistory` STAYS (game-code import uses it). If `firstOrNull` needs it, `package:collection` is already a transitive dep β€” import `package:collection/collection.dart`; if the analyzer objects to the dependency, use `players.where(...).map((p) => p.id).cast().firstWhere((_) => true, orElse: () => null)` β€” prefer `firstOrNull`. + +- [ ] **Step 4: GREEN.** `flutter test test/life_counter && cd packages/firebase_database_repository && flutter test && cd ../.. && flutter analyze` (deleting `syncGameToPlayers` must not break anything else β€” `grep -rn "syncGameToPlayers" lib/ test/ packages/` must return zero hits). + +- [ ] **Step 5: Commit.** + +```bash +git add lib/life_counter packages/firebase_database_repository test/life_counter +git commit -m "feat: game-over guard for linked slots; fan-out moves fully server-side" +``` + +--- + +### Task 7: Game-over page UI β€” dropdown exclusion, linked badges, not-playing option + +**Files:** +- Modify: `lib/life_counter/view/game_over_page.dart` (`_DetailsPanel`, `_PlayerDropdown` usage at lines ~644-649; `_StandingRow`) +- Modify: `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb` +- Test: `test/life_counter/view/game_over_page_test.dart` (create; light widget coverage) + +**Interfaces:** +- Consumes: Task 6's `GameOverBloc.notPlayingId`, preselect, and constructor. +- Produces: final phase-2/3 UX. + +- [ ] **Step 1: l10n keys.** `app_en.arb`: + +```json + "notPlayingOption": "I'm not playing", + "linkedAccountBadge": "Linked to a friend's account", +``` + +`app_es.arb`: + +```json + "notPlayingOption": "No estoy jugando", + "linkedAccountBadge": "Vinculado a la cuenta de un amigo", +``` + +Run `flutter gen-l10n --arb-dir="lib/l10n/arb"`. + +- [ ] **Step 2: Failing widget test** β€” `test/life_counter/view/game_over_page_test.dart`: pump `GameOverView` wrapped with mocked `GameBloc` (gameModel present), `TimerBloc`, `AppBloc` (user id 'host'), `PlayerRepository`, and a real `GameOverBloc` built with: one slot linked to 'friend1', one linked to 'host', one unlinked. Assertions: + - the account-owner dropdown's items do NOT include the friend-linked player's name but DO include the unlinked player, the host-linked player, and the `notPlayingOption` label; + - the friend-linked standings row shows the linked badge icon (`byIcon(Icons.link_rounded)` finds at least one); + - the dropdown preselects the host-linked slot (its name appears as the dropdown's current value). +Follow the repo's existing widget-test helpers (look at any existing test under `test/` that pumps a page with `MaterialApp` + `flutter_localizations`; reuse its pump helper if one exists). + +- [ ] **Step 3: RED.** `flutter test test/life_counter/view/game_over_page_test.dart`. + +- [ ] **Step 4: Implement in `game_over_page.dart`.** + +In `_DetailsPanel`, replace the account-owner `_PlayerDropdown` (lines ~644-649) with a dedicated dropdown that filters and appends the sentinel: + +```dart + _AccountOwnerDropdown( + value: state.selectedPlayerId, + players: players, + currentUserId: context.read().state.user.id, + onChanged: (v) => + context.read().add(UpdateSelectedPlayerEvent(v)), + ), +``` + +New widget (place near `_PlayerDropdown`, reusing its decoration verbatim): + +```dart +class _AccountOwnerDropdown extends StatelessWidget { + const _AccountOwnerDropdown({ + required this.value, + required this.players, + required this.currentUserId, + required this.onChanged, + }); + + final String? value; + final List players; + final String currentUserId; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selectable = players + .where( + (p) => p.firebaseId == null || p.firebaseId == currentUserId, + ) + .toList(); + return DropdownButtonFormField( + initialValue: value, + dropdownColor: _MC.surfaceRaised, + style: const TextStyle( + color: _MC.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + iconEnabledColor: _MC.textSecondary, + decoration: /* copy the InputDecoration from _PlayerDropdown verbatim */, + items: [ + ...selectable.map( + (p) => DropdownMenuItem(value: p.id, child: Text(p.name)), + ), + DropdownMenuItem( + value: GameOverBloc.notPlayingId, + child: Text(context.l10n.notPlayingOption), + ), + ], + onChanged: onChanged, + ); + } +} +``` + +In `_StandingRow`, after the player/commander name column (before the drag handle), add the badge: + +```dart + if (player.firebaseId != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: context.l10n.linkedAccountBadge, + child: const Icon( + Icons.link_rounded, + color: _MC.accent, + size: 16, + ), + ), + ), +``` + +- [ ] **Step 5: GREEN.** `flutter test test/life_counter && flutter test && flutter analyze` (full suite once β€” this task closes the phase's client work). + +- [ ] **Step 6: Commit.** + +```bash +git add lib/life_counter lib/l10n test/life_counter +git commit -m "feat: game-over account picker excludes linked slots, adds not-playing and linked badges" +``` + +--- + +### Task 8: Verification + INDEX update + +**Files:** +- Modify: `docs/superpowers/plans/2026-07-03-friends-INDEX.md` + +**Interfaces:** consumes everything above; produces the Plan B closure record. + +- [ ] **Step 1: Full verification suite** + +```bash +flutter analyze +flutter test +cd packages/firebase_database_repository && flutter test && cd ../.. +cd functions && npm run build && npm test && cd .. +npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules" +``` + +All green; analyze at baseline. + +- [ ] **Step 2: INDEX update.** Mark Plan B's row `complete` (file name `2026-07-03-friends-b-gate-and-sync.md`); move the satisfied "Plan B must own" items into a "Resolved in Plan B" list (hasPin self-heal, PinNotSet, TRANSITIONAL header, isComplete gate, matches tightening, overwrite guard); extend the DEPLOY GATE with: **this plan's rules tightening and the app's fan-out removal MUST deploy together with the `onGameCreated` function** β€” deploying rules without the function (or shipping the app without deploying either) silently stops friends' match-history sync; the force-upgrade pairing decision (old clients break against migrated PINs and denied cross-user match writes once rules deploy) is Josh's call at release time. + +- [ ] **Step 3: Commit.** + +```bash +git add docs/superpowers/plans/2026-07-03-friends-INDEX.md docs/superpowers/plans/2026-07-03-friends-b-gate-and-sync.md +git commit -m "chore: verify friends plan B; update index and deploy gate" +``` diff --git a/docs/superpowers/plans/2026-07-03-friends-c-social-graph-blocking.md b/docs/superpowers/plans/2026-07-03-friends-c-social-graph-blocking.md new file mode 100644 index 0000000..672e531 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-friends-c-social-graph-blocking.md @@ -0,0 +1,693 @@ +# Friends Plan C: Social Graph & Blocking 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:** Harden the social graph with deterministic request IDs and a full rules lifecycle matrix, ship full blocking (hidden from search, requests refused, unselectable via friendship removal), move friend-code search behind a block-aware callable, and close the trigger-injection debt from the Plan B review. + +**Architecture:** Spec phases 4–5 of `docs/superpowers/specs/2026-07-03-friends-feature-design.md` plus the "Plan C must own" list in `docs/superpowers/plans/2026-07-03-friends-INDEX.md`. Friend-request docs move to deterministic IDs (`{senderId}_{receiverId}`) so security rules can check pending/declined/blocked preconditions with `exists()`. Social-graph WRITES stay client-side (batches) secured by rules; the ONLY new callable is `searchByFriendCode` (rules can't filter query results, so block-hiding needs a server lookup). Declining retains the doc (`status: 'declined'`) to power silent re-send suppression. Blocks live at `users/{uid}/blocks/{blockedUid}` (owner-managed; enforced against senders via `exists()` in the request-create rule). + +**Tech Stack:** unchanged (firebase-functions v2, `@firebase/rules-unit-testing`, Flutter BLoC, fake_cloud_firestore, mocktail/bloc_test). + +## Global Constraints + +- **Environment:** rules/emulator suite runs via `npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"` from the repo root (plain `firebase` binary broken). ALWAYS pipe flutter output through `tail -5` (bare output kills agent streams). Analyzer gate: no NEW issues vs the ~165 root baseline. +- Deterministic request doc ID is exactly `'{senderId}_{receiverId}'`. CREATE enforces it in rules; UPDATE/DELETE rules are participant-scoped via `resource.data` so legacy random-ID docs remain declinable/cancelable. +- **Named, accepted legacy breakage:** pending requests created before this deploy (random doc IDs) cannot be ACCEPTED once rules tighten (the friendship-edge create rule checks the deterministic path). They CAN be declined. The accept flow maps the rules denial to friendly "ask them to re-send" copy. Record in INDEX. +- Blocking semantics: blocking removes the friendship both ways, deletes/declines pending requests both ways, and writes `users/{me}/blocks/{target}`; blocked users get not-found from search and permission-denied (surfaced as normal "sent") on request create; unblock deletes the block doc only β€” no auto re-friend. +- Block-status concealment is UI-level only (spec's accepted tradeoff): wire-level `permission-denied` on create is mapped to `FriendRequestResult.sent`. +- `searchByFriendCode` callable: request `{code}`; response `{found: bool, user?: {id, username, imageUrl, friendCode}, relationship?: 'self'|'friends'|'pendingSent'|'pendingReceived'|'none'}`; returns `found: false` when either party blocks the other; requires authenticated non-anonymous caller; normalizes code (trim/uppercase) server-side. +- Trigger hardening (Plan B review debt): `games` create rule requires `request.resource.data.hostId == request.auth.uid`; `onGameCreated` rejects `firebaseId`/`hostId` values containing `/`; malformed-input tests added (non-array `players`, missing `hostId`, slash ids). +- All new user-facing strings in BOTH arb files + `flutter gen-l10n --arb-dir="lib/l10n/arb"`. +- TDD per task; commit per task; current branch `feat/friends-hardening`; no deploys (INDEX gate). + +--- + +### Task 1: Trigger + games-create hardening (Plan B review debt) + +**Files:** +- Modify: `functions/src/on-game-created.ts` +- Modify: `firestore.rules` (games block) +- Modify: `functions/test/rules/on-game-created.integration.test.ts` +- Modify: `functions/test/rules/firestore-rules.test.ts` + +**Interfaces:** +- Consumes: Plan B's trigger and rules. +- Produces: injection-hardened fan-out; `games` create validated to the caller's uid. + +- [ ] **Step 1: Failing tests.** In `on-game-created.integration.test.ts` add: + +```typescript +test('ids containing a slash are skipped, others still fan out', async () => { + await fireWith( + gameDoc({ + hostId: 'users/alice', + players: [ + { id: 'p1', firebaseId: 'x/y' }, + { id: 'p2', firebaseId: 'ok-user' }, + ], + }), + ); + expect((await db.doc('users/ok-user/matches/g1').get()).exists).toBe(true); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(1); +}); + +test('non-array players field writes only the host copy', async () => { + await fireWith(gameDoc({ players: 'corrupt' })); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(1); + expect((await db.doc('users/host/matches/g1').get()).exists).toBe(true); +}); + +test('missing hostId key with valid players still fans out to players', async () => { + const data = gameDoc({ players: [{ id: 'p1', firebaseId: 'friend1' }] }); + delete (data as Record).hostId; + await fireWith(data); + expect((await db.doc('users/friend1/matches/g1').get()).exists).toBe(true); +}); +``` + +In `firestore-rules.test.ts`, replace the games create test: + +```typescript + test('signed-in user can create a game they host; foreign hostId denied', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'games/g10'), { hostId: 'alice', roomId: 'AB2C' }), + ); + await assertFails( + setDoc(doc(alice(), 'games/g11'), { hostId: 'bob', roomId: 'CD3E' }), + ); + }); +``` + +- [ ] **Step 2: RED** via emulators:exec (slash test fans out to the hostile path or throws; foreign-hostId create currently allowed). + +- [ ] **Step 3: Implement.** In `on-game-created.ts`, extract a helper and use it for both hostId and player ids: + +```typescript +function isPlausibleUid(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('/'); +} +``` + +Replace the two checks (`typeof game.hostId === 'string' && game.hostId.length > 0` and the player-loop condition) with `isPlausibleUid(...)`, with a comment: a `/` in an id would address a different document path entirely and, under `retry: true`, a thrown batch would starve legitimate recipients for the retry window. + +In `firestore.rules`, games block: + +``` + match /games/{gameId} { + allow read: if signedIn(); + // Creator must be the host they claim β€” the trigger fans the doc out + // to hostId's match history, so a forged hostId would inject games + // into another user's history. + allow create: if signedIn() && request.resource.data.hostId == request.auth.uid; + allow update, delete: if signedIn() && resource.data.hostId == request.auth.uid; + } +``` + +NOTE the client impact check: `GameOverBloc` sets `hostId: event.userId` (the caller) before `saveGameStats` β€” compliant. `checkIfGameIdExists` only reads. No other client writes `games/`. + +- [ ] **Step 4: GREEN** β€” full emulator suite + `cd functions && npm test`. +- [ ] **Step 5: Commit** β€” `fix: harden onGameCreated against injected ids; games create requires own hostId` + +--- + +### Task 2: Repository β€” deterministic request IDs, declined retention, silent suppression + +**Files:** +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (`addFriendRequest`, `declineFriendRequest`; add `friendRequestDocId` static helper) +- Create: `packages/firebase_database_repository/test/src/friend_request_lifecycle_test.dart` + +**Interfaces:** +- Consumes: existing `FriendRequestModel`, `FriendRequestResult`, `acceptFriendRequest` (unchanged). +- Produces (Tasks 3, 5, 6 rely on): `static String friendRequestDocId(String senderId, String receiverId) => '${senderId}_$receiverId';` β€” new requests created AT that doc id with the same field shape as today (`id` field = doc id); `declineFriendRequest(requestId)` now UPDATES `{status: 'declined'}` instead of deleting; `addFriendRequest` returns `FriendRequestResult.sent` silently when a declined own-direction doc exists OR when the create is denied by rules (blocked). + +- [ ] **Step 1: Failing tests** (fake_cloud_firestore; no rules in fakes β€” permission-denied mapping is tested via a thrown `FirebaseException` with a mocked... fake_cloud_firestore can't throw rules errors: assert the try/catch mapping by unit-testing the catch branch is exercised through a `whenCalling`... SKIP wire-level denial here; Task 3's rules tests cover the denial itself, and the catch-mapping is a 3-line `on FirebaseException catch` β€” verify by code review + the widget flow. State this explicitly in the test file header comment.) Test cases: + +```dart + test('new requests use the deterministic doc id and id field', () async { + final result = + await repository.addFriendRequest('alice', 'Alice', 'bob'); + expect(result, FriendRequestResult.sent); + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.exists, isTrue); + expect(doc.data()!['id'], 'alice_bob'); + expect(doc.data()!['status'], 'pending'); + expect(doc.data()!['senderId'], 'alice'); + expect(doc.data()!['receiverId'], 'bob'); + }); + + test('re-send onto an existing pending returns alreadyPending', () async { + await repository.addFriendRequest('alice', 'Alice', 'bob'); + expect( + await repository.addFriendRequest('alice', 'Alice', 'bob'), + FriendRequestResult.alreadyPending, + ); + }); + + test('declined doc suppresses re-send silently as sent', () async { + await repository.addFriendRequest('alice', 'Alice', 'bob'); + await repository.declineFriendRequest('alice_bob'); + final result = + await repository.addFriendRequest('alice', 'Alice', 'bob'); + expect(result, FriendRequestResult.sent); + // Still declined β€” no new pending doc, receiver never sees it again. + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.data()!['status'], 'declined'); + }); + + test('decline retains the doc with status declined', () async { + await repository.addFriendRequest('alice', 'Alice', 'bob'); + await repository.declineFriendRequest('alice_bob'); + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.exists, isTrue); + expect(doc.data()!['status'], 'declined'); + }); + + test('reverse pending auto-accepts and removes both request docs', + () async { + await firestore.collection('users').doc('alice').set({'username': 'a'}); + await firestore.collection('users').doc('bob').set({'username': 'b'}); + await repository.addFriendRequest('bob', 'Bob', 'alice'); + final result = + await repository.addFriendRequest('alice', 'Alice', 'bob'); + expect(result, FriendRequestResult.autoAccepted); + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friends/bob/friendList/alice').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friendRequests/bob_alice').get()).exists, + isFalse, + ); + }); + + test('getFriendRequests still filters to pending only', () async { + await repository.addFriendRequest('alice', 'Alice', 'bob'); + await repository.addFriendRequest('carol', 'Carol', 'bob'); + await repository.declineFriendRequest('alice_bob'); + final requests = await repository.getFriendRequests('bob'); + expect(requests.map((r) => r.senderId), ['carol']); + }); +``` + +(Standard `setUp` with `FakeFirebaseFirestore` + `FirebaseDatabaseRepository(firebase: firestore)` β€” mirror `pin_storage_test.dart`.) + +- [ ] **Step 2: RED** β€” `cd packages/firebase_database_repository && flutter test test/src/friend_request_lifecycle_test.dart 2>&1 | tail -5` + +- [ ] **Step 3: Implement.** In `addFriendRequest` (currently lines ~385-441): + - Keep the self/already-friends guards. + - Replace the two pending QUERIES with deterministic-doc reads: + +```dart + final ownDocRef = _friendCollection.doc(friendRequestDocId(senderId, receiverId)); + final ownDoc = await ownDocRef.get(); + if (ownDoc.exists) { + final status = (ownDoc.data()! as Map)['status']; + // A declined request stays declined; the sender sees "sent" and the + // receiver never sees it again (silent re-send suppression). + if (status == 'declined') return FriendRequestResult.sent; + return FriendRequestResult.alreadyPending; + } + + final reverseDoc = await _friendCollection + .doc(friendRequestDocId(receiverId, senderId)) + .get(); + if (reverseDoc.exists && + (reverseDoc.data()! as Map)['status'] == 'pending') { + final reverseModel = FriendRequestModel.fromJson( + reverseDoc.data()! as Map, + ); + await acceptFriendRequest(reverseModel, senderId); + return FriendRequestResult.autoAccepted; + } +``` + + - Create at the deterministic id, and map a rules denial (blocked) to silent `sent`: + +```dart + try { + final documentId = friendRequestDocId(senderId, receiverId); + await _friendCollection.doc(documentId).set({ + 'id': documentId, + 'senderId': senderId, + 'senderName': senderName, + 'receiverId': receiverId, + 'status': 'pending', + 'timestamp': FieldValue.serverTimestamp(), + }); + return FriendRequestResult.sent; + } on FirebaseException catch (e) { + // A blocked sender is denied by rules; concealment is deliberate β€” + // they see the same "sent" as everyone else (spec accepted tradeoff). + if (e.code == 'permission-denied') return FriendRequestResult.sent; + rethrow; + } +``` + + - `declineFriendRequest`: replace the delete with `await _friendCollection.doc(requestId).update({'status': 'declined'});` + - Add the static helper with a doc comment. + +Legacy-pending note (verbatim comment above `addFriendRequest`): reverse-direction LEGACY (random-id) pendings won't be found by the deterministic reverse read, so no auto-accept for them β€” the sender simply creates a new deterministic request and the receiver now has two pendings, one legacy (declinable) and one acceptable. Acceptable drain path. + +- [ ] **Step 4: GREEN** β€” full package suite + package analyze (no new). +- [ ] **Step 5: Commit** β€” `feat: deterministic friend-request ids with declined retention and silent re-send suppression` + +--- + +### Task 3: Rules lifecycle matrix β€” friendRequests, friendList, blocks + +**Files:** +- Modify: `firestore.rules` +- Modify: `functions/test/rules/firestore-rules.test.ts` + +**Interfaces:** +- Consumes: Task 2's deterministic IDs and field shapes; Plan A's `isOwner`. +- Produces: the spec's rules table rows for `friendRequests`, `friends/friendList`, `users/{uid}/blocks`. + +- [ ] **Step 1: Failing tests.** Replace/extend the friends + friendRequests groups: + +```typescript +describe('blocks', () => { + test('owner reads and writes own block docs; others cannot', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/blocks/bob'), { blockedAt: 1 }), + ); + await assertSucceeds(getDoc(doc(alice(), 'users/alice/blocks/bob'))); + await assertFails(getDoc(doc(bob(), 'users/alice/blocks/bob'))); + await assertFails( + setDoc(doc(bob(), 'users/alice/blocks/carol'), { blockedAt: 1 }), + ); + }); +}); + +describe('friendRequests lifecycle', () => { + const pending = { + id: 'alice_bob', + senderId: 'alice', + receiverId: 'bob', + senderName: 'Alice', + status: 'pending', + }; + + test('sender creates at the deterministic id', async () => { + await assertSucceeds(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('create with a mismatched doc id is denied', async () => { + await assertFails(setDoc(doc(alice(), 'friendRequests/wrong_id'), pending)); + }); + + test('create claiming another sender is denied', async () => { + await assertFails( + setDoc(doc(bob(), 'friendRequests/alice_bob'), pending), + ); + }); + + test('create is denied when the receiver blocks the sender', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/bob/blocks/alice'), { blockedAt: 1 }); + }); + await assertFails(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('create is denied when the sender blocks the receiver', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/alice/blocks/bob'), { blockedAt: 1 }); + }); + await assertFails(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('receiver declines pending -> declined; sender cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertFails( + updateDoc(doc(alice(), 'friendRequests/alice_bob'), { status: 'declined' }), + ); + await assertSucceeds( + updateDoc(doc(bob(), 'friendRequests/alice_bob'), { status: 'declined' }), + ); + }); + + test('declined docs are immutable to further updates', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + ...pending, + status: 'declined', + }); + }); + await assertFails( + updateDoc(doc(bob(), 'friendRequests/alice_bob'), { status: 'pending' }), + ); + await assertFails( + updateDoc(doc(alice(), 'friendRequests/alice_bob'), { status: 'pending' }), + ); + }); + + test('sender may cancel (delete) a pending; receiver may delete (accept path)', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertSucceeds(deleteDoc(doc(bob(), 'friendRequests/alice_bob'))); + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertSucceeds(deleteDoc(doc(alice(), 'friendRequests/alice_bob'))); + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertFails( + deleteDoc(doc(env.authenticatedContext('carol').firestore(), 'friendRequests/alice_bob')), + ); + }); +}); + +describe('friendList lifecycle', () => { + test('accepting receiver writes both edges while the pending doc exists', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }); + }); + // bob (receiver) writes alice onto his own list… + await assertSucceeds( + setDoc(doc(bob(), 'friends/bob/friendList/alice'), { userId: 'alice' }), + ); + // …and himself onto alice's list. + await assertSucceeds( + setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'bob' }), + ); + }); + + test('edge writes without a matching pending request are denied', async () => { + await assertFails( + setDoc(doc(bob(), 'friends/bob/friendList/carol'), { userId: 'carol' }), + ); + await assertFails( + setDoc(doc(bob(), 'friends/carol/friendList/bob'), { userId: 'bob' }), + ); + }); + + test('owner may always delete own edges; you may always delete yourself from another list', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friends/alice/friendList/bob'), { userId: 'bob' }); + await setDoc(doc(ctx.firestore(), 'friends/bob/friendList/alice'), { userId: 'alice' }); + }); + await assertSucceeds(deleteDoc(doc(alice(), 'friends/alice/friendList/bob'))); + await assertSucceeds(deleteDoc(doc(alice(), 'friends/bob/friendList/alice'))); + }); + + test('a third party cannot delete someone else's edge', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friends/alice/friendList/bob'), { userId: 'bob' }); + }); + await assertFails( + deleteDoc(doc(env.authenticatedContext('carol').firestore(), 'friends/alice/friendList/bob')), + ); + }); +}); +``` + +(Keep the existing owner-read/non-participant-read friendList tests; DELETE the old `TRANSITIONAL: signed-in users may write friend edges` test.) + +- [ ] **Step 2: RED** via emulators:exec. + +- [ ] **Step 3: Implement rules.** Replace the `friends` and `friendRequests` blocks and add `blocks` under `users/{uid}`: + +``` + match /blocks/{blockedUid} { + allow read, write: if isOwner(uid); + } +``` + +``` + match /friends/{uid}/friendList/{friendId} { + allow read: if isOwner(uid); + // Deleting: you may prune your own list, and you may always remove + // YOURSELF from someone else's list (unfriend/block cleanup). + allow delete: if isOwner(uid) || + (signedIn() && request.auth.uid == friendId); + // Creating: only as part of accepting a pending request between the + // two users involved. The receiver runs the accept batch: they write + // the sender onto their own list, and themselves onto the sender's. + allow create, update: if + (isOwner(uid) && + exists(/databases/$(database)/documents/friendRequests/$(friendId + '_' + uid))) || + (signedIn() && request.auth.uid == friendId && + exists(/databases/$(database)/documents/friendRequests/$(uid + '_' + request.auth.uid))); + } + + match /friendRequests/{requestId} { + allow read: if signedIn() && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + allow create: if signedIn() && + request.resource.data.senderId == request.auth.uid && + requestId == request.resource.data.senderId + '_' + request.resource.data.receiverId && + request.resource.data.status == 'pending' && + !exists(/databases/$(database)/documents/users/$(request.resource.data.receiverId)/blocks/$(request.auth.uid)) && + !exists(/databases/$(database)/documents/users/$(request.auth.uid)/blocks/$(request.resource.data.receiverId)); + // Decline: receiver flips pending -> declined, nothing else, once. + allow update: if signedIn() && + resource.data.receiverId == request.auth.uid && + resource.data.status == 'pending' && + request.resource.data.status == 'declined' && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']); + // Delete: sender cancels, or receiver deletes on accept. + allow delete: if signedIn() && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + } +``` + +Note on the accept batch: rules `exists()` evaluates against PRE-batch state, so the edge writes and the request delete in one batch all see the pending doc. Auto-accept (sender accepting the reverse request) is the same shape with roles swapped β€” covered by the two disjuncts. + +- [ ] **Step 4: GREEN** β€” full emulator suite. +- [ ] **Step 5: Commit** β€” `feat: rules lifecycle matrix for friend requests, friendship edges, and blocks` + +--- + +### Task 4: `searchByFriendCode` callable (block-aware) + +**Files:** +- Create: `functions/src/search-by-friend-code.ts` +- Modify: `functions/src/index.ts` +- Create: `functions/test/rules/search-by-friend-code.integration.test.ts` + +**Interfaces:** +- Consumes: users' `friendCode` field, `users/{uid}/blocks`, `friends` edges, `friendRequests` deterministic docs. +- Produces the callable Task 5 consumes: name `searchByFriendCode`; request `{code: string}`; response `{found: false}` or `{found: true, user: {id, username, imageUrl, friendCode}, relationship: 'self'|'friends'|'pendingSent'|'pendingReceived'|'none'}`. Errors: `unauthenticated`; `permission-denied` (anonymous); `invalid-argument` (missing/empty code). + +- [ ] **Step 1: Failing integration tests** (same harness conventions as the other two integration files β€” env vars, functionsTest, `require` after init, recursiveDelete `beforeEach`, `--runInBand` already set): + +```typescript +const caller = { uid: 'caller', token: { firebase: { sign_in_provider: 'password' } } }; + +async function seedTarget() { + await db.doc('users/target').set({ + id: 'target', + username: 'Target', + imageUrl: 'http://x/y.png', + friendCode: 'YETI-A3F9', + }); +} + +test('finds a user by normalized code with relationship none', async () => { + await seedTarget(); + const r = await wrapped({ data: { code: ' yeti-a3f9 ' }, auth: caller }); + expect(r).toEqual({ + found: true, + user: { id: 'target', username: 'Target', imageUrl: 'http://x/y.png', friendCode: 'YETI-A3F9' }, + relationship: 'none', + }); +}); + +test('reports friends / pendingSent / pendingReceived / self', async () => { + await seedTarget(); + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('friends'); + await db.recursiveDelete(db.collection('friends')); + await db.doc('friendRequests/caller_target').set({ senderId: 'caller', receiverId: 'target', status: 'pending' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('pendingSent'); + await db.recursiveDelete(db.collection('friendRequests')); + await db.doc('friendRequests/target_caller').set({ senderId: 'target', receiverId: 'caller', status: 'pending' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('pendingReceived'); + await db.doc('users/caller').set({ id: 'caller', friendCode: 'YETI-CCCC' }); + expect((await wrapped({ data: { code: 'YETI-CCCC' }, auth: caller })).relationship).toBe('self'); +}); + +test('declined pending reads as none (sender can silently re-send)', async () => { + await seedTarget(); + await db.doc('friendRequests/caller_target').set({ senderId: 'caller', receiverId: 'target', status: 'declined' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('none'); +}); + +test('not found when target blocks caller, when caller blocks target, or no match', async () => { + await seedTarget(); + await db.doc('users/target/blocks/caller').set({ blockedAt: 1 }); + expect(await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).toEqual({ found: false }); + await db.recursiveDelete(db.doc('users/target').collection('blocks')); + await db.doc('users/caller/blocks/target').set({ blockedAt: 1 }); + expect(await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).toEqual({ found: false }); + expect(await wrapped({ data: { code: 'YETI-ZZZZ' }, auth: caller })).toEqual({ found: false }); +}); + +test('anonymous and unauthenticated callers rejected; empty code invalid', async () => { + await expect(wrapped({ data: { code: 'YETI-A3F9' } })).rejects.toMatchObject({ code: 'unauthenticated' }); + await expect( + wrapped({ data: { code: 'YETI-A3F9' }, auth: { uid: 'x', token: { firebase: { sign_in_provider: 'anonymous' } } } }), + ).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(wrapped({ data: { code: '' }, auth: caller })).rejects.toMatchObject({ code: 'invalid-argument' }); +}); +``` + +- [ ] **Step 2: RED** via emulators:exec. + +- [ ] **Step 3: Implement** `functions/src/search-by-friend-code.ts`: + +```typescript +import * as admin from 'firebase-admin'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +interface SearchRequest { + code?: string; +} + +export const searchByFriendCode = onCall(async (request) => { + const auth = request.auth; + if (!auth) throw new HttpsError('unauthenticated', 'Sign in required.'); + if (auth.token?.firebase?.sign_in_provider === 'anonymous') { + throw new HttpsError('permission-denied', 'Anonymous users cannot search.'); + } + const raw = request.data?.code; + if (typeof raw !== 'string' || raw.trim().length === 0) { + throw new HttpsError('invalid-argument', 'code is required.'); + } + const code = raw.trim().toUpperCase(); + + const db = admin.firestore(); + const snapshot = await db + .collection('users') + .where('friendCode', '==', code) + .limit(1) + .get(); + if (snapshot.empty) return { found: false }; + + const target = snapshot.docs[0]; + const targetId = target.id; + const callerUid = auth.uid; + + // Block hiding: either direction reads as not-found. + const [targetBlocksCaller, callerBlocksTarget] = await Promise.all([ + db.doc(`users/${targetId}/blocks/${callerUid}`).get(), + db.doc(`users/${callerUid}/blocks/${targetId}`).get(), + ]); + if (targetBlocksCaller.exists || callerBlocksTarget.exists) { + return { found: false }; + } + + let relationship = 'none'; + if (targetId === callerUid) { + relationship = 'self'; + } else { + const [edge, sent, received] = await Promise.all([ + db.doc(`friends/${callerUid}/friendList/${targetId}`).get(), + db.doc(`friendRequests/${callerUid}_${targetId}`).get(), + db.doc(`friendRequests/${targetId}_${callerUid}`).get(), + ]); + if (edge.exists) relationship = 'friends'; + else if (sent.exists && sent.data()?.status === 'pending') relationship = 'pendingSent'; + else if (received.exists && received.data()?.status === 'pending') relationship = 'pendingReceived'; + } + + const data = target.data(); + return { + found: true, + user: { + id: targetId, + username: (data.username as string | undefined) ?? '', + imageUrl: (data.imageUrl as string | undefined) ?? '', + friendCode: (data.friendCode as string | undefined) ?? code, + }, + relationship, + }; +}); +``` + +Export from index.ts. Note: legacy random-ID pendings won't be seen by the deterministic reads β†’ relationship 'none'; consistent with the accepted drain story. + +- [ ] **Step 4: GREEN** β€” full emulator suite + pure suite. +- [ ] **Step 5: Commit** β€” `feat: block-aware searchByFriendCode callable with relationship status` + +--- + +### Task 5: Client β€” blocking ops + search switches to the callable + +**Files:** +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (add `blockUser`, `unblockUser`, `getBlockedUsers`; replace `searchByFriendCode` with the callable + result type; retire `searchUsers` and client-side `checkRelationshipStatus` IF unused after the SearchBloc switch β€” grep first, keep if referenced elsewhere) +- Create: `packages/firebase_database_repository/lib/models/friend_search_result.dart` (+ export in models.dart) +- Create: `packages/firebase_database_repository/lib/models/blocked_user_model.dart` (+ export) +- Modify: `lib/friends_list/search_user/bloc/search_bloc.dart` (+ its state file if search state shape changes) +- Test: `packages/firebase_database_repository/test/src/blocking_test.dart`, `packages/firebase_database_repository/test/src/search_by_friend_code_test.dart`, extend the SearchBloc test if one exists (check `ls test/friends_list`) + +**Interfaces:** +- Consumes: Task 4's callable contract; Task 3's rules (blocks owner-managed; request deletes participant-scoped). +- Produces (Task 6 relies on): + - `class BlockedUserModel` β€” `{userId, username, imageUrl, blockedAt (DateTime?)}` with fromJson/toJson (json_serializable, follow FriendModel's style; run build_runner). + - `class FriendSearchResult` β€” `{found: bool, user: UserProfileModel?, relationship: RelationshipStatus?}` (plain Equatable, no codegen; map the callable's relationship string to the existing `RelationshipStatus` enum). + - `Future searchByFriendCode(String code)` β€” callable-backed; callable errors map: `invalid-argument`β†’ rethrow as ArgumentError, everything else β†’ `FriendSearchResult(found: false)` with a `// conceal` comment? NO β€” offline must be distinguishable: wrap other FirebaseFunctionsException in a thrown `Exception('search unavailable')` so the bloc can show its existing error state. found:false is ONLY for a true not-found. + - `Future blockUser({required String currentUserId, required BlockedUserModel target})` β€” batch: set `users/{me}/blocks/{target.userId}` (blockedAt serverTimestamp + denormalized username/imageUrl), delete both friendship edges, delete `friendRequests/{me}_{target}` and `{target}_{me}` docs ONLY after reading them and confirming they exist AND have `status == 'pending'` β€” under the Task 3 rules (as hardened by the C3 review), deletes of NONEXISTENT docs are denied (null `resource.data`) and deletes of DECLINED docs are denied (pending-only delete rule, which closes the delete-and-recreate suppression dodge); either would fail the entire block batch. Declined docs don't need deleting β€” they're invisible to `getFriendRequests` and permanently suppress re-sends. Read both deterministic docs first; batch-delete only existing-and-pending ones. Plus query BOTH legacy pending directions (senderId/receiverId equality, status pending) and delete those (query results are pending by construction) in the same batch. + - `Future unblockUser({required String currentUserId, required String targetUserId})` β€” delete the block doc. + - `Stream> getBlockedUsers(String userId)` β€” snapshots of `users/{uid}/blocks` ordered by blockedAt desc. +- SearchBloc: `SearchByFriendCode` handler calls the new repository method; `SearchLoaded` carries the found user + relationship (adapt its current state shape minimally β€” read the bloc first); `AddFriendRequest` handler unchanged. + +- [ ] **Step 1: Failing tests.** `blocking_test.dart` (fake_cloud_firestore): block removes both edges + both deterministic request docs + a seeded legacy pending (random id, senderId targetβ†’me), writes the block doc with denormalized fields; unblock removes only the block doc (edges stay gone, no re-friend); `getBlockedUsers` streams seeded docs newest-first. `search_by_friend_code_test.dart` (mocktail callable, mirror `validate_pin_test.dart`): found:true payload maps to FriendSearchResult with RelationshipStatus.pendingSent; found:false maps; `unavailable` throws. + +- [ ] **Step 2: RED** (targeted files). +- [ ] **Step 3: Implement** per the interfaces above. SearchBloc: read `lib/friends_list/search_user/bloc/*` first; keep event names; internal call swap + state payload change only as far as the page requires (Task 6 adjusts the page). +- [ ] **Step 4: GREEN** β€” package suite, root `flutter test test/friends_list` (if exists) + full root suite, analyze baseline. +- [ ] **Step 5: Commit** β€” `feat: client blocking operations and callable-backed friend-code search` + +--- + +### Task 6: Blocking UI β€” block/unblock actions + blocked-users screen + legacy-accept copy + +**Files:** +- Modify: `lib/friends_list/friends_list/friends_list.dart` (block action on FriendCard via long-press menu or trailing overflow menu β€” match the existing delete-confirmation pattern) +- Modify: `lib/friends_list/search_user/search_user_page.dart` (search result renders from `FriendSearchResult`; add Block option when relationship == friends? NO β€” search-result block is out of scope creep; skip) +- Create: `lib/friends_list/blocked_users/view/blocked_users_page.dart` + `lib/friends_list/blocked_users/bloc/blocked_users_bloc.dart` (+ barrel if the feature folders use them) +- Modify: `lib/friends_list/friends_list_page.dart` (entry point: app-bar action or menu β†’ blocked users page), `lib/app/app_router/app_router.dart` (route) +- Modify: `lib/friends_list/requests/bloc/friend_request_bloc.dart` (accept error β†’ distinguish permission-denied as legacy-request case) +- Modify: `lib/friends_list/requests/friend_request_page.dart` (snackbar copy for legacy-accept failure) +- Modify: `lib/l10n/arb/app_en.arb` + `app_es.arb` +- Test: bloc test for BlockedUsersBloc; widget test for blocked list + unblock; bloc test for block-from-friends-list dispatch; FriendRequestBloc legacy-accept mapping test + +**Interfaces:** +- Consumes: Task 5's repository ops. +- Produces: user-visible blocking complete. + +- [ ] **Step 1: l10n keys** (en + es): `blockUserAction` ("Block"), `blockUserConfirmTitle` ("Block {name}?"), `blockUserConfirmBody` ("They'll be removed from your friends and won't be able to find you or send requests. They won't be notified."), `unblockUserAction` ("Unblock"), `blockedUsersTitle` ("Blocked Users"), `blockedUsersEmpty` ("You haven't blocked anyone."), `legacyRequestAcceptError` ("This request was sent from an older version. Ask them to re-send it."). Spanish equivalents. gen-l10n. +- [ ] **Step 2: BlockedUsersBloc (TDD):** events `LoadBlockedUsers(userId)` (subscribes to the stream), `UnblockUser(userId, targetUserId)`; states loading/loaded(list)/error. RED β†’ implement β†’ GREEN. +- [ ] **Step 3: FriendRequestBloc accept mapping (TDD):** stub `acceptFriendRequest` throwing a `FirebaseException(plugin: 'cloud_firestore', code: 'permission-denied')`-wrapped error (match how the repository surfaces it β€” read the repo's catch: it wraps in `Exception('Failed to accept friend request: ...')`; ADJUST the repository's `acceptFriendRequest` catch to rethrow FirebaseException permission-denied as a typed `LegacyFriendRequestException` defined next to the other repo exceptions, and map THAT in the bloc to a new state flag). RED β†’ implement β†’ GREEN. +- [ ] **Step 4: UI wiring:** friends-list block action with confirmation dialog (dispatches via a small event on the existing FriendBloc or directly repository via a new event `BlockFriend` on FriendBloc β€” extend FriendBloc: event + reload after); blocked-users page (ListView of BlockedUserModel with unblock button + confirmation); route + entry point (menu icon on FriendsListPage app bar); legacy-accept snackbar on the requests page. Widget tests: blocked page renders seeded models and unblock dispatches; friends list shows block action. +- [ ] **Step 5: GREEN** β€” `flutter test test/friends_list` + full root + analyze baseline. +- [ ] **Step 6: Commit** β€” `feat: blocking UI β€” block from friends list, blocked-users management, legacy-request copy` + +--- + +### Task 7: Verification + INDEX + +- [ ] **Step 1:** Full battery: root analyze (165 baseline) + root test + package test + functions build/test + emulators:exec rules suite. All green. +- [ ] **Step 2:** INDEX: Plan C row β†’ complete; move "Plan C must own" items to "Resolved in Plan C"; add a **data-migration note**: legacy random-ID pending requests can be declined but not accepted after deploy (accept shows the re-send copy); optionally clean them with a one-time console query (`friendRequests` where `status == 'pending'` created before the deploy date) β€” Josh's call. +- [ ] **Step 3:** Commit β€” `chore: verify friends plan C; update index` diff --git a/docs/superpowers/plans/2026-07-03-friends-d-profile-cleanup.md b/docs/superpowers/plans/2026-07-03-friends-d-profile-cleanup.md new file mode 100644 index 0000000..140ddb2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-friends-d-profile-cleanup.md @@ -0,0 +1,132 @@ +# Friends Plan D: Profile Completion & Cleanup 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:** Finish the feature: account-deletion cleanup, the profile page rebuilt on `UserProfileModel` with PIN change and friend-code sharing, the game-over debt from the Plan B review, serialization-convention alignment, UX honesty fixes, and the l10n/docs sweep. + +**Architecture:** Spec phases 6–7 of `docs/superpowers/specs/2026-07-03-friends-feature-design.md` plus every "Plan D must own / note" item in `docs/superpowers/plans/2026-07-03-friends-INDEX.md`. One new Cloud Function (`onUserDeleted`, a **v1 auth trigger** β€” v2 has no user-deletion trigger; v1 and v2 coexist in one codebase). Everything else is client work on established patterns. + +**Tech Stack:** unchanged. + +## Global Constraints + +- Environment: rules suite via `npx firebase-tools@14.9.0 emulators:exec --only firestore "npm --prefix functions run test:rules"`; ALWAYS pipe flutter output through `tail -5`; Bash timeout 300000; no flutter clean/process kills. Analyzer baseline: **168** (documented lineage in `.superpowers/sdd/progress.md`); no NEW hand-written issues (new generated `.g.dart` infos documented if unavoidable). +- Deletion semantics (spec): removing an account deletes the user's doc tree (profile, `private/`, `blocks/`, their own `matches/`), friendship edges BOTH directions, friendRequests involving them (any status, both directions), and block docs OF them in others' lists. `games/` docs and OTHER players' match copies persist. +- PIN change from the profile page requires NO old PIN (decision #5); it calls the existing `setPin` (salted, private credentials doc). +- Profile submit must never regress the Fix-2 class of bug: carry `pin: existingProfile.pin` (and `hasPin`, `friendCode`, `onboardingComplete`, `imageUrl`, `isAnonymous`, `email`) through the full-doc `set()` β€” edits only touch username/firstName/lastName/bio. +- `includeIfNull: false` adopted on ALL `firebase_database_repository` models missing it (aligns reality with CLAUDE.md's stated convention). `updateUserProfile` remains a full-doc `set()` β€” omitted/null fields vanish either way, so behavior is unchanged for full-set paths; the change eliminates explicit-null hazards on any future merge-path writes. +- All new user-facing strings in BOTH arb files + gen-l10n; the sweep localizes the previously-hardcoded friends-list dialog strings. +- TDD per task; commit per task; branch `feat/friends-hardening`; no deploys. + +--- + +### Task 1: `onUserDeleted` cleanup trigger + +**Files:** +- Create: `functions/src/on-user-deleted.ts` +- Modify: `functions/src/index.ts` +- Create: `functions/test/rules/on-user-deleted.integration.test.ts` + +**Interfaces:** +- Consumes: edge docs carry `userId` (rules-enforced == key); block docs carry `userId` (BlockedUserModel.toJson). +- Produces: `export const onUserDeleted = functionsV1.auth.user().onDelete(...)` (import `firebase-functions/v1` as `functionsV1`). + +Cleanup, in one handler (order matters only for readability; each step idempotent): +1. `db.recursiveDelete(db.doc('users/{uid}'))` β€” profile + private + blocks + own matches. +2. `db.recursiveDelete(db.doc('friends/{uid}'))` β€” own friend list. +3. `collectionGroup('friendList').where('userId', '==', uid)` β†’ batch-delete (self-edges in others' lists). +4. `friendRequests` where `senderId == uid` and where `receiverId == uid` (NO status filter β€” declined docs go too; the suppression marker is moot once the account is gone) β†’ batch-delete. +5. `collectionGroup('blocks').where('userId', '==', uid)` β†’ batch-delete (block docs OF this user in others' lists; frees the doc-id slot if they re-register). +Games untouched. + +**Test** (integration, wrap the v1 handler: `testEnv.wrap(onUserDeleted)` called with `{ uid: 'victim' }`-shaped user record β€” firebase-functions-test supports v1 auth triggers directly; seed: victim profile + private/credentials + blocks/other + matches/g1, friends/victim/friendList/friend1, friends/friend1/friendList/victim, friendRequests victim_friend1 (pending) + friend2_victim (declined), users/friend1/blocks/victim (with userId field), games/g1, users/friend1/matches/g1). Assert after firing: every victim-rooted doc gone, friend1's edge-to-victim gone, both requests gone, friend1's block-of-victim gone, `games/g1` and `users/friend1/matches/g1` INTACT. +NOTE: collectionGroup queries in the Admin SDK against the emulator work without manual indexes. If `testEnv.wrap` for the v1 auth trigger needs a different envelope, adapt the harness only; the cleanup contract is binding. + +Steps: failing test β†’ RED (module not found) β†’ implement β†’ GREEN (full emulator suite + pure suite) β†’ commit `feat: onUserDeleted trigger cleans the social graph, keeping shared game history`. + +--- + +### Task 2: Game-over debt β€” failure UX, self-slot unlink, props, widget hardening + +**Files:** +- Modify: `lib/life_counter/bloc/game_over_bloc.dart` + `game_over_state.dart` +- Modify: `lib/life_counter/view/game_over_page.dart` +- Modify: `test/life_counter/bloc/game_over_bloc_test.dart`, `test/life_counter/view/game_over_page_test.dart` +- Modify: `lib/l10n/arb/app_en.arb` + `app_es.arb` (one key: `gameSaveFailedError` β€” "Couldn't save the game. Check your connection and try again." / es "No se pudo guardar la partida. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.") + +Changes (TDD each): +1. **State:** add `status` and `gameModel` to `GameOverState.props` (currently omitted β€” Equatable swallows status emissions). +2. **Failure surfacing:** wrap `saveGameStats` in try/catch β†’ emit `GameOverStatus.failure`. Move navigation out of the buttons' `onPressed` into a `BlocListener` on the view: buttons ONLY dispatch `SendGameOverStatsEvent` (plus set an intent β€” add a `GameOverExitIntent { home, playAgain }` field to the event or state so the listener knows where to go); on `success` the listener performs the existing navigation (+ the GameReset/Timer events for playAgain); on `failure` it shows a `gameSaveFailedError` snackbar and re-enables the buttons (buttons disabled while `status == loading`). Keep the game restorable (do NOT reset on failure). +3. **Self-slot unlink:** in the placement map, a slot whose `firebaseId == event.userId` but is NOT the selected slot gets `firebaseId: () => null` (the user disowned it by selecting another slot or notPlaying); foreign ids still preserved; the selected-slot guard unchanged. Bloc tests: switch-slot (old self slot unlinked, new gets uid), notPlaying (self slot unlinked, no slot has uid). +4. **Widget hardening:** give the account-owner dropdown a `ValueKey('game_over_account_owner_dropdown')` and switch the widget test lookup from positional `.last` to the key; wrap the account-owner label `Row`'s text in `Flexible` (Spanish overflow); shrink the test viewport back toward realistic (keep whatever still passes β€” the overflow fix should allow ~1280Γ—800). + +Steps: RED (props test: two states differing only in status must be unequal; failure-emission test; unlink tests; widget key/overflow tests) β†’ implement β†’ GREEN (test/life_counter + full root + analyze) β†’ commit `fix: game-over save failures surface before navigation; disowned slots unlink`. + +--- + +### Task 3: `includeIfNull: false` convention alignment + +**Files:** +- Modify: every model in `packages/firebase_database_repository/lib/models/` whose `@JsonSerializable` lacks `includeIfNull: false` (audit all; at minimum `user_profile_model.dart`) + regenerate `.g.dart` +- Modify: `packages/firebase_database_repository/test/models/user_profile_model_test.dart` (add: `toJson()` of a model with null `pin` contains NO `pin` key) + +Audit note for the implementer: grep the package's models for `@JsonSerializable`; `GameModel` and `Player` (player_repository) serialize into game docs β€” GameModel lives in this package? Check; apply the annotation ONLY within firebase_database_repository models and verify no test depends on explicit-null keys (run the FULL package + root suites). The onboarding pin-carry test must still pass (carry of a NON-null legacy pin still serializes; null pin now omits the key β€” same full-set outcome). + +Steps: failing toJson-omits-null test β†’ implement annotation(s) + codegen β†’ GREEN (package + root + analyze; watch for generated-info analyzer deltas β€” document if any) β†’ commit `chore: adopt includeIfNull:false across firebase_database_repository models`. + +--- + +### Task 4: Profile page rebuilt on `UserProfileModel` (+ PIN change, friend-code share) + +**Files:** +- Modify: `lib/profile/bloc/profile_bloc.dart` + state/event files +- Modify: `lib/profile/view/profile_page.dart` +- Modify: `lib/l10n/arb/*` (keys: `changePinTitle` "Change PIN", `changePinDescription` "Your PIN confirms your identity when friends add you to a game.", `newPinLabel` "New PIN", `pinChangedMessage` "PIN updated!", `shareFriendCodeTooltip` "Share friend code", `profileSavedMessage` "Profile saved", `profileSaveFailedMessage` "Couldn't save your profile. Try again." + es) +- Test: `test/profile/bloc/profile_bloc_test.dart`, `test/profile/view/profile_page_test.dart` (create if missing) + +Bloc rework (keep event names where they exist): +- New `ProfileLoadRequested(userId)` on creation: `getUserProfileOnce` β†’ state carries the full `UserProfileModel` (loading/loaded/failure statuses). Constructor keeps the auth `User` only for id/email display. +- `_onSubmitted` (currently commented out): build the save model from the LOADED profile β€” `loaded.copyWith(username: ..., firstName: ..., lastName: ..., bio: ...)` β€” so `pin`/`hasPin`/`friendCode`/`onboardingComplete`/`imageUrl` are carried automatically (Fix-2-class regression impossible); `updateUserProfile(userId, model)`; success flips `isEditing` off and refreshes the loaded profile. Email becomes READ-ONLY display (auth-managed; drop `ProfileEmailChanged` usage from the form β€” keep the event registered as a no-op removal or delete it and its call sites). +- New `ProfilePinChanged(String pin)` (reuses the `Pin` formz input) + `ProfilePinSubmitted`: valid 4-digit β†’ `setPin(userId, pin)` β†’ success message state (transient flag or status enum value `pinSaved`). NO old-PIN prompt (decision #5 β€” note it in a comment). +- Delete flow unchanged (Task 1's trigger now does the Firestore cleanup server-side β€” add that comment). + +Page rework: render username/name/bio from the loaded profile (fallback shimmer/spinner while loading); friend code row keeps the existing copy button and gains a share icon (`Share.share` if `share_plus` is already a dependency β€” CHECK pubspec; if absent, keep copy-only and note it β€” do NOT add a new dependency for this); PIN change section with the new-PIN field + save; snackbars for saved/failed/pinChanged driven by a BlocListener. + +Steps: bloc tests RED (load, submit-carries-pin [capture the model passed to updateUserProfile and assert pin/friendCode/hasPin preserved], pin-submit calls setPin, failure paths) β†’ implement β†’ widget tests (renders loaded profile fields; PIN section present) β†’ GREEN (test/profile + full root + analyze) β†’ commit `feat: profile page on UserProfileModel with PIN change and friend-code sharing`. + +--- + +### Task 5: Search-accept honesty + anonymous placeholders + +**Files:** +- Modify: `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` (`addFriendRequest` guard order) +- Modify: `packages/firebase_database_repository/test/src/friend_request_lifecycle_test.dart` +- Modify: `lib/player/view/customize_player_page.dart` (`_FriendSection`) +- Modify: `lib/friends_list/search_user/search_user_page.dart` +- Modify: `lib/l10n/arb/*` (keys: `signInToLinkFriends` "Sign in to link friends to players." / es; `signInToSearchFriends` "Sign in to add friends." / es) +- Test: extend `test/player/` + `test/friends_list/` widget tests + +Changes: +1. **Guard reorder** (C-review triage item 2): in `addFriendRequest`, check the REVERSE-pending query BEFORE the own-doc declined short-circuit, so "Alice declined Bob; Bob taps Accept on Alice's later request from the search card" auto-accepts instead of a silent fake 'sent'. Order becomes: self β†’ already-friends β†’ reverse-pending (auto-accept) β†’ own-doc (declinedβ†’sent / pendingβ†’alreadyPending) β†’ create. Update/extend lifecycle tests: the declined-then-reverse-pending case now returns `autoAccepted` and writes both edges (rules-legal: pending doc exists; verify the accept batch direction passes the exact same disjuncts as the normal auto-accept β€” it does, same code path). +2. **Anonymous placeholders:** in `_FriendSection`, when `context.read().state.status == AppStatus.anonymous` (or the user `isAnonymous`), render the `signInToLinkFriends` copy instead of the friend list/loader; in the search page, same pattern with `signInToSearchFriends` (the callable rejects anonymous anyway β€” this replaces a confusing error with intentional copy). Widget tests for both anonymous states. + +Steps: RED β†’ implement β†’ GREEN (package + root + analyze) β†’ commit `fix: reverse-pending wins over declined suppression; anonymous states get intentional copy`. + +--- + +### Task 6: l10n sweep + stale-doc rewrite + +**Files:** +- Modify: `lib/friends_list/friends_list/friends_list.dart` (localize `_confirmRemoveFriend` title/body/Cancel/Remove + the popup-menu labels; keys: `removeFriendAction` "Remove", `removeFriendConfirmTitle` "Remove {name}?", `removeFriendConfirmBody` "They won't be notified. You can add each other again anytime." + es; reuse `cancelTextButton`) +- Sweep: grep `lib/friends_list lib/player/view lib/life_counter lib/onboarding lib/profile` for remaining hardcoded user-facing string literals introduced on this branch (Text('...') with English prose); localize any found (report the list). Known one: onboarding_form's failure snackbar 'Failed to save profile. Please try again.' β†’ `onboardingSaveFailedMessage` + es. +- Rewrite: `docs/friends_feature_plan.md` β€” replace the stale plan (keep the title) with a short "as shipped" summary: what the feature does today, pointers to the spec/INDEX, the deploy gates, and the PIN/blocking security model in five bullets. Delete the superseded-banner + old body. + +Steps: RED (widget test asserting the localized remove-dialog strings render) β†’ implement β†’ gen-l10n β†’ GREEN (root suite + analyze) β†’ commit `chore: localize remaining friends-feature strings; rewrite stale feature doc`. + +--- + +### Task 7: Verification + INDEX close-out + +- Full battery (root analyze/test, package test, functions build + pure test, emulator rules suite) β€” all green. +- INDEX: Plan D row complete; "Plan D must own"/notes sections converted to "Resolved in Plan D" (with the users-list wire-bypass residual EXPLICITLY retained as the one open hardening follow-up, plus the force-upgrade deploy decision); CLAUDE.md's `includeIfNull` claim now true (note it). +- Commit `chore: verify friends plan D; close out index`. diff --git a/docs/superpowers/plans/2026-07-05-player-owner-friend-selector.md b/docs/superpowers/plans/2026-07-05-player-owner-friend-selector.md new file mode 100644 index 0000000..381db64 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-player-owner-friend-selector.md @@ -0,0 +1,2055 @@ +# Player Owner + Friend Selector 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:** Replace the friend-tile list on the Customize Player page with a searchable dropdown that also lists the account owner, fix the rehydration bug that can silently drop or reassign an existing link, and fix two bugs in the PIN verification dialog (missing loading state, small-device keyboard overlap). + +**Architecture:** All changes are confined to `PlayerCustomizationBloc`/`PlayerCustomizationState`/`PlayerCustomizationEvent`, `customize_player_page.dart`, and `player_identity_panel.dart`, plus two new/reused l10n keys. No new packages, no backend/Firestore rules changes β€” the existing `validatePin` callable and `getUserProfileOnce` repository method are reused as-is. + +**Tech Stack:** Flutter/Dart, `flutter_bloc`, Material 3's `DropdownMenu` widget (`flutter/material.dart`, no new dependency), `bloc_test` + `mocktail` for tests. + +Spec: `docs/superpowers/specs/2026-07-05-player-owner-friend-selector-design.md` + +## Global Constraints + +- Dart SDK `>=3.8.0 <4.0.0`; `useMaterial3: true` is already set in `app_ui`'s theme (`packages/app_ui/lib/src/theme/app_theme.dart:14`) β€” `DropdownMenu` needs no new dependency or theme change. +- Lint: `very_good_analysis` β€” every task must leave `flutter analyze` clean. +- No new dependencies in any `pubspec.yaml`. +- l10n: after any ARB edit, run `flutter gen-l10n --arb-dir="lib/l10n/arb"` and commit the regenerated `lib/l10n/arb/app_localizations*.dart` files alongside the ARB source. +- No backend changes: `validatePin` (Cloud Function) and `FirebaseDatabaseRepository.getUserProfileOnce` are reused exactly as they exist today. +- Test conventions already established in this codebase (follow them, don't invent new ones): `blocTest` from `package:bloc_test`, `mocktail`'s `Mock`/`MockBloc`, the `tester.pumpApp(...)` helper from `test/helpers/pump_app.dart`, and mock classes declared locally at the top of each test file (see `test/player/player_customization_bloc_test.dart` and `test/player/customize_player_page_anonymous_test.dart` for the exact existing pattern). + +--- + +## File Structure + +| File | Role | +|---|---| +| `lib/player/view/bloc/player_customization_state.dart` | Adds `isPinValidating` (Task 1) and `ownerUsername` (Task 3) fields; adds the three link-transition helper methods (Task 3). | +| `lib/player/view/bloc/player_customization_event.dart` | Adds `OwnerSelected`; renames `ClearFriend` β†’ `LinkCleared` (Task 3). | +| `lib/player/view/bloc/player_customization_bloc.dart` | `_onValidatePin` emits `isPinValidating` (Task 1); new `_onOwnerSelected`, modified `_onSelectFriend`, renamed `_onLinkCleared` (Task 3). | +| `lib/player/view/widgets/player_identity_panel.dart` | `isLinked` covers the owner case; `_FriendLinkRow` shows the right name for either case (Task 4). | +| `lib/player/view/customize_player_page.dart` | PIN dialog loading spinner + `scrollable: true` (Task 2); one-line Clear-button event rename to keep compiling (Task 3); `initState` rehydration + name-sync listeners (Task 5); `_FriendSection` becomes a `DropdownMenu`-based `StatefulWidget`, `_FriendTile` and the Clear button are deleted (Task 6). | +| `lib/l10n/arb/app_en.arb`, `app_es.arb` | Two new keys (`accountOwnerOptionLabel`, `notLinkedOptionLabel`); the existing-but-unused `linkedToFriend` key is put back into use (Task 6, Task 4). | +| `test/player/player_customization_bloc_test.dart` | Extended in Tasks 1 and 3. | +| `test/player/customize_player_page_friend_picker_test.dart` | **New file**, created in Task 2, extended in Tasks 4, 5, 6. | + +--- + +### Task 1: `isPinValidating` loading state on the bloc + +**Files:** +- Modify: `lib/player/view/bloc/player_customization_state.dart` +- Modify: `lib/player/view/bloc/player_customization_bloc.dart:222-273` (`_onValidatePin`) +- Test: `test/player/player_customization_bloc_test.dart` + +**Interfaces:** +- Produces: `PlayerCustomizationState.isPinValidating` (`bool`, default `false`) β€” later tasks (Task 2) read this to drive the Verify button's spinner. + +- [ ] **Step 1: Write the failing tests** + +Modify `test/player/player_customization_bloc_test.dart`. Replace the `'emits pinValidated on PinValid'` test (the first test in `group('ValidatePin', ...)`, currently lines 201-219) with a version that asserts both emitted states, and add `skip: 1` to the other four `ValidatePin` tests so they keep asserting only the terminal state. Replace the entire `group('ValidatePin', ...)` block (lines 200-319) with: + +```dart + group('ValidatePin', () { + blocTest( + 'emits isPinValidating true, then pinValidated on PinValid', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinValid()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', true), + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having((s) => s.pinValidated, 'pinValidated', true) + .having((s) => s.pinFlowError, 'pinFlowError', PinFlowError.none), + ], + ); + + blocTest( + 'emits incorrect with attemptsRemaining on PinInvalid', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer((_) async => const PinInvalid(attemptsRemaining: 2)); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having((s) => s.pinValidated, 'pinValidated', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.incorrect, + ) + .having((s) => s.pinAttemptsRemaining, 'attempts', 2), + ], + ); + + blocTest( + 'emits lockedOut with expiry on PinLockedOut', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer( + (_) async => PinLockedOut(lockedUntil: DateTime(2026, 7, 3, 12)), + ); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.lockedOut, + ) + .having( + (s) => s.pinLockedUntil, + 'lockedUntil', + DateTime(2026, 7, 3, 12), + ), + ], + ); + + blocTest( + 'emits unavailable on PinCheckUnavailable', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinCheckUnavailable()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.unavailable, + ), + ], + ); + + blocTest( + 'emits notSet on PinNotSet', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinNotSet()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.notSet, + ), + ], + ); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/player/player_customization_bloc_test.dart` +Expected: FAIL to compile β€” `The getter 'isPinValidating' isn't defined for the class 'PlayerCustomizationState'`. + +- [ ] **Step 3: Implement β€” add the field** + +In `lib/player/view/bloc/player_customization_state.dart`, add `isPinValidating` to the constructor (after `this.pinLockedUntil,` on line 44): + +```dart + this.pinLockedUntil, + this.isPinValidating = false, + }); +``` + +Add the field declaration (after `final DateTime? pinLockedUntil;` on line 65): + +```dart + final DateTime? pinLockedUntil; + final bool isPinValidating; +``` + +Add it to `props` (after `pinLockedUntil,` on line 101): + +```dart + pinLockedUntil, + isPinValidating, + ]; +``` + +Add it to `copyWith`'s parameter list (after `DateTime? Function()? pinLockedUntil,` on line 124) and body (after the `pinLockedUntil:` assignment on lines 145-146): + +```dart + DateTime? Function()? pinLockedUntil, + bool? isPinValidating, + }) { + return PlayerCustomizationState( + ... + pinLockedUntil: + pinLockedUntil != null ? pinLockedUntil() : this.pinLockedUntil, + isPinValidating: isPinValidating ?? this.isPinValidating, + ); + } +``` + +`copyWithClearedFriend()` is left unchanged β€” `isPinValidating` isn't in its explicit field list, so it correctly resets to `false` there, same as the other pin-flow fields. + +- [ ] **Step 4: Implement β€” emit it around the validate call** + +In `lib/player/view/bloc/player_customization_bloc.dart`, replace `_onValidatePin` (lines 222-273) with: + +```dart + Future _onValidatePin( + ValidatePin event, + Emitter emit, + ) async { + emit(state.copyWith(isPinValidating: true)); + final result = await _firebaseDatabaseRepository.validatePin( + targetUserId: event.friendUserId, + pin: event.pin, + ); + switch (result) { + case PinValid(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: true, + pinFlowError: PinFlowError.none, + pinLockedUntil: () => null, + ), + ); + case PinInvalid(:final attemptsRemaining): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.incorrect, + pinAttemptsRemaining: attemptsRemaining, + pinLockedUntil: () => null, + ), + ); + case PinLockedOut(:final lockedUntil): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.lockedOut, + pinLockedUntil: () => lockedUntil, + ), + ); + case PinNotSet(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.notSet, + pinLockedUntil: () => null, + ), + ); + case PinCheckUnavailable(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.unavailable, + pinLockedUntil: () => null, + ), + ); + } + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/player/player_customization_bloc_test.dart` +Expected: PASS (all tests, including the 5 in `group('ValidatePin', ...)`). + +- [ ] **Step 6: Commit** + +```bash +git add lib/player/view/bloc/player_customization_state.dart lib/player/view/bloc/player_customization_bloc.dart test/player/player_customization_bloc_test.dart +git commit -m "feat: add isPinValidating loading state to PlayerCustomizationBloc" +``` + +--- + +### Task 2: PIN dialog loading spinner + small-device keyboard fix + +**Files:** +- Modify: `lib/player/view/customize_player_page.dart:337-482` (`_showPinDialog`) +- Test: Create `test/player/customize_player_page_friend_picker_test.dart` + +**Interfaces:** +- Consumes: `PlayerCustomizationState.isPinValidating` (Task 1). +- Produces: nothing new for later tasks β€” this task only changes the dialog's internals, not its trigger point (still the friend tile's `onTap` until Task 6 moves it to the dropdown). + +- [ ] **Step 1: Write the failing tests** + +Create `test/player/customize_player_page_friend_picker_test.dart`: + +```dart +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/commander_library/commander_library_repository.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; +import 'package:magic_yeti/player/bloc/player_bloc.dart'; +import 'package:magic_yeti/player/view/bloc/player_customization_bloc.dart'; +import 'package:magic_yeti/player/view/customize_player_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:player_repository/player_repository.dart'; +import 'package:scryfall_repository/scryfall_repository.dart'; + +import '../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFriendBloc extends MockBloc + implements FriendBloc {} + +class MockPlayerBloc extends MockBloc + implements PlayerBloc {} + +class MockPlayerRepository extends Mock implements PlayerRepository {} + +class MockScryfallRepository extends Mock implements ScryfallRepository {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class FakeCommanderLibraryRepository implements CommanderLibraryRepository { + @override + Future addRecent(Commander c) async {} + @override + Future> getRecents() async => []; + @override + Future> getFavorites() async => []; + @override + Future isFavorite(Commander c) async => false; + @override + Future toggleFavorite(Commander c) async => false; +} + +const testPlayer = Player( + id: 'p1', + name: 'Sarah', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, +); + +const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + friendCode: 'YETI-B0B1', +); + +void main() { + group('CustomizePlayerView PIN dialog', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + }); + + Future pumpCustomizePlayer( + WidgetTester tester, { + Size size = const Size(1600, 1200), + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'Verify button shows a spinner while ValidatePin is pending and ' + 'blocks re-submission', (tester) async { + final completer = Completer(); + when(() => db.validatePin(targetUserId: 'bob', pin: '1234')) + .thenAnswer((_) => completer.future); + + await pumpCustomizePlayer(tester); + await tester.tap(find.text('Bob')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).last, '1234'); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Verify')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Verify'), findsNothing); + + final verifyButton = tester.widget( + find.byType(FilledButton), + ); + expect(verifyButton.onPressed, isNull); + + final cancelButton = tester.widget( + find.widgetWithText(TextButton, 'Cancel'), + ); + expect(cancelButton.onPressed, isNull); + + completer.complete(const PinValid()); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'PIN dialog stays reachable on a small screen with the keyboard ' + 'open', (tester) async { + when(() => db.validatePin(targetUserId: 'bob', pin: '1234')) + .thenAnswer((_) async => const PinValid()); + + await pumpCustomizePlayer(tester, size: const Size(375, 667)); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(() => tester.view.resetViewInsets()); + + await tester.tap(find.text('Bob')); + await tester.pumpAndSettle(); + + final dialog = tester.widget(find.byType(AlertDialog)); + expect(dialog.scrollable, isTrue); + + await tester.enterText(find.byType(TextField).last, '1234'); + await tester.pump(); + await tester.tap( + find.widgetWithText(FilledButton, 'Verify'), + warnIfMissed: true, + ); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + }); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: FAIL β€” the spinner test fails because `CircularProgressIndicator` is never found (the button still shows a `Text` even mid-validation) and `FilledButton.onPressed`/`TextButton.onPressed` aren't `null`; the small-screen test fails because `AlertDialog.scrollable` is `false`. + +- [ ] **Step 3: Implement** + +In `lib/player/view/customize_player_page.dart`, in `_showPinDialog` (lines 337-482): + +1. Add `scrollable: true` to the `AlertDialog` (the `return AlertDialog(` block starting at line 368): + +```dart + return AlertDialog( + scrollable: true, + backgroundColor: AppColors.surface, +``` + +2. Replace the Cancel `TextButton` (lines 442-449) with a `BlocBuilder` that disables it while validating: + +```dart + BlocBuilder( + buildWhen: (previous, current) => + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + return TextButton( + onPressed: state.isPinValidating + ? null + : () => Navigator.pop(dialogContext), + child: Text( + l10n.cancelTextButton, + style: + const TextStyle(color: AppColors.neutral60), + ), + ); + }, + ), +``` + +3. Replace the Verify `BlocBuilder` (lines 450-472) to key off `isPinValidating` too and swap its child for a spinner: + +```dart + BlocBuilder( + buildWhen: (previous, current) => + previous.pinFlowError != current.pinFlowError || + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + final isLockedOut = + state.pinFlowError == PinFlowError.lockedOut; + final canSubmit = pinController.text.length == 4 && + !isLockedOut && + !state.isPinValidating; + return FilledButton( + onPressed: canSubmit + ? () { + bloc.add( + ValidatePin( + pin: pinController.text, + friendUserId: friend.userId, + ), + ); + } + : null, + child: state.isPinValidating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.white, + ), + ) + : Text(l10n.verifyButtonText), + ); + }, + ), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: PASS (both tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/player/view/customize_player_page.dart test/player/customize_player_page_friend_picker_test.dart +git commit -m "fix: PIN dialog shows a loading spinner and stays reachable on small screens" +``` + +--- + +### Task 3: Owner selection, mutual exclusivity, and the owner's username + +**Files:** +- Modify: `lib/player/view/bloc/player_customization_state.dart` +- Modify: `lib/player/view/bloc/player_customization_event.dart` +- Modify: `lib/player/view/bloc/player_customization_bloc.dart` +- Modify: `lib/player/view/customize_player_page.dart` (one-line rename only β€” see Step 6) +- Modify: `lib/player/view/widgets/player_identity_panel.dart` (one-line rename only β€” see Step 6 correction; missed in planning, has a second `ClearFriend()` dispatch site) +- Test: `test/player/player_customization_bloc_test.dart` + +**Interfaces:** +- Produces: + - `OwnerSelected({required String userId})` event β€” confirms the signed-in user as this seat's link, fetches their `UserProfileModel.username` into state, and clears any friend selection. + - `SelectFriend({required FriendModel friend})` (existing event, changed behavior) β€” now unconditionally marks `pinValidated: true` and clears `isAccountOwner`. Safe because both call sites (the PIN dialog's success listener, and Task 5's `initState` rehydration) represent an already-established link, never a fresh unverified pick. + - `LinkCleared()` event (renamed from `ClearFriend`) β€” clears both the owner and friend link. + - `PlayerCustomizationState.ownerUsername` (`String?`) β€” the signed-in user's username, once fetched. + - Invariant later tasks rely on: at most one of `state.isAccountOwner` or (`state.selectedFriend != null && state.pinValidated`) is ever true at once. + +- [ ] **Step 1: Write the failing tests** + +In `test/player/player_customization_bloc_test.dart`, add this new group right after the `group('ResetPinFlow', ...)` block (before the file's closing `}`): + +```dart + + group('OwnerSelected', () { + blocTest( + 'confirms isAccountOwner and clears any existing friend selection', + build: build, + seed: () => PlayerCustomizationState( + selectedFriend: const FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + ), + pinValidated: true, + ), + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isTrue); + expect(bloc.state.selectedFriend, isNull); + expect(bloc.state.pinValidated, isFalse); + }, + ); + + blocTest( + 'fetches and stores the owner username', + build: () { + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.ownerUsername, 'Alice'); + }, + ); + + blocTest( + 'leaves ownerUsername unset if the profile fetch returns null', + build: () { + when(() => db.getUserProfileOnce('alice')) + .thenAnswer((_) async => null); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.ownerUsername, isNull); + }, + ); + + blocTest( + 'still confirms isAccountOwner if the profile fetch throws', + build: () { + when(() => db.getUserProfileOnce('alice')) + .thenThrow(Exception('offline')); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isTrue); + expect(bloc.state.ownerUsername, isNull); + }, + ); + }); + + group('SelectFriend', () { + const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + ); + + blocTest( + 'confirms the friend as validated and clears isAccountOwner', + build: build, + seed: () => const PlayerCustomizationState(isAccountOwner: true), + act: (bloc) => bloc.add(const SelectFriend(friend: bob)), + verify: (bloc) { + expect(bloc.state.selectedFriend, bob); + expect(bloc.state.pinValidated, isTrue); + expect(bloc.state.isAccountOwner, isFalse); + }, + ); + }); + + group('LinkCleared', () { + blocTest( + 'clears both a friend link and owner status', + build: build, + seed: () => const PlayerCustomizationState(isAccountOwner: true), + act: (bloc) => bloc.add(const LinkCleared()), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isFalse); + expect(bloc.state.selectedFriend, isNull); + expect(bloc.state.pinValidated, isFalse); + }, + ); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/player/player_customization_bloc_test.dart` +Expected: FAIL to compile β€” `OwnerSelected` and `LinkCleared` aren't defined, `PlayerCustomizationState` has no `ownerUsername` getter, and `db.getUserProfileOnce` isn't stubbed-recognizable until the mock class covers it (it already does, since `MockDb` implements the full `FirebaseDatabaseRepository`). + +- [ ] **Step 3: Implement β€” state** + +In `lib/player/view/bloc/player_customization_state.dart`, add `ownerUsername` to the constructor (after the `isPinValidating` param added in Task 1): + +```dart + this.isPinValidating = false, + this.ownerUsername, + }); +``` + +Add the field (after `final bool isPinValidating;`): + +```dart + final bool isPinValidating; + final String? ownerUsername; +``` + +Add to `props` (after `isPinValidating,`): + +```dart + isPinValidating, + ownerUsername, + ]; +``` + +Add to `copyWith`'s parameters (after `bool? isPinValidating,`) and body (after the `isPinValidating:` line): + +```dart + bool? isPinValidating, + String? ownerUsername, + }) { + return PlayerCustomizationState( + ... + isPinValidating: isPinValidating ?? this.isPinValidating, + ownerUsername: ownerUsername ?? this.ownerUsername, + ); + } +``` + +Replace `copyWithClearedFriend()` with three methods β€” a renamed/expanded version plus two new ones. This is now the **only** place that constructs a confirmed link transition, so all three must explicitly carry `ownerUsername` through and set `isAccountOwner` deliberately (not preserve it) so at most one link type is ever true at once: + +```dart + /// Clears any link (owner or friend) β€” returns this seat to a fully + /// unlinked, freely-editable state. + PlayerCustomizationState copyWithLinkCleared() { + return PlayerCustomizationState( + status: status, + name: name, + commander: commander, + partner: partner, + background: background, + cardList: cardList, + magicCardList: magicCardList, + isAccountOwner: false, + showOnlyLegendary: showOnlyLegendary, + availablePairing: availablePairing, + selectingSecondCard: selectingSecondCard, + recents: recents, + favorites: favorites, + favoriteIds: favoriteIds, + ownerUsername: ownerUsername, + ); + } + + /// Confirms the account owner as this seat's linked identity, clearing + /// any friend link β€” a seat is linked to at most one account at a time. + PlayerCustomizationState copyWithOwnerSelected() { + return PlayerCustomizationState( + status: status, + name: name, + commander: commander, + partner: partner, + background: background, + cardList: cardList, + magicCardList: magicCardList, + isAccountOwner: true, + showOnlyLegendary: showOnlyLegendary, + availablePairing: availablePairing, + selectingSecondCard: selectingSecondCard, + recents: recents, + favorites: favorites, + favoriteIds: favoriteIds, + ownerUsername: ownerUsername, + ); + } + + /// Confirms [friend] as this seat's linked identity β€” always treated as + /// already PIN-validated, since both call sites (the PIN dialog's success + /// listener, and initState rehydration from an already-persisted + /// firebaseId) represent a link that was already established, never a + /// fresh unverified pick. Clears any owner selection. + PlayerCustomizationState copyWithFriendSelected(FriendModel friend) { + return PlayerCustomizationState( + status: status, + name: name, + commander: commander, + partner: partner, + background: background, + cardList: cardList, + magicCardList: magicCardList, + isAccountOwner: false, + showOnlyLegendary: showOnlyLegendary, + availablePairing: availablePairing, + selectingSecondCard: selectingSecondCard, + recents: recents, + favorites: favorites, + favoriteIds: favoriteIds, + ownerUsername: ownerUsername, + selectedFriend: friend, + pinValidated: true, + ); + } +``` + +- [ ] **Step 4: Implement β€” events** + +In `lib/player/view/bloc/player_customization_event.dart`, replace `ClearFriend` (lines 100-102) with: + +```dart +final class OwnerSelected extends PlayerCustomizationEvent { + const OwnerSelected({required this.userId}); + + final String userId; + + @override + List get props => [userId]; +} + +final class LinkCleared extends PlayerCustomizationEvent { + const LinkCleared(); +} +``` + +- [ ] **Step 5: Implement β€” bloc** + +In `lib/player/view/bloc/player_customization_bloc.dart`: + +Replace the event registration for `ClearFriend` (line 34, `on(_onClearFriend);`) with: + +```dart + on(_onOwnerSelected); + on(_onLinkCleared); +``` + +Replace `_onSelectFriend` (lines 203-213) with: + +```dart + void _onSelectFriend( + SelectFriend event, + Emitter emit, + ) { + emit(state.copyWithFriendSelected(event.friend)); + } + + Future _onOwnerSelected( + OwnerSelected event, + Emitter emit, + ) async { + emit(state.copyWithOwnerSelected()); + try { + final profile = + await _firebaseDatabaseRepository.getUserProfileOnce(event.userId); + if (profile?.username != null && profile!.username!.isNotEmpty) { + emit(state.copyWith(ownerUsername: profile.username)); + } + } on Exception catch (_) { + // Leave ownerUsername unset β€” PlayerIdentityPanel falls back to + // whatever name was already persisted for this seat. isAccountOwner + // stays confirmed either way; a failed username fetch shouldn't + // block linking the seat to the owner's account. + } + } +``` + +Replace `_onClearFriend` (lines 215-220) with: + +```dart + void _onLinkCleared( + LinkCleared event, + Emitter emit, + ) { + emit(state.copyWithLinkCleared()); + } +``` + +- [ ] **Step 6: Keep the still-tile-based Clear button compiling** + +`_FriendSection`'s tile-based UI isn't replaced until Task 6, but its existing +Clear button dispatches the event class just renamed above. Left alone, this +would leave `customize_player_page.dart` failing to compile for Tasks 3-5. In +`lib/player/view/customize_player_page.dart`, inside `_FriendSection.build()`'s +Clear `TextButton`, replace: + +```dart + TextButton( + onPressed: () { + context + .read() + .add(const ClearFriend()); + nameController.clear(); + }, +``` + +with: + +```dart + TextButton( + onPressed: () { + context + .read() + .add(const LinkCleared()); + nameController.clear(); + }, +``` + +This is a pure rename, no behavior change β€” `_FriendSection` still deletes +and gets fully replaced by Task 6. + +**Correction (found during implementation, not caught in planning):** `_FriendLinkRow`'s +"Unlink" button in `lib/player/view/widgets/player_identity_panel.dart` *also* +dispatches `ClearFriend()` β€” a second call site the pre-flight review missed. It +needs the identical rename. Replace: + +```dart + TextButton( + onPressed: () => context + .read() + .add(const ClearFriend()), + child: const Text('Unlink'), + ), +``` + +with: + +```dart + TextButton( + onPressed: () => context + .read() + .add(const LinkCleared()), + child: const Text('Unlink'), + ), +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `flutter test test/player/player_customization_bloc_test.dart` +Expected: PASS (all tests, including the 3 new groups). + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: PASS (both Task 2 tests) β€” this confirms `customize_player_page.dart` still compiles after the Step 6 rename. + +- [ ] **Step 8: Commit** + +```bash +git add lib/player/view/bloc/player_customization_state.dart lib/player/view/bloc/player_customization_event.dart lib/player/view/bloc/player_customization_bloc.dart lib/player/view/customize_player_page.dart test/player/player_customization_bloc_test.dart +git commit -m "feat: add OwnerSelected event, enforce owner/friend mutual exclusivity" +``` + +--- + +### Task 4: Lock the name field for the owner case too + +**Files:** +- Modify: `lib/player/view/widgets/player_identity_panel.dart:25-26,176` +- Modify: `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb` (no new keys β€” see below) +- Test: `test/player/customize_player_page_friend_picker_test.dart` + +**Interfaces:** +- Consumes: `PlayerCustomizationState.isAccountOwner`, `.ownerUsername` (Task 3). +- Produces: `PlayerIdentityPanel`'s `isLinked` now also covers the owner case β€” Task 6's dropdown doesn't depend on this directly, but the visual lock is what the design's "auto-fill and lock" decision is actually about. + +The existing `linkedToFriend` ARB key (`"Linked to {name}"`, defined in both `app_en.arb:728` and `app_es.arb:31` but currently unreferenced β€” the March 2026 predecessor spec left it in place but stopped using it) is put back into use for both the friend and owner cases. No new ARB keys are needed for this task. + +- [ ] **Step 1: Write the failing test** + +Add this group to `test/player/customize_player_page_friend_picker_test.dart` (add `import 'package:magic_yeti/player/view/bloc/player_customization_event.dart';` and `import 'package:player_repository/player_repository.dart';` if not already present from Task 2 β€” they are): + +```dart + + group('CustomizePlayerView name lock', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + testWidgets( + 'locks the name field and shows "Linked to Alice" once the owner ' + 'is confirmed', (tester) async { + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + late PlayerCustomizationBloc customizationBloc; + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) { + customizationBloc = PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ); + return customizationBloc; + }, + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + + customizationBloc.add(const OwnerSelected(userId: 'alice')); + await tester.pumpAndSettle(); + + final nameField = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Player name', + ), + ); + expect(nameField.readOnly, isTrue); + expect(find.text('Linked to Alice'), findsOneWidget); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: FAIL β€” `nameField.readOnly` is `false` and `find.text('Linked to Alice')` finds nothing, because `isLinked` doesn't yet consider `isAccountOwner`. + +- [ ] **Step 3: Implement** + +In `lib/player/view/widgets/player_identity_panel.dart`, replace the `isLinked` computation (lines 25-26): + +```dart + final isLinked = state.isAccountOwner || + (state.selectedFriend != null && state.pinValidated); +``` + +Replace the `_FriendLinkRow` text (line 176): + +```dart + child: Text( + context.l10n.linkedToFriend( + state.isAccountOwner + ? (state.ownerUsername ?? context.l10n.accountOwnerOptionLabel) + : (state.selectedFriend?.username ?? ''), + ), + style: const TextStyle(color: AppColors.white, fontSize: 13), + ), +``` + +This references `context.l10n.accountOwnerOptionLabel`, which Task 6 adds. Add it now (Task 6 will also add `notLinkedOptionLabel` at the same time it builds the dropdown, but this task needs `accountOwnerOptionLabel` to exist first). In `lib/l10n/arb/app_en.arb`, add after the `verifyButtonText` block (after line 757): + +```json + "accountOwnerOptionLabel": "Me", + "@accountOwnerOptionLabel": { + "description": "Dropdown entry / fallback label to link a player slot to the signed-in user's own account" + }, +``` + +In `lib/l10n/arb/app_es.arb`, add after `"verifyButtonText": "Verificar",` (line 35): + +```json + "accountOwnerOptionLabel": "Yo", +``` + +Also import `player_customization_event.dart` at the top of `player_identity_panel.dart` if it's not already reachable through `player_customization_bloc.dart`'s barrel export β€” check first: `player_customization_bloc.dart` already does `part 'player_customization_event.dart';` and `player_identity_panel.dart` already imports `player_customization_bloc.dart`, so `OwnerSelected` etc. are already visible; no new import needed there. + +- [ ] **Step 4: Regenerate localizations** + +Run: `flutter gen-l10n --arb-dir="lib/l10n/arb"` +Expected: regenerates `lib/l10n/arb/app_localizations.dart`, `app_localizations_en.dart`, `app_localizations_es.dart` with no errors. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/player/view/widgets/player_identity_panel.dart lib/l10n/arb/app_en.arb lib/l10n/arb/app_es.arb lib/l10n/arb/app_localizations.dart lib/l10n/arb/app_localizations_en.dart lib/l10n/arb/app_localizations_es.dart test/player/customize_player_page_friend_picker_test.dart +git commit -m "fix: lock and populate the player name field for the owner link too" +``` + +--- + +### Task 5: Rehydrate link state on reopen, and centralize name-field syncing + +**Files:** +- Modify: `lib/player/view/customize_player_page.dart` (`initState`, `build`, `_showPinDialog`'s listener) +- Test: `test/player/customize_player_page_friend_picker_test.dart` + +**Interfaces:** +- Consumes: `OwnerSelected`, `SelectFriend`, `PlayerCustomizationState.isAccountOwner`/`.ownerUsername`/`.selectedFriend` (Task 3); `FriendBloc`'s `FriendsLoaded` state (existing). +- Produces: after this task, `_nameController.text` is kept in sync with the bloc's confirmed link state by a single listener β€” Task 6's dropdown `onSelected` handler must NOT also write to `_nameController` directly, to avoid double-handling. + +- [ ] **Step 1: Write the failing tests** + +Add this group to `test/player/customize_player_page_friend_picker_test.dart`: + +```dart + + group('CustomizePlayerView rehydration', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + const linkedToOwnerPlayer = Player( + id: 'p1', + name: 'Old Name', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, + firebaseId: 'alice', + ); + + const linkedToFriendPlayer = Player( + id: 'p1', + name: 'Bob', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, + firebaseId: 'bob', + ); + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + Future pump(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + } + + testWidgets( + 'reopening an owner-linked seat re-confirms Me and shows the owner ' + 'username, without needing a fresh selection', (tester) async { + when(() => playerRepository.getPlayerById('p1')) + .thenReturn(linkedToOwnerPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: linkedToOwnerPlayer)); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + + await pump(tester); + await tester.pumpAndSettle(); + + expect(find.text('Linked to Alice'), findsOneWidget); + }); + + testWidgets( + 'reopening a friend-linked seat re-confirms that friend once the ' + 'friend list finishes loading, without a PIN prompt', (tester) async { + when(() => playerRepository.getPlayerById('p1')) + .thenReturn(linkedToFriendPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: linkedToFriendPlayer)); + // Starts loading, then transitions to FriendsLoaded β€” whenListen + // drives both the initial `.state` read and the later stream event + // that the page's BlocListener reacts to, without a manual re-stub. + whenListen( + friendBloc, + Stream.fromIterable([const FriendsLoaded([bob])]), + initialState: FriendsLoading(), + ); + + await pump(tester); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Linked to Bob'), findsOneWidget); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: FAIL β€” neither "Linked to Alice" nor "Linked to Bob" is found, because `initState` still only dispatches the old (Task-3-removed) `UpdateAccountOwnership` event and nothing reacts to `FriendBloc` reaching `FriendsLoaded`. (This will actually fail to *compile* first, since `UpdateAccountOwnership` was not removed from `player_customization_event.dart`/`_bloc.dart` β€” it's untouched by Task 3 and still exists, so it still compiles; the failure here is a runtime assertion failure, not a compile error.) + +- [ ] **Step 3: Implement** + +In `lib/player/view/customize_player_page.dart`, replace the ownership-detection block at the end of `initState` (originally lines 85-88, now shifted slightly by earlier tasks β€” locate by content, it's the last statements in `initState`): + +```dart + final isOwner = context.read().state.player.firebaseId != null; + context.read().add( + UpdateAccountOwnership(isOwner: isOwner), + ); + } +``` + +with: + +```dart + final currentUserId = context.read().state.user.id; + final linkedFirebaseId = context.read().state.player.firebaseId; + if (linkedFirebaseId != null && linkedFirebaseId == currentUserId) { + context.read().add( + OwnerSelected(userId: currentUserId), + ); + } + } +``` + +`UpdateAccountOwnership` and its handler/state field (`isAccountOwner`'s setter path via that event) are no longer dispatched from anywhere in the app after this change. Remove the now-dead event entirely: delete the `UpdateAccountOwnership` class from `lib/player/view/bloc/player_customization_event.dart` (the block at lines 73-80), delete its registration `on(_onUpdateAccountOwnership);` and its handler method `_onUpdateAccountOwnership` from `lib/player/view/bloc/player_customization_bloc.dart` (lines 30 and 169-174). `isAccountOwner` itself stays on `PlayerCustomizationState` β€” it's now only ever set via `copyWithOwnerSelected()`/`copyWithFriendSelected()`/`copyWithLinkCleared()` from Task 3. + +Now replace `CustomizePlayerView.build()`'s top-level return (the `return BlocBuilder(...)` that wraps the whole `Scaffold`) so it's wrapped in a `MultiBlocListener`: + +```dart + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => current is FriendsLoaded, + listener: (context, friendState) { + final customState = context.read().state; + if (customState.isAccountOwner || + customState.selectedFriend != null) { + return; + } + final linkedFirebaseId = + context.read().state.player.firebaseId; + if (linkedFirebaseId == null) return; + final friends = (friendState as FriendsLoaded).friends; + FriendModel? match; + for (final f in friends) { + if (f.userId == linkedFirebaseId) { + match = f; + break; + } + } + if (match != null) { + context.read().add( + SelectFriend(friend: match), + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.isAccountOwner != current.isAccountOwner || + previous.ownerUsername != current.ownerUsername || + previous.selectedFriend != current.selectedFriend, + listener: (context, state) { + if (state.isAccountOwner) { + final owner = state.ownerUsername; + if (owner != null && owner.isNotEmpty) { + _nameController.text = owner; + } + } else if (state.selectedFriend != null) { + _nameController.text = state.selectedFriend!.username; + } else { + _nameController.clear(); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + // ...existing builder body, unchanged... + }, + ), + ); +``` + +Finally, remove the now-redundant manual name write from `_showPinDialog`'s `BlocListener` (the page-level listener above now owns this) β€” replace: + +```dart + listener: (listenerContext, state) { + if (state.pinValidated) { + // PIN succeeded β€” select friend, populate name, close + bloc.add(SelectFriend(friend: friend)); + nameController.text = friend.username; + Navigator.pop(listenerContext); + } + // pinFlowError is shown reactively via the BlocBuilder below + }, +``` + +with: + +```dart + listener: (listenerContext, state) { + if (state.pinValidated) { + // PIN succeeded β€” select friend, close. The page-level + // BlocListener in CustomizePlayerView.build() populates + // the name field once selectedFriend changes. + bloc.add(SelectFriend(friend: friend)); + Navigator.pop(listenerContext); + } + // pinFlowError is shown reactively via the BlocBuilder below + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: PASS (all groups so far: PIN dialog, name lock, rehydration). + +- [ ] **Step 5: Run the full test suite to check for regressions** + +Run: `flutter test` +Expected: PASS. In particular `test/player/customize_player_page_anonymous_test.dart` must still pass unchanged β€” it never sets a `firebaseId` on its `player` fixture, so the new `initState` owner check is a no-op for it, same as before. + +- [ ] **Step 6: Commit** + +```bash +git add lib/player/view/customize_player_page.dart lib/player/view/bloc/player_customization_event.dart lib/player/view/bloc/player_customization_bloc.dart test/player/customize_player_page_friend_picker_test.dart +git commit -m "fix: rehydrate owner/friend link state when reopening an already-linked seat" +``` + +--- + +### Task 6: Replace the tile list with a searchable dropdown + +**Files:** +- Modify: `lib/player/view/customize_player_page.dart` (`_FriendSection`, `_FriendTile` deleted, `CustomizePlayerView.build()`'s call site) +- Modify: `lib/l10n/arb/app_en.arb`, `app_es.arb` +- Test: `test/player/customize_player_page_friend_picker_test.dart` + +**Interfaces:** +- Consumes: `OwnerSelected`, `SelectFriend`, `LinkCleared` (Task 3); the name-sync `BlocListener` (Task 5, so `onSelected` below must only dispatch bloc events, never touch `_nameController` itself). +- Produces: nothing further β€” this is the last task. + +- [ ] **Step 1: Write the failing tests** + +Add this group to `test/player/customize_player_page_friend_picker_test.dart`: + +```dart + + group('CustomizePlayerView friend/owner dropdown', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + Future pump(WidgetTester tester, {List? friends}) async { + when(() => friendBloc.state) + .thenReturn(FriendsLoaded(friends ?? const [bob])); + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + } + + testWidgets('shows Me as an entry even with zero friends', (tester) async { + await pump(tester, friends: []); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + + expect(find.text('Me'), findsOneWidget); + expect(find.text('Not linked'), findsOneWidget); + }); + + testWidgets('typing filters the entries', (tester) async { + const zara = FriendModel( + userId: 'zara', + username: 'Zara', + profilePictureUrl: '', + ); + await pump(tester, friends: const [bob, zara]); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'Za'); + await tester.pumpAndSettle(); + + expect(find.text('Zara'), findsWidgets); + expect(find.text('Bob'), findsNothing); + }); + + testWidgets('selecting Me dispatches OwnerSelected with no PIN dialog', + (tester) async { + await pump(tester); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Me').last); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Linked to Alice'), findsOneWidget); + }); + + testWidgets('selecting a friend opens the PIN dialog', (tester) async { + await pump(tester); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Verify Bob'), findsOneWidget); + }); + + testWidgets('cancelling the PIN dialog reverts the dropdown display', + (tester) async { + await pump(tester); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + final field = tester.widget(find.byType(TextField).first); + expect(field.controller?.text, isNot('Bob')); + }); + + testWidgets('selecting Not linked clears an existing link', (tester) async { + late PlayerCustomizationBloc customizationBloc; + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) { + customizationBloc = PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ); + return customizationBloc; + }, + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + customizationBloc.add(const OwnerSelected(userId: 'alice')); + await tester.pumpAndSettle(); + expect(customizationBloc.state.isAccountOwner, isTrue); + + await tester.tap(find.byType(DropdownMenu)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Not linked').last); + await tester.pumpAndSettle(); + + expect(customizationBloc.state.isAccountOwner, isFalse); + expect(find.text('Linked to Alice'), findsNothing); + }); + }); +``` + +**Corrections (found during implementation, not caught in planning):** two of the +assertions above and the tap target used throughout don't hold against real +`DropdownMenu` internals in this Flutter SDK: + +1. `find.byType(DropdownMenu)` cannot be tapped to open the menu once + wrapped in `SizedBox(width: double.infinity)` β€” the computed tap center lands + outside the internal `TextField`'s actual hit-testable area. Use + `find.byType(TextField).first` as the tap target everywhere a test needs to + open the dropdown (matches the existing `find.byType(TextField).last` idiom + already used for the PIN field elsewhere in this file). This also applies to + the two pre-existing PIN-dialog tests from Task 2, which tapped `'Bob'` + directly against the old tile UI β€” they need the same "open the dropdown + first" treatment now that the tile is gone. +2. `DropdownMenu` keeps a permanent, invisible, `excludeSemantics: true` copy of + every entry's label in the tree (for internal width measurement, built from + the *unfiltered* entry list) β€” separate from the live, filterable overlay. + So `findsOneWidget`/`findsNothing` against a label's `Text` will never hold: + every original label always has at least one match, filtered or not, open or + closed. Use `findsWidgets` for "the entry exists" (as the "typing filters the + entries" test already does for "Zara"), and to assert something is + genuinely *filtered out*, scope the finder to exclude the invisible copy + (e.g. via an `ExcludeSemantics(excluding: false)` ancestor predicate, + distinguishing the real interactive list from the hidden measurement one). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: FAIL β€” `find.byType(DropdownMenu)` finds nothing (the tile list is still in place), and `'Not linked'`/`'Me'` text doesn't exist anywhere yet. + +- [ ] **Step 3: Implement** + +In `lib/player/view/customize_player_page.dart`: + +Update the `_FriendSection` call site inside `CustomizePlayerView.build()` (the `Column` inside the left `_Panel`): + +```dart + children: [ + const _FriendSection(), + PlayerIdentityPanel( +``` + +Delete `_FriendTile` entirely (the class at the end of the file, originally lines 485-561). + +Replace the whole `_FriendSection` class with: + +```dart +class _FriendSection extends StatefulWidget { + const _FriendSection(); + + @override + State<_FriendSection> createState() => _FriendSectionState(); +} + +class _FriendSectionState extends State<_FriendSection> { + int _resetNonce = 0; + + void _forceReset() { + if (mounted) setState(() => _resetNonce++); + } + + @override + Widget build(BuildContext context) { + final customState = context.watch().state; + final friendState = context.watch().state; + final appState = context.watch().state; + final isAnonymous = appState.status == AppStatus.anonymous; + + // Anonymous users have no friend graph to link against β€” the callable + // backing this list requires an authenticated uid, so show intentional + // copy instead of an empty/loading friend list. + if (isAnonymous) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xlg, + vertical: AppSpacing.sm, + ), + child: Text( + context.l10n.signInToLinkFriends, + style: const TextStyle( + fontSize: 14, + color: AppColors.neutral60, + ), + ), + ); + } + + final friends = + friendState is FriendsLoaded ? friendState.friends : []; + final sortedFriends = List.from(friends) + ..sort( + (a, b) => + a.username.toLowerCase().compareTo(b.username.toLowerCase()), + ); + + final currentUserId = appState.user.id; + final confirmedValue = customState.isAccountOwner + ? currentUserId + : (customState.selectedFriend != null && customState.pinValidated + ? customState.selectedFriend!.userId + : null); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xlg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.selectFriendLabel, + style: const TextStyle( + fontSize: 14, + color: AppColors.neutral60, + ), + ), + const SizedBox(height: AppSpacing.xs), + SizedBox( + width: double.infinity, + child: DropdownMenu( + key: ValueKey('$confirmedValue-$_resetNonce'), + initialSelection: confirmedValue, + enableFilter: true, + enableSearch: true, + // Correction (found during implementation, not caught in + // planning): DropdownMenu.requestFocusOnTap defaults to false + // on iOS/Android/Fuchsia, which makes the internal TextField + // readOnly and silently disables type-to-search on this app's + // two primary platforms. Required for enableFilter to do + // anything on a real device. + requestFocusOnTap: true, + hintText: context.l10n.notLinkedOptionLabel, + dropdownMenuEntries: [ + DropdownMenuEntry( + value: null, + label: context.l10n.notLinkedOptionLabel, + ), + DropdownMenuEntry( + value: currentUserId, + label: context.l10n.accountOwnerOptionLabel, + ), + ...sortedFriends.map( + (friend) => DropdownMenuEntry( + value: friend.userId, + label: friend.username, + ), + ), + ], + onSelected: (value) { + if (value == null) { + context.read().add( + const LinkCleared(), + ); + } else if (value == currentUserId) { + context.read().add( + OwnerSelected(userId: currentUserId), + ); + } else { + final friend = sortedFriends.firstWhere( + (f) => f.userId == value, + ); + _showPinDialog(context, friend); + } + }, + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ); + } + + String? _pinErrorText(BuildContext context, PlayerCustomizationState state) { + final l10n = context.l10n; + return switch (state.pinFlowError) { + PinFlowError.none => null, + PinFlowError.incorrect => + l10n.pinIncorrectError(state.pinAttemptsRemaining), + PinFlowError.lockedOut => l10n.pinLockedOutError( + state.pinLockedUntil == null + ? 15 + : (state.pinLockedUntil! + .difference(DateTime.now()) + .inSeconds / + 60) + .ceil() + .clamp(1, 15), + ), + PinFlowError.unavailable => l10n.pinUnavailableError, + PinFlowError.notSet => l10n.pinNotSetError, + }; + } + + void _showPinDialog(BuildContext context, FriendModel friend) { + final pinController = TextEditingController(); + final bloc = context.read(); + final l10n = context.l10n; + + // Clear any stale error/lockout state left over from a previous + // friend's dialog before this one opens. + bloc.add(const ResetPinFlow()); + + unawaited( + showDialog( + context: context, + builder: (dialogContext) { + return BlocProvider.value( + value: bloc, + child: BlocListener( + listenWhen: (previous, current) => + previous.pinValidated != current.pinValidated || + previous.pinFlowError != current.pinFlowError, + listener: (listenerContext, state) { + if (state.pinValidated) { + // PIN succeeded β€” select friend, close. The page-level + // BlocListener in CustomizePlayerView.build() populates + // the name field once selectedFriend changes. + bloc.add(SelectFriend(friend: friend)); + Navigator.pop(listenerContext); + } + // pinFlowError is shown reactively via the BlocBuilder below + }, + child: StatefulBuilder( + builder: (dialogContext, setDialogState) { + return AlertDialog( + scrollable: true, + backgroundColor: AppColors.surface, + title: Text( + l10n.verifyFriendTitle(friend.username), + style: const TextStyle(color: AppColors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.enterPinPrompt, + style: + const TextStyle(color: AppColors.neutral60), + ), + const SizedBox(height: AppSpacing.md), + BlocBuilder( + buildWhen: (previous, current) => + previous.pinFlowError != current.pinFlowError || + previous.pinAttemptsRemaining != + current.pinAttemptsRemaining || + previous.pinLockedUntil != + current.pinLockedUntil, + builder: (context, state) { + return TextField( + controller: pinController, + keyboardType: TextInputType.number, + maxLength: 4, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + letterSpacing: 8, + color: AppColors.white, + ), + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: InputDecoration( + filled: true, + fillColor: AppColors.surface, + counterText: '', + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.neutral60, + ), + ), + focusedBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.tertiary, + ), + ), + errorBorder: const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.red, + ), + ), + focusedErrorBorder: + const OutlineInputBorder( + borderSide: BorderSide( + color: AppColors.red, + ), + ), + errorText: _pinErrorText(context, state), + errorStyle: const TextStyle( + color: AppColors.red, + ), + ), + onChanged: (_) => setDialogState(() {}), + ); + }, + ), + ], + ), + actions: [ + BlocBuilder( + buildWhen: (previous, current) => + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + return TextButton( + onPressed: state.isPinValidating + ? null + : () => Navigator.pop(dialogContext), + child: Text( + l10n.cancelTextButton, + style: + const TextStyle(color: AppColors.neutral60), + ), + ); + }, + ), + BlocBuilder( + buildWhen: (previous, current) => + previous.pinFlowError != current.pinFlowError || + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + final isLockedOut = + state.pinFlowError == PinFlowError.lockedOut; + final canSubmit = + pinController.text.length == 4 && + !isLockedOut && + !state.isPinValidating; + return FilledButton( + onPressed: canSubmit + ? () { + bloc.add( + ValidatePin( + pin: pinController.text, + friendUserId: friend.userId, + ), + ); + } + : null, + child: state.isPinValidating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.white, + ), + ) + : Text(l10n.verifyButtonText), + ); + }, + ), + ], + ); + }, + ), + ), + ); + }, + ).then((_) { + final confirmed = + bloc.state.selectedFriend?.userId == friend.userId && + bloc.state.pinValidated; + if (!confirmed) { + _forceReset(); + } + }), + ); + } +} +``` + +(This step folds in Task 2's and Task 5's dialog changes verbatim since the whole class is being replaced β€” nothing from those tasks is lost, the `scrollable: true`, the two `isPinValidating`-aware `BlocBuilder`s, and the `SelectFriend`-without-manual-name-write listener are all still here.) + +Add the two new l10n keys. In `lib/l10n/arb/app_en.arb`, after the `accountOwnerOptionLabel` block added in Task 4: + +```json + "notLinkedOptionLabel": "Not linked", + "@notLinkedOptionLabel": { + "description": "Dropdown entry meaning this player slot is not linked to any account" + }, +``` + +In `lib/l10n/arb/app_es.arb`, after `"accountOwnerOptionLabel": "Yo",`: + +```json + "notLinkedOptionLabel": "Sin vincular", +``` + +- [ ] **Step 4: Regenerate localizations** + +Run: `flutter gen-l10n --arb-dir="lib/l10n/arb"` +Expected: no errors. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/player/customize_player_page_friend_picker_test.dart` +Expected: PASS (every group in the file). + +- [ ] **Step 6: Run the full test suite and analyzer** + +Run: `flutter test` +Expected: PASS, including `test/player/customize_player_page_anonymous_test.dart` (the anonymous placeholder path is untouched by this task β€” `_FriendSection` still checks `isAnonymous` first, before ever building the dropdown). + +Run: `flutter analyze` +Expected: no new issues. + +- [ ] **Step 7: Manual verification** + +Run the app (`flutter run --flavor development --target lib/main_development.dart`) on a small simulated device (e.g. iPhone SE) and confirm: the dropdown opens and filters as you type with 0 and with several friends; picking "Me" locks the name immediately with no dialog; picking a friend opens the PIN dialog and the Verify button is reachable with the keyboard open; cancelling reverts the dropdown's displayed text. + +- [ ] **Step 8: Commit** + +```bash +git add lib/player/view/customize_player_page.dart lib/l10n/arb/app_en.arb lib/l10n/arb/app_es.arb lib/l10n/arb/app_localizations.dart lib/l10n/arb/app_localizations_en.dart lib/l10n/arb/app_localizations_es.dart test/player/customize_player_page_friend_picker_test.dart +git commit -m "feat: replace the friend tile list with a searchable Me/friend/unlinked dropdown" +``` + +--- + +## Deliberate deviations from the spec (called out, not hidden) + +- **Stale/unfriended link on reopen:** the spec says "don't silently clear a real, persisted link" if the friend list doesn't contain a match. This plan does exactly that by taking no action β€” but since `_save()` always recomputes `firebaseId` fresh from the confirmed bloc state (never "preserves whatever was there if the dropdown was never touched"), an untouched dropdown showing "Not linked" *will* save as unlinked, clearing the stale id on next save. Implementing true preservation would require a fourth state bucket ("unresolved but present") purely to distinguish "user explicitly chose unlinked" from "we couldn't resolve a display label" β€” not justified for an edge case (a friend who unfriended you) where clearing the link on next save is arguably the more correct outcome anyway, not just the simpler one. +- **Fast-tap-save race:** if a user hits Save before `FriendBloc` finishes loading on an already friend-linked seat, the same clearing-on-save behavior applies (the rehydration listener hasn't had a chance to fire yet). Not mitigated β€” blocking Save until friends finish loading would hurt the common no-link-anyway case to guard a narrow, low-frequency race. + +## Self-Review Notes + +- **Spec coverage:** dropdown+search (Task 6), owner as a selectable entry (Tasks 3, 6), rehydration fix (Task 5), name lock extended to owner (Task 4), PIN dialog loading state + click-only-submit preserved (Tasks 1, 2), keyboard overlap fix (Task 2) β€” all covered. The two accepted deviations above are the only places this plan doesn't literally match the spec's wording, and both are explained. +- **Placeholder scan:** no TBD/TODO; every step has complete code; the one place code is repeated verbatim across tasks (Task 6 restates Task 2's and Task 5's dialog changes) is because Task 6 replaces the entire enclosing class, not a "see Task N" reference. +- **Type consistency:** `OwnerSelected(userId: ...)`, `SelectFriend(friend: ...)`, `LinkCleared()`, `isPinValidating`, `ownerUsername`, `copyWithOwnerSelected()`/`copyWithFriendSelected()`/`copyWithLinkCleared()` are named and typed identically everywhere they're introduced (Tasks 1, 3) and consumed (Tasks 2, 4, 5, 6). + +## Post-implementation design pass (2026-07-06) + +After all 6 tasks shipped, the dropdown didn't visually match the page's +existing dark theme (`DropdownMenu` has its own dedicated theme slot, +`DropdownMenuThemeData`, which nothing in `app_theme.dart` sets β€” it was +rendering in unthemed Material 3 defaults while every sibling control had +explicit dark-theme styling). Fixed in `_FriendSectionState.build()`: + +- `inputDecorationTheme`, `textStyle`, `menuStyle`, and a `leadingIcon` + added directly to the `DropdownMenu` construction, reusing only + colors/spacing already established elsewhere on this page + (`AppColors.surface`/`neutral60`/`white`, `AppSpacing.sm` corner radius) β€” + no new design tokens introduced. +- Each `DropdownMenuEntry` gets a shared `_entryStyle` (white foreground) + since the popup's dark background made Material 3's default entry text + color barely legible. +- Replaced the `SizedBox(width: double.infinity)` wrapper with + `expandedInsets: EdgeInsets.zero` β€” confirmed via the Flutter SDK source + that `DropdownMenu` sizes itself to content width regardless of a + `SizedBox` parent; the field never actually stretched to match the name + field below it (visually confirmed by the user on a real device/tablet + build, since this sandbox's browser preview never got past initial + compile β€” see below). +- `expandedInsets` has a real, SDK-documented side effect: it skips + building the always-present "invisible measurement copy" of every entry + label that a non-expanded `DropdownMenu` keeps in the tree for width + calculation. `test/player/customize_player_page_anonymous_test.dart`'s + "shows the friend list... when authenticated" test relied on that + implementation detail (`find.text('Bob')` with no interaction) and had + to open the dropdown first before checking for the entry. + +**Environment note:** this session's browser-preview tooling repeatedly +stalled at the same Flutter web DDC-compile step (twice, including one +outright server crash) and could never render the page β€” likely a sandbox +resource limit for this app's dependency size, not a code issue. All +visual confirmation for this pass came from a screenshot the user +provided from their own device. diff --git a/docs/superpowers/plans/2026-07-07-friends-flow-username-cleanup.md b/docs/superpowers/plans/2026-07-07-friends-flow-username-cleanup.md new file mode 100644 index 0000000..e343e9e --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-friends-flow-username-cleanup.md @@ -0,0 +1,835 @@ +# Friends Flow Username Cleanup 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 username the sole user identity β€” harden its validation, remove firstName/lastName from every layer (model, onboarding, profile, auth, l10n, tests), and clean up friends-search error copy. + +**Architecture:** BLoC + Repository Flutter app. Changes are ordered so every commit leaves the tree green: validator first, l10n string additions second, then usage removal (onboarding β†’ profile), then model field removal + codegen, then the auth layer, then the search-copy fix. The Firestore docs need no migration: `updateUserProfile` writes the full doc with `set()` (no merge), so stale name keys drop on each user's next save, and `json_serializable` ignores unknown keys on read. + +**Tech Stack:** Flutter, bloc/flutter_bloc, formz (`form_inputs` package), json_serializable codegen, flutter gen-l10n (ARB, en+es), mocktail/bloc_test. + +**Spec:** `docs/superpowers/specs/2026-07-07-friends-flow-username-cleanup-design.md` + +## Global Constraints + +- Lints: `very_good_analysis` (strict). Run `flutter analyze` before each commit; pre-existing `app_ui` gallery errors are the known baseline β€” introduce nothing new. +- Codegen: after editing any `@JsonSerializable` model run `dart run build_runner build --delete-conflicting-outputs` in that package. +- Localization: ARB files in `lib/l10n/arb/` (en + es, both mandatory); regenerate with `flutter gen-l10n --arb-dir="lib/l10n/arb"`; generated `app_localizations*.dart` files are committed. +- Username bounds (spec): trimmed, min 2 chars, max 30 chars. `UserProfileModel.isComplete` keeps its loose non-empty check (legacy users must not be bounced into onboarding). +- Commit messages: semantic prefixes (`feat:`/`fix:`/`test:`/`chore:`/`refactor:`), ending with `Co-Authored-By: Claude Fable 5 `. +- Do NOT touch `functions/`, `firestore.rules`, or `firestore.indexes.json` β€” this plan is app-client only and must add no deploy gates. + +--- + +### Task 0: Commit the pending friend-dropdown polish + +The working tree carries finished-but-uncommitted UI polish from the previous session (dropdown theming/alignment in `customize_player_page.dart`, X-to-clear button in `player_identity_panel.dart`, matching l10n + test updates). Land it as its own commit so this plan's commits stay clean. + +**Files:** +- Modify (already modified, just commit): `lib/player/view/customize_player_page.dart`, `lib/player/view/widgets/player_identity_panel.dart`, `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb`, `lib/l10n/arb/app_localizations*.dart`, `test/player/customize_player_page_anonymous_test.dart`, `test/player/customize_player_page_friend_picker_test.dart` + +- [ ] **Step 1: Verify the pending work is green** + +Run: `flutter test test/player/customize_player_page_anonymous_test.dart test/player/customize_player_page_friend_picker_test.dart && flutter analyze lib/player` +Expected: all tests pass; no analyzer issues in lib/player. + +- [ ] **Step 2: Commit** + +```bash +git add lib/player lib/l10n test/player +git commit -m "style: theme the friend dropdown for the dark surface and move clear into the name field + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 1: Harden the Username formz input + +**Files:** +- Create: `packages/form_inputs/test/src/username_test.dart` +- Modify: `packages/form_inputs/lib/src/username.dart` + +**Interfaces:** +- Produces: `UsernameValidationError { empty, tooShort, tooLong }`, `Username.minLength == 2`, `Username.maxLength == 30`. Validation runs on the **trimmed** value. Later tasks map each error to l10n copy and persist `value.trim()`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/form_inputs/test/src/username_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:form_inputs/form_inputs.dart'; + +void main() { + group('Username', () { + test('pure empty value reports no display error and is not valid', () { + const username = Username.pure(); + expect(username.displayError, isNull); + expect(username.isValid, isFalse); + }); + + test('empty dirty value has the empty error', () { + const username = Username.dirty(); + expect(username.error, UsernameValidationError.empty); + }); + + test('whitespace-only value has the empty error', () { + const username = Username.dirty(' '); + expect(username.error, UsernameValidationError.empty); + }); + + test('one character after trimming is too short', () { + const username = Username.dirty(' a '); + expect(username.error, UsernameValidationError.tooShort); + }); + + test('31 characters after trimming is too long', () { + final username = Username.dirty('a' * 31); + expect(username.error, UsernameValidationError.tooLong); + }); + + test('2 and 30 trimmed characters are valid', () { + expect(Username.dirty('ab').isValid, isTrue); + expect(Username.dirty(' ${'a' * 30} ').isValid, isTrue); + }); + + test('interior spaces are allowed', () { + expect(Username.dirty('Cool Name').isValid, isTrue); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/form_inputs && flutter test test/src/username_test.dart` +Expected: FAIL β€” `tooShort`/`tooLong` are not defined; whitespace test fails against the current `isNotEmpty` check. + +- [ ] **Step 3: Implement the validator** + +Replace the body of `packages/form_inputs/lib/src/username.dart`: + +```dart +import 'package:formz/formz.dart'; + +/// Username Form Input Validation Error +enum UsernameValidationError { + /// Username is empty or whitespace-only. + empty, + + /// Username is shorter than [Username.minLength] after trimming. + tooShort, + + /// Username is longer than [Username.maxLength] after trimming. + tooLong, +} + +/// {@template username} +/// Reusable username form input. +/// +/// Validates the trimmed value; callers persist `value.trim()` so stored +/// usernames never carry edge whitespace. +/// {@endtemplate} +class Username extends FormzInput { + /// {@macro username} + const Username.pure() : super.pure(''); + + /// {@macro username} + const Username.dirty([super.value = '']) : super.dirty(); + + /// Minimum trimmed length β€” matches the server-side username search's + /// 2-character minimum query, so every valid username is discoverable. + static const minLength = 2; + + /// Maximum trimmed length. + static const maxLength = 30; + + @override + UsernameValidationError? validator(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return UsernameValidationError.empty; + if (trimmed.length < minLength) return UsernameValidationError.tooShort; + if (trimmed.length > maxLength) return UsernameValidationError.tooLong; + return null; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/form_inputs && flutter test` +Expected: PASS (all package tests). + +- [ ] **Step 5: Check downstream suites still pass** + +Run: `flutter test test/onboarding test/profile` +Expected: PASS β€” existing tests use usernames like `'josh'` which satisfy the new bounds. If any test uses a 1-char username, lengthen it to a 2+ char value in that test. + +- [ ] **Step 6: Commit** + +```bash +git add packages/form_inputs +git commit -m "feat: validate usernames on the trimmed value with 2-30 char bounds + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Add the new l10n strings (en + es) + +**Files:** +- Modify: `lib/l10n/arb/app_en.arb`, `lib/l10n/arb/app_es.arb` +- Regenerate: `lib/l10n/arb/app_localizations*.dart` + +**Interfaces:** +- Produces l10n getters used by Tasks 3, 4, 7: `usernameRequiredError`, `usernameTooShortError`, `usernameTooLongError`, `usernameInvalidMessage`, `searchFailedMessage`. + +- [ ] **Step 1: Add the English strings** + +In `lib/l10n/arb/app_en.arb`, insert after the `@usernameHelperText` block (currently ends at line 898, right before `"firstNameLabel"`): + +```json + "usernameRequiredError": "Username is required", + "@usernameRequiredError": { + "description": "Inline error when the username field is empty or whitespace-only" + }, + "usernameTooShortError": "Username must be at least 2 characters", + "@usernameTooShortError": { + "description": "Inline error when the trimmed username is shorter than 2 characters" + }, + "usernameTooLongError": "Username must be 30 characters or fewer", + "@usernameTooLongError": { + "description": "Inline error when the trimmed username is longer than 30 characters" + }, + "usernameInvalidMessage": "Fix your username before saving.", + "@usernameInvalidMessage": { + "description": "Snackbar shown when a profile save is blocked by an invalid username" + }, + "searchFailedMessage": "Search failed. Check your connection and try again.", + "@searchFailedMessage": { + "description": "Friendly error shown when a friend search fails" + }, +``` + +- [ ] **Step 2: Add the Spanish strings** + +In `lib/l10n/arb/app_es.arb`, insert after the `"usernameHelperText"` line (line 67; the es file carries values only, no `@` description blocks): + +```json + "usernameRequiredError": "El nombre de usuario es obligatorio", + "usernameTooShortError": "El nombre de usuario debe tener al menos 2 caracteres", + "usernameTooLongError": "El nombre de usuario debe tener 30 caracteres o menos", + "usernameInvalidMessage": "Corrige tu nombre de usuario antes de guardar.", + "searchFailedMessage": "La bΓΊsqueda fallΓ³. Comprueba tu conexiΓ³n e intΓ©ntalo de nuevo.", +``` + +- [ ] **Step 3: Regenerate localizations** + +Run: `flutter gen-l10n --arb-dir="lib/l10n/arb"` +Expected: exits 0; `lib/l10n/arb/app_localizations.dart` gains the five new abstract getters; en/es implementations updated. + +- [ ] **Step 4: Analyze** + +Run: `flutter analyze lib/l10n` +Expected: no issues. + +- [ ] **Step 5: Commit** + +```bash +git add lib/l10n +git commit -m "feat: add localized username validation and search failure copy + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Onboarding β€” username-only identity step, trimmed save, localized errors + +**Files:** +- Modify: `lib/onboarding/bloc/onboarding_event.dart` (delete lines 26–40) +- Modify: `lib/onboarding/bloc/onboarding_state.dart` +- Modify: `lib/onboarding/bloc/onboarding_bloc.dart` +- Modify: `lib/onboarding/view/onboarding_form.dart` +- Test: `test/onboarding/bloc/onboarding_bloc_test.dart` + +**Interfaces:** +- Consumes: `UsernameValidationError.{empty,tooShort,tooLong}` (Task 1), l10n getters (Task 2). +- Produces: `OnboardingState` without `firstName`/`lastName`; submit persists `state.username.value.trim()`. + +- [ ] **Step 1: Write the failing bloc tests** + +Append a new group inside `main()` in `test/onboarding/bloc/onboarding_bloc_test.dart` (harness already provides `buildBloc` and `firebaseDatabaseRepository`): + +```dart + group('username hardening', () { + test('whitespace-only username keeps step 0 invalid', () { + final bloc = buildBloc()..add(const OnboardingUsernameChanged(' ')); + addTearDown(bloc.close); + expect(bloc.state.isStepValid, isFalse); + }); + + blocTest( + 'submit persists the trimmed username', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer( + (_) async => const UserProfileModel(id: 'u1', friendCode: 'ABCD1234'), + ); + when( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + act: (bloc) => bloc + ..add(const OnboardingUsernameChanged(' josh ')) + ..add(const OnboardingSubmitted('u1')), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.updateUserProfile('u1', captureAny()), + ).captured.single as UserProfileModel; + expect(saved.username, 'josh'); + }, + ); + }); +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `flutter test test/onboarding/bloc/onboarding_bloc_test.dart` +Expected: the trim test FAILS (saved username is `' josh '`); the whitespace test FAILS only if Task 1 was skipped β€” it should already pass. (If the submit test fails on an unstubbed `setPin`, no PIN was entered so `setPin` must not be called β€” that would indicate a harness drift to fix here.) + +- [ ] **Step 3: Remove firstName/lastName from the onboarding bloc layer and trim the saved username** + +`lib/onboarding/bloc/onboarding_event.dart`: delete the `OnboardingFirstNameChanged` and `OnboardingLastNameChanged` classes. + +`lib/onboarding/bloc/onboarding_state.dart`: delete the `firstName`/`lastName` constructor params (`this.firstName = ''`, `this.lastName = ''`), field declarations, `copyWith` params and assignments, and their two `props` entries. + +`lib/onboarding/bloc/onboarding_bloc.dart`: +- Delete `firstName: existingProfile?.firstName ?? '',` and `lastName: existingProfile?.lastName ?? '',` from the constructor's initial state. +- Delete `on(_onFirstNameChanged);` and `on(_onLastNameChanged);` plus both handler methods. +- In `_onSubmitted`'s `UserProfileModel(...)`: delete `firstName: state.firstName,` and `lastName: state.lastName,`, and change `username: state.username.value,` to: + +```dart + username: state.username.value.trim(), +``` + +- [ ] **Step 4: Remove the name fields from the identity step and localize the username error** + +`lib/onboarding/view/onboarding_form.dart`, in `_IdentityStepState`: +- Delete `_firstNameController`/`_lastNameController` declarations, their `initState` assignments, and their `dispose()` calls. +- Delete the two name `TextField`s and the two `const SizedBox(height: 16)` separators that precede them (everything after the username `BlocBuilder` inside the `Column`). +- Replace the hardcoded error text: + +```dart + errorText: switch (state.username.displayError) { + UsernameValidationError.empty => + context.l10n.usernameRequiredError, + UsernameValidationError.tooShort => + context.l10n.usernameTooShortError, + UsernameValidationError.tooLong => + context.l10n.usernameTooLongError, + null => null, + }, +``` + +Add `import 'package:form_inputs/form_inputs.dart';` and `import 'package:magic_yeti/l10n/l10n.dart';` if not already present. + +- [ ] **Step 5: Run tests and analyze** + +Run: `flutter test test/onboarding && flutter analyze lib/onboarding` +Expected: PASS; no analyzer issues. + +- [ ] **Step 6: Commit** + +```bash +git add lib/onboarding test/onboarding +git commit -m "refactor: onboarding identity step collects only a username, trimmed on save + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Profile β€” remove names, inline username errors, distinct blocked-save copy + +**Files:** +- Modify: `lib/profile/bloc/profile_event.dart` (delete `ProfileFirstNameChanged`, `ProfileLastNameChanged`) +- Modify: `lib/profile/bloc/profile_state.dart` +- Modify: `lib/profile/bloc/profile_bloc.dart` +- Modify: `lib/profile/view/profile_page.dart` +- Test: `test/profile/bloc/profile_bloc_test.dart`, `test/profile/view/profile_page_test.dart` + +**Interfaces:** +- Consumes: `UsernameValidationError` (Task 1), `usernameInvalidMessage` + username error getters (Task 2). +- Produces: `ProfileStatus.usernameInvalid` enum value; `ProfileState` without `firstName`/`lastName`; saves persist `state.username?.value.trim()`. + +- [ ] **Step 1: Write the failing bloc tests** + +In `test/profile/bloc/profile_bloc_test.dart`: + +Remove `firstName: 'Josh',` and `lastName: 'Shew',` from the `loadedProfile` fixture and `name: 'Josh'` stays for now (auth cleanup is Task 6). Replace the existing "saves first/last name" expectations (the test around lines 140–165 that dispatches `ProfileFirstNameChanged('NewFirst')` / `ProfileLastNameChanged('NewLast')` and asserts `saved.firstName`/`saved.lastName`) with a bio-based equivalent, and add the two new tests: + +```dart + blocTest( + 'submit saves edited bio and trimmed username onto the loaded profile', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => loadedProfile); + when( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + act: (bloc) => bloc + ..add(const ProfileLoadRequested('u1')) + ..add(const ProfileUsernameChanged(' josh2 ')) + ..add(const ProfileBioChanged('new bio')) + ..add(const ProfileSubmitted()), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.updateUserProfile('u1', captureAny()), + ).captured.single as UserProfileModel; + expect(saved.username, 'josh2'); + expect(saved.bio, 'new bio'); + // Untouched fields carry over from the loaded profile. + expect(saved.friendCode, 'YETI-A3F9'); + expect(saved.hasPin, isTrue); + expect(saved.onboardingComplete, isTrue); + }, + ); + + blocTest( + 'submit with an invalid username emits usernameInvalid and never saves', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => loadedProfile); + return buildBloc(); + }, + act: (bloc) => bloc + ..add(const ProfileLoadRequested('u1')) + ..add(const ProfileUsernameChanged(' ')) + ..add(const ProfileSubmitted()), + verify: (bloc) { + expect(bloc.state.status, ProfileStatus.usernameInvalid); + verifyNever( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ); + }, + ); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/profile/bloc/profile_bloc_test.dart` +Expected: FAIL β€” `ProfileStatus.usernameInvalid` undefined; trim not implemented; old first/last test now removed so no reference errors remain. + +- [ ] **Step 3: Update the profile bloc layer** + +`lib/profile/bloc/profile_event.dart`: delete the `ProfileFirstNameChanged` and `ProfileLastNameChanged` classes. + +`lib/profile/bloc/profile_state.dart`: delete `firstName`/`lastName` constructor params, fields, `copyWith` params/assignments, and `props` entries. Change the enum: + +```dart +enum ProfileStatus { + initial, + loading, + loaded, + success, + failure, + pinSaved, + usernameInvalid, +} +``` + +`lib/profile/bloc/profile_bloc.dart`: +- Delete the two `on<...>` registrations and both handler methods for first/last name. +- In `_onSubmitted`, replace the invalid-username guard's emit and the copyWith: + +```dart + // A present-but-invalid username (e.g. cleared to empty) must never + // reach updateUserProfile: saving `username: ''` would flip + // UserProfileModel.isComplete false, bouncing the user back into + // onboarding on the next auth event. + if (state.username != null && !Formz.validate([state.username!])) { + emit(state.copyWith(status: ProfileStatus.usernameInvalid)); + return; + } +``` + +```dart + final updatedProfile = loaded.copyWith( + username: state.username?.value.trim() ?? loaded.username, + bio: state.bio ?? loaded.bio, + ); +``` + +- [ ] **Step 4: Update the profile page** + +`lib/profile/view/profile_page.dart`: +- Delete the two `_ProfileField` widgets for `firstNameLabel`/`lastNameLabel`. +- In the `BlocListener`, add before the generic failure branch: + +```dart + if (state.status == ProfileStatus.usernameInvalid) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.usernameInvalidMessage), + backgroundColor: Colors.red, + ), + ); + } +``` + +- Extend `_ProfileField` with a live error builder (the parent builder only rebuilds on status/isEditing, so the error must come through this widget's own `BlocBuilder`): + +```dart +class _ProfileField extends StatelessWidget { + const _ProfileField({ + required this.label, + required this.initialValue, + required this.onChanged, + this.helperText, + this.errorTextBuilder, + }); + + final String label; + final String initialValue; + final void Function(String) onChanged; + final String? helperText; + + /// Builds live inline error copy from bloc state; null for fields + /// without validation. + final String? Function(BuildContext context, ProfileState state)? + errorTextBuilder; +``` + +with `buildWhen` extended to `previous.isEditing != current.isEditing || previous.username != current.username` and the editor's decoration becoming: + +```dart + decoration: InputDecoration( + hintText: 'Enter $label', + errorText: + errorTextBuilder?.call(context, state), + ), +``` + +- Wire it on the username field only: + +```dart + _ProfileField( + label: context.l10n.usernameLabel, + initialValue: profile.username ?? '', + helperText: context.l10n.usernameHelperText, + errorTextBuilder: (context, state) => + switch (state.username?.displayError) { + UsernameValidationError.empty => + context.l10n.usernameRequiredError, + UsernameValidationError.tooShort => + context.l10n.usernameTooShortError, + UsernameValidationError.tooLong => + context.l10n.usernameTooLongError, + null => null, + }, + onChanged: (value) => context + .read() + .add(ProfileUsernameChanged(value)), + ), +``` + +Add `import 'package:form_inputs/form_inputs.dart';` to the page. + +- In `test/profile/view/profile_page_test.dart`, delete `firstName: 'Josh',` and `lastName: 'Shew',` from the profile fixture (and any finder expecting the removed fields, if present). + +- [ ] **Step 5: Run tests and analyze** + +Run: `flutter test test/profile && flutter analyze lib/profile` +Expected: PASS; no analyzer issues. + +- [ ] **Step 6: Commit** + +```bash +git add lib/profile test/profile +git commit -m "refactor: profile page drops name fields and gains inline username validation + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Remove firstName/lastName from UserProfileModel + ARB labels + +**Files:** +- Modify: `packages/firebase_database_repository/lib/models/user_profile_model.dart` +- Regenerate: `packages/firebase_database_repository/lib/models/user_profile_model.g.dart` +- Modify: `lib/l10n/arb/app_en.arb` (delete `firstNameLabel`/`lastNameLabel` + their `@` blocks, lines 899–906), `lib/l10n/arb/app_es.arb` (delete the two label lines) +- Regenerate: `lib/l10n/arb/app_localizations*.dart` + +**Interfaces:** +- Produces: `UserProfileModel` without `firstName`/`lastName` (constructor, fields, `copyWith`, `props`). `isComplete` is untouched. + +- [ ] **Step 1: Remove the model fields** + +In `user_profile_model.dart` delete: constructor params `this.firstName,`/`this.lastName,`; the two field declarations with their doc comments (`/// First name of the user` etc.); the two `copyWith` params and assignments; the two `props` entries. + +- [ ] **Step 2: Regenerate JSON codegen** + +Run: `cd packages/firebase_database_repository && dart run build_runner build --delete-conflicting-outputs` +Expected: exits 0; `user_profile_model.g.dart` no longer mentions firstName/lastName. + +- [ ] **Step 3: Remove the ARB labels and regenerate l10n** + +Delete from `app_en.arb`: + +```json + "firstNameLabel": "First Name", + "@firstNameLabel": { + "description": "Label for the first name field on the profile page" + }, + "lastNameLabel": "Last Name", + "@lastNameLabel": { + "description": "Label for the last name field on the profile page" + }, +``` + +Delete from `app_es.arb`: + +```json + "firstNameLabel": "Nombre", + "lastNameLabel": "Apellido", +``` + +Run: `flutter gen-l10n --arb-dir="lib/l10n/arb"` + +- [ ] **Step 4: Verify nothing references the fields anymore** + +Run: `grep -rn "firstName\|lastName" lib packages test --include="*.dart" | grep -v app_localizations` +Expected: no output. (Then `grep -rn "firstName" lib/l10n` must also be empty after regen.) + +- [ ] **Step 5: Run tests and analyze** + +Run: `cd packages/firebase_database_repository && flutter test && cd ../.. && flutter test && flutter analyze lib packages/firebase_database_repository` +Expected: all PASS; no new analyzer issues. + +- [ ] **Step 6: Commit** + +```bash +git add packages/firebase_database_repository lib/l10n +git commit -m "feat: drop firstName/lastName from the user profile model and localizations + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Auth layer β€” remove User.name and the Apple fullName scope + +**Files:** +- Modify: `packages/authentication_client/authentication_client/lib/src/models/user.dart` +- Modify: `packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart` +- Test: `test/profile/bloc/profile_bloc_test.dart:18`, `test/profile/view/profile_page_test.dart:25` + +**Interfaces:** +- Produces: `User` without `name` (field, constructor param, `copyWith`, `props`). Apple sign-in requests only the `email` scope. + +- [ ] **Step 1: Remove the field from the User model** + +In `user.dart` delete: `this.name,` from the constructor; the `/// The current user's name (display name).` field; `String? name,` + `name: name ?? this.name,` from `copyWith`; `name` from `props`. + +- [ ] **Step 2: Update the Firebase client** + +In `firebase_authentication_client.dart`: +- In the `toUser()` extension (line ~332), delete `name: displayName,`. +- In `logInWithApple()`, change the scopes list to request only email: + +```dart + scopes: [ + AppleIDAuthorizationScopes.email, + ], +``` + +- [ ] **Step 3: Fix the fixtures** + +`test/profile/bloc/profile_bloc_test.dart` and `test/profile/view/profile_page_test.dart`: change + +```dart + const authUser = User(id: 'u1', email: 'josh@example.com', name: 'Josh'); +``` + +to + +```dart + const authUser = User(id: 'u1', email: 'josh@example.com'); +``` + +(same change for the page test's `authUser`). + +- [ ] **Step 4: Sweep for stragglers** + +Run: `grep -rn "name:" lib test packages --include="*.dart" | grep -i "user(" ; grep -rn "\.name" packages/user_repository packages/authentication_client --include="*.dart" | grep -v "\.named\|Name"` +Expected: no `User(name: ...)` constructions and no `.name` reads on the auth user remain. Fix any hits by deleting the argument/read. + +- [ ] **Step 5: Run tests and analyze** + +Run: `cd packages/authentication_client/authentication_client && flutter test; cd ../firebase_authentication_client && flutter test; cd ../../.. && flutter test && flutter analyze` +Expected: PASS everywhere (package test suites may be small/empty β€” a "No tests" result is acceptable where none exist); analyzer clean apart from the known app_ui baseline. + +- [ ] **Step 6: Commit** + +```bash +git add packages/authentication_client test/profile +git commit -m "feat: stop collecting the provider display name; identity is username-only + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Friendly friend-search error copy + +**Files:** +- Modify: `lib/friends_list/search_user/search_user_page.dart` (the `SearchError` branch, lines ~163–171) +- Create: `test/friends_list/search_user/search_user_page_error_test.dart` + +**Interfaces:** +- Consumes: `searchFailedMessage` (Task 2). `SearchError.message` keeps carrying the raw detail for logs/tests; only the rendering changes. + +- [ ] **Step 1: Write the failing widget test** + +Create `test/friends_list/search_user/search_user_page_error_test.dart` (mirrors the harness of `search_user_page_anonymous_test.dart`): + +```dart +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('SearchUserPage error state', () { + late MockAppBloc appBloc; + late MockFirebaseDatabaseRepository databaseRepository; + + setUp(() { + appBloc = MockAppBloc(); + databaseRepository = MockFirebaseDatabaseRepository(); + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + }); + + testWidgets('renders localized copy, not the raw exception', + (tester) async { + when(() => databaseRepository.searchByUsername(any())) + .thenThrow(Exception('boom')); + + await tester.pumpApp( + MultiBlocProvider( + providers: [BlocProvider.value(value: appBloc)], + child: RepositoryProvider.value( + value: databaseRepository, + child: const SearchUserPage(), + ), + ), + ); + await tester.pump(); + + await tester.enterText(find.byType(TextField), 'someone'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + find.text('Search failed. Check your connection and try again.'), + findsOneWidget, + ); + expect(find.textContaining('boom'), findsNothing); + expect(find.textContaining('Exception'), findsNothing); + }); + }); +} +``` + +(If `searchByUsername` needs the caller id β€” check its signature in the repository β€” adjust the stub to `searchByUsername(any(), any())` accordingly.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/friends_list/search_user/search_user_page_error_test.dart` +Expected: FAIL β€” the page currently renders `Error: Failed to search: Exception: boom`. + +- [ ] **Step 3: Render localized copy** + +In `search_user_page.dart`, replace the `SearchError` branch's text: + +```dart + } else if (state is SearchError) { + return Center( + child: Text( + context.l10n.searchFailedMessage, + style: const TextStyle( + color: AppColors.red, + ), + ), + ); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/friends_list` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/friends_list test/friends_list +git commit -m "fix: friend search failures show localized copy instead of raw exceptions + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: Full verification sweep + +**Files:** none new β€” verification only, plus doc touch-ups if greps hit. + +- [ ] **Step 1: Repo-wide leftovers grep** + +Run: `grep -rniE "firstname|lastname|first name|last name" lib packages test docs/friends_feature_plan.md --include="*.dart" --include="*.md" | grep -v superpowers` +Expected: no code hits. If `docs/friends_feature_plan.md` mentions the identity fields, update that prose to username-only. + +- [ ] **Step 2: Full analyze** + +Run: `flutter analyze` +Expected: no NEW issues versus the known pre-existing `app_ui` gallery baseline. + +- [ ] **Step 3: Full test suites** + +Run: `flutter test` (root), then `flutter test` in `packages/form_inputs`, `packages/firebase_database_repository`, `packages/player_repository`, `packages/authentication_client/authentication_client`, `packages/authentication_client/firebase_authentication_client`, `packages/user_repository`. +Expected: all PASS. + +- [ ] **Step 4: Commit any doc fixes** + +```bash +git add docs +git commit -m "docs: friends feature README reflects username-only identity + +Co-Authored-By: Claude Fable 5 " +``` + +(Skip if Step 1 found nothing to fix.) diff --git a/docs/superpowers/specs/2026-07-03-friends-feature-design.md b/docs/superpowers/specs/2026-07-03-friends-feature-design.md new file mode 100644 index 0000000..602f95e --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-friends-feature-design.md @@ -0,0 +1,280 @@ +# Friends Feature β€” Hardening & Completion Design + +**Date:** 2026-07-03 +**Status:** Approved +**Supersedes:** `docs/friends_feature_plan.md` (stale β€” most of its "missing" items are implemented on main) + +## Context + +Magic Yeti is played by people sitting around one table, usually on one device. The friends +feature lets a logged-in host add friends by friend code, then link a friend's account to a +player slot during game setup so the finished game lands in *every* linked player's match +history β€” not just the host's. The safety rail is a 4-digit PIN: linking your account on +someone else's device requires entering *your* PIN. The one hard constraint: friends must +never have to log in on the host's device. + +### What already exists on main (verified 2026-07-03) + +- Friend codes (`YETI-XXXX`), generation, and search: `generateUniqueFriendCode`, + `searchByFriendCode` in `packages/firebase_database_repository/lib/src/firebase_database_repository.dart` +- Friend request send/accept/decline/remove flows, models, BLoCs, and UI + (`lib/friends_list/`), including mutual-request auto-accept and relationship status +- 4-digit PIN: `Pin` formz input, `hashPin`/`validatePin`/`setPin` (SHA-256, unsalted), + stored as a `pin` field on the world-readable user profile doc +- Onboarding: 4-step wizard (identity β†’ PIN β†’ picture β†’ bio) at `lib/onboarding/`; + username and PIN steps are required; gated by `UserProfileModel.onboardingComplete` + via `AppBloc` β†’ `AppStatus.onboardingRequired` β†’ router redirect +- Friend selection on the customize player page (`_FriendSection` in + `lib/player/view/customize_player_page.dart`): pick friend β†’ PIN dialog β†’ + `validatePin` β†’ `firebaseId` set on the `Player` at save +- Post-game sync: `GameOverBloc` saves to `games/{docId}` then client-side fans out to + `users/{firebaseId}/matches/{docId}` for every linked player (`syncGameToPlayers`) +- Game-code import for non-friends: 4-char `roomId` on `GameModel`; another user enters + it on the home page β†’ `getGame(roomId)` β†’ copy into their own `matches` subcollection + +### Why more work is needed + +- **No Firestore security rules are versioned in the repo.** The current design depends on + permissive rules: the host's client writes into *other users'* `matches` subcollections, + and PIN hashes sit on world-readable profile docs. An unsalted SHA-256 of a 4-digit PIN + is a 10,000-candidate brute force for anyone who can read the doc. +- **No PIN attempt limiting** anywhere; the PIN dialog allows unlimited retries. +- **Legacy loopholes:** a profile with an empty-string `pin` passes the onboarding PIN step + (`existingPinHash` non-null check), and users with `onboardingComplete: true` but a + missing username/PIN are never re-prompted. +- **Game-over overwrite bug:** the "which slot is mine" picker in `GameOverBloc` + unconditionally assigns the host's `firebaseId` to the selected slot, clobbering a slot + already PIN-linked to a friend. +- **Profile page is unfinished:** reads the auth `User` instead of `UserProfileModel`; + `ProfileBloc` submit is stubbed; no PIN change UI; no block management. +- **No abuse rails:** declined friend requests can be re-sent forever; no blocking. + +### Decisions (made with Josh, 2026-07-03) + +1. **Legacy enforcement:** reuse the existing onboarding gate (no new bottom sheet). +2. **Backend:** add Cloud Functions (Blaze plan accepted) + versioned Firestore rules. +3. **PIN frequency:** PIN required on **every** link β€” no trusted-device state. +4. **Abuse protection:** full blocking ships in this iteration. +5. PIN change from the profile page requires no old PIN β€” a logged-in session is stronger + proof of identity than 4 digits. +6. Synced match-history copies are immutable snapshots: host edits/deletes do not + propagate; unfriending/blocking does not remove already-synced games. +7. Architecture: keep direct Firestore reads/writes/streams wherever security rules can + express the check (social graph, PIN set, all reads); Cloud Functions only where rules + cannot β€” secret comparison + rate limiting (`validatePin`), guaranteed fan-out + (`onGameCreated`), post-deletion cleanup (`onUserDeleted`), and search-time block + hiding (`searchByFriendCode`). + +## Goals + +- Enforce name + PIN for all users (new and legacy) with one unskippable path. +- Keep Firebase's direct-read/write/stream strengths everywhere rules can secure them; + reserve Cloud Functions for what rules cannot express. Lock rules down to match. +- Rate-limit and scope PIN validation so the rail actually holds. +- Ship full blocking (hidden from search, requests refused, unselectable in games). +- Finish the profile page (PIN change, friend code share, blocked users). +- Fix the game-over `firebaseId` overwrite bug. +- Keep the game-code import flow working unchanged for non-friends. + +## Non-goals (future work) + +- Viewing a friend's stats from the friends list +- Push notifications / badges for friend requests beyond the existing load-time count +- Trusted devices / "remember this device" PIN skips +- Editing or retracting synced games across accounts + +## Architecture: direct Firestore wherever rules can express it; functions only where they can't + +Firebase's strengths β€” direct low-latency reads, offline-queued writes, realtime streams β€” +are kept everywhere security rules can express the required check. The enabling structural +change is **deterministic friend-request doc IDs** (`friendRequests/{senderId}_{receiverId}`): +rules can't run queries, but they can `exists()`/`get()` known paths, which makes +pending/declined/blocked preconditions and cross-user edge writes all rules-expressible. +The existing client-side repository logic (batch writes for accept/remove, etc.) is largely +retained and hardened, not ported to a server. + +Cloud Functions (TypeScript, new `functions/` directory, developed against the Firebase +emulator suite) are reserved for the three jobs rules physically cannot do: comparing a +secret the caller must never read (`validatePin`), guaranteed server-side fan-out +(`onGameCreated`), and cleanup after the client is gone (`onUserDeleted`) β€” plus one +judgment call (`searchByFriendCode`, see below). `firestore.rules` is added to the repo +and wired into `firebase.json`. + +### Data model changes + +| Path | Change | +|---|---| +| `users/{uid}` | `pin` field **deprecated** (lazily migrated out, then removed from `UserProfileModel`); new `isComplete` getter (username non-empty AND has PIN AND `onboardingComplete`) β€” completeness of PIN is tracked via a `hasPin` boolean on the profile so the client never needs the hash | +| `users/{uid}/private/credentials` | **New.** `{ pinHash, salt, updatedAt }`. Salted SHA-256 for new PINs; legacy hashes carried over unsalted (`salt: null`) until the user changes their PIN. Owner-only rules; functions read via Admin SDK | +| `users/{uid}/blocks/{blockedUid}` | **New.** `{ blockedAt, username, imageUrl }` (denormalized for the management UI). Owner-readable; function-write-only | +| `pinAttempts/{callerUid}_{targetUid}` | **New.** `{ failCount, lockedUntilMillis (number\|null), updatedAt }`. No client access; function-only | +| `friendRequests/{senderId}_{receiverId}` | **Doc IDs become deterministic** (one migration-free change: new requests use the new ID scheme; legacy random-ID pending requests are honored by accept/decline until drained). `status` gains `declined`; declined docs are retained (they power re-send suppression) instead of being deleted | +| `games/{docId}`, `Player.firebaseId`, `GameModel` | Unchanged | + +### Direct Firestore operations (client + rules) + +These stay as direct client calls β€” fast, offline-queued, stream-capable β€” secured by +rules using deterministic doc IDs: + +- **`sendFriendRequest`**: client checks the reverse-direction doc first (mutual β†’ + auto-accept path) and its own prior doc (`declined` β†’ silently no-op, UI shows "sent"). + Rules allow create only when: `senderId == request.auth.uid`, doc ID matches + `{senderId}_{receiverId}`, no block exists in either direction + (`exists(users/{receiverId}/blocks/{senderId})` etc.), and the doc doesn't already exist. +- **`acceptFriendRequest`**: existing client batch write (both friendship edges + request + deletion). Rules allow writing yourself onto another user's `friendList` only when a + matching pending request exists at the deterministic path; only the receiver may accept. +- **`declineFriendRequest`**: receiver updates `status` to `declined` (doc retained for + re-send suppression). Only the receiver may decline; senders cannot update or re-create. +- **`removeFriend`**: existing two-edge batch delete. Rules: you may always delete your own + `friendList` docs, and you may always delete *yourself* from someone else's `friendList`. +- **`blockUser` / `unblockUser`**: client batch β€” write/delete own block doc (owner-only + path), remove friendship edges both ways (covered by the remove rules), delete/suppress + requests both ways. A partially-failed batch is self-healing: the block doc alone is the + security boundary; edge cleanup can be retried. +- **`setPin`**: client salts + hashes locally (it knows the plaintext anyway) and writes + its own `private/credentials` doc (owner-only rules). +- **All reads and realtime streams**: friends list, requests, match history, game-code + lookup β€” unchanged direct queries/streams scoped by rules. + +**Accepted tradeoff:** a rules denial surfaces as `permission-denied`, so a technically +savvy blocked user could distinguish "blocked" from "ignored" by inspecting error codes. +Accepted at this threat model; a function-mediated send that fakes success is the future +escape hatch if it ever matters. + +### Cloud Functions + +Reserved for what rules cannot do. Callables require an authenticated, non-anonymous +caller and return typed error codes (`unauthenticated`, `permission-denied`, +`failed-precondition`, `resource-exhausted`, `not-found`). + +1. **`validatePin({ targetUserId, pin })` β†’ `{ valid }`** β€” *the irreducible function*: + compares a submitted PIN against a hash the caller must never be able to read, with + server-side rate limiting rules can't count. Preconditions: caller is a friend of + target (strangers can't attempt); target not currently locked out for this caller. + Reads `private/credentials`, falling back to the legacy `users/{uid}.pin` field for + not-yet-migrated accounts. On failure increments `pinAttempts`; **5 failures β†’ + 15-minute lockout** per callerβ†’target pair (`resource-exhausted`, `lockedUntil` in + details). Success resets the counter. +2. **`searchByFriendCode({ code })` β†’ profile summary or not-found** β€” *judgment call*: + rules can't filter query results, so hiding blocked users from search requires a + server-side lookup. Search is a cold, deliberate path where callable latency is + invisible. Returns not-found when either party has blocked the other; response carries + only public profile fields + relationship status. (Fallback if we ever regret it: + direct query + request-level block enforcement, with the profile card visible to + holders of your exact code.) +3. **Trigger `onGameCreated` (`games/{docId}` create)** β€” *right tool, no latency cost*: + one client write (`games/`) instead of N cross-user writes from a device at a kitchen + table; the server guarantees delivery with retries, and rules can deny all cross-user + `matches` writes. Reads `players[].firebaseId`, dedupes, writes the game to each linked + player's `users/{id}/matches/{docId}` (host included). Idempotent (doc id = game id). + Replaces the client-side `syncGameToPlayers` call. +4. **Trigger `onUserDeleted` (Auth delete)** β€” runs after the client is gone. Cleans up: + profile + private subcollections, friend edges both directions, requests both + directions, block docs both directions. Games and other players' match copies persist + (it's their history too). + +### Firestore rules summary + +| Path | Read | Write | +|---|---|---| +| `users/{uid}` | any signed-in user | owner only | +| `users/{uid}/private/**` | owner only | owner only (functions bypass) | +| `users/{uid}/blocks/**` | owner only | owner only (create/delete own block docs) | +| `users/{uid}/matches/**` | owner only | owner only (covers game-code import; fan-out via functions) | +| `games/{id}` | any signed-in user (game-code lookup) | create: any signed-in; update/delete: `hostId` only | +| `friends/{uid}/friendList/{fid}` | owner only | owner: delete always, create when a matching pending request exists; non-owner: may create/delete only the doc keyed by *their own* uid, create gated on the matching pending request | +| `friendRequests/{sid}_{rid}` | sender or receiver | create: sender, deterministic ID, no block either direction, doc must not exist; update: receiver only (`pending β†’ declined`); delete: sender (cancel) or receiver (accept) | +| `pinAttempts/**` | none | functions only | + +### PIN migration (lazy) + +On login, the client moves its **own** `pin` field into `private/credentials` and clears +the profile field, setting `hasPin: true`. Both migration and `setPin` are direct +owner-only writes; the client salts + hashes locally (it holds the plaintext PIN in that +moment anyway). The migration runs inside the AppBloc profile load, **before** +completeness is evaluated, and completeness treats a legacy non-empty `pin` field as +having a PIN β€” so already-PIN'd legacy users are never bounced into onboarding. Friends +who haven't logged in since the update stay selectable because `validatePin` falls back to +the legacy field via Admin SDK. `UserProfileModel.pin` is removed once the fallback is +retired. + +## Client changes + +1. **AppBloc gate** (`lib/app/bloc/app_bloc.dart`): emit `onboardingRequired` when + `profile == null || !profile.isComplete`. Legacy users re-enter the (pre-filled) + wizard and complete only what's missing. The offline fallback to `authenticated` + stays β€” PIN linking is unusable offline anyway, and the gate re-arms next online + launch. Anonymous sessions bypass the gate as today. +2. **Onboarding** (`lib/onboarding/bloc/onboarding_bloc.dart`): seed `existingPinHash` + as null when the stored value is empty (closes the empty-PIN loophole); submit writes + the PIN to `private/credentials` + `hasPin` instead of the profile field. +3. **Repository** (`firebase_database_repository`): only `validatePin` and + `searchByFriendCode` become callable invocations. `addFriendRequest`, + `acceptFriendRequest`, `declineFriendRequest`, `removeFriend` keep their direct + Firestore implementations, updated for deterministic request IDs and declined-doc + retention; `setPin` writes salted hash to `private/credentials`; new + `blockUser`/`unblockUser`/`getBlockedUsers` (direct); `syncGameToPlayers` deleted. + BLoC events/states are unchanged except where noted. +4. **Customize player page**: PIN dialog gains lockout and offline states ("try again in + N minutes" / "PIN check needs a connection"); selecting a friend already linked to + another slot in this game is prevented at selection; anonymous host sees a + "Sign in to link friends" placeholder instead of the friend list. +5. **GameOverBloc / game over page**: slots with a `firebaseId` belonging to someone other + than the host are excluded from the "which slot is mine" picker and shown with a + linked badge; if the host already linked their own slot at setup it is preselected. + The client fan-out call is removed (trigger owns it). +6. **Profile page**: rebuilt on `UserProfileModel`; implement `ProfileBloc` submit; + PIN change (new PIN + confirm, no old PIN); friend code with copy/share; entry point + to a blocked-users screen (list + unblock). +7. **Friends list**: block action on friend cards and search results (with confirm + dialog); blocked-users management screen. +8. **Localization**: all new strings in `app_en.arb` and `app_es.arb`. + +## Error handling + +- Callable failures surface as retryable snackbars with distinct copy for offline vs. + server error; nothing crashes game setup β€” a failed link leaves the slot in manual-name + mode. +- PIN dialog distinguishes wrong PIN, lockout (shows remaining minutes), and offline. +- Game save: host's `games/` write is the only client-critical path; fan-out failures + retry server-side and never block the game-over screen. +- Blocked interactions don't reveal block status in the UI (requests appear "sent"; + search returns not-found). Known limit: rules denials are `permission-denied` at the + wire level β€” accepted at this threat model (see Architecture). + +## Testing + +- **Bloc tests** for every changed bloc (AppBloc gate matrix incl. legacy/empty-PIN cases, + onboarding, customization PIN states, game-over guard, profile submit, block flows). +- **Repository tests** with a faked functions client (success, each error code, offline). +- **Rules tests** (`@firebase/rules-unit-testing` against the emulator) β€” first-class, + since rules now carry the social-graph invariants: request create/update/delete matrix + (deterministic IDs, block gating, declined immutability), friendship edge writes, + cross-user `matches` write denial, private-doc isolation. +- **Functions tests** against the emulator: PIN validation incl. rate limiting, friendship + precondition, legacy-hash fallback; search block-hiding; fan-out idempotency; deletion + cleanup. +- **Widget tests**: PIN dialog states, friend section anonymous state, game-over picker + exclusions, blocked-users screen. +- **Manual integration pass**: sign up β†’ code + PIN β†’ add friend β†’ block/unblock β†’ link in + game (right PIN, wrong PIN Γ—5 β†’ lockout) β†’ finish game β†’ both histories updated β†’ + game-code import on a third, non-friend account. + +## Build order + +Each phase is independently shippable: + +1. **Backend foundation** β€” `functions/` scaffold, emulator config, `firestore.rules`, + `validatePin` callable + private credentials + lazy migration + rate limiting; client + PIN calls switch over. +2. **Legacy enforcement** β€” AppBloc gate extension, `isComplete`, empty-PIN fix. +3. **Game sync** β€” `onGameCreated` fan-out, remove client fan-out, game-over picker guard. +4. **Social graph hardening** β€” deterministic request IDs (with legacy-pending handling), + declined suppression, rules for the request lifecycle and friendship edge writes; + repository updates stay client-side. +5. **Blocking** β€” block docs + client batch cleanup, `searchByFriendCode` callable, block + gating in rules, block UI + management screen. +6. **Profile completion** β€” ProfileBloc submit, PIN change, friend code share. +7. **Cleanup** β€” `onUserDeleted`, rewrite `docs/friends_feature_plan.md`, l10n sweep, + remove deprecated `pin` field path once migration fallback is retired. diff --git a/docs/superpowers/specs/2026-07-05-player-owner-friend-selector-design.md b/docs/superpowers/specs/2026-07-05-player-owner-friend-selector-design.md new file mode 100644 index 0000000..663ef99 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-player-owner-friend-selector-design.md @@ -0,0 +1,210 @@ +# Player Owner + Friend Selector Redesign β€” Design Spec + +Supersedes: `2026-03-13-friend-picker-redesign.md` (the tile-list implementation that +spec produced is the current code being replaced here β€” its "friend search/filter" +non-goal is explicitly reversed below now that scale is a real concern). + +## Problem + +The friend picker on `CustomizePlayerPage` (`_FriendSection` in +`lib/player/view/customize_player_page.dart`) has four issues: + +1. **Doesn't scale.** Friends render as tiles in a `ListView` capped at a 160px + max height ([customize_player_page.dart:288-289](../../../lib/player/view/customize_player_page.dart)). + Fine for a handful of friends, unusable for a long list. +2. **The account owner isn't a selectable option.** `isAccountOwner` is inferred once, + in `initState`, from `context.read().state.player.firebaseId != null` + β€” a bare non-null check, not an equality check against the signed-in user. Since + every newly-created player seat starts with `firebaseId: null` + ([game_bloc.dart:83-100](../../../lib/game/bloc/game_bloc.dart)), there is today no + path in this screen for the owner to explicitly link their own seat. (The only + existing "is this seat mine" picker lives on the *game-over* screen β€” + `_AccountOwnerDropdown` in `lib/life_counter/view/game_over_page.dart` β€” which is + a different flow this spec doesn't touch.) +3. **Reopening an already-linked seat loses the link state, and can silently corrupt it.** + `PlayerCustomizationBloc` is recreated fresh every time the page opens, so + `selectedFriend`/`pinValidated` always start unset regardless of the player's + persisted `firebaseId`. Combined with issue 2's bare non-null check, reopening a + *friend*-linked seat to tweak the commander and hitting Save can silently + reassign that seat's `firebaseId` to the current owner, clobbering the friend + link β€” because `_save()` falls back to `isAccountOwner` whenever + `selectedFriend` is null. +4. **Name auto-fill/lock is inconsistent and incomplete.** A *fresh* friend selection + already sets `nameController.text = friend.username` and + [player_identity_panel.dart:48](../../../lib/player/view/widgets/player_identity_panel.dart) + already sets `readOnly: isLinked`. But `isLinked` only checks + `selectedFriend != null && pinValidated` β€” it ignores `isAccountOwner` entirely, so + the owner's name is never locked to anything. And because of issue 3, even the + friend case's lock doesn't survive reopening the page. + +Separately, the PIN verification dialog (`_showPinDialog`) has two independent bugs +that need fixing since this change already touches its trigger point: + +5. **No loading state.** `_onValidatePin` never emits an in-flight state, so the + Verify button has nothing to key a spinner off while the async call is running, + and nothing prevents a double-submit. +6. **Small-device keyboard overlap.** The `AlertDialog`'s content isn't scrollable, + so on a small phone the keyboard can push the Verify button out of the reachable + area. + +## Approach + +Replace the tile list with a single `DropdownMenu` (Material 3, part of +`flutter/material.dart` β€” no new dependency) with `enableFilter: true` for +type-to-search. Entries, in order: **Not linked** (sentinel/default), **Me**, then +friends alphabetically. The dropdown's value *is* the resulting `firebaseId` for +that seat (`null` for unlinked, the current user's id for "Me", `friend.userId` for +a friend), which keeps the save-path logic almost unchanged. + +Fix the rehydration bug at its root: derive the real link state from +`player.firebaseId` compared against the signed-in user's id and the loaded friend +list, instead of a bare non-null check. Extend the name-lock behavior +(auto-fill + `readOnly`) to cover the owner case, which requires fetching the +current user's `UserProfileModel.username` into the bloc (the auth-layer `User` on +`AppBloc` has no `username` field β€” only the Firestore profile does). + +Fix the two PIN dialog bugs as incremental changes to the same dialog, not a +redesign of it. + +## Design + +### 1. Picker Widget + +- `DropdownMenu` replaces `_FriendSection`'s `ListView.separated` + + `_FriendTile`. `dropdownMenuEntries`: + - `DropdownMenuEntry(value: null, label: <"Not linked">)` + - `DropdownMenuEntry(value: currentUserId, label: <"Me">)` + - one entry per friend, `value: friend.userId`, `label: friend.username`, + sorted alphabetically by username +- `enableFilter: true` (and `enableSearch: true`) gives type-to-filter across + however many friends exist, with no custom search plumbing. +- The separate "Clear" `TextButton` is deleted β€” picking "Not linked" is the new + way to unlink, consistent with how every other option in the same control works. +- `_FriendTile` is deleted entirely. +- Section visibility: today the whole section hides when there are no friends and + nothing is linked ([customize_player_page.dart:248](../../../lib/player/view/customize_player_page.dart)). + Since "Me" is always a valid option for a signed-in, non-anonymous user regardless + of friend count, that guard is removed β€” the section (and dropdown) always + renders for non-anonymous users. The existing anonymous-user placeholder copy is + unchanged. + +### 2. Link State & Rehydration + +On `initState`, replace the bare `firebaseId != null` check with: + +- `player.firebaseId == currentUserId` β†’ confirmed **Me**. No PIN. +- `player.firebaseId` matches a friend in the loaded `FriendBloc` state β†’ confirmed + **that friend**, treated as already PIN-validated. Re-opening a page for a seat + that's already linked to a friend must **not** re-prompt for that friend's PIN β€” + PIN entry is required only when *establishing* a new link, not every time the + screen reopens on an existing one. (This is scoped per-seat: linking that same + friend to a *different* seat for the first time still requires the PIN. A + cross-seat "trusted this session" cache was considered and explicitly rejected as + unneeded scope β€” no real flow links the same friend to two seats at once.) +- Otherwise β†’ **unlinked**, name freely editable (today's default behavior). + +Because the confirmed state now actually reflects what's persisted, saving after +just tweaking a commander can no longer silently reassign or drop an existing link +β€” this is the fix for problem 3. + +Selecting a new dropdown entry: +- **Not linked** β†’ clears the link, unlocks the name, blanks it (matches today's + Clear button exactly). +- **Me** β†’ confirms immediately, no PIN, locks the name to the owner's username. +- **A friend** β†’ opens the existing PIN dialog (trigger moves from tile `onTap` to + the dropdown's `onSelected`; the dialog's own await-then-react control flow is + unchanged). On success: confirms, locks the name to the friend's username. On + cancel/failure: the dropdown's displayed value reverts to whatever was previously + confirmed (requires giving the `DropdownMenu` an explicit controller so the + visible selection can be programmatically reset rather than left on the + provisional, unconfirmed entry). + +### 3. Name Auto-fill & Lock (Owner Case) + +`AppBloc.state.user` is the auth-layer `User` model +([user.dart](../../../packages/authentication_client/authentication_client/lib/src/models/user.dart)) +β€” `id`/`email`/`name`/`photo` from the sign-in provider. It has no app `username`. +The app's `username` lives on `UserProfileModel`, fetched via +`FirebaseDatabaseRepository.getUserProfileOnce`/`getUserProfile` β€” the same call +`ProfileBloc` already makes ([profile_bloc.dart:39-40](../../../lib/profile/bloc/profile_bloc.dart)). +`PlayerCustomizationBloc` already depends on `FirebaseDatabaseRepository`, so this +is a new call on an existing dependency, not a new dependency. + +Changes: +- `PlayerCustomizationBloc` fetches the current user's `UserProfileModel` once at + init (alongside the existing `LibraryRequested` load) and stores `username` in + state for use as the "Me" entry's locked name. +- `PlayerIdentityPanel`'s `isLinked` computation + ([player_identity_panel.dart:25-26](../../../lib/player/view/widgets/player_identity_panel.dart)) + changes from `selectedFriend != null && pinValidated` to also treat a confirmed + `isAccountOwner` as linked, so the name field locks (`readOnly`) for the owner + case exactly as it already does for friends. +- `_FriendLinkRow`'s "Linked to {username}" copy needs an owner-case label too + (e.g. "Linked to your account" or reusing the owner's own username β€” exact copy + is a small l10n detail, not a design fork). + +### 4. PIN Dialog Fixes + +**Loading state.** Add `isPinValidating` (bool) to `PlayerCustomizationState`. +`_onValidatePin` emits it `true` before the `await +_firebaseDatabaseRepository.validatePin(...)` call and `false` on every terminal +branch (valid/invalid/lockedOut/notSet/unavailable). The Verify button's +`BlocBuilder` swaps its label for a small `CircularProgressIndicator` while true, +and `onPressed` becomes `null` (blocks double-submit). Cancel and the dropdown +itself are also disabled while validating β€” without that, cancelling mid-flight +leaves an in-flight `ValidatePin` result with no listener left to react to it once +the dialog is gone (the `BlocListener` reacting to `pinValidated` lives inside the +dialog's own builder). + +PIN submission stays gated behind the Verify button's `onPressed` only β€” this is +already true today (`onChanged` on the PIN field only triggers a local +`setDialogState` rebuild, never dispatches `ValidatePin`); the fix preserves that +and must not introduce any auto-submit-on-4-digits behavior. + +**Small-device keyboard overlap.** Add `scrollable: true` to the `AlertDialog`, +which wraps `content` in a `SingleChildScrollView` so the title/PIN field can +scroll while `actions` (Cancel/Verify) stays pinned and reachable at the bottom. +Tighten the dialog's internal spacing somewhat so it needs less room to begin +with. Verify on a small simulated screen size once built. + +### 5. Error Handling + +- **Friend list still loading, seat already friend-linked:** lock/display the name + using the player's already-persisted `name` immediately (it was saved as the + friend's username last time this seat was linked); reconcile against the live + `FriendModel` once `FriendBloc` finishes loading, in case the friend renamed + themselves since. +- **Seat linked to a friend no longer in the friends list** (unfriended since): + don't silently clear a real, persisted link just because the picker can't + resolve a display entry for it β€” leave `firebaseId`/name alone unless the user + actively picks a different dropdown entry. +- **Anonymous users:** unchanged β€” existing sign-in placeholder copy, no dropdown + rendered at all. +- **Owner profile fetch fails or is incomplete:** fall back to leaving the name + field as whatever was already persisted rather than blocking the page; this + should be rare in practice since the onboarding gate requires a complete profile + (username + PIN) before reaching gameplay. + +### 6. Files to Modify + +| File | Changes | +|---|---| +| `lib/player/view/customize_player_page.dart` | Replace tile list with `DropdownMenu`; delete `_FriendTile` and the Clear `TextButton`; remove the no-friends visibility guard; rewrite `initState` to derive real link state; Verify button gets a loading spinner + disabled state; `AlertDialog` gets `scrollable: true` + tightened spacing; Cancel/dropdown disabled while validating | +| `lib/player/view/bloc/player_customization_bloc.dart` | New handling to fetch the owner's `UserProfileModel` once at init; new/extended handling to resolve real link state (owner/friend/unlinked) from `firebaseId`; `_onValidatePin` emits `isPinValidating` start/stop | +| `lib/player/view/bloc/player_customization_state.dart` | Add `isPinValidating: bool`; add a field for the owner's fetched `username` | +| `lib/player/view/bloc/player_customization_event.dart` | New event(s) for the profile fetch and link-state rehydration | +| `lib/player/view/widgets/player_identity_panel.dart` | `isLinked` includes the owner case; `_FriendLinkRow` gets owner-case copy | +| `lib/l10n/arb/app_en.arb`, `app_es.arb` (+ generated files via `flutter gen-l10n`) | New keys for the "Me" / "Not linked" dropdown entries and the owner-linked label | +| `test/player/player_customization_bloc_test.dart` | Rehydration cases (owner match, friend match, stale/unfriended match, no match); `isPinValidating` transitions; owner-profile fetch | +| Widget tests (new, alongside `test/player/customize_player_page_anonymous_test.dart`) | "Me" visible with zero friends; filter-as-you-type; PIN-gated friend selection; locked name for both owner and friend; spinner during validation; small-screen dialog reachability | + +## Out of Scope + +- Any change to the game-over screen's separate `_AccountOwnerDropdown`/ + `_PlayerDropdown` β€” used only as prior art for the dropdown pattern. +- A cross-seat "already validated this friend elsewhere this session" cache β€” + explicitly considered and rejected; PIN trust is scoped per-seat. +- The broader friends-autosync mechanism (fan-out to match history) β€” already + implemented; this spec is about the picker UI and its bugs, not the sync path. +- Reusable/favorite commander-per-friend suggestions (mentioned in the + friends-autosync roadmap as a future idea, not part of this change). diff --git a/docs/superpowers/specs/2026-07-07-friends-flow-username-cleanup-design.md b/docs/superpowers/specs/2026-07-07-friends-flow-username-cleanup-design.md new file mode 100644 index 0000000..c94db76 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-friends-flow-username-cleanup-design.md @@ -0,0 +1,186 @@ +# Friends Flow Review β€” Username-Only Identity & Name Cleanup + +**Date:** 2026-07-07 +**Status:** Approved (autonomous goal session; decisions documented below) +**Branch:** feat/friends-hardening +**Builds on:** `2026-07-03-friends-feature-design.md` (hardening plans A–D, complete) + +## Context + +Goal directive: review the entire friends user flow β€” signup through in-game friend +linking and cross-account sync β€” and enforce three requirements: + +1. Users must have a username; anyone without one is forced to set it on app open. +2. The username is the identity shown during games and in the friends list. +3. First name / last name are no longer needed; remove all relevance from the code. +4. The flow should follow friends-list best practices. + +### Flow as it exists today (verified 2026-07-07) + +- **Signup/login** (`lib/sign_up/`, `lib/login/`): email+password, Google, Apple. + No profile fields collected at signup. +- **Username gate β€” already exists.** `AppBloc._onUserChanged` + (`lib/app/bloc/app_bloc.dart:100-145`) fetches the Firestore profile and emits + `AppStatus.onboardingRequired` unless `UserProfileModel.isComplete` + (`onboardingComplete && username non-empty && hasPin/legacy pin`, + `user_profile_model.dart:120-123`). The router redirects that status to + `/onboarding`, whose step 1 requires a valid username before Next enables. + Legacy users with incomplete profiles re-enter the pre-filled wizard. Anonymous + sessions bypass the gate (they cannot use friends features). Offline fetch + failure falls back to `authenticated` β€” an accepted decision from the 2026-07-03 + spec (PIN linking is unusable offline; the gate re-arms next online launch). +- **Username display β€” already correct everywhere that matters.** Friends list + shows `friend.username` (+ friendCode subtitle); requests show `senderName`, + which `SearchBloc._onAddFriendRequest` sources from the sender's own profile + `username`; search results show `username`; a seat linked to "Me" or a friend + gets the account's username written into the locked `Player.name`, which is what + the life counter and match history display. Unlinked seats keep a free-typed + name β€” intended (guests without accounts). +- **First/last name β€” collected but never displayed.** Optional fields on + onboarding step 1 and the profile page, stored on `UserProfileModel`. No UI + reads them back except those two edit forms. Cloud Functions never touch them. + The auth-level `User.name` (Firebase `displayName`; Apple sign-in explicitly + requests the `fullName` scope) is mapped into the model but **unused by any app + code**. +- **Friends/social backend** (hardening branch, complete): deterministic friend + request IDs, declined-doc re-send suppression, full two-direction blocking, + block-aware code/username search callables, rate-limited `validatePin` (5 + fails β†’ 15-min lockout), server-side `onGameCreated` fan-out to + `users/{uid}/matches`, immutable synced copies, versioned rules + emulator + tests. + +## Requirement analysis + +### R1 β€” Force username on app open: gate exists; harden the edges + +No new gate is needed. Gaps found and fixed in this pass: + +1. **Whitespace-only usernames pass.** `Username.validator` + (`packages/form_inputs/lib/src/username.dart`) only checks `isNotEmpty`, so + `" "` is accepted by onboarding and the profile page, and + `isComplete` treats it as complete. Fix: validate the **trimmed** value β€” + empty β†’ `empty`, trimmed length < 2 β†’ `tooShort`, > 30 β†’ `tooLong`. Blocs + save the trimmed value so stored usernames carry no edge whitespace. + *Min 2 matches the `searchByUsername` 2-character minimum, so every + username is discoverable by search. `isComplete` itself keeps the loose + non-empty check β€” legacy users with short usernames are not bounced into + onboarding; they only meet the new rule when they next edit the field.* +2. **Profile page shows a generic "save failed" snackbar when the username is + empty** (open follow-up from Plan D). Fix: inline `errorText` on the username + field from the formz error, and distinct snackbar copy when save is blocked + by an invalid username. + +### R2 β€” Username is the displayed identity: verified, no changes + +Covered by the current code (see Context). This pass adds no display changes; +regression tests already pin the linked-seat name behavior. + +### R3 β€” Remove firstName/lastName everywhere + +**Model & persistence** + +- `UserProfileModel`: drop `firstName`/`lastName` fields, `copyWith` params, + `props` entries; regenerate `user_profile_model.g.dart`. +- No Firestore migration needed: `json_serializable` ignores unknown keys on + read, and `updateUserProfile` writes the **full document with `set()` (no + merge)** β€” stale `firstName`/`lastName` keys disappear from a user's doc on + their next profile save. Rules don't reference the fields. + +**Onboarding** (`lib/onboarding/`) + +- Remove `firstName`/`lastName` from state (fields, initial values, copyWith, + props), the `OnboardingFirstNameChanged`/`OnboardingLastNameChanged` events and + handlers, the submit payload, and the two `_IdentityStep` text fields with + their controllers. Step 1 becomes username-only. + +**Profile page** (`lib/profile/`) + +- Same removal: state fields/copyWith/props, `ProfileFirstNameChanged`/ + `ProfileLastNameChanged` events and handlers, save fallbacks, two form fields. + +**Auth layer** + +- Remove the unused `User.name` field from + `packages/authentication_client/.../models/user.dart` (field, copyWith, + props), the `displayName` mapping in `firebase_authentication_client.dart`'s + `toUser()` extensions, and stop requesting `AppleIDAuthorizationScopes.fullName` + at sign-in (keep `email`). The provider display name *is* the user's real + first+last name; the app's identity is username-only, so we stop collecting it + (data-minimization). Update package tests that construct `User(name: ...)`. + +**Localization & tests** + +- Remove `firstNameLabel`/`lastNameLabel` from `app_en.arb`/`app_es.arb`; + regenerate localizations. Update test fixtures in `profile_bloc_test.dart`, + `profile_page_test.dart`, and any `User(name:)` constructions. + +### R4 β€” Best-practices assessment + +Already aligned with common friends-system practice: request/accept model with +deterministic IDs (no duplicate spam), silent declined-request suppression +(decliner isn't revealed), full bidirectional blocking hidden from search, +secret validation server-side with rate limiting, server-guaranteed history +fan-out, immutable synced snapshots, owner-only match history, denormalized +display data for cheap list rendering. + +**Fixed in this pass** + +- Username input hardening (R1.1) β€” prevents blank/unsearchable identities. +- Distinct username error copy on profile save (R1.2). +- `search_user_page` renders raw exception text (`SearchError('Failed to + search: $e')`) β€” replace with typed error states mapped to localized, + user-friendly copy in the UI (pre-existing follow-up, squarely a friends-list + UX issue). + +**Documented follow-ups (out of scope β€” backend/deploy-gated or product calls)** + +- **Username uniqueness** is *not* enforced; `friendCode` is the unique + discovery key and the profile page says so ("Not unique β€” others may share + this name"). Enforcing uniqueness retroactively needs a reserved-names + registry, a migration for existing duplicates, and function/rules changes β€” + a deliberate product decision for Josh, not this pass. +- **Rename staleness:** friends-list entries denormalize `username` at accept + time; a later rename isn't propagated to existing friend edges/blocks/request + docs. Fix would be an `onProfileUpdated` fan-out function. +- Restrict `users` collection `list` reads (existing Plan D follow-up). + +## Non-goals + +- No Cloud Functions, Firestore rules, or index changes β€” this pass is + app-client only and adds **no new deploy gates**. +- No username uniqueness enforcement (see above). +- No change to unlinked-seat free-text player names (guests are a feature). +- No removal of the auth `photo` field (profile pictures are still a feature). + +## Error handling + +- Username field errors render inline via formz error β†’ localized message + (empty / too short / too long), on both onboarding step 1 and the profile page. +- Profile save blocked by invalid username shows the specific message, not the + generic save-failed snackbar. +- Search errors show localized friendly copy; the exception detail stays in + logs, not the UI. + +## Testing + +- `form_inputs`: unit tests for trim/tooShort/tooLong/valid. +- Onboarding bloc/widget tests: whitespace-only username cannot advance step 1; + submitted username is trimmed; no first/last events remain. +- Profile bloc/page tests: fixtures lose names; invalid-username save gate emits + the distinct error; trimmed save. +- Auth package tests: `User` without `name`; Apple scope list contains only + `email`. +- Search bloc/page tests: typed error states render localized copy. +- Full: `flutter analyze`, root + affected package test suites, regenerate + codegen and l10n, confirm no `firstName|lastName` references remain outside + generated localization history. + +## Build order + +1. `form_inputs` Username validator + tests (foundation, no dependents break). +2. firstName/lastName removal: model + codegen β†’ onboarding β†’ profile β†’ l10n β†’ + fixtures (single commit-per-layer or one sweep; deletions dominate). +3. Auth `User.name` removal + Apple scope change + package tests. +4. Username UX hardening: trimmed saves, inline errors, distinct profile copy. +5. Search error copy cleanup. +6. Verification sweep + repo-wide grep for leftovers. diff --git a/firebase.json b/firebase.json index b9019c2..2ec2ab7 100644 --- a/firebase.json +++ b/firebase.json @@ -1 +1,47 @@ -{"flutter":{"platforms":{"android":{"default":{"projectId":"magic-yeti","appId":"1:370172089725:android:8e686daa66153c82ad6814","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"magic-yeti","appId":"1:370172089725:ios:bf69e58694170008ad6814","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"magic-yeti","configurations":{"android":"1:370172089725:android:8e686daa66153c82ad6814","ios":"1:370172089725:ios:bf69e58694170008ad6814"}}}}}} \ No newline at end of file +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "magic-yeti", + "appId": "1:370172089725:android:8e686daa66153c82ad6814", + "fileOutput": "android/app/google-services.json" + } + }, + "ios": { + "default": { + "projectId": "magic-yeti", + "appId": "1:370172089725:ios:bf69e58694170008ad6814", + "uploadDebugSymbols": false, + "fileOutput": "ios/Runner/GoogleService-Info.plist" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "magic-yeti", + "configurations": { + "android": "1:370172089725:android:8e686daa66153c82ad6814", + "ios": "1:370172089725:ios:bf69e58694170008ad6814" + } + } + } + } + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"] + } + ], + "emulators": { + "auth": { "port": 9099 }, + "firestore": { "port": 8080 }, + "functions": { "port": 5001 }, + "ui": { "enabled": true } + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..2f976c5 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,21 @@ +{ + "indexes": [], + "fieldOverrides": [ + { + "collectionGroup": "friendList", + "fieldPath": "userId", + "indexes": [ + { "order": "ASCENDING", "queryScope": "COLLECTION" }, + { "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" } + ] + }, + { + "collectionGroup": "blocks", + "fieldPath": "userId", + "indexes": [ + { "order": "ASCENDING", "queryScope": "COLLECTION" }, + { "order": "ASCENDING", "queryScope": "COLLECTION_GROUP" } + ] + } + ] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..ef35e26 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,113 @@ +rules_version = '2'; +// Magic Yeti Firestore rules. +// Fully hardened as of Plan C: no TRANSITIONAL grants remain. Social-graph +// writes are client batches validated here (deterministic friendRequests +// ids, pending-gated friendship edges, owner-only blocks); cross-user match +// fan-out and PIN checks run server-side (Admin SDK bypasses these rules). +// Strategy + deploy gate: docs/superpowers/plans/2026-07-03-friends-INDEX.md +service cloud.firestore { + match /databases/{database}/documents { + function signedIn() { + return request.auth != null; + } + function isOwner(uid) { + return signedIn() && request.auth.uid == uid; + } + + match /users/{uid} { + allow read: if signedIn(); + // usernameLower powers server-side prefix search (searchByUsername); + // when present it must be a faithful lowercase of username, so a + // client can't make itself discoverable under a name it doesn't have. + allow write: if isOwner(uid) && + (!('usernameLower' in request.resource.data) || + ('username' in request.resource.data && + request.resource.data.usernameLower == + request.resource.data.username.lower())); + + match /private/{document=**} { + allow read, write: if isOwner(uid); + } + + match /matches/{gameId} { + // Owner-only. Fan-out to other players happens server-side via + // the onGameCreated trigger (Admin SDK bypasses rules); the + // owner write covers the game-code import flow. + allow read, write: if isOwner(uid); + } + + match /blocks/{blockedUid} { + allow read, write: if isOwner(uid); + } + } + + // Server-only rate limiting state. No client access, ever. + match /pinAttempts/{attemptId} { + allow read, write: if false; + } + + match /games/{gameId} { + allow read: if signedIn(); + // Creator must be the host they claim β€” the trigger fans the doc out + // to hostId's match history, so a forged hostId would inject games + // into another user's history. + allow create: if signedIn() && request.resource.data.hostId == request.auth.uid; + allow update, delete: if signedIn() && resource.data.hostId == request.auth.uid; + } + + match /friends/{uid}/friendList/{friendId} { + allow read: if isOwner(uid); + // Deleting: you may prune your own list, and you may always remove + // YOURSELF from someone else's list (unfriend/block cleanup). + allow delete: if isOwner(uid) || + (signedIn() && request.auth.uid == friendId); + // Creating: only as part of accepting a pending request between the + // two users involved. The receiver runs the accept batch: they write + // the sender onto their own list, and themselves onto the sender's. + // The edge doc's userId must match its key β€” FriendModel.userId feeds + // PIN-validation targets, so a mismatched field would misdirect them. + // Pending-status gate + block checks close the declined-doc capability + // a blocked user could otherwise exploit: existence alone isn't enough + // (a declined doc still exists), and a block must win even over a + // pending request. `exists() && get().data...` is evaluated per + // disjunct so get() never runs on a missing doc. + allow create, update: if + request.resource.data.userId == friendId && + !exists(/databases/$(database)/documents/users/$(uid)/blocks/$(friendId)) && + !exists(/databases/$(database)/documents/users/$(friendId)/blocks/$(uid)) && + ((isOwner(uid) && + exists(/databases/$(database)/documents/friendRequests/$(friendId + '_' + uid)) && + get(/databases/$(database)/documents/friendRequests/$(friendId + '_' + uid)).data.status == 'pending') || + (signedIn() && request.auth.uid == friendId && + exists(/databases/$(database)/documents/friendRequests/$(uid + '_' + request.auth.uid)) && + get(/databases/$(database)/documents/friendRequests/$(uid + '_' + request.auth.uid)).data.status == 'pending')); + } + + match /friendRequests/{requestId} { + allow read: if signedIn() && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + allow create: if signedIn() && + request.resource.data.senderId == request.auth.uid && + requestId == request.resource.data.senderId + '_' + request.resource.data.receiverId && + request.resource.data.status == 'pending' && + !exists(/databases/$(database)/documents/users/$(request.resource.data.receiverId)/blocks/$(request.auth.uid)) && + !exists(/databases/$(database)/documents/users/$(request.auth.uid)/blocks/$(request.resource.data.receiverId)); + // Decline: receiver flips pending -> declined, nothing else, once. + allow update: if signedIn() && + resource.data.receiverId == request.auth.uid && + resource.data.status == 'pending' && + request.resource.data.status == 'declined' && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']); + // Delete: sender cancels, or receiver deletes on accept β€” PENDING + // only. Declined docs are permanent suppression markers: letting the + // sender delete one would allow delete-and-recreate to dodge the + // "declined senders are never seen again" promise. Recovery path: + // the decliner can send their own request, which the sender accepts. + allow delete: if signedIn() && + resource.data.status == 'pending' && + (resource.data.senderId == request.auth.uid || + resource.data.receiverId == request.auth.uid); + } + } +} diff --git a/functions/.gitignore b/functions/.gitignore new file mode 100644 index 0000000..19fb729 --- /dev/null +++ b/functions/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +lib/ +*.log diff --git a/functions/package-lock.json b/functions/package-lock.json new file mode 100644 index 0000000..488e40d --- /dev/null +++ b/functions/package-lock.json @@ -0,0 +1,7209 @@ +{ + "name": "functions", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "functions", + "dependencies": { + "firebase-admin": "^13.0.0", + "firebase-functions": "^6.1.0" + }, + "devDependencies": { + "@firebase/rules-unit-testing": "^4.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "firebase": "^11.0.0", + "firebase-functions-test": "^3.3.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.5.0" + }, + "engines": { + "node": "20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/ai": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", + "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", + "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", + "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.17", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", + "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", + "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", + "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.10.1", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", + "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.13.2", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", + "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.28", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", + "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.10.8", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", + "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", + "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", + "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", + "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/database": "1.0.20", + "@firebase/database-types": "1.0.15", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", + "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.12.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", + "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.53", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", + "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.12.9", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", + "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", + "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/functions": "0.12.9", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", + "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", + "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", + "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", + "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/messaging": "0.12.22", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", + "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", + "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.7.7", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", + "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", + "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-types": "0.4.0", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", + "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@firebase/rules-unit-testing": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@firebase/rules-unit-testing/-/rules-unit-testing-4.0.1.tgz", + "integrity": "sha512-Vu8iMLP+dO9hCAqUCitWZQdORyM6CxucilRZtleeTZd5bejZmyOiaBPwYm3NOYG6025ac99CEeA+ETmJRxa9zg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "firebase": "^11.0.0" + } + }, + "node_modules/@firebase/storage": { + "version": "0.13.14", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", + "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", + "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", + "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/firebase": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", + "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "1.4.1", + "@firebase/analytics": "0.10.17", + "@firebase/analytics-compat": "0.2.23", + "@firebase/app": "0.13.2", + "@firebase/app-check": "0.10.1", + "@firebase/app-check-compat": "0.3.26", + "@firebase/app-compat": "0.4.2", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.10.8", + "@firebase/auth-compat": "0.5.28", + "@firebase/data-connect": "0.3.10", + "@firebase/database": "1.0.20", + "@firebase/database-compat": "2.0.11", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-compat": "0.3.53", + "@firebase/functions": "0.12.9", + "@firebase/functions-compat": "0.3.26", + "@firebase/installations": "0.6.18", + "@firebase/installations-compat": "0.2.18", + "@firebase/messaging": "0.12.22", + "@firebase/messaging-compat": "0.2.22", + "@firebase/performance": "0.7.7", + "@firebase/performance-compat": "0.2.20", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-compat": "0.2.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-compat": "0.3.24", + "@firebase/util": "1.12.1" + } + }, + "node_modules/firebase-admin": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.10.0.tgz", + "integrity": "sha512-rbuCrJvYRwqBqvbccMS8fj/x2zsaMisdf5RQbRzQzr14Rbq9r2UlpuBHqWAwrO6c9dIRF56xF/xoepXsD5yDuQ==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/firebase-functions": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.6.0.tgz", + "integrity": "sha512-wwfo6JF+N7HUExVs5gUFgkgVGHDEog9O+qtouh7IuJWk8TBQ+KwXEgRiXbatSj7EbTu3/yYnHuzh3XExbfF6wQ==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.5", + "@types/express": "^4.17.21", + "cors": "^2.8.5", + "express": "^4.21.0", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" + }, + "engines": { + "node": ">=14.10.0" + }, + "peerDependencies": { + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" + } + }, + "node_modules/firebase-functions-test": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-3.5.0.tgz", + "integrity": "sha512-Jg+f9GeWkWpgYRY9s+Oxst/b9kEnOJ6pU/XNFthOFYs2Ax2fU2Zxc7ckfTozYKEH/mzptSDxV5JYiPTvR+RhKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.104", + "lodash": "^4.17.5", + "ts-deepmerge": "^2.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "firebase-admin": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "firebase-functions": ">=4.9.0", + "jest": ">=28.0.0" + } + }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/google-gax/node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/ts-deepmerge": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-2.0.7.tgz", + "integrity": "sha512-3phiGcxPSSR47RBubQxPoZ+pqXsEsozLo4G4AlSrsMKTFg9TA3l+3he5BqpUi9wiuDbaHWXH/amlzQ49uEdXtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-jest": { + "version": "29.4.11", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", + "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.0", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..39c186c --- /dev/null +++ b/functions/package.json @@ -0,0 +1,31 @@ +{ + "name": "functions", + "private": true, + "engines": { "node": "20" }, + "main": "lib/index.js", + "scripts": { + "build": "tsc", + "test": "jest --testPathIgnorePatterns test/rules", + "test:rules": "jest test/rules --runInBand", + "serve": "npm run build && firebase emulators:start --only functions,firestore,auth" + }, + "dependencies": { + "firebase-admin": "^13.0.0", + "firebase-functions": "^6.1.0" + }, + "devDependencies": { + "@firebase/rules-unit-testing": "^4.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "firebase": "^11.0.0", + "firebase-functions-test": "^3.3.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.5.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": ["/test"] + } +} diff --git a/functions/src/index.ts b/functions/src/index.ts new file mode 100644 index 0000000..95658a9 --- /dev/null +++ b/functions/src/index.ts @@ -0,0 +1,7 @@ +// Cloud Functions entry point. Each function lives in its own module and +// is re-exported here so the Firebase CLI discovers it. +export { validatePin } from './validate-pin'; +export { onGameCreated } from './on-game-created'; +export { searchByFriendCode } from './search-by-friend-code'; +export { searchByUsername } from './search-by-username'; +export { onUserDeleted } from './on-user-deleted'; diff --git a/functions/src/on-game-created.ts b/functions/src/on-game-created.ts new file mode 100644 index 0000000..9710fb5 --- /dev/null +++ b/functions/src/on-game-created.ts @@ -0,0 +1,50 @@ +import * as admin from 'firebase-admin'; +import { onDocumentCreated } from 'firebase-functions/v2/firestore'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +// A `/` in an id would address a different document path entirely (path +// traversal into an arbitrary collection/doc) and, under `retry: true`, a +// thrown batch would starve legitimate recipients for the whole retry +// window. Reject anything that isn't a plausible bare uid before it's used +// to build a document path. +function isPlausibleUid(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('/'); +} + +/** + * Fans a newly saved game out to every linked player's match history. + * Recipients: set(hostId βˆͺ players[].firebaseId). Idempotent β€” the copy + * doc id is the games/ doc id, so retries and re-fires overwrite in place. + */ +export const onGameCreated = onDocumentCreated( + { document: 'games/{gameId}', retry: true }, + async (event) => { + const snapshot = event.data; + if (!snapshot) return; + const game = snapshot.data(); + const gameId = event.params.gameId; + + const ids = new Set(); + if (isPlausibleUid(game.hostId)) { + ids.add(game.hostId); + } + const players = Array.isArray(game.players) ? game.players : []; + for (const player of players) { + const firebaseId = player?.firebaseId; + if (isPlausibleUid(firebaseId)) { + ids.add(firebaseId); + } + } + if (ids.size === 0) return; + + const db = admin.firestore(); + const batch = db.batch(); + for (const id of ids) { + batch.set(db.doc(`users/${id}/matches/${gameId}`), game); + } + await batch.commit(); + }, +); diff --git a/functions/src/on-user-deleted.ts b/functions/src/on-user-deleted.ts new file mode 100644 index 0000000..d19682d --- /dev/null +++ b/functions/src/on-user-deleted.ts @@ -0,0 +1,76 @@ +import * as admin from 'firebase-admin'; +// firebase-functions v2 has no user-deletion trigger β€” auth.user().onDelete +// only exists in v1, so this module coexists with the v2 exports elsewhere. +import * as functionsV1 from 'firebase-functions/v1'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +const BATCH_LIMIT = 400; + +/** + * Deletes every doc in `query`, chunked at BATCH_LIMIT per commit so + * fan-out that grows past a single batch's 500-write cap doesn't throw. + */ +async function deleteQueryResults(query: FirebaseFirestore.Query): Promise { + const snapshot = await query.get(); + if (snapshot.empty) return; + + const db = admin.firestore(); + for (let i = 0; i < snapshot.docs.length; i += BATCH_LIMIT) { + const chunk = snapshot.docs.slice(i, i + BATCH_LIMIT); + const batch = db.batch(); + for (const doc of chunk) { + batch.delete(doc.ref); + } + await batch.commit(); + } +} + +/** + * Cleans up the social graph when an auth account is deleted. Each step is + * independently idempotent, so retries or partial failures are safe: + * + * 1. users/{uid} subtree β€” profile, private, own blocks, own matches. + * 2. friends/{uid} subtree β€” the victim's own friend list. + * 3. Other users' friendList edges pointing at the victim. + * 4. friendRequests naming the victim as sender or receiver, any status β€” + * a declined doc is just a suppression marker, moot once the account + * is gone. + * 5. Other users' blocks docs naming the victim, freeing the doc-id slot + * if they re-register. + * + * Games and other users' match history are untouched β€” shared game + * history survives the account that created it. + */ +export const onUserDeleted = functionsV1.auth.user().onDelete(async (user) => { + const uid = user.uid; + const db = admin.firestore(); + + await db.recursiveDelete(db.doc(`users/${uid}`)); + await db.recursiveDelete(db.doc(`friends/${uid}`)); + + // Collection-group queries require a COLLECTION_GROUP-scoped single-field + // index β€” Firestore's automatic indexes are collection-scope only. See + // firestore.indexes.json (fieldOverrides for friendList/blocks.userId). + // The emulator does NOT enforce this (it allows collection-group queries + // with no index), so this can pass every emulator/rules test and still + // throw FAILED_PRECONDITION in production if indexes aren't deployed + // first. Always run `firebase deploy --only firestore:indexes` (and wait + // for the build to reach READY) before deploying this function. + await deleteQueryResults( + db.collectionGroup('friendList').where('userId', '==', uid), + ); + + await deleteQueryResults( + db.collection('friendRequests').where('senderId', '==', uid), + ); + await deleteQueryResults( + db.collection('friendRequests').where('receiverId', '==', uid), + ); + + await deleteQueryResults( + db.collectionGroup('blocks').where('userId', '==', uid), + ); +}); diff --git a/functions/src/pin-logic.ts b/functions/src/pin-logic.ts new file mode 100644 index 0000000..a24e9f2 --- /dev/null +++ b/functions/src/pin-logic.ts @@ -0,0 +1,60 @@ +import { createHash } from 'crypto'; + +export const MAX_ATTEMPTS = 5; +export const LOCKOUT_MS = 15 * 60 * 1000; + +export interface StoredCredentials { + pinHash: string; + salt: string | null; +} + +export interface AttemptState { + failCount: number; + lockedUntilMillis: number | null; +} + +export function hashPin(pin: string): string { + return createHash('sha256').update(pin).digest('hex'); +} + +export function saltedPinHash(pin: string, salt: string): string { + return createHash('sha256').update(salt + pin).digest('hex'); +} + +export function checkPin(stored: StoredCredentials, pin: string): boolean { + const expected = + stored.salt === null ? hashPin(pin) : saltedPinHash(pin, stored.salt); + return stored.pinHash === expected; +} + +export function evaluateAttempt( + state: AttemptState | null, + nowMillis: number, +): { lockedOut: boolean; lockedUntilMillis: number | null } { + if ( + state?.lockedUntilMillis != null && + state.lockedUntilMillis > nowMillis + ) { + return { lockedOut: true, lockedUntilMillis: state.lockedUntilMillis }; + } + return { lockedOut: false, lockedUntilMillis: null }; +} + +// failCount intentionally never decays on its own; only a lockout expiring +// resets it back to 0 (see the lockoutExpired check below). +export function recordFailure( + state: AttemptState | null, + nowMillis: number, +): AttemptState { + if (evaluateAttempt(state, nowMillis).lockedOut) { + return state!; + } + const lockoutExpired = + state?.lockedUntilMillis != null && state.lockedUntilMillis <= nowMillis; + const previousCount = state === null || lockoutExpired ? 0 : state.failCount; + const failCount = previousCount + 1; + return { + failCount, + lockedUntilMillis: failCount >= MAX_ATTEMPTS ? nowMillis + LOCKOUT_MS : null, + }; +} diff --git a/functions/src/search-by-friend-code.ts b/functions/src/search-by-friend-code.ts new file mode 100644 index 0000000..90ebead --- /dev/null +++ b/functions/src/search-by-friend-code.ts @@ -0,0 +1,49 @@ +import * as admin from 'firebase-admin'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { isBlocked, resolveRelationship, toSearchPayload } from './search-helpers'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +interface SearchRequest { + code?: string; +} + +export const searchByFriendCode = onCall(async (request) => { + const auth = request.auth; + if (!auth) throw new HttpsError('unauthenticated', 'Sign in required.'); + if (auth.token?.firebase?.sign_in_provider === 'anonymous') { + throw new HttpsError('permission-denied', 'Anonymous users cannot search.'); + } + const raw = request.data?.code; + if (typeof raw !== 'string' || raw.trim().length === 0) { + throw new HttpsError('invalid-argument', 'code is required.'); + } + const code = raw.trim().toUpperCase(); + + const db = admin.firestore(); + const snapshot = await db + .collection('users') + .where('friendCode', '==', code) + .limit(1) + .get(); + if (snapshot.empty) return { found: false }; + + const target = snapshot.docs[0]; + const targetId = target.id; + const callerUid = auth.uid; + + // Block hiding: either direction reads as not-found. + if (await isBlocked(db, targetId, callerUid)) { + return { found: false }; + } + + const relationship = await resolveRelationship(db, targetId, callerUid); + + return { + found: true, + user: toSearchPayload(targetId, target.data(), code), + relationship, + }; +}); diff --git a/functions/src/search-by-username.ts b/functions/src/search-by-username.ts new file mode 100644 index 0000000..fd01895 --- /dev/null +++ b/functions/src/search-by-username.ts @@ -0,0 +1,52 @@ +import * as admin from 'firebase-admin'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { isBlocked, resolveRelationship, toSearchPayload } from './search-helpers'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +interface SearchRequest { + query?: string; +} + +const MAX_RESULTS = 10; +const MIN_QUERY_LENGTH = 2; + +export const searchByUsername = onCall(async (request) => { + const auth = request.auth; + if (!auth) throw new HttpsError('unauthenticated', 'Sign in required.'); + if (auth.token?.firebase?.sign_in_provider === 'anonymous') { + throw new HttpsError('permission-denied', 'Anonymous users cannot search.'); + } + const raw = request.data?.query; + if (typeof raw !== 'string' || raw.trim().length < MIN_QUERY_LENGTH) { + throw new HttpsError( + 'invalid-argument', + `query must be at least ${MIN_QUERY_LENGTH} characters.`, + ); + } + const prefix = raw.trim().toLowerCase(); + const callerUid = auth.uid; + + const db = admin.firestore(); + const snapshot = await db + .collection('users') + .where('usernameLower', '>=', prefix) + .where('usernameLower', '<', `${prefix}ο£Ώ`) + .limit(MAX_RESULTS) + .get(); + + const candidates = await Promise.all( + snapshot.docs.map(async (targetDoc) => { + const targetId = targetDoc.id; + if (await isBlocked(db, targetId, callerUid)) return null; + const relationship = await resolveRelationship(db, targetId, callerUid); + return { user: toSearchPayload(targetId, targetDoc.data()), relationship }; + }), + ); + + return { + matches: candidates.filter((c): c is NonNullable => c !== null), + }; +}); diff --git a/functions/src/search-helpers.ts b/functions/src/search-helpers.ts new file mode 100644 index 0000000..3268784 --- /dev/null +++ b/functions/src/search-helpers.ts @@ -0,0 +1,60 @@ +import * as admin from 'firebase-admin'; + +export interface SearchUserPayload { + id: string; + username: string; + imageUrl: string; + friendCode: string; +} + +/** Block hiding: true if either direction has blocked the other. */ +export async function isBlocked( + db: admin.firestore.Firestore, + targetId: string, + callerUid: string, +): Promise { + const [targetBlocksCaller, callerBlocksTarget] = await Promise.all([ + db.doc(`users/${targetId}/blocks/${callerUid}`).get(), + db.doc(`users/${callerUid}/blocks/${targetId}`).get(), + ]); + return targetBlocksCaller.exists || callerBlocksTarget.exists; +} + +/** Resolves the relationship string between caller and target. */ +export async function resolveRelationship( + db: admin.firestore.Firestore, + targetId: string, + callerUid: string, +): Promise { + if (targetId === callerUid) return 'self'; + + const [edge, sent, received] = await Promise.all([ + // Deliberately the TARGET's list (is the caller on it), matching + // validate-pin's gate. Edges can be asymmetric (users may remove + // themselves from the other side); this direction fails CLOSED β€” + // an inert "friends" display β€” where reading the caller's own list + // would show "Add Friend" and create a spurious request against a + // target who still lists the caller. Do not "fix" this back. + db.doc(`friends/${targetId}/friendList/${callerUid}`).get(), + db.doc(`friendRequests/${callerUid}_${targetId}`).get(), + db.doc(`friendRequests/${targetId}_${callerUid}`).get(), + ]); + if (edge.exists) return 'friends'; + if (sent.exists && sent.data()?.status === 'pending') return 'pendingSent'; + if (received.exists && received.data()?.status === 'pending') return 'pendingReceived'; + return 'none'; +} + +/** Shapes a users/{id} doc's public fields for a search response. */ +export function toSearchPayload( + id: string, + data: FirebaseFirestore.DocumentData, + fallbackFriendCode?: string, +): SearchUserPayload { + return { + id, + username: (data.username as string | undefined) ?? '', + imageUrl: (data.imageUrl as string | undefined) ?? '', + friendCode: (data.friendCode as string | undefined) ?? fallbackFriendCode ?? '', + }; +} diff --git a/functions/src/validate-pin.ts b/functions/src/validate-pin.ts new file mode 100644 index 0000000..f7b5483 --- /dev/null +++ b/functions/src/validate-pin.ts @@ -0,0 +1,121 @@ +import * as admin from 'firebase-admin'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; +import { + AttemptState, + checkPin, + evaluateAttempt, + MAX_ATTEMPTS, + recordFailure, + StoredCredentials, +} from './pin-logic'; + +if (admin.apps.length === 0) { + admin.initializeApp(); +} + +interface ValidatePinRequest { + targetUserId?: string; + pin?: string; +} + +export const validatePin = onCall(async (request) => { + const auth = request.auth; + if (!auth) { + throw new HttpsError('unauthenticated', 'Sign in required.'); + } + if (auth.token?.firebase?.sign_in_provider === 'anonymous') { + throw new HttpsError('permission-denied', 'Anonymous users cannot validate PINs.'); + } + + const { targetUserId, pin } = request.data ?? {}; + if (typeof targetUserId !== 'string' || targetUserId.length === 0) { + throw new HttpsError('invalid-argument', 'targetUserId is required.'); + } + if (targetUserId.includes('/')) { + throw new HttpsError('invalid-argument', 'targetUserId is malformed.'); + } + if (typeof pin !== 'string' || !/^\d{4}$/.test(pin)) { + throw new HttpsError('invalid-argument', 'pin must be exactly 4 digits.'); + } + + const db = admin.firestore(); + const callerUid = auth.uid; + + // Only friends of the target may attempt validation. Deliberately + // outside the transaction below: an unfriend racing this call can at + // worst let the caller land one extra rate-limited attempt, which is + // an acceptable trade for not having to read/watch the friend edge + // transactionally on every validation. + const friendEdge = await db + .doc(`friends/${targetUserId}/friendList/${callerUid}`) + .get(); + if (!friendEdge.exists) { + throw new HttpsError('permission-denied', 'Caller is not a friend of the target.'); + } + + const credentialsRef = db.doc(`users/${targetUserId}/private/credentials`); + const legacyProfileRef = db.doc(`users/${targetUserId}`); + const attemptsRef = db.doc(`pinAttempts/${callerUid}_${targetUserId}`); + + return db.runTransaction(async (tx) => { + // All transactional reads must happen before any writes. + const credentials = await tx.get(credentialsRef); + const legacyProfile = credentials.exists + ? null + : await tx.get(legacyProfileRef); + const attemptsSnap = await tx.get(attemptsRef); + + const now = Date.now(); + + // Load stored credentials: private doc first, legacy profile field fallback. + let stored: StoredCredentials | null = null; + if (credentials.exists) { + const data = credentials.data()!; + stored = { + pinHash: data.pinHash as string, + salt: (data.salt as string | null) ?? null, + }; + } else { + const legacyHash = legacyProfile?.data()?.pin as string | undefined; + if (legacyHash != null && legacyHash.length > 0) { + stored = { pinHash: legacyHash, salt: null }; + } + } + if (stored === null) { + throw new HttpsError('failed-precondition', 'Target user has no PIN set.'); + } + + const state: AttemptState | null = attemptsSnap.exists + ? { + failCount: attemptsSnap.data()!.failCount as number, + lockedUntilMillis: + (attemptsSnap.data()!.lockedUntilMillis as number | null) ?? null, + } + : null; + + const lock = evaluateAttempt(state, now); + if (lock.lockedOut) { + throw new HttpsError('resource-exhausted', 'Too many failed attempts.', { + lockedUntilMillis: lock.lockedUntilMillis, + }); + } + + if (checkPin(stored, pin)) { + if (attemptsSnap.exists) { + tx.delete(attemptsRef); + } + return { valid: true }; + } + + const next = recordFailure(state, now); + tx.set(attemptsRef, { + failCount: next.failCount, + lockedUntilMillis: next.lockedUntilMillis, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + return { + valid: false, + attemptsRemaining: Math.max(0, MAX_ATTEMPTS - next.failCount), + }; + }); +}); diff --git a/functions/test/pin-logic.test.ts b/functions/test/pin-logic.test.ts new file mode 100644 index 0000000..c11e153 --- /dev/null +++ b/functions/test/pin-logic.test.ts @@ -0,0 +1,75 @@ +import { + hashPin, + saltedPinHash, + checkPin, + evaluateAttempt, + recordFailure, + MAX_ATTEMPTS, + LOCKOUT_MS, +} from '../src/pin-logic'; + +describe('hashing', () => { + test('hashPin matches known sha256 of "0742"', () => { + // echo -n 0742 | shasum -a 256 + expect(hashPin('0742')).toBe( + 'a2f8d2eed38aea1c4e9a90af0a0ad9fc833b5f923afc5c7709745e56f766c87c', + ); + }); + + test('salted hash differs from unsalted and is stable', () => { + const salted = saltedPinHash('0742', 'abc123'); + expect(salted).not.toBe(hashPin('0742')); + expect(saltedPinHash('0742', 'abc123')).toBe(salted); + }); +}); + +describe('checkPin', () => { + test('legacy credentials (salt null) validate with plain hash', () => { + expect(checkPin({ pinHash: hashPin('1234'), salt: null }, '1234')).toBe(true); + expect(checkPin({ pinHash: hashPin('1234'), salt: null }, '4321')).toBe(false); + }); + + test('salted credentials validate with salted hash', () => { + const stored = { pinHash: saltedPinHash('1234', 's4lt'), salt: 's4lt' }; + expect(checkPin(stored, '1234')).toBe(true); + expect(checkPin(stored, '0000')).toBe(false); + }); +}); + +describe('lockout state machine', () => { + const NOW = 1_000_000; + + test('null state is not locked out', () => { + expect(evaluateAttempt(null, NOW)).toEqual({ + lockedOut: false, + lockedUntilMillis: null, + }); + }); + + test('recordFailure increments and locks at MAX_ATTEMPTS', () => { + let state = recordFailure(null, NOW); // 1 + for (let i = 1; i < MAX_ATTEMPTS - 1; i++) state = recordFailure(state, NOW); + expect(state.failCount).toBe(MAX_ATTEMPTS - 1); + expect(state.lockedUntilMillis).toBeNull(); + + state = recordFailure(state, NOW); // 5th failure + expect(state.failCount).toBe(MAX_ATTEMPTS); + expect(state.lockedUntilMillis).toBe(NOW + LOCKOUT_MS); + expect(evaluateAttempt(state, NOW + 1).lockedOut).toBe(true); + }); + + test('lockout expires and a new failure starts a fresh count', () => { + const locked = { failCount: 5, lockedUntilMillis: NOW + LOCKOUT_MS }; + const after = NOW + LOCKOUT_MS + 1; + expect(evaluateAttempt(locked, after).lockedOut).toBe(false); + expect(recordFailure(locked, after)).toEqual({ + failCount: 1, + lockedUntilMillis: null, + }); + }); + + test('recordFailure during an active lockout does not extend it', () => { + const locked = { failCount: 5, lockedUntilMillis: NOW + LOCKOUT_MS }; + expect(recordFailure(locked, NOW + 1)).toEqual(locked); + }); +}); diff --git a/functions/test/rules/firestore-rules.test.ts b/functions/test/rules/firestore-rules.test.ts new file mode 100644 index 0000000..4c4f55c --- /dev/null +++ b/functions/test/rules/firestore-rules.test.ts @@ -0,0 +1,398 @@ +import { + assertFails, + assertSucceeds, + initializeTestEnvironment, + RulesTestEnvironment, +} from '@firebase/rules-unit-testing'; +import { readFileSync } from 'fs'; +import { + doc, + getDoc, + getDocs, + setDoc, + updateDoc, + deleteDoc, + query, + collection, + where, + limit, +} from 'firebase/firestore'; + +let env: RulesTestEnvironment; + +beforeAll(async () => { + env = await initializeTestEnvironment({ + projectId: 'magic-yeti-rules-test', + firestore: { rules: readFileSync('../firestore.rules', 'utf8') }, + }); +}); + +afterAll(async () => { + await env.cleanup(); +}); + +beforeEach(async () => { + await env.clearFirestore(); +}); + +const alice = () => env.authenticatedContext('alice').firestore(); +const bob = () => env.authenticatedContext('bob').firestore(); +const anon = () => env.unauthenticatedContext().firestore(); + +describe('private credentials', () => { + test('owner can read and write own credentials', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/private/credentials'), { + pinHash: 'h', + salt: 's', + }), + ); + await assertSucceeds( + getDoc(doc(alice(), 'users/alice/private/credentials')), + ); + }); + + test('another signed-in user cannot read or write credentials', async () => { + await assertFails(getDoc(doc(bob(), 'users/alice/private/credentials'))); + await assertFails( + setDoc(doc(bob(), 'users/alice/private/credentials'), { pinHash: 'x' }), + ); + }); +}); + +describe('pinAttempts', () => { + test('no client may read or write pinAttempts', async () => { + await assertFails(getDoc(doc(alice(), 'pinAttempts/alice_bob'))); + await assertFails( + setDoc(doc(alice(), 'pinAttempts/alice_bob'), { failCount: 0 }), + ); + }); +}); + +describe('users', () => { + test('any signed-in user can read a profile; anon cannot', async () => { + await assertSucceeds(getDoc(doc(bob(), 'users/alice'))); + await assertFails(getDoc(doc(anon(), 'users/alice'))); + }); + + test('only the owner can write their profile doc', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice'), { username: 'alice' }), + ); + await assertFails(setDoc(doc(bob(), 'users/alice'), { username: 'evil' })); + }); + + test('usernameLower must be a faithful lowercase of username', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice'), { + username: 'Alice', + usernameLower: 'alice', + }), + ); + await assertFails( + setDoc(doc(alice(), 'users/alice'), { + username: 'Alice', + usernameLower: 'someoneelse', + }), + ); + await assertFails( + setDoc(doc(alice(), 'users/alice'), { usernameLower: 'alice' }), + ); + // Writes that omit usernameLower entirely are unaffected. + await assertSucceeds( + setDoc(doc(alice(), 'users/alice'), { username: 'Alice' }), + ); + }); +}); + +describe('matches', () => { + test('owner can read own matches; others cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/alice/matches/g1'), { id: 'g1' }); + }); + await assertSucceeds(getDoc(doc(alice(), 'users/alice/matches/g1'))); + await assertFails(getDoc(doc(bob(), 'users/alice/matches/g1'))); + }); + + test('cross-user match writes are denied (fan-out is server-side)', async () => { + await assertFails( + setDoc(doc(bob(), 'users/alice/matches/g2'), { id: 'g2' }), + ); + }); + + test('owner may write own matches (game-code import path)', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/matches/g3'), { id: 'g3' }), + ); + }); +}); + +describe('games', () => { + test('signed-in user can create a game they host; foreign hostId denied', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'games/g10'), { hostId: 'alice', roomId: 'AB2C' }), + ); + // Any signed-in user may read a game (game-code lookup). + await assertSucceeds(getDoc(doc(bob(), 'games/g10'))); + await assertFails( + setDoc(doc(alice(), 'games/g11'), { hostId: 'bob', roomId: 'CD3E' }), + ); + }); + + test('only the host can update or delete a game', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'games/g1'), { hostId: 'alice' }); + }); + await assertSucceeds(updateDoc(doc(alice(), 'games/g1'), { x: 1 })); + await assertFails(updateDoc(doc(bob(), 'games/g1'), { x: 2 })); + await assertFails(deleteDoc(doc(bob(), 'games/g1'))); + }); +}); + +describe('friends (TRANSITIONAL until Plan C)', () => { + test('owner reads own friend list; non-participant cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friends/alice/friendList/bob'), { + userId: 'bob', + }); + }); + await assertSucceeds(getDoc(doc(alice(), 'friends/alice/friendList/bob'))); + await assertFails(getDoc(doc(bob(), 'friends/alice/friendList/bob'))); + }); +}); + +describe('friendRequests', () => { + test('participants can read; strangers cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/r1'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }); + }); + await assertSucceeds(getDoc(doc(alice(), 'friendRequests/r1'))); + await assertSucceeds(getDoc(doc(bob(), 'friendRequests/r1'))); + await assertFails( + getDoc(doc(env.authenticatedContext('carol').firestore(), 'friendRequests/r1')), + ); + }); +}); + +describe('blocks', () => { + test('owner reads and writes own block docs; others cannot', async () => { + await assertSucceeds( + setDoc(doc(alice(), 'users/alice/blocks/bob'), { blockedAt: 1 }), + ); + await assertSucceeds(getDoc(doc(alice(), 'users/alice/blocks/bob'))); + await assertFails(getDoc(doc(bob(), 'users/alice/blocks/bob'))); + await assertFails( + setDoc(doc(bob(), 'users/alice/blocks/carol'), { blockedAt: 1 }), + ); + }); +}); + +describe('friendRequests lifecycle', () => { + const pending = { + id: 'alice_bob', + senderId: 'alice', + receiverId: 'bob', + senderName: 'Alice', + status: 'pending', + }; + + test('sender creates at the deterministic id', async () => { + await assertSucceeds(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('create with a mismatched doc id is denied', async () => { + await assertFails(setDoc(doc(alice(), 'friendRequests/wrong_id'), pending)); + }); + + test('create claiming another sender is denied', async () => { + await assertFails( + setDoc(doc(bob(), 'friendRequests/alice_bob'), pending), + ); + }); + + test('create is denied when the receiver blocks the sender', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/bob/blocks/alice'), { blockedAt: 1 }); + }); + await assertFails(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('create is denied when the sender blocks the receiver', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'users/alice/blocks/bob'), { blockedAt: 1 }); + }); + await assertFails(setDoc(doc(alice(), 'friendRequests/alice_bob'), pending)); + }); + + test('receiver declines pending -> declined; sender cannot', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertFails( + updateDoc(doc(alice(), 'friendRequests/alice_bob'), { status: 'declined' }), + ); + await assertSucceeds( + updateDoc(doc(bob(), 'friendRequests/alice_bob'), { status: 'declined' }), + ); + }); + + test('declined docs are immutable to further updates', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + ...pending, + status: 'declined', + }); + }); + await assertFails( + updateDoc(doc(bob(), 'friendRequests/alice_bob'), { status: 'pending' }), + ); + await assertFails( + updateDoc(doc(alice(), 'friendRequests/alice_bob'), { status: 'pending' }), + ); + }); + + test('sender may cancel (delete) a pending; receiver may delete (accept path)', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertSucceeds(deleteDoc(doc(bob(), 'friendRequests/alice_bob'))); + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertSucceeds(deleteDoc(doc(alice(), 'friendRequests/alice_bob'))); + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), pending); + }); + await assertFails( + deleteDoc(doc(env.authenticatedContext('carol').firestore(), 'friendRequests/alice_bob')), + ); + }); + + test('declined docs cannot be deleted (delete-and-recreate suppression dodge)', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + ...pending, + status: 'declined', + }); + }); + await assertFails(deleteDoc(doc(alice(), 'friendRequests/alice_bob'))); + await assertFails(deleteDoc(doc(bob(), 'friendRequests/alice_bob'))); + }); + + test('point get on a NONEXISTENT request doc is denied (why the client uses queries)', async () => { + await assertFails(getDoc(doc(alice(), 'friendRequests/alice_bob'))); + }); + + test('participant-constrained queries are allowed with no matching docs', async () => { + await assertSucceeds( + getDocs(query( + collection(alice(), 'friendRequests'), + where('senderId', '==', 'alice'), + where('receiverId', '==', 'bob'), + limit(1), + )), + ); + await assertSucceeds( + getDocs(query( + collection(alice(), 'friendRequests'), + where('senderId', '==', 'bob'), + where('receiverId', '==', 'alice'), + where('status', '==', 'pending'), + limit(1), + )), + ); + }); +}); + +describe('friendList lifecycle', () => { + test('accepting receiver writes both edges while the pending doc exists', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }); + }); + // bob (receiver) writes alice onto his own list… + await assertSucceeds( + setDoc(doc(bob(), 'friends/bob/friendList/alice'), { userId: 'alice' }), + ); + // …and himself onto alice's list. + await assertSucceeds( + setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'bob' }), + ); + }); + + test('edge writes without a matching pending request are denied', async () => { + await assertFails( + setDoc(doc(bob(), 'friends/bob/friendList/carol'), { userId: 'carol' }), + ); + await assertFails( + setDoc(doc(bob(), 'friends/carol/friendList/bob'), { userId: 'bob' }), + ); + }); + + test('edge doc userId must match its key even with a valid pending gate', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + senderId: 'alice', + receiverId: 'bob', + status: 'pending', + }); + }); + // FriendModel.userId feeds PIN-validation targets β€” a mismatched field + // would point the friend tile at a different account. + await assertFails( + setDoc(doc(bob(), 'friends/bob/friendList/alice'), { userId: 'mallory' }), + ); + await assertFails( + setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'mallory' }), + ); + }); + + test('owner may always delete own edges; you may always delete yourself from another list', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + const seedDb = ctx.firestore(); + await setDoc(doc(seedDb, 'friends/alice/friendList/bob'), { userId: 'bob' }); + await setDoc(doc(seedDb, 'friends/bob/friendList/alice'), { userId: 'alice' }); + }); + const aliceDb = alice(); + await assertSucceeds(deleteDoc(doc(aliceDb, 'friends/alice/friendList/bob'))); + await assertSucceeds(deleteDoc(doc(aliceDb, 'friends/bob/friendList/alice'))); + }); + + test("a third party cannot delete someone else's edge", async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friends/alice/friendList/bob'), { userId: 'bob' }); + }); + await assertFails( + deleteDoc(doc(env.authenticatedContext('carol').firestore(), 'friends/alice/friendList/bob')), + ); + }); + + test('a declined request grants no edge writes, even for the decliner', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'friendRequests/alice_bob'), { + senderId: 'alice', receiverId: 'bob', status: 'declined', + }); + }); + await assertFails(setDoc(doc(bob(), 'friends/bob/friendList/alice'), { userId: 'alice' })); + await assertFails(setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'bob' })); + }); + + test('a block denies edge writes even with a pending request', async () => { + await env.withSecurityRulesDisabled(async (ctx) => { + const seedDb = ctx.firestore(); + await setDoc(doc(seedDb, 'friendRequests/alice_bob'), { + senderId: 'alice', receiverId: 'bob', status: 'pending', + }); + await setDoc(doc(seedDb, 'users/alice/blocks/bob'), { blockedAt: 1 }); + }); + await assertFails(setDoc(doc(bob(), 'friends/bob/friendList/alice'), { userId: 'alice' })); + await assertFails(setDoc(doc(bob(), 'friends/alice/friendList/bob'), { userId: 'bob' })); + }); +}); diff --git a/functions/test/rules/on-game-created.integration.test.ts b/functions/test/rules/on-game-created.integration.test.ts new file mode 100644 index 0000000..9ee8c16 --- /dev/null +++ b/functions/test/rules/on-game-created.integration.test.ts @@ -0,0 +1,118 @@ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { onGameCreated } = require('../../src/on-game-created'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(onGameCreated); + +afterAll(() => { + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all(collections.map((c) => db.recursiveDelete(c))); +}); + +function gameDoc(overrides: Record = {}) { + return { + id: 'g1', + hostId: 'host', + roomId: 'AB2C', + winnerId: 'p1', + players: [ + { id: 'p1', name: 'Josh', firebaseId: 'host' }, + { id: 'p2', name: 'Friend', firebaseId: 'friend1' }, + { id: 'p3', name: 'Guest', firebaseId: null }, + { id: 'p4', name: 'Dup', firebaseId: 'friend1' }, + ], + ...overrides, + }; +} + +async function fireWith(data: Record, gameId = 'g1') { + const snap = testEnv.firestore.makeDocumentSnapshot(data, `games/${gameId}`); + await wrapped({ data: snap, params: { gameId } }); +} + +test('fans out to host and linked players, deduped, skipping null ids', async () => { + await fireWith(gameDoc()); + const host = await db.doc('users/host/matches/g1').get(); + const friend = await db.doc('users/friend1/matches/g1').get(); + expect(host.exists).toBe(true); + expect(friend.exists).toBe(true); + expect(friend.data()!.roomId).toBe('AB2C'); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(2); +}); + +test('host gets a copy even when not in any player slot', async () => { + await fireWith( + gameDoc({ + players: [{ id: 'p1', name: 'Friend', firebaseId: 'friend1' }], + }), + ); + expect((await db.doc('users/host/matches/g1').get()).exists).toBe(true); +}); + +test('no linked players and empty hostId writes nothing', async () => { + await fireWith( + gameDoc({ hostId: '', players: [{ id: 'p1', name: 'X', firebaseId: null }] }), + ); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(0); +}); + +test('is idempotent: re-firing overwrites the same doc, not a new one', async () => { + await fireWith(gameDoc()); + await fireWith(gameDoc()); + const friendDocs = await db.collection('users/friend1/matches').get(); + expect(friendDocs.size).toBe(1); +}); + +test('malformed firebaseId values are skipped without throwing', async () => { + await fireWith( + gameDoc({ + players: [ + { id: 'p1', firebaseId: 42 }, + { id: 'p2', firebaseId: 'ok-user' }, + ], + }), + ); + expect((await db.doc('users/ok-user/matches/g1').get()).exists).toBe(true); +}); + +test('ids containing a slash are skipped, others still fan out', async () => { + await fireWith( + gameDoc({ + hostId: 'users/alice', + players: [ + { id: 'p1', firebaseId: 'x/y' }, + { id: 'p2', firebaseId: 'ok-user' }, + ], + }), + ); + expect((await db.doc('users/ok-user/matches/g1').get()).exists).toBe(true); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(1); +}); + +test('non-array players field writes only the host copy', async () => { + await fireWith(gameDoc({ players: 'corrupt' })); + const all = await db.collectionGroup('matches').get(); + expect(all.size).toBe(1); + expect((await db.doc('users/host/matches/g1').get()).exists).toBe(true); +}); + +test('missing hostId key with valid players still fans out to players', async () => { + const data = gameDoc({ players: [{ id: 'p1', firebaseId: 'friend1' }] }); + delete (data as Record).hostId; + await fireWith(data); + expect((await db.doc('users/friend1/matches/g1').get()).exists).toBe(true); +}); diff --git a/functions/test/rules/on-user-deleted.integration.test.ts b/functions/test/rules/on-user-deleted.integration.test.ts new file mode 100644 index 0000000..26ede8f --- /dev/null +++ b/functions/test/rules/on-user-deleted.integration.test.ts @@ -0,0 +1,95 @@ +/** + * Runs inside `firebase emulators:exec --only firestore` via the test:rules + * script. Uses firebase-functions-test in online mode against the emulator. + */ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// Import AFTER functionsTest() so admin.initializeApp inside the module +// picks up emulator env. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { onUserDeleted } = require('../../src/on-user-deleted'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(onUserDeleted); + +afterAll(() => { + // testEnv.cleanup() is synchronous (returns void in this version of + // firebase-functions-test) β€” nothing to await here. + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all(collections.map((c) => db.recursiveDelete(c))); +}); + +async function seed() { + // Victim's own tree: profile, private/credentials, blocks/other, matches/g1. + await db.doc('users/victim').set({ id: 'victim', username: 'Victim' }); + await db.doc('users/victim/private/credentials').set({ secret: 'shh' }); + await db.doc('users/victim/blocks/other').set({ userId: 'other', username: 'Other', imageUrl: '' }); + await db.doc('users/victim/matches/g1').set({ id: 'g1', roomId: 'AB2C' }); + + // Victim's own friend list (friends/victim root) and the mirrored edge. + await db.doc('friends/victim/friendList/friend1').set({ userId: 'friend1' }); + await db.doc('friends/friend1/friendList/victim').set({ userId: 'victim' }); + + // Requests in both directions, different statuses. + await db.doc('friendRequests/victim_friend1').set({ + senderId: 'victim', + receiverId: 'friend1', + status: 'pending', + }); + await db.doc('friendRequests/friend2_victim').set({ + senderId: 'friend2', + receiverId: 'victim', + status: 'declined', + }); + + // friend1's block record naming the victim. + await db.doc('users/friend1/blocks/victim').set({ + userId: 'victim', + username: 'Victim', + imageUrl: '', + }); + + // Shared game history that must survive cleanup untouched. + await db.doc('games/g1').set({ id: 'g1', hostId: 'friend1', roomId: 'AB2C' }); + await db.doc('users/friend1/matches/g1').set({ id: 'g1', roomId: 'AB2C' }); +} + +test('deletes the victim social graph but leaves shared game history intact', async () => { + await seed(); + + await wrapped({ uid: 'victim' }); + + // Victim's whole tree is gone. + expect((await db.doc('users/victim').get()).exists).toBe(false); + expect((await db.doc('users/victim/private/credentials').get()).exists).toBe(false); + expect((await db.doc('users/victim/blocks/other').get()).exists).toBe(false); + expect((await db.doc('users/victim/matches/g1').get()).exists).toBe(false); + + // Victim's own friend list root is gone. + expect((await db.doc('friends/victim/friendList/friend1').get()).exists).toBe(false); + const victimFriendListDocs = await db.collection('friends/victim/friendList').get(); + expect(victimFriendListDocs.size).toBe(0); + + // friend1's edge pointing at the victim is gone. + expect((await db.doc('friends/friend1/friendList/victim').get()).exists).toBe(false); + + // Both friend requests involving the victim are gone, regardless of status. + expect((await db.doc('friendRequests/victim_friend1').get()).exists).toBe(false); + expect((await db.doc('friendRequests/friend2_victim').get()).exists).toBe(false); + + // friend1's block-of-victim doc is gone. + expect((await db.doc('users/friend1/blocks/victim').get()).exists).toBe(false); + + // Shared game history is untouched. + expect((await db.doc('games/g1').get()).exists).toBe(true); + expect((await db.doc('users/friend1/matches/g1').get()).exists).toBe(true); +}); diff --git a/functions/test/rules/search-by-friend-code.integration.test.ts b/functions/test/rules/search-by-friend-code.integration.test.ts new file mode 100644 index 0000000..857b474 --- /dev/null +++ b/functions/test/rules/search-by-friend-code.integration.test.ts @@ -0,0 +1,90 @@ +/** + * Runs inside `firebase emulators:exec --only firestore` via the test:rules + * script. Uses firebase-functions-test in online mode against the emulator. + */ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// Import AFTER functionsTest() so admin.initializeApp inside the module +// picks up emulator env. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { searchByFriendCode } = require('../../src/search-by-friend-code'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(searchByFriendCode); + +const caller = { uid: 'caller', token: { firebase: { sign_in_provider: 'password' } } }; + +async function seedTarget() { + await db.doc('users/target').set({ + id: 'target', + username: 'Target', + imageUrl: 'http://x/y.png', + friendCode: 'YETI-A3F9', + }); +} + +afterAll(() => { + // testEnv.cleanup() is synchronous (returns void in this version of + // firebase-functions-test) β€” nothing to await here. + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all( + collections.map((c) => db.recursiveDelete(c)), + ); +}); + +test('finds a user by normalized code with relationship none', async () => { + await seedTarget(); + const r = await wrapped({ data: { code: ' yeti-a3f9 ' }, auth: caller }); + expect(r).toEqual({ + found: true, + user: { id: 'target', username: 'Target', imageUrl: 'http://x/y.png', friendCode: 'YETI-A3F9' }, + relationship: 'none', + }); +}); + +test('reports friends / pendingSent / pendingReceived / self', async () => { + await seedTarget(); + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('friends'); + await db.recursiveDelete(db.collection('friends')); + await db.doc('friendRequests/caller_target').set({ senderId: 'caller', receiverId: 'target', status: 'pending' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('pendingSent'); + await db.recursiveDelete(db.collection('friendRequests')); + await db.doc('friendRequests/target_caller').set({ senderId: 'target', receiverId: 'caller', status: 'pending' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('pendingReceived'); + await db.doc('users/caller').set({ id: 'caller', friendCode: 'YETI-CCCC' }); + expect((await wrapped({ data: { code: 'YETI-CCCC' }, auth: caller })).relationship).toBe('self'); +}); + +test('declined pending reads as none (sender can silently re-send)', async () => { + await seedTarget(); + await db.doc('friendRequests/caller_target').set({ senderId: 'caller', receiverId: 'target', status: 'declined' }); + expect((await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).relationship).toBe('none'); +}); + +test('not found when target blocks caller, when caller blocks target, or no match', async () => { + await seedTarget(); + await db.doc('users/target/blocks/caller').set({ blockedAt: 1 }); + expect(await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).toEqual({ found: false }); + await db.recursiveDelete(db.doc('users/target').collection('blocks')); + await db.doc('users/caller/blocks/target').set({ blockedAt: 1 }); + expect(await wrapped({ data: { code: 'YETI-A3F9' }, auth: caller })).toEqual({ found: false }); + expect(await wrapped({ data: { code: 'YETI-ZZZZ' }, auth: caller })).toEqual({ found: false }); +}); + +test('anonymous and unauthenticated callers rejected; empty code invalid', async () => { + await expect(wrapped({ data: { code: 'YETI-A3F9' } })).rejects.toMatchObject({ code: 'unauthenticated' }); + await expect( + wrapped({ data: { code: 'YETI-A3F9' }, auth: { uid: 'x', token: { firebase: { sign_in_provider: 'anonymous' } } } }), + ).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(wrapped({ data: { code: '' }, auth: caller })).rejects.toMatchObject({ code: 'invalid-argument' }); +}); diff --git a/functions/test/rules/search-by-username.integration.test.ts b/functions/test/rules/search-by-username.integration.test.ts new file mode 100644 index 0000000..793ccdc --- /dev/null +++ b/functions/test/rules/search-by-username.integration.test.ts @@ -0,0 +1,98 @@ +/** + * Runs inside `firebase emulators:exec --only firestore` via the test:rules + * script. Uses firebase-functions-test in online mode against the emulator. + */ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// Import AFTER functionsTest() so admin.initializeApp inside the module +// picks up emulator env. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { searchByUsername } = require('../../src/search-by-username'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(searchByUsername); + +const caller = { uid: 'caller', token: { firebase: { sign_in_provider: 'password' } } }; + +async function seedUser( + id: string, + username: string, + overrides: Record = {}, +) { + await db.doc(`users/${id}`).set({ + id, + username, + usernameLower: username.toLowerCase(), + imageUrl: 'http://x/y.png', + friendCode: `YETI-${id.toUpperCase()}`, + ...overrides, + }); +} + +afterAll(() => { + // testEnv.cleanup() is synchronous (returns void in this version of + // firebase-functions-test) β€” nothing to await here. + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all( + collections.map((c) => db.recursiveDelete(c)), + ); +}); + +test('finds users by lowercase prefix, case-insensitively', async () => { + await seedUser('josh', 'Josh'); + await seedUser('john', 'John'); + await seedUser('mira', 'Mira'); + const r = await wrapped({ data: { query: 'Jo' }, auth: caller }); + const ids = r.matches.map((m: { user: { id: string } }) => m.user.id).sort(); + expect(ids).toEqual(['john', 'josh']); +}); + +test('no matches returns an empty list, not an error', async () => { + await seedUser('josh', 'Josh'); + const r = await wrapped({ data: { query: 'zz' }, auth: caller }); + expect(r).toEqual({ matches: [] }); +}); + +test('reports relationship per match and includes self', async () => { + await seedUser('josh', 'Josh'); + await seedUser('caller', 'Caller'); + await db.doc('friends/josh/friendList/caller').set({ userId: 'caller' }); + const r = await wrapped({ data: { query: 'jo' }, auth: caller }); + expect(r.matches).toEqual([ + { + user: { id: 'josh', username: 'Josh', imageUrl: 'http://x/y.png', friendCode: 'YETI-JOSH' }, + relationship: 'friends', + }, + ]); + + const self = await wrapped({ data: { query: 'call' }, auth: caller }); + expect(self.matches[0].relationship).toBe('self'); +}); + +test('block-hides either direction from the results', async () => { + await seedUser('josh', 'Josh'); + await db.doc('users/josh/blocks/caller').set({ blockedAt: 1 }); + expect(await wrapped({ data: { query: 'jo' }, auth: caller })).toEqual({ matches: [] }); + + await db.recursiveDelete(db.doc('users/josh').collection('blocks')); + await db.doc('users/caller/blocks/josh').set({ blockedAt: 1 }); + expect(await wrapped({ data: { query: 'jo' }, auth: caller })).toEqual({ matches: [] }); +}); + +test('anonymous/unauthenticated callers rejected; short query invalid', async () => { + await expect(wrapped({ data: { query: 'jo' } })).rejects.toMatchObject({ code: 'unauthenticated' }); + await expect( + wrapped({ data: { query: 'jo' }, auth: { uid: 'x', token: { firebase: { sign_in_provider: 'anonymous' } } } }), + ).rejects.toMatchObject({ code: 'permission-denied' }); + await expect(wrapped({ data: { query: 'j' }, auth: caller })).rejects.toMatchObject({ code: 'invalid-argument' }); + await expect(wrapped({ data: { query: '' }, auth: caller })).rejects.toMatchObject({ code: 'invalid-argument' }); +}); diff --git a/functions/test/rules/validate-pin.integration.test.ts b/functions/test/rules/validate-pin.integration.test.ts new file mode 100644 index 0000000..1d58595 --- /dev/null +++ b/functions/test/rules/validate-pin.integration.test.ts @@ -0,0 +1,148 @@ +/** + * Runs inside `firebase emulators:exec --only firestore` via the test:rules + * script. Uses firebase-functions-test in online mode against the emulator. + */ +process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; +process.env.GCLOUD_PROJECT = 'magic-yeti-fn-test'; + +import functionsTest from 'firebase-functions-test'; +import * as admin from 'firebase-admin'; +import { hashPin, saltedPinHash, MAX_ATTEMPTS } from '../../src/pin-logic'; + +const testEnv = functionsTest({ projectId: 'magic-yeti-fn-test' }); +// Import AFTER functionsTest() so admin.initializeApp inside the module +// picks up emulator env. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { validatePin } = require('../../src/validate-pin'); + +const db = admin.firestore(); +const wrapped = testEnv.wrap(validatePin); + +const callerAuth = { + uid: 'caller', + token: { firebase: { sign_in_provider: 'password' } }, +}; + +async function seedFriendshipAndPin(opts: { salted: boolean }) { + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + if (opts.salted) { + await db.doc('users/target/private/credentials').set({ + pinHash: saltedPinHash('0742', 'somesalt'), + salt: 'somesalt', + }); + } else { + await db.doc('users/target').set({ pin: hashPin('0742') }); + } +} + +afterAll(() => { + // testEnv.cleanup() is synchronous (returns void in this version of + // firebase-functions-test) β€” nothing to await here. + testEnv.cleanup(); +}); + +beforeEach(async () => { + const collections = await db.listCollections(); + await Promise.all( + collections.map((c) => db.recursiveDelete(c)), + ); +}); + +test('correct PIN against salted credentials returns valid', async () => { + await seedFriendshipAndPin({ salted: true }); + const result = await wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: callerAuth, + }); + expect(result).toEqual({ valid: true }); +}); + +test('correct PIN against legacy profile field returns valid (fallback)', async () => { + await seedFriendshipAndPin({ salted: false }); + const result = await wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: callerAuth, + }); + expect(result).toEqual({ valid: true }); +}); + +test('wrong PIN decrements attempts and locks out after MAX_ATTEMPTS', async () => { + await seedFriendshipAndPin({ salted: true }); + for (let i = 1; i < MAX_ATTEMPTS; i++) { + const r = await wrapped({ + data: { targetUserId: 'target', pin: '9999' }, + auth: callerAuth, + }); + expect(r.valid).toBe(false); + expect(r.attemptsRemaining).toBe(MAX_ATTEMPTS - i); + } + // 5th failure locks + const fifth = await wrapped({ + data: { targetUserId: 'target', pin: '9999' }, + auth: callerAuth, + }); + expect(fifth.valid).toBe(false); + expect(fifth.attemptsRemaining).toBe(0); + + // 6th attempt β€” even with the CORRECT pin β€” is locked out + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'resource-exhausted' }); +}); + +test('success clears the attempt counter', async () => { + await seedFriendshipAndPin({ salted: true }); + await wrapped({ data: { targetUserId: 'target', pin: '9999' }, auth: callerAuth }); + await wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }); + const attempts = await db.doc('pinAttempts/caller_target').get(); + expect(attempts.exists).toBe(false); +}); + +test('non-friend caller is rejected', async () => { + await db.doc('users/target/private/credentials').set({ + pinHash: saltedPinHash('0742', 's'), + salt: 's', + }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'permission-denied' }); +}); + +test('anonymous caller is rejected', async () => { + await seedFriendshipAndPin({ salted: true }); + await expect( + wrapped({ + data: { targetUserId: 'target', pin: '0742' }, + auth: { + uid: 'caller', + token: { firebase: { sign_in_provider: 'anonymous' } }, + }, + }), + ).rejects.toMatchObject({ code: 'permission-denied' }); +}); + +test('missing auth is rejected', async () => { + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' } }), + ).rejects.toMatchObject({ code: 'unauthenticated' }); +}); + +test('malformed pin is rejected', async () => { + await seedFriendshipAndPin({ salted: true }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '12' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'invalid-argument' }); +}); + +test('target without any PIN yields failed-precondition', async () => { + await db.doc('friends/target/friendList/caller').set({ userId: 'caller' }); + await expect( + wrapped({ data: { targetUserId: 'target', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'failed-precondition' }); +}); + +test('targetUserId containing a slash is rejected', async () => { + await expect( + wrapped({ data: { targetUserId: 'a/b', pin: '0742' }, auth: callerAuth }), + ).rejects.toMatchObject({ code: 'invalid-argument' }); +}); diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 0000000..da46a6c --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2022", + "lib": ["es2022"], + "outDir": "lib", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/ios/Podfile b/ios/Podfile index 100a6d5..3f1599e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -49,5 +49,17 @@ post_install do |installer| end end end + # Swift 6.3 (Xcode 26.4) miscompiles `async let` teardown in optimized + # builds, aborting inside FirebaseFunctions' and FirebaseAuth's shared + # FunctionsContextProvider.context() (three concurrent async let calls + # for auth/App Check tokens) with a Swift Concurrency runtime fatal + # error. Forcing -Onone on these two pods sidesteps the optimizer bug + # until firebase-ios-sdk ships the Task-based fix from PR #15991 + # (firebase/firebase-ios-sdk#15974). Remove once that version lands. + if ['FirebaseFunctions', 'FirebaseAuth'].include?(target.name) + target.build_configurations.each do |config| + config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Onone' + end + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 076227f..ab1d115 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1161,6 +1161,10 @@ PODS: - Firebase/Firestore (= 11.8.0) - firebase_core - Flutter + - cloud_functions (5.3.4): + - Firebase/Functions (= 11.8.0) + - firebase_core + - Flutter - Firebase/Analytics (11.8.0): - Firebase/Core - Firebase/Auth (11.8.0): @@ -1177,6 +1181,9 @@ PODS: - Firebase/Firestore (11.8.0): - Firebase/CoreOnly - FirebaseFirestore (~> 11.8.0) + - Firebase/Functions (11.8.0): + - Firebase/CoreOnly + - FirebaseFunctions (~> 11.8.0) - Firebase/Storage (11.8.0): - Firebase/CoreOnly - FirebaseStorage (~> 11.8.0) @@ -1262,11 +1269,20 @@ PODS: - gRPC-Core (~> 1.65.0) - leveldb-library (~> 1.22) - nanopb (~> 3.30910.0) + - FirebaseFunctions (11.8.0): + - FirebaseAppCheckInterop (~> 11.0) + - FirebaseAuthInterop (~> 11.0) + - FirebaseCore (~> 11.8.0) + - FirebaseCoreExtension (~> 11.8.0) + - FirebaseMessagingInterop (~> 11.0) + - FirebaseSharedSwift (~> 11.0) + - GTMSessionFetcher/Core (< 5.0, >= 3.4) - FirebaseInstallations (11.8.0): - FirebaseCore (~> 11.8.0) - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) + - FirebaseMessagingInterop (11.15.0) - FirebaseSharedSwift (11.15.0) - FirebaseStorage (11.8.0): - FirebaseAppCheckInterop (~> 11.0) @@ -1461,6 +1477,7 @@ PODS: DEPENDENCIES: - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) + - cloud_functions (from `.symlinks/plugins/cloud_functions/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -1493,7 +1510,9 @@ SPEC REPOS: - FirebaseDatabase - FirebaseFirestore - FirebaseFirestoreInternal + - FirebaseFunctions - FirebaseInstallations + - FirebaseMessagingInterop - FirebaseSharedSwift - FirebaseStorage - GoogleAppMeasurement @@ -1511,6 +1530,8 @@ SPEC REPOS: EXTERNAL SOURCES: cloud_firestore: :path: ".symlinks/plugins/cloud_firestore/ios" + cloud_functions: + :path: ".symlinks/plugins/cloud_functions/ios" firebase_analytics: :path: ".symlinks/plugins/firebase_analytics/ios" firebase_auth: @@ -1547,6 +1568,7 @@ SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 BoringSSL-GRPC: ca6a8e5d04812fce8ffd6437810c2d46f925eaeb cloud_firestore: e61acbf808607d2c88ee32b00cd3aec027b38b3c + cloud_functions: b49e005ef7349170a7391533877d785c84024fae Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf firebase_analytics: 4e93dbe66872104d28ae9750fec1800e1fd66858 firebase_auth: 9ebbd83276bf977dea1c74dfc199acf9d2a2f42f @@ -1563,7 +1585,9 @@ SPEC CHECKSUMS: FirebaseDatabase: f86d3da3a67e41b551a84a8c5b081d0f86a5a896 FirebaseFirestore: 563a4ab1a65e2858f05e150bb4c31b0f8f79248b FirebaseFirestoreInternal: 8c5921c360a70e447bfeefb245f450e8b50e750b + FirebaseFunctions: 63d23f720c9a2c537369add7e54cf7e56eb3520a FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917 + FirebaseMessagingInterop: 63e504b147a7206cfe64cbe2f40c2ddd009945bd FirebaseSharedSwift: e17c654ef1f1a616b0b33054e663ad1035c8fd40 FirebaseStorage: 8eede00081a6ce904eaa8d2daa66f1e053e8e6ea Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 @@ -1588,6 +1612,6 @@ SPEC CHECKSUMS: url_launcher_ios: 694010445543906933d732453a59da0a173ae33d wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49 -PODFILE CHECKSUM: bc7eae3e2661f1b11146bfb0087f8fdf5ee02315 +PODFILE CHECKSUM: aba3a06a51369be0332ecb8e440cf9fa3ef3a108 COCOAPODS: 1.16.2 diff --git a/lib/app/app_router/app_router.dart b/lib/app/app_router/app_router.dart index fa735e6..5eed45a 100644 --- a/lib/app/app_router/app_router.dart +++ b/lib/app/app_router/app_router.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:magic_yeti/app/app_router/app_route.dart'; import 'package:magic_yeti/app/app_router/go_router_refresh_stream.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/blocked_users/view/blocked_users_page.dart'; import 'package:magic_yeti/friends_list/friends_list_page.dart'; import 'package:magic_yeti/friends_list/requests/friend_request_page.dart'; import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; @@ -93,6 +94,14 @@ class AppRouter { child: FriendRequestsPage.pageBuilder(context, state), ), ), + AppRoute( + name: BlockedUsersPage.routeName, + path: BlockedUsersPage.routePath, + pageBuilder: (context, state) => NoTransitionPage( + name: BlockedUsersPage.routeName, + child: BlockedUsersPage.pageBuilder(context, state), + ), + ), AppRoute( name: SearchUserPage.routeName, path: SearchUserPage.routePath, diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 507cf65..099df8f 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -122,11 +122,18 @@ class AppBloc extends Bloc { final generation = ++_onboardingCheckGeneration; // Check Firestore for onboarding completion try { + // Lazily move any legacy profile-doc PIN hash into the private + // credentials doc before the profile is evaluated. Best-effort: + // the callable's legacy fallback covers failures. + await _firebaseDatabaseRepository.migrateLegacyPin(event.user.id); final profile = await _firebaseDatabaseRepository .getUserProfileOnce(event.user.id); // Stale check β€” a newer event has arrived if (generation != _onboardingCheckGeneration) return; - if (profile == null || !profile.onboardingComplete) { + // Completeness requires onboardingComplete AND a username AND a + // PIN (hasPin flag or unmigrated legacy field) β€” legacy users + // missing any of these re-enter the pre-filled onboarding wizard. + if (profile == null || !profile.isComplete) { return emit(AppState.onboardingRequired(event.user)); } return emit(AppState.authenticated(event.user)); diff --git a/lib/friends_list/blocked_users/bloc/blocked_users_bloc.dart b/lib/friends_list/blocked_users/bloc/blocked_users_bloc.dart new file mode 100644 index 0000000..eff31d6 --- /dev/null +++ b/lib/friends_list/blocked_users/bloc/blocked_users_bloc.dart @@ -0,0 +1,52 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; + +part 'blocked_users_event.dart'; +part 'blocked_users_state.dart'; + +/// Bloc implementation for managing the user's blocked-users list. +/// +/// Key features: +/// - Subscribes to the repository's blocked-users stream +/// - Unblocks a previously blocked user +/// +/// @dependencies +/// - FirebaseDatabaseRepository: For interacting with Firestore +/// - Flutter Bloc: For state management +class BlockedUsersBloc extends Bloc { + BlockedUsersBloc({required this.repository}) + : super(BlockedUsersLoading()) { + on(_onLoadBlockedUsers); + on(_onUnblockUser); + } + + final FirebaseDatabaseRepository repository; + + Future _onLoadBlockedUsers( + LoadBlockedUsers event, + Emitter emit, + ) async { + emit(BlockedUsersLoading()); + await emit.forEach>( + repository.getBlockedUsers(event.userId), + onData: BlockedUsersLoaded.new, + onError: (error, stackTrace) => + BlockedUsersError('Failed to load blocked users: $error'), + ); + } + + Future _onUnblockUser( + UnblockUser event, + Emitter emit, + ) async { + try { + await repository.unblockUser( + currentUserId: event.userId, + targetUserId: event.targetUserId, + ); + } on Exception catch (e) { + emit(BlockedUsersError('Failed to unblock user: $e')); + } + } +} diff --git a/lib/friends_list/blocked_users/bloc/blocked_users_event.dart b/lib/friends_list/blocked_users/bloc/blocked_users_event.dart new file mode 100644 index 0000000..f507f1d --- /dev/null +++ b/lib/friends_list/blocked_users/bloc/blocked_users_event.dart @@ -0,0 +1,31 @@ +part of 'blocked_users_bloc.dart'; + +/// Events for the BlockedUsersBloc. +/// +/// Key events: +/// - LoadBlockedUsers: Subscribes to the current user's blocked-users stream +/// - UnblockUser: Unblocks a previously blocked user + +sealed class BlockedUsersEvent extends Equatable { + const BlockedUsersEvent(); + + @override + List get props => []; +} + +class LoadBlockedUsers extends BlockedUsersEvent { + const LoadBlockedUsers(this.userId); + final String userId; + + @override + List get props => [userId]; +} + +class UnblockUser extends BlockedUsersEvent { + const UnblockUser(this.userId, this.targetUserId); + final String userId; + final String targetUserId; + + @override + List get props => [userId, targetUserId]; +} diff --git a/lib/friends_list/blocked_users/bloc/blocked_users_state.dart b/lib/friends_list/blocked_users/bloc/blocked_users_state.dart new file mode 100644 index 0000000..a07c84f --- /dev/null +++ b/lib/friends_list/blocked_users/bloc/blocked_users_state.dart @@ -0,0 +1,33 @@ +part of 'blocked_users_bloc.dart'; + +/// States for the BlockedUsersBloc. +/// +/// Key states: +/// - BlockedUsersLoading: Indicates loading state +/// - BlockedUsersLoaded: Indicates blocked users are successfully loaded +/// - BlockedUsersError: Indicates an error occurred + +abstract class BlockedUsersState extends Equatable { + const BlockedUsersState(); + + @override + List get props => []; +} + +class BlockedUsersLoading extends BlockedUsersState {} + +class BlockedUsersLoaded extends BlockedUsersState { + const BlockedUsersLoaded(this.blockedUsers); + final List blockedUsers; + + @override + List get props => [blockedUsers]; +} + +class BlockedUsersError extends BlockedUsersState { + const BlockedUsersError(this.message); + final String message; + + @override + List get props => [message]; +} diff --git a/lib/friends_list/blocked_users/view/blocked_users_page.dart b/lib/friends_list/blocked_users/view/blocked_users_page.dart new file mode 100644 index 0000000..f599c42 --- /dev/null +++ b/lib/friends_list/blocked_users/view/blocked_users_page.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:app_ui/app_ui.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/blocked_users/bloc/blocked_users_bloc.dart'; +import 'package:magic_yeti/friends_list/widgets/friend_card.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; + +class BlockedUsersPage extends StatelessWidget { + const BlockedUsersPage({super.key}); + + factory BlockedUsersPage.pageBuilder(_, __) { + return const BlockedUsersPage(key: Key('blocked_users_page')); + } + + static const routeName = 'blockedUsersPage'; + static const routePath = '/blockedUsersPage'; + + @override + Widget build(BuildContext context) { + final userId = context.read().state.user.id; + return BlocProvider( + create: (context) => BlockedUsersBloc( + repository: context.read(), + )..add(LoadBlockedUsers(userId)), + child: const BlockedUsersView(), + ); + } +} + +class BlockedUsersView extends StatelessWidget { + const BlockedUsersView({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.quaternary, + iconTheme: const IconThemeData( + color: AppColors.onSurfaceVariant, + ), + title: Text( + l10n.blockedUsersTitle, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppColors.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + body: BlocBuilder( + builder: (context, state) { + if (state is BlockedUsersLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is BlockedUsersLoaded) { + if (state.blockedUsers.isEmpty) { + return Center( + child: Text( + l10n.blockedUsersEmpty, + style: const TextStyle(color: AppColors.onSurfaceVariant), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.only(top: 8), + itemCount: state.blockedUsers.length, + itemBuilder: (context, index) { + final blockedUser = state.blockedUsers[index]; + return FriendCard( + initial: blockedUser.username.isNotEmpty + ? blockedUser.username[0] + : '?', + title: blockedUser.username, + subtitle: blockedUser.friendCode, + trailing: TextButton( + onPressed: () => + _confirmUnblock(context, blockedUser), + child: Text( + l10n.unblockUserAction, + style: const TextStyle(color: AppColors.tertiary), + ), + ), + ); + }, + ); + } else if (state is BlockedUsersError) { + return Center( + child: Text( + l10n.blockedUsersLoadFailedError, + style: const TextStyle(color: AppColors.onSurfaceVariant), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } + + void _confirmUnblock(BuildContext context, BlockedUserModel blockedUser) { + final l10n = context.l10n; + final userId = context.read().state.user.id; + unawaited( + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + l10n.unblockUserAction, + style: const TextStyle(color: AppColors.white), + ), + content: Text( + l10n.unblockUserConfirmBody(blockedUser.username), + style: const TextStyle(color: AppColors.neutral60), + ), + actions: [ + TextButton( + child: Text( + l10n.cancelTextButton, + style: const TextStyle(color: AppColors.neutral60), + ), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + TextButton( + child: Text( + l10n.unblockUserAction, + style: const TextStyle(color: AppColors.tertiary), + ), + onPressed: () { + context.read().add( + UnblockUser(userId, blockedUser.userId), + ); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/friends_list/friends_list/bloc/friend_list_bloc.dart b/lib/friends_list/friends_list/bloc/friend_list_bloc.dart index 45b891b..c703303 100644 --- a/lib/friends_list/friends_list/bloc/friend_list_bloc.dart +++ b/lib/friends_list/friends_list/bloc/friend_list_bloc.dart @@ -24,6 +24,7 @@ class FriendBloc extends Bloc { FriendBloc({required this.repository}) : super(FriendsLoading()) { on(_onLoadFriends); on(_onRemoveFriend); + on(_onBlockFriend); } final FirebaseDatabaseRepository repository; @@ -58,4 +59,20 @@ class FriendBloc extends Bloc { emit(FriendsError('Failed to remove friend: $e')); } } + + Future _onBlockFriend( + BlockFriend event, + Emitter emit, + ) async { + try { + await repository.blockUser( + currentUserId: event.userId, + target: event.target, + ); + final friends = await repository.getFriends(event.userId); + emit(FriendsLoaded(friends)); + } on Exception catch (e) { + emit(FriendsError('Failed to block friend: $e')); + } + } } diff --git a/lib/friends_list/friends_list/bloc/friend_list_event.dart b/lib/friends_list/friends_list/bloc/friend_list_event.dart index f4036bc..bf3a286 100644 --- a/lib/friends_list/friends_list/bloc/friend_list_event.dart +++ b/lib/friends_list/friends_list/bloc/friend_list_event.dart @@ -6,6 +6,7 @@ part of 'friend_list_bloc.dart'; /// Key events: /// - LoadFriends: Triggered to load the friends list /// - RemoveFriend: Triggered to remove a friend +/// - BlockFriend: Triggered to block a friend (removes them from friends too) sealed class FriendEvent extends Equatable { const FriendEvent(); @@ -30,3 +31,17 @@ class RemoveFriend extends FriendEvent { @override List get props => [userId, friendId]; } + +/// Triggered to block a friend. +/// +/// [userId] is the current user performing the block. +/// [target] is the [BlockedUserModel] built from the [FriendModel] being +/// blocked, denormalizing the fields the blocks collection needs. +class BlockFriend extends FriendEvent { + const BlockFriend(this.userId, this.target); + final String userId; + final BlockedUserModel target; + + @override + List get props => [userId, target]; +} diff --git a/lib/friends_list/friends_list/friends_list.dart b/lib/friends_list/friends_list/friends_list.dart index 5d43c71..7811c9e 100644 --- a/lib/friends_list/friends_list/friends_list.dart +++ b/lib/friends_list/friends_list/friends_list.dart @@ -7,6 +7,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; import 'package:magic_yeti/friends_list/widgets/friend_card.dart'; +import 'package:magic_yeti/l10n/arb/app_localizations.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; + +enum _FriendCardAction { remove, block } class FriendsList extends StatelessWidget { const FriendsList({super.key}); @@ -29,6 +33,7 @@ class FriendsListView extends StatelessWidget { @override Widget build(BuildContext context) { final userId = context.read().state.user.id; + final l10n = context.l10n; return BlocBuilder( builder: (context, state) { if (state is FriendsLoading) { @@ -53,13 +58,29 @@ class FriendsListView extends StatelessWidget { : '?', title: friend.username, subtitle: friend.friendCode, - trailing: IconButton( + trailing: PopupMenuButton<_FriendCardAction>( icon: const Icon( - Icons.delete_outline, + Icons.more_vert, color: AppColors.neutral60, ), - onPressed: () => - _confirmRemoveFriend(context, friend, userId), + onSelected: (action) { + switch (action) { + case _FriendCardAction.remove: + _confirmRemoveFriend(context, friend, userId, l10n); + case _FriendCardAction.block: + _confirmBlockFriend(context, friend, userId, l10n); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: _FriendCardAction.remove, + child: Text(l10n.removeFriendAction), + ), + PopupMenuItem( + value: _FriendCardAction.block, + child: Text(l10n.blockUserAction), + ), + ], ), ); }, @@ -86,43 +107,99 @@ class FriendsListView extends StatelessWidget { BuildContext context, FriendModel friend, String userId, + AppLocalizations l10n, ) { unawaited( showDialog( context: context, builder: (dialogContext) { - return AlertDialog( - backgroundColor: AppColors.surface, - title: const Text( - 'Remove Friend', - style: TextStyle(color: AppColors.white), - ), - content: Text( - 'Are you sure you want to remove ${friend.username}?', - style: const TextStyle(color: AppColors.neutral60), - ), - actions: [ - TextButton( - child: const Text( - 'Cancel', - style: TextStyle(color: AppColors.neutral60), - ), - onPressed: () => Navigator.of(dialogContext).pop(), + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + l10n.removeFriendConfirmTitle(friend.username), + style: const TextStyle(color: AppColors.white), ), - TextButton( - child: const Text( - 'Remove', - style: TextStyle(color: AppColors.red), + content: Text( + l10n.removeFriendConfirmBody, + style: const TextStyle(color: AppColors.neutral60), + ), + actions: [ + TextButton( + child: Text( + l10n.cancelTextButton, + style: const TextStyle(color: AppColors.neutral60), + ), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + TextButton( + child: Text( + l10n.removeFriendAction, + style: const TextStyle(color: AppColors.red), + ), + onPressed: () { + context + .read() + .add(RemoveFriend(userId, friend.userId)); + Navigator.of(dialogContext).pop(); + }, ), - onPressed: () { - context - .read() - .add(RemoveFriend(userId, friend.userId)); - Navigator.of(dialogContext).pop(); - }, + ], + ); + }, + ), + ); + } + + void _confirmBlockFriend( + BuildContext context, + FriendModel friend, + String userId, + AppLocalizations l10n, + ) { + unawaited( + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + l10n.blockUserConfirmTitle(friend.username), + style: const TextStyle(color: AppColors.white), ), - ], - ); + content: Text( + l10n.blockUserConfirmBody, + style: const TextStyle(color: AppColors.neutral60), + ), + actions: [ + TextButton( + child: Text( + l10n.cancelTextButton, + style: const TextStyle(color: AppColors.neutral60), + ), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + TextButton( + child: Text( + l10n.blockUserAction, + style: const TextStyle(color: AppColors.red), + ), + onPressed: () { + context.read().add( + BlockFriend( + userId, + BlockedUserModel( + userId: friend.userId, + username: friend.username, + imageUrl: friend.profilePictureUrl, + friendCode: friend.friendCode, + ), + ), + ); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ); }, ), ); diff --git a/lib/friends_list/friends_list_page.dart b/lib/friends_list/friends_list_page.dart index 8fb6708..739f932 100644 --- a/lib/friends_list/friends_list_page.dart +++ b/lib/friends_list/friends_list_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/blocked_users/view/blocked_users_page.dart'; import 'package:magic_yeti/friends_list/friends_list/friends_list.dart'; import 'package:magic_yeti/friends_list/requests/friend_request_page.dart'; import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; @@ -63,6 +64,13 @@ class _FriendsListPageState extends State { fontWeight: FontWeight.bold, ), ), + actions: [ + IconButton( + icon: const Icon(Icons.block), + tooltip: l10n.blockedUsersTitle, + onPressed: () => context.push(BlockedUsersPage.routePath), + ), + ], bottom: TabBar( tabs: [ Tab(text: l10n.friendsTitle), diff --git a/lib/friends_list/requests/bloc/friend_request_bloc.dart b/lib/friends_list/requests/bloc/friend_request_bloc.dart index df8b61c..496fa87 100644 --- a/lib/friends_list/requests/bloc/friend_request_bloc.dart +++ b/lib/friends_list/requests/bloc/friend_request_bloc.dart @@ -39,6 +39,11 @@ class FriendRequestBloc extends Bloc { AcceptFriendRequest event, Emitter emit, ) async { + // Captured before the attempt so a legacy-accept failure can restore + // the list the page was showing β€” the builder renders the error state + // as an empty "No pending requests" placeholder, so without recovery + // the whole list would appear to vanish over one bad request. + final priorState = state; try { await repository.acceptFriendRequest(event.request, event.userId); // Remove accepted request from in-memory list @@ -49,6 +54,11 @@ class FriendRequestBloc extends Bloc { .toList(); emit(FriendRequestLoaded(updated)); } + } on LegacyFriendRequestException { + emit(const FriendRequestLegacyAcceptError()); + if (priorState is FriendRequestLoaded) { + emit(priorState); + } } catch (e) { emit(const FriendRequestError('Failed to accept friend request')); } diff --git a/lib/friends_list/requests/bloc/friend_request_state.dart b/lib/friends_list/requests/bloc/friend_request_state.dart index 2441c26..ab61671 100644 --- a/lib/friends_list/requests/bloc/friend_request_state.dart +++ b/lib/friends_list/requests/bloc/friend_request_state.dart @@ -6,6 +6,9 @@ part of 'friend_request_bloc.dart'; /// - FriendRequestLoading: Indicates loading state. /// - FriendRequestLoaded: Indicates successful loading of friend requests. /// - FriendRequestError: Indicates an error occurred while loading requests. +/// - FriendRequestLegacyAcceptError: Indicates accepting a request failed +/// because it predates the current permission rules (see +/// [LegacyFriendRequestException]). abstract class FriendRequestState extends Equatable { const FriendRequestState(); @@ -31,3 +34,10 @@ class FriendRequestError extends FriendRequestState { @override List get props => [message]; } + +/// Emitted when accepting a friend request fails because the request +/// predates the current friend/block permission rules. The UI maps this to +/// the `legacyRequestAcceptError` copy asking the sender to re-send it. +class FriendRequestLegacyAcceptError extends FriendRequestState { + const FriendRequestLegacyAcceptError(); +} diff --git a/lib/friends_list/requests/friend_request_page.dart b/lib/friends_list/requests/friend_request_page.dart index c6f8a50..2a0a521 100644 --- a/lib/friends_list/requests/friend_request_page.dart +++ b/lib/friends_list/requests/friend_request_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; import 'package:magic_yeti/friends_list/requests/bloc/friend_request_bloc.dart'; import 'package:magic_yeti/friends_list/widgets/friend_card.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; class FriendRequestsPage extends StatelessWidget { const FriendRequestsPage({super.key}); @@ -33,7 +34,17 @@ class FriendRequestView extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( + return BlocConsumer( + listener: (context, state) { + if (state is FriendRequestLegacyAcceptError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.legacyRequestAcceptError), + backgroundColor: AppColors.neutral60, + ), + ); + } + }, builder: (context, state) { if (state is FriendRequestLoading) { return const Center(child: CircularProgressIndicator()); @@ -56,7 +67,7 @@ class FriendRequestView extends StatelessWidget { ? request.senderName[0] : '?', title: request.senderName, - subtitle: 'Wants to be your friend', + subtitle: request.senderFriendCode, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -95,6 +106,15 @@ class FriendRequestView extends StatelessWidget { style: const TextStyle(color: AppColors.onSurfaceVariant), ), ); + } else if (state is FriendRequestLegacyAcceptError) { + // The snackbar (see listener above) carries the actual copy; + // avoid rendering an empty tab underneath it. + return const Center( + child: Text( + 'No pending requests', + style: TextStyle(color: AppColors.onSurfaceVariant), + ), + ); } return const SizedBox.shrink(); }, diff --git a/lib/friends_list/search_user/bloc/search_bloc.dart b/lib/friends_list/search_user/bloc/search_bloc.dart index dc39d81..c5e0e98 100644 --- a/lib/friends_list/search_user/bloc/search_bloc.dart +++ b/lib/friends_list/search_user/bloc/search_bloc.dart @@ -12,29 +12,56 @@ part 'search_state.dart'; class SearchBloc extends Bloc { SearchBloc({required this.repository}) : super(SearchInitial()) { - on(_onSearchByFriendCode); + on(_onSearchSubmitted); on(_onAddFriendRequest); } final FirebaseDatabaseRepository repository; - Future _onSearchByFriendCode( - SearchByFriendCode event, + /// Friend codes are a plain 8-character A-Z0-9 string (see + /// [FirebaseDatabaseRepository.generateUniqueFriendCode]) β€” plus the + /// legacy `YETI-` + 4-character shape already issued to existing users + /// before that format changed. Anything else typed into the search box + /// is treated as a username query instead. + static final RegExp _friendCodePattern = + RegExp(r'^(YETI-[A-Z0-9]{4}|[A-Z0-9]{8})$'); + + static bool _looksLikeFriendCode(String query) => + _friendCodePattern.hasMatch(query.trim().toUpperCase()); + + Future _onSearchSubmitted( + SearchSubmitted event, Emitter emit, ) async { emit(SearchLoading()); try { - final user = await repository.searchByFriendCode(event.friendCode); - if (user != null) { - final status = await repository.checkRelationshipStatus( - event.currentUserId, - user.id, - ); - emit(SearchLoaded([user], status)); - } else { - emit(const SearchLoaded([], RelationshipStatus.none)); + if (_looksLikeFriendCode(event.query)) { + final result = await repository.searchByFriendCode(event.query); + if (result.found && result.user != null) { + emit( + SearchLoaded([ + UserSearchMatch( + user: result.user!, + relationship: result.relationship ?? RelationshipStatus.none, + ), + ]), + ); + return; + } + // Not found as a code β€” an 8-character string could coincidentally + // also be a real username, so fall through to a name search + // rather than reporting a false "no user found". } + final matches = await repository.searchByUsername(event.query); + emit(SearchLoaded(matches)); + // The repository throws ArgumentError for a callable + // `invalid-argument` response (e.g. an empty/too-short query); + // surface it the same as any other search failure instead of + // crashing the bloc. + // ignore: avoid_catching_errors + } on ArgumentError catch (e) { + emit(SearchError('Failed to search: $e')); } on Exception catch (e) { - emit(SearchError('Failed to search by friend code: $e')); + emit(SearchError('Failed to search: $e')); } } @@ -42,18 +69,41 @@ class SearchBloc extends Bloc { AddFriendRequest event, Emitter emit, ) async { - // Preserve current users from state for re-display - final currentUsers = state is SearchLoaded - ? (state as SearchLoaded).users - : []; + // Preserve the current matches for re-display, updating only the + // entry the request was actually sent to/accepted from β€” a name + // search can have several results on screen at once. + final currentMatches = state is SearchLoaded + ? (state as SearchLoaded).matches + : []; try { + // The UI only ever knows the sender's id β€” the real username/friend + // code to denormalize onto the request comes from the sender's own + // profile, not a display name passed in from the caller. + final senderProfile = await repository.getUserProfileOnce( + event.senderId, + ); final result = await repository.addFriendRequest( event.senderId, - event.senderName, + senderProfile?.username ?? '', + senderProfile?.friendCode, event.receiverId, ); - emit(FriendRequestSent(result, currentUsers)); + final updatedStatus = switch (result) { + FriendRequestResult.sent => RelationshipStatus.pendingSent, + FriendRequestResult.autoAccepted => RelationshipStatus.friends, + FriendRequestResult.alreadyFriends => RelationshipStatus.friends, + FriendRequestResult.alreadyPending => RelationshipStatus.pendingSent, + FriendRequestResult.self => RelationshipStatus.self, + }; + final updatedMatches = [ + for (final match in currentMatches) + if (match.user.id == event.receiverId) + UserSearchMatch(user: match.user, relationship: updatedStatus) + else + match, + ]; + emit(FriendRequestSent(result, updatedMatches)); } on Exception catch (e) { emit(SearchError('Failed to add friend request: $e')); } diff --git a/lib/friends_list/search_user/bloc/search_event.dart b/lib/friends_list/search_user/bloc/search_event.dart index 327be44..ae3ecee 100644 --- a/lib/friends_list/search_user/bloc/search_event.dart +++ b/lib/friends_list/search_user/bloc/search_event.dart @@ -7,25 +7,30 @@ sealed class SearchEvent extends Equatable { List get props => []; } -class SearchByFriendCode extends SearchEvent { - const SearchByFriendCode(this.friendCode, this.currentUserId); - final String friendCode; +/// A search-box submission. The bloc decides whether [query] looks like a +/// friend code or a username and dispatches accordingly. +class SearchSubmitted extends SearchEvent { + const SearchSubmitted(this.query, this.currentUserId); + final String query; final String currentUserId; @override - List get props => [friendCode, currentUserId]; + List get props => [query, currentUserId]; } +/// Sends (or accepts, for a mutual pending) a friend request from +/// [senderId] to [receiverId]. The bloc looks up the sender's own profile +/// to denormalize the real username/friend code onto the request β€” the +/// UI never passes a display name directly, so it can't accidentally send +/// the wrong one (e.g. a stale Firebase Auth display name). class AddFriendRequest extends SearchEvent { const AddFriendRequest( this.senderId, - this.senderName, this.receiverId, ); final String senderId; - final String senderName; final String receiverId; @override - List get props => [senderId, senderName, receiverId]; + List get props => [senderId, receiverId]; } diff --git a/lib/friends_list/search_user/bloc/search_state.dart b/lib/friends_list/search_user/bloc/search_state.dart index 67aa7ee..3f0b2dd 100644 --- a/lib/friends_list/search_user/bloc/search_state.dart +++ b/lib/friends_list/search_user/bloc/search_state.dart @@ -12,21 +12,20 @@ class SearchInitial extends SearchState {} class SearchLoading extends SearchState {} class SearchLoaded extends SearchState { - const SearchLoaded(this.users, this.relationshipStatus); - final List users; - final RelationshipStatus relationshipStatus; + const SearchLoaded(this.matches); + final List matches; @override - List get props => [users, relationshipStatus]; + List get props => [matches]; } class FriendRequestSent extends SearchState { - const FriendRequestSent(this.result, this.users); + const FriendRequestSent(this.result, this.matches); final FriendRequestResult result; - final List users; + final List matches; @override - List get props => [result, users]; + List get props => [result, matches]; } class SearchError extends SearchState { diff --git a/lib/friends_list/search_user/search_user_page.dart b/lib/friends_list/search_user/search_user_page.dart index 40238f2..469f284 100644 --- a/lib/friends_list/search_user/search_user_page.dart +++ b/lib/friends_list/search_user/search_user_page.dart @@ -60,6 +60,23 @@ class SearchUserFormState extends State { @override Widget build(BuildContext context) { + // The search-by-friend-code callable rejects anonymous callers outright; + // show intentional copy instead of a confusing error after they search. + final isAnonymous = + context.watch().state.status == AppStatus.anonymous; + if (isAnonymous) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + context.l10n.signInToSearchFriends, + style: const TextStyle(color: AppColors.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ), + ); + } + final currentUserId = context.read().state.user.id; return Column( children: [ @@ -93,7 +110,7 @@ class SearchUserFormState extends State { onSubmitted: (value) { if (value.isNotEmpty) { context.read().add( - SearchByFriendCode(value, currentUserId), + SearchSubmitted(value, currentUserId), ); } }, @@ -130,25 +147,9 @@ class SearchUserFormState extends State { child: CircularProgressIndicator(), ); } else if (state is FriendRequestSent) { - if (state.users.isEmpty) { - return const SizedBox.shrink(); - } - final status = switch (state.result) { - FriendRequestResult.sent => RelationshipStatus.pendingSent, - FriendRequestResult.autoAccepted => - RelationshipStatus.friends, - FriendRequestResult.alreadyFriends => - RelationshipStatus.friends, - FriendRequestResult.alreadyPending => - RelationshipStatus.pendingSent, - FriendRequestResult.self => RelationshipStatus.self, - }; - return _SearchResultCard( - user: state.users.first, - status: status, - ); + return _SearchResultsList(matches: state.matches); } else if (state is SearchLoaded) { - if (state.users.isEmpty) { + if (state.matches.isEmpty) { return Center( child: Text( context.l10n.noUserFoundMessage, @@ -158,14 +159,13 @@ class SearchUserFormState extends State { ), ); } - return _SearchResultCard( - user: state.users.first, - status: state.relationshipStatus, - ); + return _SearchResultsList(matches: state.matches); } else if (state is SearchError) { + // state.message carries the raw exception detail for + // logs/tests; the UI shows friendly localized copy only. return Center( child: Text( - 'Error: ${state.message}', + context.l10n.searchFailedMessage, style: const TextStyle( color: AppColors.red, ), @@ -195,10 +195,33 @@ class SearchUserFormState extends State { } } +class _SearchResultsList extends StatelessWidget { + const _SearchResultsList({required this.matches}); + + final List matches; + + @override + Widget build(BuildContext context) { + if (matches.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + itemCount: matches.length, + itemBuilder: (context, index) { + final match = matches[index]; + return _SearchResultCard( + key: ValueKey(match.user.id), + user: match.user, + status: match.relationship, + ); + }, + ); + } +} + class _SearchResultCard extends StatelessWidget { const _SearchResultCard({ required this.user, required this.status, + super.key, }); final UserProfileModel user; @@ -269,7 +292,6 @@ class _SearchResultCard extends StatelessWidget { context.read().add( AddFriendRequest( appBloc.state.user.id, - appBloc.state.user.name ?? '', user.id, ), ); @@ -295,7 +317,6 @@ class _SearchResultCard extends StatelessWidget { context.read().add( AddFriendRequest( appBloc.state.user.id, - appBloc.state.user.name ?? '', user.id, ), ); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 462ba47..277db5b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -455,6 +455,18 @@ "type": "text", "placeholders": {} }, + "notPlayingOption": "I'm not playing", + "@notPlayingOption": { + "description": "Account owner dropdown option meaning the current user is not one of the players", + "type": "text", + "placeholders": {} + }, + "linkedAccountBadge": "Linked to a friend's account", + "@linkedAccountBadge": { + "description": "Tooltip for the badge shown on a standings row linked to another account", + "type": "text", + "placeholders": {} + }, "cancel": "Cancel", "@cancel": { "description": "Cancel button text", @@ -693,25 +705,25 @@ "@findFriendsTitle": { "description": "Title for the friend search page" }, - "friendCodeSearchHint": "Enter friend code (e.g. YETI-A3F9)", + "friendCodeSearchHint": "Search by name or friend code (e.g. A3F9K2XQ)", "@friendCodeSearchHint": { - "description": "Hint text for friend code search input" + "description": "Hint text for the friend search input, which accepts either a username or a friend code" }, "friendRequestSentMessage": "Friend request sent!", "@friendRequestSentMessage": { "description": "Message shown after sending a friend request" }, - "noUserFoundMessage": "No user found with that code.", + "noUserFoundMessage": "No user found.", "@noUserFoundMessage": { - "description": "Message shown when friend code search returns no results" + "description": "Message shown when a name or friend code search returns no results" }, - "friendCodeSearchPrompt": "Enter a friend code to find players.", + "friendCodeSearchPrompt": "Search by name or friend code to find players.", "@friendCodeSearchPrompt": { "description": "Prompt shown on the friend search page before searching" }, - "selectFriendLabel": "Select a friend", + "selectFriendLabel": "Select an account", "@selectFriendLabel": { - "description": "Label above friend selection chips on customize player page" + "description": "Hint text shown in the owner/friend link dropdown on the customize player page before anything is selected" }, "linkedToFriend": "Linked to {name}", "@linkedToFriend": { @@ -743,6 +755,36 @@ "@verifyButtonText": { "description": "Button text to verify PIN" }, + "accountOwnerOptionLabel": "Me", + "@accountOwnerOptionLabel": { + "description": "Dropdown entry / fallback label to link a player slot to the signed-in user's own account" + }, + "pinIncorrectError": "Incorrect PIN. {count, plural, =1{1 attempt} other{{count} attempts}} remaining.", + "@pinIncorrectError": { + "description": "Error shown when a friend PIN is incorrect, with attempts remaining", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pinLockedOutError": "Too many attempts. Try again in {minutes} min.", + "@pinLockedOutError": { + "description": "Error shown when the PIN flow is locked out after too many failed attempts", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "pinUnavailableError": "Couldn't verify the PIN. Check your connection and try again.", + "@pinUnavailableError": { + "description": "Error shown when the PIN could not be verified due to offline or server error" + }, + "pinNotSetError": "This friend hasn't set a PIN yet. Ask them to set one in their profile.", + "@pinNotSetError": { + "description": "Error shown when the selected friend has no PIN set" + }, "friendCodeLabel": "Friend Code", "@friendCodeLabel": { "description": "Label for the friend code display on profile page" @@ -755,6 +797,10 @@ "@friendCodeCopiedMessage": { "description": "Snackbar message shown when friend code is copied" }, + "friendCodeHelperText": "Your unique code. Share it so a specific friend can add you exactly β€” even if someone else shares your username.", + "@friendCodeHelperText": { + "description": "Helper text explaining the friend code is the unique identifier, unlike username" + }, "setYourPinTitle": "Set Your PIN", "@setYourPinTitle": { "description": "Title for the PIN setup dialog shown to existing users" @@ -770,6 +816,159 @@ "addFriendButtonText": "Add", "@addFriendButtonText": { "description": "Button text to send a friend request" + }, + "blockUserAction": "Block", + "@blockUserAction": { + "description": "Action label to block a user" + }, + "blockUserConfirmTitle": "Block {name}?", + "@blockUserConfirmTitle": { + "description": "Title for the block-user confirmation dialog", + "placeholders": { + "name": { + "type": "String", + "example": "Alice" + } + } + }, + "blockUserConfirmBody": "They'll be removed from your friends and won't be able to find you or send requests. They won't be notified.", + "@blockUserConfirmBody": { + "description": "Body copy for the block-user confirmation dialog" + }, + "unblockUserAction": "Unblock", + "unblockUserConfirmBody": "Are you sure you want to unblock {name}?", + "@unblockUserConfirmBody": { + "description": "Body of the unblock confirmation dialog", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "@unblockUserAction": { + "description": "Action label to unblock a user" + }, + "blockedUsersTitle": "Blocked Users", + "@blockedUsersTitle": { + "description": "Title for the blocked-users management page" + }, + "blockedUsersEmpty": "You haven't blocked anyone.", + "@blockedUsersEmpty": { + "description": "Empty state message for the blocked-users page" + }, + "legacyRequestAcceptError": "This request was sent from an older version. Ask them to re-send it.", + "@legacyRequestAcceptError": { + "description": "Snackbar error shown when accepting a friend request fails because it predates current permission rules" + }, + "gameSaveFailedError": "Couldn't save the game. Check your connection and try again.", + "@gameSaveFailedError": { + "description": "Snackbar error shown when saving the game-over stats to the database fails" + }, + "changePinTitle": "Change PIN", + "@changePinTitle": { + "description": "Title for the change-PIN section on the profile page" + }, + "changePinDescription": "Your PIN confirms your identity when friends add you to a game.", + "@changePinDescription": { + "description": "Description for the change-PIN section on the profile page" + }, + "newPinLabel": "New PIN", + "@newPinLabel": { + "description": "Label for the new-PIN input field on the profile page" + }, + "pinChangedMessage": "PIN updated!", + "@pinChangedMessage": { + "description": "Snackbar message shown after the PIN is changed successfully" + }, + "profileSavedMessage": "Profile saved", + "@profileSavedMessage": { + "description": "Snackbar message shown after the profile is saved successfully" + }, + "profileSaveFailedMessage": "Couldn't save your profile. Try again.", + "@profileSaveFailedMessage": { + "description": "Snackbar error shown when saving the profile fails" + }, + "usernameLabel": "Username", + "@usernameLabel": { + "description": "Label for the username field on the profile page" + }, + "usernameHelperText": "How friends find and recognize you. Not unique β€” others may share this name.", + "@usernameHelperText": { + "description": "Helper text under the username field explaining its purpose and that it isn't unique" + }, + "usernameRequiredError": "Username is required", + "@usernameRequiredError": { + "description": "Inline error when the username field is empty or whitespace-only" + }, + "usernameTooShortError": "Username must be at least 2 characters", + "@usernameTooShortError": { + "description": "Inline error when the trimmed username is shorter than 2 characters" + }, + "usernameTooLongError": "Username must be 30 characters or fewer", + "@usernameTooLongError": { + "description": "Inline error when the trimmed username is longer than 30 characters" + }, + "usernameInvalidMessage": "Fix your username before saving.", + "@usernameInvalidMessage": { + "description": "Snackbar shown when a profile save is blocked by an invalid username" + }, + "searchFailedMessage": "Search failed. Check your connection and try again.", + "@searchFailedMessage": { + "description": "Friendly error shown when a friend search fails" + }, + "bioLabel": "Bio", + "@bioLabel": { + "description": "Label for the bio field on the profile page" + }, + "emailLabel": "Email", + "@emailLabel": { + "description": "Label for the read-only email field on the profile page" + }, + "notSetLabel": "Not set", + "@notSetLabel": { + "description": "Placeholder shown for an empty profile field when not editing" + }, + "saveProfileButton": "Save Profile", + "@saveProfileButton": { + "description": "Button text to save profile changes when in editing mode" + }, + "editProfileButton": "Edit Profile", + "@editProfileButton": { + "description": "Button text to enter profile editing mode" + }, + "signInToLinkFriends": "Sign in to link friends to players.", + "@signInToLinkFriends": { + "description": "Placeholder shown instead of the friend list on the customize player page when the user is anonymous" + }, + "signInToSearchFriends": "Sign in to add friends.", + "@signInToSearchFriends": { + "description": "Placeholder shown instead of the search field on the find-friends page when the user is anonymous" + }, + "removeFriendAction": "Remove", + "@removeFriendAction": { + "description": "Action label to remove a friend" + }, + "removeFriendConfirmTitle": "Remove {name}?", + "@removeFriendConfirmTitle": { + "description": "Title for the remove-friend confirmation dialog", + "placeholders": { + "name": { + "type": "String", + "example": "Alice" + } + } + }, + "removeFriendConfirmBody": "They won't be notified. You can add each other again anytime.", + "@removeFriendConfirmBody": { + "description": "Body copy for the remove-friend confirmation dialog" + }, + "onboardingSaveFailedMessage": "Failed to save profile. Please try again.", + "@onboardingSaveFailedMessage": { + "description": "Snackbar error shown when submitting the onboarding form fails" + }, + "blockedUsersLoadFailedError": "Couldn't load your blocked list. Check your connection and try again.", + "@blockedUsersLoadFailedError": { + "description": "Error message shown when the blocked-users page fails to load or update" } } diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index c9347d3..eb1e820 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -23,21 +23,63 @@ "pinInputHelper": "Confirma tu identidad cuando amigos te agregan a una partida", "pinInputError": "Debe ser de 4 dΓ­gitos", "findFriendsTitle": "Buscar Amigos", - "friendCodeSearchHint": "Ingresa cΓ³digo de amigo (ej. YETI-A3F9)", + "friendCodeSearchHint": "Busca por nombre o cΓ³digo de amigo (ej. A3F9K2XQ)", "friendRequestSentMessage": "Β‘Solicitud de amistad enviada!", - "noUserFoundMessage": "No se encontrΓ³ usuario con ese cΓ³digo.", - "friendCodeSearchPrompt": "Ingresa un cΓ³digo de amigo para buscar jugadores.", - "selectFriendLabel": "Seleccionar un amigo", + "noUserFoundMessage": "No se encontrΓ³ ningΓΊn usuario.", + "friendCodeSearchPrompt": "Busca por nombre o cΓ³digo de amigo para encontrar jugadores.", + "selectFriendLabel": "Seleccionar una cuenta", "linkedToFriend": "Vinculado a {name}", "clearButtonText": "Limpiar", "verifyFriendTitle": "Verificar {name}", "enterPinPrompt": "Ingresa su PIN de 4 dΓ­gitos para confirmar identidad.", "verifyButtonText": "Verificar", + "accountOwnerOptionLabel": "Yo", + "pinIncorrectError": "PIN incorrecto. {count, plural, =1{Queda 1 intento} other{Quedan {count} intentos}}.", + "pinLockedOutError": "Demasiados intentos. IntΓ©ntalo de nuevo en {minutes} min.", + "pinUnavailableError": "No se pudo verificar el PIN. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.", + "pinNotSetError": "Este amigo aΓΊn no ha configurado un PIN. PΓ­dele que configure uno en su perfil.", "friendCodeLabel": "CΓ³digo de Amigo", "copyFriendCodeTooltip": "Copiar cΓ³digo de amigo", "friendCodeCopiedMessage": "Β‘CΓ³digo de amigo copiado!", + "friendCodeHelperText": "Tu cΓ³digo ΓΊnico. CompΓ‘rtelo para que un amigo especΓ­fico pueda agregarte con exactitud, incluso si alguien mΓ‘s comparte tu nombre de usuario.", "setYourPinTitle": "Configura tu PIN", "setYourPinDescription": "Configura un PIN de 4 dΓ­gitos para que tus amigos puedan verificar tu identidad al agregarte a una partida.", "savePinButtonText": "Guardar PIN", - "addFriendButtonText": "Agregar" -} \ No newline at end of file + "addFriendButtonText": "Agregar", + "notPlayingOption": "No estoy jugando", + "linkedAccountBadge": "Vinculado a la cuenta de un amigo", + "blockUserAction": "Bloquear", + "blockUserConfirmTitle": "ΒΏBloquear a {name}?", + "blockUserConfirmBody": "Se eliminarΓ‘ de tus amigos y no podrΓ‘ encontrarte ni enviarte solicitudes. No se le notificarΓ‘.", + "unblockUserAction": "Desbloquear", + "unblockUserConfirmBody": "ΒΏSeguro que quieres desbloquear a {name}?", + "blockedUsersTitle": "Usuarios Bloqueados", + "blockedUsersEmpty": "No has bloqueado a nadie.", + "legacyRequestAcceptError": "Esta solicitud se enviΓ³ desde una versiΓ³n anterior. PΓ­dele que la vuelva a enviar.", + "gameSaveFailedError": "No se pudo guardar la partida. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.", + "changePinTitle": "Cambiar PIN", + "changePinDescription": "Tu PIN confirma tu identidad cuando tus amigos te agregan a una partida.", + "newPinLabel": "Nuevo PIN", + "pinChangedMessage": "Β‘PIN actualizado!", + "profileSavedMessage": "Perfil guardado", + "profileSaveFailedMessage": "No se pudo guardar tu perfil. IntΓ©ntalo de nuevo.", + "usernameLabel": "Nombre de usuario", + "usernameHelperText": "CΓ³mo te encuentran y reconocen tus amigos. No es ΓΊnico: otros pueden compartir este nombre.", + "usernameRequiredError": "El nombre de usuario es obligatorio", + "usernameTooShortError": "El nombre de usuario debe tener al menos 2 caracteres", + "usernameTooLongError": "El nombre de usuario debe tener 30 caracteres o menos", + "usernameInvalidMessage": "Corrige tu nombre de usuario antes de guardar.", + "searchFailedMessage": "La bΓΊsqueda fallΓ³. Comprueba tu conexiΓ³n e intΓ©ntalo de nuevo.", + "bioLabel": "BiografΓ­a", + "emailLabel": "Correo electrΓ³nico", + "notSetLabel": "Sin configurar", + "saveProfileButton": "Guardar perfil", + "editProfileButton": "Editar perfil", + "signInToLinkFriends": "Inicia sesiΓ³n para vincular amigos a los jugadores.", + "signInToSearchFriends": "Inicia sesiΓ³n para agregar amigos.", + "removeFriendAction": "Eliminar", + "removeFriendConfirmTitle": "ΒΏEliminar a {name}?", + "removeFriendConfirmBody": "No se le notificarΓ‘. Pueden agregarse de nuevo en cualquier momento.", + "onboardingSaveFailedMessage": "No se pudo guardar el perfil. IntΓ©ntalo de nuevo.", + "blockedUsersLoadFailedError": "No se pudo cargar tu lista de bloqueados. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo." +} diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index d781908..d8a9b5f 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -524,6 +524,18 @@ abstract class AppLocalizations { /// **'Please select the account owner from the list to sync the game stats to their account:'** String get accountOwner; + /// Account owner dropdown option meaning the current user is not one of the players + /// + /// In en, this message translates to: + /// **'I\'m not playing'** + String get notPlayingOption; + + /// Tooltip for the badge shown on a standings row linked to another account + /// + /// In en, this message translates to: + /// **'Linked to a friend\'s account'** + String get linkedAccountBadge; + /// Cancel button text /// /// In en, this message translates to: @@ -800,10 +812,10 @@ abstract class AppLocalizations { /// **'Find Friends'** String get findFriendsTitle; - /// Hint text for friend code search input + /// Hint text for the friend search input, which accepts either a username or a friend code /// /// In en, this message translates to: - /// **'Enter friend code (e.g. YETI-A3F9)'** + /// **'Search by name or friend code (e.g. A3F9K2XQ)'** String get friendCodeSearchHint; /// Message shown after sending a friend request @@ -812,22 +824,22 @@ abstract class AppLocalizations { /// **'Friend request sent!'** String get friendRequestSentMessage; - /// Message shown when friend code search returns no results + /// Message shown when a name or friend code search returns no results /// /// In en, this message translates to: - /// **'No user found with that code.'** + /// **'No user found.'** String get noUserFoundMessage; /// Prompt shown on the friend search page before searching /// /// In en, this message translates to: - /// **'Enter a friend code to find players.'** + /// **'Search by name or friend code to find players.'** String get friendCodeSearchPrompt; - /// Label above friend selection chips on customize player page + /// Hint text shown in the owner/friend link dropdown on the customize player page before anything is selected /// /// In en, this message translates to: - /// **'Select a friend'** + /// **'Select an account'** String get selectFriendLabel; /// Text shown when a friend is linked to a player slot @@ -860,6 +872,36 @@ abstract class AppLocalizations { /// **'Verify'** String get verifyButtonText; + /// Dropdown entry / fallback label to link a player slot to the signed-in user's own account + /// + /// In en, this message translates to: + /// **'Me'** + String get accountOwnerOptionLabel; + + /// Error shown when a friend PIN is incorrect, with attempts remaining + /// + /// In en, this message translates to: + /// **'Incorrect PIN. {count, plural, =1{1 attempt} other{{count} attempts}} remaining.'** + String pinIncorrectError(int count); + + /// Error shown when the PIN flow is locked out after too many failed attempts + /// + /// In en, this message translates to: + /// **'Too many attempts. Try again in {minutes} min.'** + String pinLockedOutError(int minutes); + + /// Error shown when the PIN could not be verified due to offline or server error + /// + /// In en, this message translates to: + /// **'Couldn\'t verify the PIN. Check your connection and try again.'** + String get pinUnavailableError; + + /// Error shown when the selected friend has no PIN set + /// + /// In en, this message translates to: + /// **'This friend hasn\'t set a PIN yet. Ask them to set one in their profile.'** + String get pinNotSetError; + /// Label for the friend code display on profile page /// /// In en, this message translates to: @@ -878,6 +920,12 @@ abstract class AppLocalizations { /// **'Friend code copied!'** String get friendCodeCopiedMessage; + /// Helper text explaining the friend code is the unique identifier, unlike username + /// + /// In en, this message translates to: + /// **'Your unique code. Share it so a specific friend can add you exactly β€” even if someone else shares your username.'** + String get friendCodeHelperText; + /// Title for the PIN setup dialog shown to existing users /// /// In en, this message translates to: @@ -901,6 +949,210 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Add'** String get addFriendButtonText; + + /// Action label to block a user + /// + /// In en, this message translates to: + /// **'Block'** + String get blockUserAction; + + /// Title for the block-user confirmation dialog + /// + /// In en, this message translates to: + /// **'Block {name}?'** + String blockUserConfirmTitle(String name); + + /// Body copy for the block-user confirmation dialog + /// + /// In en, this message translates to: + /// **'They\'ll be removed from your friends and won\'t be able to find you or send requests. They won\'t be notified.'** + String get blockUserConfirmBody; + + /// Action label to unblock a user + /// + /// In en, this message translates to: + /// **'Unblock'** + String get unblockUserAction; + + /// Body of the unblock confirmation dialog + /// + /// In en, this message translates to: + /// **'Are you sure you want to unblock {name}?'** + String unblockUserConfirmBody(String name); + + /// Title for the blocked-users management page + /// + /// In en, this message translates to: + /// **'Blocked Users'** + String get blockedUsersTitle; + + /// Empty state message for the blocked-users page + /// + /// In en, this message translates to: + /// **'You haven\'t blocked anyone.'** + String get blockedUsersEmpty; + + /// Snackbar error shown when accepting a friend request fails because it predates current permission rules + /// + /// In en, this message translates to: + /// **'This request was sent from an older version. Ask them to re-send it.'** + String get legacyRequestAcceptError; + + /// Snackbar error shown when saving the game-over stats to the database fails + /// + /// In en, this message translates to: + /// **'Couldn\'t save the game. Check your connection and try again.'** + String get gameSaveFailedError; + + /// Title for the change-PIN section on the profile page + /// + /// In en, this message translates to: + /// **'Change PIN'** + String get changePinTitle; + + /// Description for the change-PIN section on the profile page + /// + /// In en, this message translates to: + /// **'Your PIN confirms your identity when friends add you to a game.'** + String get changePinDescription; + + /// Label for the new-PIN input field on the profile page + /// + /// In en, this message translates to: + /// **'New PIN'** + String get newPinLabel; + + /// Snackbar message shown after the PIN is changed successfully + /// + /// In en, this message translates to: + /// **'PIN updated!'** + String get pinChangedMessage; + + /// Snackbar message shown after the profile is saved successfully + /// + /// In en, this message translates to: + /// **'Profile saved'** + String get profileSavedMessage; + + /// Snackbar error shown when saving the profile fails + /// + /// In en, this message translates to: + /// **'Couldn\'t save your profile. Try again.'** + String get profileSaveFailedMessage; + + /// Label for the username field on the profile page + /// + /// In en, this message translates to: + /// **'Username'** + String get usernameLabel; + + /// Helper text under the username field explaining its purpose and that it isn't unique + /// + /// In en, this message translates to: + /// **'How friends find and recognize you. Not unique β€” others may share this name.'** + String get usernameHelperText; + + /// Inline error when the username field is empty or whitespace-only + /// + /// In en, this message translates to: + /// **'Username is required'** + String get usernameRequiredError; + + /// Inline error when the trimmed username is shorter than 2 characters + /// + /// In en, this message translates to: + /// **'Username must be at least 2 characters'** + String get usernameTooShortError; + + /// Inline error when the trimmed username is longer than 30 characters + /// + /// In en, this message translates to: + /// **'Username must be 30 characters or fewer'** + String get usernameTooLongError; + + /// Snackbar shown when a profile save is blocked by an invalid username + /// + /// In en, this message translates to: + /// **'Fix your username before saving.'** + String get usernameInvalidMessage; + + /// Friendly error shown when a friend search fails + /// + /// In en, this message translates to: + /// **'Search failed. Check your connection and try again.'** + String get searchFailedMessage; + + /// Label for the bio field on the profile page + /// + /// In en, this message translates to: + /// **'Bio'** + String get bioLabel; + + /// Label for the read-only email field on the profile page + /// + /// In en, this message translates to: + /// **'Email'** + String get emailLabel; + + /// Placeholder shown for an empty profile field when not editing + /// + /// In en, this message translates to: + /// **'Not set'** + String get notSetLabel; + + /// Button text to save profile changes when in editing mode + /// + /// In en, this message translates to: + /// **'Save Profile'** + String get saveProfileButton; + + /// Button text to enter profile editing mode + /// + /// In en, this message translates to: + /// **'Edit Profile'** + String get editProfileButton; + + /// Placeholder shown instead of the friend list on the customize player page when the user is anonymous + /// + /// In en, this message translates to: + /// **'Sign in to link friends to players.'** + String get signInToLinkFriends; + + /// Placeholder shown instead of the search field on the find-friends page when the user is anonymous + /// + /// In en, this message translates to: + /// **'Sign in to add friends.'** + String get signInToSearchFriends; + + /// Action label to remove a friend + /// + /// In en, this message translates to: + /// **'Remove'** + String get removeFriendAction; + + /// Title for the remove-friend confirmation dialog + /// + /// In en, this message translates to: + /// **'Remove {name}?'** + String removeFriendConfirmTitle(String name); + + /// Body copy for the remove-friend confirmation dialog + /// + /// In en, this message translates to: + /// **'They won\'t be notified. You can add each other again anytime.'** + String get removeFriendConfirmBody; + + /// Snackbar error shown when submitting the onboarding form fails + /// + /// In en, this message translates to: + /// **'Failed to save profile. Please try again.'** + String get onboardingSaveFailedMessage; + + /// Error message shown when the blocked-users page fails to load or update + /// + /// In en, this message translates to: + /// **'Couldn\'t load your blocked list. Check your connection and try again.'** + String get blockedUsersLoadFailedError; } class _AppLocalizationsDelegate diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 92f6d40..448de0e 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -239,6 +239,12 @@ class AppLocalizationsEn extends AppLocalizations { String get accountOwner => 'Please select the account owner from the list to sync the game stats to their account:'; + @override + String get notPlayingOption => 'I\'m not playing'; + + @override + String get linkedAccountBadge => 'Linked to a friend\'s account'; + @override String get cancel => 'Cancel'; @@ -384,19 +390,21 @@ class AppLocalizationsEn extends AppLocalizations { String get findFriendsTitle => 'Find Friends'; @override - String get friendCodeSearchHint => 'Enter friend code (e.g. YETI-A3F9)'; + String get friendCodeSearchHint => + 'Search by name or friend code (e.g. A3F9K2XQ)'; @override String get friendRequestSentMessage => 'Friend request sent!'; @override - String get noUserFoundMessage => 'No user found with that code.'; + String get noUserFoundMessage => 'No user found.'; @override - String get friendCodeSearchPrompt => 'Enter a friend code to find players.'; + String get friendCodeSearchPrompt => + 'Search by name or friend code to find players.'; @override - String get selectFriendLabel => 'Select a friend'; + String get selectFriendLabel => 'Select an account'; @override String linkedToFriend(String name) { @@ -417,6 +425,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get verifyButtonText => 'Verify'; + @override + String get accountOwnerOptionLabel => 'Me'; + + @override + String pinIncorrectError(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count attempts', + one: '1 attempt', + ); + return 'Incorrect PIN. $_temp0 remaining.'; + } + + @override + String pinLockedOutError(int minutes) { + return 'Too many attempts. Try again in $minutes min.'; + } + + @override + String get pinUnavailableError => + 'Couldn\'t verify the PIN. Check your connection and try again.'; + + @override + String get pinNotSetError => + 'This friend hasn\'t set a PIN yet. Ask them to set one in their profile.'; + @override String get friendCodeLabel => 'Friend Code'; @@ -426,6 +461,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get friendCodeCopiedMessage => 'Friend code copied!'; + @override + String get friendCodeHelperText => + 'Your unique code. Share it so a specific friend can add you exactly β€” even if someone else shares your username.'; + @override String get setYourPinTitle => 'Set Your PIN'; @@ -438,4 +477,122 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addFriendButtonText => 'Add'; + + @override + String get blockUserAction => 'Block'; + + @override + String blockUserConfirmTitle(String name) { + return 'Block $name?'; + } + + @override + String get blockUserConfirmBody => + 'They\'ll be removed from your friends and won\'t be able to find you or send requests. They won\'t be notified.'; + + @override + String get unblockUserAction => 'Unblock'; + + @override + String unblockUserConfirmBody(String name) { + return 'Are you sure you want to unblock $name?'; + } + + @override + String get blockedUsersTitle => 'Blocked Users'; + + @override + String get blockedUsersEmpty => 'You haven\'t blocked anyone.'; + + @override + String get legacyRequestAcceptError => + 'This request was sent from an older version. Ask them to re-send it.'; + + @override + String get gameSaveFailedError => + 'Couldn\'t save the game. Check your connection and try again.'; + + @override + String get changePinTitle => 'Change PIN'; + + @override + String get changePinDescription => + 'Your PIN confirms your identity when friends add you to a game.'; + + @override + String get newPinLabel => 'New PIN'; + + @override + String get pinChangedMessage => 'PIN updated!'; + + @override + String get profileSavedMessage => 'Profile saved'; + + @override + String get profileSaveFailedMessage => + 'Couldn\'t save your profile. Try again.'; + + @override + String get usernameLabel => 'Username'; + + @override + String get usernameHelperText => + 'How friends find and recognize you. Not unique β€” others may share this name.'; + + @override + String get usernameRequiredError => 'Username is required'; + + @override + String get usernameTooShortError => 'Username must be at least 2 characters'; + + @override + String get usernameTooLongError => 'Username must be 30 characters or fewer'; + + @override + String get usernameInvalidMessage => 'Fix your username before saving.'; + + @override + String get searchFailedMessage => + 'Search failed. Check your connection and try again.'; + + @override + String get bioLabel => 'Bio'; + + @override + String get emailLabel => 'Email'; + + @override + String get notSetLabel => 'Not set'; + + @override + String get saveProfileButton => 'Save Profile'; + + @override + String get editProfileButton => 'Edit Profile'; + + @override + String get signInToLinkFriends => 'Sign in to link friends to players.'; + + @override + String get signInToSearchFriends => 'Sign in to add friends.'; + + @override + String get removeFriendAction => 'Remove'; + + @override + String removeFriendConfirmTitle(String name) { + return 'Remove $name?'; + } + + @override + String get removeFriendConfirmBody => + 'They won\'t be notified. You can add each other again anytime.'; + + @override + String get onboardingSaveFailedMessage => + 'Failed to save profile. Please try again.'; + + @override + String get blockedUsersLoadFailedError => + 'Couldn\'t load your blocked list. Check your connection and try again.'; } diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index c4ee53d..4ff8acd 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -241,6 +241,12 @@ class AppLocalizationsEs extends AppLocalizations { String get accountOwner => 'Please select the account owner from the list to sync the game stats to their account:'; + @override + String get notPlayingOption => 'No estoy jugando'; + + @override + String get linkedAccountBadge => 'Vinculado a la cuenta de un amigo'; + @override String get cancel => 'Cancel'; @@ -386,20 +392,21 @@ class AppLocalizationsEs extends AppLocalizations { String get findFriendsTitle => 'Buscar Amigos'; @override - String get friendCodeSearchHint => 'Ingresa cΓ³digo de amigo (ej. YETI-A3F9)'; + String get friendCodeSearchHint => + 'Busca por nombre o cΓ³digo de amigo (ej. A3F9K2XQ)'; @override String get friendRequestSentMessage => 'Β‘Solicitud de amistad enviada!'; @override - String get noUserFoundMessage => 'No se encontrΓ³ usuario con ese cΓ³digo.'; + String get noUserFoundMessage => 'No se encontrΓ³ ningΓΊn usuario.'; @override String get friendCodeSearchPrompt => - 'Ingresa un cΓ³digo de amigo para buscar jugadores.'; + 'Busca por nombre o cΓ³digo de amigo para encontrar jugadores.'; @override - String get selectFriendLabel => 'Seleccionar un amigo'; + String get selectFriendLabel => 'Seleccionar una cuenta'; @override String linkedToFriend(String name) { @@ -421,6 +428,33 @@ class AppLocalizationsEs extends AppLocalizations { @override String get verifyButtonText => 'Verificar'; + @override + String get accountOwnerOptionLabel => 'Yo'; + + @override + String pinIncorrectError(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Quedan $count intentos', + one: 'Queda 1 intento', + ); + return 'PIN incorrecto. $_temp0.'; + } + + @override + String pinLockedOutError(int minutes) { + return 'Demasiados intentos. IntΓ©ntalo de nuevo en $minutes min.'; + } + + @override + String get pinUnavailableError => + 'No se pudo verificar el PIN. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.'; + + @override + String get pinNotSetError => + 'Este amigo aΓΊn no ha configurado un PIN. PΓ­dele que configure uno en su perfil.'; + @override String get friendCodeLabel => 'CΓ³digo de Amigo'; @@ -430,6 +464,10 @@ class AppLocalizationsEs extends AppLocalizations { @override String get friendCodeCopiedMessage => 'Β‘CΓ³digo de amigo copiado!'; + @override + String get friendCodeHelperText => + 'Tu cΓ³digo ΓΊnico. CompΓ‘rtelo para que un amigo especΓ­fico pueda agregarte con exactitud, incluso si alguien mΓ‘s comparte tu nombre de usuario.'; + @override String get setYourPinTitle => 'Configura tu PIN'; @@ -442,4 +480,126 @@ class AppLocalizationsEs extends AppLocalizations { @override String get addFriendButtonText => 'Agregar'; + + @override + String get blockUserAction => 'Bloquear'; + + @override + String blockUserConfirmTitle(String name) { + return 'ΒΏBloquear a $name?'; + } + + @override + String get blockUserConfirmBody => + 'Se eliminarΓ‘ de tus amigos y no podrΓ‘ encontrarte ni enviarte solicitudes. No se le notificarΓ‘.'; + + @override + String get unblockUserAction => 'Desbloquear'; + + @override + String unblockUserConfirmBody(String name) { + return 'ΒΏSeguro que quieres desbloquear a $name?'; + } + + @override + String get blockedUsersTitle => 'Usuarios Bloqueados'; + + @override + String get blockedUsersEmpty => 'No has bloqueado a nadie.'; + + @override + String get legacyRequestAcceptError => + 'Esta solicitud se enviΓ³ desde una versiΓ³n anterior. PΓ­dele que la vuelva a enviar.'; + + @override + String get gameSaveFailedError => + 'No se pudo guardar la partida. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.'; + + @override + String get changePinTitle => 'Cambiar PIN'; + + @override + String get changePinDescription => + 'Tu PIN confirma tu identidad cuando tus amigos te agregan a una partida.'; + + @override + String get newPinLabel => 'Nuevo PIN'; + + @override + String get pinChangedMessage => 'Β‘PIN actualizado!'; + + @override + String get profileSavedMessage => 'Perfil guardado'; + + @override + String get profileSaveFailedMessage => + 'No se pudo guardar tu perfil. IntΓ©ntalo de nuevo.'; + + @override + String get usernameLabel => 'Nombre de usuario'; + + @override + String get usernameHelperText => + 'CΓ³mo te encuentran y reconocen tus amigos. No es ΓΊnico: otros pueden compartir este nombre.'; + + @override + String get usernameRequiredError => 'El nombre de usuario es obligatorio'; + + @override + String get usernameTooShortError => + 'El nombre de usuario debe tener al menos 2 caracteres'; + + @override + String get usernameTooLongError => + 'El nombre de usuario debe tener 30 caracteres o menos'; + + @override + String get usernameInvalidMessage => + 'Corrige tu nombre de usuario antes de guardar.'; + + @override + String get searchFailedMessage => + 'La bΓΊsqueda fallΓ³. Comprueba tu conexiΓ³n e intΓ©ntalo de nuevo.'; + + @override + String get bioLabel => 'BiografΓ­a'; + + @override + String get emailLabel => 'Correo electrΓ³nico'; + + @override + String get notSetLabel => 'Sin configurar'; + + @override + String get saveProfileButton => 'Guardar perfil'; + + @override + String get editProfileButton => 'Editar perfil'; + + @override + String get signInToLinkFriends => + 'Inicia sesiΓ³n para vincular amigos a los jugadores.'; + + @override + String get signInToSearchFriends => 'Inicia sesiΓ³n para agregar amigos.'; + + @override + String get removeFriendAction => 'Eliminar'; + + @override + String removeFriendConfirmTitle(String name) { + return 'ΒΏEliminar a $name?'; + } + + @override + String get removeFriendConfirmBody => + 'No se le notificarΓ‘. Pueden agregarse de nuevo en cualquier momento.'; + + @override + String get onboardingSaveFailedMessage => + 'No se pudo guardar el perfil. IntΓ©ntalo de nuevo.'; + + @override + String get blockedUsersLoadFailedError => + 'No se pudo cargar tu lista de bloqueados. Revisa tu conexiΓ³n e intΓ©ntalo de nuevo.'; } diff --git a/lib/life_counter/bloc/game_over_bloc.dart b/lib/life_counter/bloc/game_over_bloc.dart index 8659d73..fc17abc 100644 --- a/lib/life_counter/bloc/game_over_bloc.dart +++ b/lib/life_counter/bloc/game_over_bloc.dart @@ -9,13 +9,18 @@ part 'game_over_state.dart'; class GameOverBloc extends Bloc { GameOverBloc({ required List players, + required String currentUserId, required FirebaseDatabaseRepository firebaseDatabaseRepository, }) : _firebaseDatabaseRepository = firebaseDatabaseRepository, super( GameOverState( standings: List.from(players) ..sort((a, b) => a.placement.compareTo(b.placement)), - selectedPlayerId: null, + selectedPlayerId: players + .where((p) => p.firebaseId == currentUserId) + .map((p) => p.id) + .cast() + .firstWhere((_) => true, orElse: () => null), firstPlayerId: null, ), ) { @@ -25,6 +30,9 @@ class GameOverBloc extends Bloc { on(_onSendGameStatsToDatabase); } + /// Dropdown sentinel meaning the current user is not one of the players. + static const notPlayingId = 'game_over_not_playing'; + final FirebaseDatabaseRepository _firebaseDatabaseRepository; void _onUpdateStandings( @@ -59,8 +67,20 @@ class GameOverBloc extends Bloc { SendGameOverStatsEvent event, Emitter emit, ) async { - emit(state.copyWith(status: GameOverStatus.loading)); + // A second tap during the save round-trip must not create a second + // game doc. The loading status is emitted synchronously below, before + // this handler's first await, so any later submission started while a + // save is in flight sees it here and bails. (A stream transformer like + // droppable() would also work, but its cancellation semantics hang + // bloc.close() when a save future never completes β€” e.g. in tests.) + if (state.status == GameOverStatus.loading) return; if (event.gameModel == null) return; + emit( + state.copyWith( + status: GameOverStatus.loading, + exitIntent: event.exitIntent, + ), + ); // Create a new game model with updated player placements and ownership final updatedGameModel = event.gameModel!.copyWith( @@ -71,31 +91,39 @@ class GameOverBloc extends Bloc { // Update player with new placement and firebase ID if selected return player.copyWith( placement: Value(index + 1), - firebaseId: () => player.id == state.selectedPlayerId - ? event.userId - : player.firebaseId, + firebaseId: () { + if (player.id == state.selectedPlayerId) { + // Never clobber a slot already linked to another account + // (PIN-linked friend); the UI excludes these, this guards it. + if (player.firebaseId != null && + player.firebaseId != event.userId) { + return player.firebaseId; + } + return event.userId; + } + // The user disowned this slot by selecting a different one (or + // notPlaying); unlink it so it doesn't stay tied to their + // account. Foreign-linked slots are left untouched. + if (player.firebaseId == event.userId) { + return null; + } + return player.firebaseId; + }, ); }).toList(), winnerId: state.standings.first.id, startingPlayerId: state.firstPlayerId, ); - final docId = - await _firebaseDatabaseRepository.saveGameStats(updatedGameModel); - - final savedGame = updatedGameModel.copyWith(id: docId); - - // Collect all player firebase IDs (host + friends) - final playerFirebaseIds = savedGame.players - .map((p) => p.firebaseId) - .whereType() - .toSet() - ..add(event.userId); + try { + await _firebaseDatabaseRepository.saveGameStats(updatedGameModel); + } on Object catch (_) { + emit(state.copyWith(status: GameOverStatus.failure)); + return; + } - await _firebaseDatabaseRepository.syncGameToPlayers( - savedGame, - playerFirebaseIds.toList(), - ); + // Fan-out to players' match histories happens server-side + // (onGameCreated). emit(state.copyWith(status: GameOverStatus.success)); } diff --git a/lib/life_counter/bloc/game_over_event.dart b/lib/life_counter/bloc/game_over_event.dart index df64b1d..6a00c94 100644 --- a/lib/life_counter/bloc/game_over_event.dart +++ b/lib/life_counter/bloc/game_over_event.dart @@ -38,15 +38,20 @@ class UpdateFirstPlayerEvent extends GameOverEvent { List get props => [playerId]; } +/// Where the view should navigate after a successful save. +enum GameOverExitIntent { home, playAgain } + class SendGameOverStatsEvent extends GameOverEvent { const SendGameOverStatsEvent({ required this.gameModel, required this.userId, + this.exitIntent = GameOverExitIntent.home, }); final GameModel? gameModel; final String userId; + final GameOverExitIntent exitIntent; @override - List get props => [gameModel, userId]; + List get props => [gameModel, userId, exitIntent]; } diff --git a/lib/life_counter/bloc/game_over_state.dart b/lib/life_counter/bloc/game_over_state.dart index 1079ad8..af40954 100644 --- a/lib/life_counter/bloc/game_over_state.dart +++ b/lib/life_counter/bloc/game_over_state.dart @@ -9,6 +9,7 @@ class GameOverState extends Equatable { required this.firstPlayerId, this.gameModel, this.status = GameOverStatus.initial, + this.exitIntent = GameOverExitIntent.home, }); final List standings; @@ -17,12 +18,18 @@ class GameOverState extends Equatable { final String? firstPlayerId; final GameOverStatus status; + /// Where the view should navigate after a successful save. Set from the + /// triggering [SendGameOverStatsEvent] so the view's listener knows the + /// destination without needing to inspect the event directly. + final GameOverExitIntent exitIntent; + GameOverState copyWith({ List? standings, GameModel? gameModel, String? selectedPlayerId, String? firstPlayerId, GameOverStatus? status, + GameOverExitIntent? exitIntent, }) { return GameOverState( standings: standings ?? this.standings, @@ -30,9 +37,17 @@ class GameOverState extends Equatable { selectedPlayerId: selectedPlayerId ?? this.selectedPlayerId, firstPlayerId: firstPlayerId ?? this.firstPlayerId, status: status ?? this.status, + exitIntent: exitIntent ?? this.exitIntent, ); } @override - List get props => [standings, selectedPlayerId, firstPlayerId]; + List get props => [ + standings, + selectedPlayerId, + firstPlayerId, + gameModel, + status, + exitIntent, + ]; } diff --git a/lib/life_counter/view/game_over_page.dart b/lib/life_counter/view/game_over_page.dart index 399767b..b41ad77 100644 --- a/lib/life_counter/view/game_over_page.dart +++ b/lib/life_counter/view/game_over_page.dart @@ -57,6 +57,7 @@ class GameOverPage extends StatelessWidget { return BlocProvider( create: (context) => GameOverBloc( players: context.read().getPlayers(), + currentUserId: context.read().state.user.id, firebaseDatabaseRepository: context.read(), ), child: const GameOverView(), @@ -82,41 +83,66 @@ class GameOverView extends StatelessWidget { return Scaffold( backgroundColor: _MC.background, - body: BlocBuilder( - builder: (context, state) { - final players = state.standings; - final winner = players.first; + body: BlocListener( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + switch (state.status) { + case GameOverStatus.success: + switch (state.exitIntent) { + case GameOverExitIntent.home: + context.go(HomePage.routeName); + case GameOverExitIntent.playAgain: + context.read().add(const GameResetEvent()); + context.read() + ..add(const TimerResetEvent()) + ..add(const TimerStartEvent()); + context.go(GamePage.routePath); + } + case GameOverStatus.failure: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.gameSaveFailedError)), + ); + case GameOverStatus.initial: + case GameOverStatus.loading: + break; + } + }, + child: BlocBuilder( + builder: (context, state) { + final players = state.standings; + final winner = players.first; - return Column( - children: [ - _Header(canRestoreGame: canRestoreGame), - _WinnerHero(winner: winner), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 5, - child: _StandingsPanel(state: state), - ), - const SizedBox(width: 12), - Expanded( - flex: 4, - child: _DetailsPanel( - state: state, - players: players, - gameModel: gameModel, + return Column( + children: [ + _Header(canRestoreGame: canRestoreGame), + _WinnerHero(winner: winner), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 5, + child: _StandingsPanel(state: state), ), - ), - ], + const SizedBox(width: 12), + Expanded( + flex: 4, + child: _DetailsPanel( + state: state, + players: players, + gameModel: gameModel, + ), + ), + ], + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } @@ -543,6 +569,19 @@ class _StandingRow extends StatelessWidget { ), ), + if (player.firebaseId != null) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Tooltip( + message: context.l10n.linkedAccountBadge, + child: const Icon( + Icons.link_rounded, + color: _MC.accent, + size: 16, + ), + ), + ), + // Drag handle const Padding( padding: EdgeInsets.symmetric(horizontal: 16), @@ -576,7 +615,9 @@ class _DetailsPanel extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final canSubmit = - state.selectedPlayerId != null && state.firstPlayerId != null; + state.selectedPlayerId != null && + state.firstPlayerId != null && + state.status != GameOverStatus.loading; return Container( padding: const EdgeInsets.all(20), @@ -613,13 +654,15 @@ class _DetailsPanel extends StatelessWidget { // Account owner Row( children: [ - Text( - l10n.accountOwner, - style: const TextStyle( - color: _MC.textSecondary, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, + Flexible( + child: Text( + l10n.accountOwner, + style: const TextStyle( + color: _MC.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ), ), const SizedBox(width: 6), @@ -641,9 +684,11 @@ class _DetailsPanel extends StatelessWidget { ], ), const SizedBox(height: 8), - _PlayerDropdown( + _AccountOwnerDropdown( + key: const ValueKey('game_over_account_owner_dropdown'), value: state.selectedPlayerId, players: players, + currentUserId: context.read().state.user.id, onChanged: (v) => context.read().add(UpdateSelectedPlayerEvent(v)), ), @@ -689,7 +734,6 @@ class _DetailsPanel extends StatelessWidget { userId: userId, ), ); - context.go(HomePage.routeName); }, ), ), @@ -705,13 +749,9 @@ class _DetailsPanel extends StatelessWidget { SendGameOverStatsEvent( gameModel: gameModel, userId: userId, + exitIntent: GameOverExitIntent.playAgain, ), ); - context.read().add(const GameResetEvent()); - context.read() - ..add(const TimerResetEvent()) - ..add(const TimerStartEvent()); - context.go(GamePage.routePath); }, ), ), @@ -797,6 +837,70 @@ class _PlayerDropdown extends StatelessWidget { } } +class _AccountOwnerDropdown extends StatelessWidget { + const _AccountOwnerDropdown({ + required this.value, + required this.players, + required this.currentUserId, + required this.onChanged, + super.key, + }); + + final String? value; + final List players; + final String currentUserId; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selectable = players + .where( + (p) => p.firebaseId == null || p.firebaseId == currentUserId, + ) + .toList(); + return DropdownButtonFormField( + initialValue: value, + dropdownColor: _MC.surfaceRaised, + style: const TextStyle( + color: _MC.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + iconEnabledColor: _MC.textSecondary, + decoration: InputDecoration( + filled: true, + fillColor: _MC.surfaceRaised, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: _MC.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: _MC.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: _MC.accent, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + items: [ + ...selectable.map( + (p) => DropdownMenuItem(value: p.id, child: Text(p.name)), + ), + DropdownMenuItem( + value: GameOverBloc.notPlayingId, + child: Text(context.l10n.notPlayingOption), + ), + ], + onChanged: onChanged, + ); + } +} + class _ActionButton extends StatelessWidget { const _ActionButton({ required this.label, diff --git a/lib/main_development.dart b/lib/main_development.dart index 13a08d8..3c696ec 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -2,6 +2,7 @@ // https://verygood.ventures import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:fake_app_config_repository/fake_app_config_repository.dart'; import 'package:firebase_authentication_client/firebase_authentication_client.dart'; import 'package:firebase_database_repository/firebase_database_repository.dart'; @@ -16,10 +17,17 @@ import 'package:user_repository/user_repository.dart'; void main() { bootstrap( (FirebaseFirestore firebaseFirestore) async { + const useEmulator = bool.fromEnvironment('USE_FIREBASE_EMULATOR'); + if (useEmulator) { + FirebaseFunctions.instance.useFunctionsEmulator('localhost', 5001); + FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080); + } + final authenticationClient = FirebaseAuthenticationClient(); final playerRepository = PlayerRepository(); final firebaseDatabaseRepository = FirebaseDatabaseRepository( firebase: FirebaseFirestore.instance, + functions: FirebaseFunctions.instance, ); final userRepository = UserRepository( diff --git a/lib/main_production.dart b/lib/main_production.dart index 88c63dd..2f835ac 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -2,6 +2,7 @@ // https://verygood.ventures import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:fake_app_config_repository/fake_app_config_repository.dart'; import 'package:firebase_authentication_client/firebase_authentication_client.dart'; import 'package:firebase_database_repository/firebase_database_repository.dart'; @@ -20,6 +21,7 @@ void main() { final firebaseDatabaseRepository = FirebaseDatabaseRepository( firebase: FirebaseFirestore.instance, + functions: FirebaseFunctions.instance, ); final userRepository = UserRepository( diff --git a/lib/main_staging.dart b/lib/main_staging.dart index e8b0e66..836fa2c 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -2,6 +2,7 @@ // https://verygood.ventures import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:fake_app_config_repository/fake_app_config_repository.dart'; import 'package:firebase_authentication_client/firebase_authentication_client.dart'; import 'package:firebase_database_repository/firebase_database_repository.dart'; @@ -20,6 +21,7 @@ void main() { final firebaseDatabaseRepository = FirebaseDatabaseRepository( firebase: FirebaseFirestore.instance, + functions: FirebaseFunctions.instance, ); final userRepository = UserRepository( authenticationClient: authenticationClient, diff --git a/lib/onboarding/bloc/onboarding_bloc.dart b/lib/onboarding/bloc/onboarding_bloc.dart index 3e4d10d..ecfb7bb 100644 --- a/lib/onboarding/bloc/onboarding_bloc.dart +++ b/lib/onboarding/bloc/onboarding_bloc.dart @@ -18,17 +18,14 @@ class OnboardingBloc extends Bloc { existingProfile!.username!.isNotEmpty ? Username.dirty(existingProfile.username!) : const Username.pure(), - firstName: existingProfile?.firstName ?? '', - lastName: existingProfile?.lastName ?? '', bio: existingProfile?.bio ?? '', - existingPinHash: existingProfile?.pin, + hasExistingPin: (existingProfile?.hasPin ?? false) || + (existingProfile?.pin?.isNotEmpty ?? false), existingImageUrl: existingProfile?.imageUrl, ), ) { on(_onUsernameChanged); on(_onPinChanged); - on(_onFirstNameChanged); - on(_onLastNameChanged); on(_onBioChanged); on(_onStepNext); on(_onStepBack); @@ -52,20 +49,6 @@ class OnboardingBloc extends Bloc { emit(state.copyWith(pin: Pin.dirty(event.pin))); } - void _onFirstNameChanged( - OnboardingFirstNameChanged event, - Emitter emit, - ) { - emit(state.copyWith(firstName: event.firstName)); - } - - void _onLastNameChanged( - OnboardingLastNameChanged event, - Emitter emit, - ) { - emit(state.copyWith(lastName: event.lastName)); - } - void _onBioChanged( OnboardingBioChanged event, Emitter emit, @@ -126,26 +109,52 @@ class OnboardingBloc extends Bloc { final friendCode = existingProfile?.friendCode ?? await _firebaseDatabaseRepository.generateUniqueFriendCode(); - // Hash PIN β€” use new PIN if entered, otherwise keep existing - final pinHash = state.pin.value.isNotEmpty - ? FirebaseDatabaseRepository.hashPin(state.pin.value) - : state.existingPinHash ?? ''; + final hasNewPin = state.pin.value.isNotEmpty; + + // Persist a newly entered PIN as a salted hash in the private + // credentials subcollection BEFORE the profile save. Ordering + // invariant β€” do not swap without re-reading this comment: + // * setPin fails: nothing has been written yet, submit emits + // failure, and the user can retry cleanly from an untouched + // state. + // * setPin succeeds but the profile save below fails: setPin's + // own batch already merged `hasPin: true` onto the profile + // doc (see FirebaseDatabaseRepository.setPin), so a retry (or + // a later onboarding re-entry) seeds `hasExistingPin: true` + // from that flag and the user is never re-asked for a PIN; + // the profile save then simply completes onboarding. + // Reversing this order would let a profile-save success mark the + // user onboarded-with-PIN while leaving no credentials doc behind, + // permanently locking them out of validatePin with no re-prompt. + if (hasNewPin) { + await _firebaseDatabaseRepository.setPin( + event.userId, + state.pin.value, + ); + } await _firebaseDatabaseRepository.updateUserProfile( event.userId, UserProfileModel( id: event.userId, email: existingProfile?.email, - username: state.username.value, - firstName: state.firstName, - lastName: state.lastName, + username: state.username.value.trim(), bio: state.bio, imageUrl: imageUrl, friendCode: friendCode, - pin: pinHash, + // When a NEW pin was set, setPin already deleted the legacy + // `pin` field and it must stay deleted (null) here. When + // keeping an existing pin, an unmigrated legacy hash + // (existingProfile?.pin) must survive this full-doc set() β€” + // updateUserProfile has no merge option β€” until login-time + // migration moves it into the private credentials doc. + // Losing this would silently erase the user's only PIN copy. + pin: hasNewPin ? null : existingProfile?.pin, + hasPin: hasNewPin || state.hasExistingPin, onboardingComplete: true, ), ); + emit(state.copyWith(status: FormzSubmissionStatus.success)); } on Exception catch (_) { emit(state.copyWith(status: FormzSubmissionStatus.failure)); diff --git a/lib/onboarding/bloc/onboarding_event.dart b/lib/onboarding/bloc/onboarding_event.dart index e8a875a..4169eb4 100644 --- a/lib/onboarding/bloc/onboarding_event.dart +++ b/lib/onboarding/bloc/onboarding_event.dart @@ -23,22 +23,6 @@ class OnboardingPinChanged extends OnboardingEvent { List get props => [pin]; } -class OnboardingFirstNameChanged extends OnboardingEvent { - const OnboardingFirstNameChanged(this.firstName); - final String firstName; - - @override - List get props => [firstName]; -} - -class OnboardingLastNameChanged extends OnboardingEvent { - const OnboardingLastNameChanged(this.lastName); - final String lastName; - - @override - List get props => [lastName]; -} - class OnboardingBioChanged extends OnboardingEvent { const OnboardingBioChanged(this.bio); final String bio; diff --git a/lib/onboarding/bloc/onboarding_state.dart b/lib/onboarding/bloc/onboarding_state.dart index d565e9f..19310cf 100644 --- a/lib/onboarding/bloc/onboarding_state.dart +++ b/lib/onboarding/bloc/onboarding_state.dart @@ -5,11 +5,9 @@ class OnboardingState extends Equatable { this.currentStep = 0, this.username = const Username.pure(), this.pin = const Pin.pure(), - this.firstName = '', - this.lastName = '', this.bio = '', this.profileImagePath, - this.existingPinHash, + this.hasExistingPin = false, this.existingImageUrl, this.status = FormzSubmissionStatus.initial, }); @@ -17,24 +15,22 @@ class OnboardingState extends Equatable { final int currentStep; final Username username; final Pin pin; - final String firstName; - final String lastName; final String bio; final String? profileImagePath; - final String? existingPinHash; + final bool hasExistingPin; final String? existingImageUrl; final FormzSubmissionStatus status; /// Per-step validation. /// Step 0: username must be valid - /// Step 1: PIN must be valid OR existing PIN hash exists + /// Step 1: PIN must be valid OR an existing PIN is already set /// Steps 2-3: always valid (optional fields) bool get isStepValid { switch (currentStep) { case 0: return username.isValid; case 1: - return pin.isValid || existingPinHash != null; + return pin.isValid || hasExistingPin; case 2: case 3: return true; @@ -47,11 +43,9 @@ class OnboardingState extends Equatable { int? currentStep, Username? username, Pin? pin, - String? firstName, - String? lastName, String? bio, String? Function()? profileImagePath, - String? Function()? existingPinHash, + bool? hasExistingPin, String? Function()? existingImageUrl, FormzSubmissionStatus? status, }) { @@ -59,15 +53,11 @@ class OnboardingState extends Equatable { currentStep: currentStep ?? this.currentStep, username: username ?? this.username, pin: pin ?? this.pin, - firstName: firstName ?? this.firstName, - lastName: lastName ?? this.lastName, bio: bio ?? this.bio, profileImagePath: profileImagePath != null ? profileImagePath() : this.profileImagePath, - existingPinHash: existingPinHash != null - ? existingPinHash() - : this.existingPinHash, + hasExistingPin: hasExistingPin ?? this.hasExistingPin, existingImageUrl: existingImageUrl != null ? existingImageUrl() : this.existingImageUrl, @@ -80,11 +70,9 @@ class OnboardingState extends Equatable { currentStep, username, pin, - firstName, - lastName, bio, profileImagePath, - existingPinHash, + hasExistingPin, existingImageUrl, status, ]; diff --git a/lib/onboarding/view/onboarding_form.dart b/lib/onboarding/view/onboarding_form.dart index 161a892..3c60828 100644 --- a/lib/onboarding/view/onboarding_form.dart +++ b/lib/onboarding/view/onboarding_form.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:form_inputs/form_inputs.dart'; import 'package:image_picker/image_picker.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; import 'package:magic_yeti/onboarding/onboarding.dart'; class OnboardingForm extends StatefulWidget { @@ -53,10 +54,8 @@ class _OnboardingFormState extends State { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - const SnackBar( - content: Text( - 'Failed to save profile. Please try again.', - ), + SnackBar( + content: Text(context.l10n.onboardingSaveFailedMessage), backgroundColor: AppColors.red, ), ); @@ -126,23 +125,17 @@ class _IdentityStep extends StatefulWidget { class _IdentityStepState extends State<_IdentityStep> { late final TextEditingController _usernameController; - late final TextEditingController _firstNameController; - late final TextEditingController _lastNameController; @override void initState() { super.initState(); final state = context.read().state; _usernameController = TextEditingController(text: state.username.value); - _firstNameController = TextEditingController(text: state.firstName); - _lastNameController = TextEditingController(text: state.lastName); } @override void dispose() { _usernameController.dispose(); - _firstNameController.dispose(); - _lastNameController.dispose(); super.dispose(); } @@ -187,60 +180,20 @@ class _IdentityStepState extends State<_IdentityStep> { borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.red), ), - errorText: state.username.displayError != null - ? 'Username is required' - : null, + errorText: switch (state.username.displayError) { + UsernameValidationError.empty => + context.l10n.usernameRequiredError, + UsernameValidationError.tooShort => + context.l10n.usernameTooShortError, + UsernameValidationError.tooLong => + context.l10n.usernameTooLongError, + null => null, + }, errorStyle: const TextStyle(color: AppColors.red), ), ); }, ), - const SizedBox(height: 16), - TextField( - key: const Key('onboarding_firstName_input'), - controller: _firstNameController, - onChanged: (value) => context - .read() - .add(OnboardingFirstNameChanged(value)), - style: const TextStyle(color: AppColors.white), - decoration: InputDecoration( - labelText: 'First Name (Optional)', - labelStyle: const TextStyle(color: AppColors.neutral60), - filled: true, - fillColor: AppColors.surface, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.neutral60), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.tertiary), - ), - ), - ), - const SizedBox(height: 16), - TextField( - key: const Key('onboarding_lastName_input'), - controller: _lastNameController, - onChanged: (value) => context - .read() - .add(OnboardingLastNameChanged(value)), - style: const TextStyle(color: AppColors.white), - decoration: InputDecoration( - labelText: 'Last Name (Optional)', - labelStyle: const TextStyle(color: AppColors.neutral60), - filled: true, - fillColor: AppColors.surface, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.neutral60), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.tertiary), - ), - ), - ), ], ), ); @@ -256,9 +209,9 @@ class _PinStep extends StatelessWidget { return BlocBuilder( buildWhen: (previous, current) => previous.pin != current.pin || - previous.existingPinHash != current.existingPinHash, + previous.hasExistingPin != current.hasExistingPin, builder: (context, state) { - final hasExistingPin = state.existingPinHash != null; + final hasExistingPin = state.hasExistingPin; return _StepLayout( header: 'Set Your PIN', explanation: hasExistingPin diff --git a/lib/player/view/bloc/player_customization_bloc.dart b/lib/player/view/bloc/player_customization_bloc.dart index 30a5e51..36a490a 100644 --- a/lib/player/view/bloc/player_customization_bloc.dart +++ b/lib/player/view/bloc/player_customization_bloc.dart @@ -27,12 +27,13 @@ class PlayerCustomizationBloc on(_onCancelSelectingSecondCard); on(_onSecondCardCleared); on(_onFavoriteToggled); - on(_onUpdateAccountOwnership); on(_onUpdateCommanderFilters); on(_onClearCardList); on(_onSelectFriend); - on(_onClearFriend); + on(_onOwnerSelected); + on(_onLinkCleared); on(_onValidatePin); + on(_onResetPinFlow); } final ScryfallRepository _scryfallRepository; @@ -165,13 +166,6 @@ class PlayerCustomizationBloc ); } - void _onUpdateAccountOwnership( - UpdateAccountOwnership event, - Emitter emit, - ) { - emit(state.copyWith(isAccountOwner: event.isOwner)); - } - void _onClearCardList( ClearCardList event, Emitter emit, @@ -203,37 +197,104 @@ class PlayerCustomizationBloc SelectFriend event, Emitter emit, ) { - emit(state.copyWith(selectedFriend: event.friend, pinError: '')); + emit(state.copyWithFriendSelected(event.friend)); } - void _onClearFriend( - ClearFriend event, + Future _onOwnerSelected( + OwnerSelected event, + Emitter emit, + ) async { + emit(state.copyWithOwnerSelected()); + try { + final profile = + await _firebaseDatabaseRepository.getUserProfileOnce(event.userId); + if (profile?.username != null && profile!.username!.isNotEmpty) { + emit(state.copyWith(ownerUsername: profile.username)); + } + } on Exception catch (_) { + // Leave ownerUsername unset β€” PlayerIdentityPanel falls back to + // whatever name was already persisted for this seat. isAccountOwner + // stays confirmed either way; a failed username fetch shouldn't + // block linking the seat to the owner's account. + } + } + + void _onLinkCleared( + LinkCleared event, Emitter emit, ) { - emit(state.copyWithClearedFriend()); + emit(state.copyWithLinkCleared()); } Future _onValidatePin( ValidatePin event, Emitter emit, ) async { - try { - final isValid = await _firebaseDatabaseRepository.validatePin( - event.friendUserId, - event.pin, - ); - if (isValid) { - emit(state.copyWith(pinValidated: true, pinError: '')); - } else { - emit(state.copyWith(pinValidated: false, pinError: 'Incorrect PIN')); - } - } on Exception catch (_) { - emit( - state.copyWith( - pinValidated: false, - pinError: 'Failed to validate PIN', - ), - ); + emit(state.copyWith(isPinValidating: true)); + final result = await _firebaseDatabaseRepository.validatePin( + targetUserId: event.friendUserId, + pin: event.pin, + ); + switch (result) { + case PinValid(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: true, + pinFlowError: PinFlowError.none, + pinLockedUntil: () => null, + ), + ); + case PinInvalid(:final attemptsRemaining): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.incorrect, + pinAttemptsRemaining: attemptsRemaining, + pinLockedUntil: () => null, + ), + ); + case PinLockedOut(:final lockedUntil): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.lockedOut, + pinLockedUntil: () => lockedUntil, + ), + ); + case PinNotSet(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.notSet, + pinLockedUntil: () => null, + ), + ); + case PinCheckUnavailable(): + emit( + state.copyWith( + isPinValidating: false, + pinValidated: false, + pinFlowError: PinFlowError.unavailable, + pinLockedUntil: () => null, + ), + ); } } + + void _onResetPinFlow( + ResetPinFlow event, + Emitter emit, + ) { + emit( + state.copyWith( + pinFlowError: PinFlowError.none, + pinAttemptsRemaining: 0, + pinLockedUntil: () => null, + ), + ); + } } diff --git a/lib/player/view/bloc/player_customization_event.dart b/lib/player/view/bloc/player_customization_event.dart index ce52778..31c8421 100644 --- a/lib/player/view/bloc/player_customization_event.dart +++ b/lib/player/view/bloc/player_customization_event.dart @@ -70,15 +70,6 @@ final class ClearCardList extends PlayerCustomizationEvent { const ClearCardList(); } -final class UpdateAccountOwnership extends PlayerCustomizationEvent { - const UpdateAccountOwnership({required this.isOwner}); - - final bool isOwner; - - @override - List get props => [isOwner]; -} - final class UpdateCommanderFilters extends PlayerCustomizationEvent { const UpdateCommanderFilters({required this.showOnlyLegendary}); @@ -97,8 +88,17 @@ final class SelectFriend extends PlayerCustomizationEvent { List get props => [friend]; } -final class ClearFriend extends PlayerCustomizationEvent { - const ClearFriend(); +final class OwnerSelected extends PlayerCustomizationEvent { + const OwnerSelected({required this.userId}); + + final String userId; + + @override + List get props => [userId]; +} + +final class LinkCleared extends PlayerCustomizationEvent { + const LinkCleared(); } final class ValidatePin extends PlayerCustomizationEvent { @@ -110,3 +110,12 @@ final class ValidatePin extends PlayerCustomizationEvent { @override List get props => [pin, friendUserId]; } + +/// Clears stale PIN-flow error/lockout state before a dialog opens, so a +/// lockout or error from a previous friend's attempt doesn't leak into the +/// next dialog. Does not touch [PlayerCustomizationState.pinValidated] or +/// [PlayerCustomizationState.selectedFriend] β€” an already-linked friend +/// selection must survive opening and cancelling a second dialog. +final class ResetPinFlow extends PlayerCustomizationEvent { + const ResetPinFlow(); +} diff --git a/lib/player/view/bloc/player_customization_state.dart b/lib/player/view/bloc/player_customization_state.dart index 025aa56..be265ee 100644 --- a/lib/player/view/bloc/player_customization_state.dart +++ b/lib/player/view/bloc/player_customization_state.dart @@ -2,6 +2,25 @@ part of 'player_customization_bloc.dart'; enum PlayerCustomizationStatus { initial, loading, success, failure } +/// Errors surfaced by the friend-PIN validation flow. +enum PinFlowError { + /// No error. + none, + + /// The PIN was wrong. + incorrect, + + /// Too many failed attempts; locked until + /// [PlayerCustomizationState.pinLockedUntil]. + lockedOut, + + /// The check could not run (offline or server error). + unavailable, + + /// The selected friend has not set a PIN yet. + notSet, +} + class PlayerCustomizationState extends Equatable { const PlayerCustomizationState({ this.status = PlayerCustomizationStatus.initial, @@ -20,7 +39,11 @@ class PlayerCustomizationState extends Equatable { this.favoriteIds = const {}, this.selectedFriend, this.pinValidated = false, - this.pinError = '', + this.pinFlowError = PinFlowError.none, + this.pinAttemptsRemaining = 0, + this.pinLockedUntil, + this.isPinValidating = false, + this.ownerUsername, }); final PlayerCustomizationStatus status; @@ -39,7 +62,11 @@ class PlayerCustomizationState extends Equatable { final Set favoriteIds; final FriendModel? selectedFriend; final bool pinValidated; - final String pinError; + final PinFlowError pinFlowError; + final int pinAttemptsRemaining; + final DateTime? pinLockedUntil; + final bool isPinValidating; + final String? ownerUsername; /// Commander-damage clocks this player will be tracked with: the commander, /// plus the partner if present. A background never adds a clock. @@ -74,7 +101,11 @@ class PlayerCustomizationState extends Equatable { favoriteIds, selectedFriend, pinValidated, - pinError, + pinFlowError, + pinAttemptsRemaining, + pinLockedUntil, + isPinValidating, + ownerUsername, ]; PlayerCustomizationState copyWith({ @@ -94,7 +125,11 @@ class PlayerCustomizationState extends Equatable { Set? favoriteIds, FriendModel? selectedFriend, bool? pinValidated, - String? pinError, + PinFlowError? pinFlowError, + int? pinAttemptsRemaining, + DateTime? Function()? pinLockedUntil, + bool? isPinValidating, + String? ownerUsername, }) { return PlayerCustomizationState( status: status ?? this.status, @@ -113,11 +148,64 @@ class PlayerCustomizationState extends Equatable { favoriteIds: favoriteIds ?? this.favoriteIds, selectedFriend: selectedFriend ?? this.selectedFriend, pinValidated: pinValidated ?? this.pinValidated, - pinError: pinError ?? this.pinError, + pinFlowError: pinFlowError ?? this.pinFlowError, + pinAttemptsRemaining: pinAttemptsRemaining ?? this.pinAttemptsRemaining, + pinLockedUntil: + pinLockedUntil != null ? pinLockedUntil() : this.pinLockedUntil, + isPinValidating: isPinValidating ?? this.isPinValidating, + ownerUsername: ownerUsername ?? this.ownerUsername, + ); + } + + /// Clears any link (owner or friend) β€” returns this seat to a fully + /// unlinked, freely-editable state. + PlayerCustomizationState copyWithLinkCleared() { + return PlayerCustomizationState( + status: status, + name: name, + commander: commander, + partner: partner, + background: background, + cardList: cardList, + magicCardList: magicCardList, + showOnlyLegendary: showOnlyLegendary, + availablePairing: availablePairing, + selectingSecondCard: selectingSecondCard, + recents: recents, + favorites: favorites, + favoriteIds: favoriteIds, + ownerUsername: ownerUsername, + ); + } + + /// Confirms the account owner as this seat's linked identity, clearing + /// any friend link β€” a seat is linked to at most one account at a time. + PlayerCustomizationState copyWithOwnerSelected() { + return PlayerCustomizationState( + status: status, + name: name, + commander: commander, + partner: partner, + background: background, + cardList: cardList, + magicCardList: magicCardList, + isAccountOwner: true, + showOnlyLegendary: showOnlyLegendary, + availablePairing: availablePairing, + selectingSecondCard: selectingSecondCard, + recents: recents, + favorites: favorites, + favoriteIds: favoriteIds, + ownerUsername: ownerUsername, ); } - PlayerCustomizationState copyWithClearedFriend() { + /// Confirms [friend] as this seat's linked identity β€” always treated as + /// already PIN-validated, since both call sites (the PIN dialog's success + /// listener, and initState rehydration from an already-persisted + /// firebaseId) represent a link that was already established, never a + /// fresh unverified pick. Clears any owner selection. + PlayerCustomizationState copyWithFriendSelected(FriendModel friend) { return PlayerCustomizationState( status: status, name: name, @@ -126,13 +214,15 @@ class PlayerCustomizationState extends Equatable { background: background, cardList: cardList, magicCardList: magicCardList, - isAccountOwner: isAccountOwner, showOnlyLegendary: showOnlyLegendary, availablePairing: availablePairing, selectingSecondCard: selectingSecondCard, recents: recents, favorites: favorites, favoriteIds: favoriteIds, + ownerUsername: ownerUsername, + selectedFriend: friend, + pinValidated: true, ); } } diff --git a/lib/player/view/customize_player_page.dart b/lib/player/view/customize_player_page.dart index 6399c44..25f3e96 100644 --- a/lib/player/view/customize_player_page.dart +++ b/lib/player/view/customize_player_page.dart @@ -82,10 +82,13 @@ class _CustomizePlayerViewState extends State { } }); - final isOwner = context.read().state.player.firebaseId != null; - context.read().add( - UpdateAccountOwnership(isOwner: isOwner), - ); + final currentUserId = context.read().state.user.id; + final linkedFirebaseId = context.read().state.player.firebaseId; + if (linkedFirebaseId != null && linkedFirebaseId == currentUserId) { + context.read().add( + OwnerSelected(userId: currentUserId), + ); + } } @override @@ -123,71 +126,119 @@ class _CustomizePlayerViewState extends State { widget.playerId, ); - return BlocBuilder( - builder: (context, state) { - final commander = state.commander ?? player.commander; - return Scaffold( - backgroundColor: Colors.transparent, - extendBodyBehindAppBar: true, - appBar: AppBar( + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => current is FriendsLoaded, + listener: (context, friendState) { + final customState = context.read().state; + if (customState.isAccountOwner || + customState.selectedFriend != null) { + return; + } + final linkedFirebaseId = + context.read().state.player.firebaseId; + if (linkedFirebaseId == null) return; + final friends = (friendState as FriendsLoaded).friends; + FriendModel? match; + for (final f in friends) { + if (f.userId == linkedFirebaseId) { + match = f; + break; + } + } + if (match != null) { + context.read().add( + SelectFriend(friend: match), + ); + } + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.isAccountOwner != current.isAccountOwner || + previous.ownerUsername != current.ownerUsername || + previous.selectedFriend != current.selectedFriend, + listener: (context, state) { + if (state.isAccountOwner) { + final owner = state.ownerUsername; + if (owner != null && owner.isNotEmpty) { + _nameController.text = owner; + } + } else if (state.selectedFriend != null) { + _nameController.text = state.selectedFriend!.username; + } else { + _nameController.clear(); + } + }, + ), + ], + child: BlocBuilder( + builder: (context, state) { + final commander = state.commander ?? player.commander; + return Scaffold( backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ), - body: Stack( - fit: StackFit.expand, - children: [ - CommanderHeroBanner( - commander: commander, - partner: state.partner, - background: state.background, - playerColor: player.color, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ), - ColoredBox(color: AppColors.black.withValues(alpha: 0.45)), - SafeArea( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 39, - child: _Panel( - child: SingleChildScrollView( - child: Column( - children: [ - _FriendSection(nameController: _nameController), - PlayerIdentityPanel( - nameController: _nameController, - nameFocusNode: _nameFocusNode, - playerColor: player.color, - onSave: () => _save(context, state), - ), - ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + CommanderHeroBanner( + commander: commander, + partner: state.partner, + background: state.background, + playerColor: player.color, + ), + ColoredBox(color: AppColors.black.withValues(alpha: 0.45)), + SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 39, + child: _Panel( + child: SingleChildScrollView( + child: Column( + children: [ + const _FriendSection(), + PlayerIdentityPanel( + nameController: _nameController, + nameFocusNode: _nameFocusNode, + playerColor: player.color, + onSave: () => _save(context, state), + ), + ], + ), ), ), ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - flex: 61, - child: _Panel( - child: CommanderPickerPanel( - searchController: _searchController, + const SizedBox(width: AppSpacing.md), + Expanded( + flex: 61, + child: _Panel( + child: CommanderPickerPanel( + searchController: _searchController, + ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ); } } @@ -209,94 +260,230 @@ class _Panel extends StatelessWidget { } } -class _FriendSection extends StatelessWidget { - const _FriendSection({required this.nameController}); +class _FriendSection extends StatefulWidget { + const _FriendSection(); + + @override + State<_FriendSection> createState() => _FriendSectionState(); +} + +class _FriendSectionState extends State<_FriendSection> { + int _resetNonce = 0; - final TextEditingController nameController; + // The menu popup's background is dark (AppColors.surface); Material 3's + // default entry text color assumes a light surface and renders + // near-illegibly here without this override. + static const _entryStyle = ButtonStyle( + foregroundColor: WidgetStatePropertyAll(AppColors.white), + ); + + void _forceReset() { + if (mounted) setState(() => _resetNonce++); + } @override Widget build(BuildContext context) { final customState = context.watch().state; final friendState = context.watch().state; - final isLinked = - customState.selectedFriend != null && customState.pinValidated; - - // Hide section if no friends loaded and no friend selected - final hasFriends = - friendState is FriendsLoaded && friendState.friends.isNotEmpty; - if (!hasFriends && !isLinked) { - return const SizedBox.shrink(); + final appState = context.watch().state; + final isAnonymous = appState.status == AppStatus.anonymous; + + // Anonymous users have no friend graph to link against β€” the callable + // backing this list requires an authenticated uid, so show intentional + // copy instead of an empty/loading friend list. + if (isAnonymous) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xlg, + vertical: AppSpacing.sm, + ), + child: Text( + context.l10n.signInToLinkFriends, + style: const TextStyle( + fontSize: 14, + color: AppColors.neutral60, + ), + ), + ); } final friends = friendState is FriendsLoaded ? friendState.friends : []; + final sortedFriends = List.from(friends) + ..sort( + (a, b) => + a.username.toLowerCase().compareTo(b.username.toLowerCase()), + ); + + final currentUserId = appState.user.id; + final confirmedValue = customState.isAccountOwner + ? currentUserId + : (customState.selectedFriend != null && customState.pinValidated + ? customState.selectedFriend!.userId + : null); return Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xlg), + // Matches PlayerIdentityPanel's own padding (AppSpacing.md) below it β€” + // this section previously used AppSpacing.xlg, which didn't line up + // with its sibling at all. + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Section header with optional Clear button Row( children: [ - Text( - context.l10n.selectFriendLabel, - style: const TextStyle( - fontSize: 14, - color: AppColors.neutral60, - ), - ), - const Spacer(), - if (isLinked) - TextButton( - onPressed: () { - context - .read() - .add(const ClearFriend()); - nameController.clear(); - }, - child: Text( - context.l10n.clearButtonText, - style: const TextStyle(color: AppColors.neutral60), + // Matches the 14px player-color circle + AppSpacing.sm gap + // that precedes the name field in PlayerIdentityPanel below, + // so this field's content lines up with that one's instead + // of sitting under the circle. + const SizedBox(width: 14 + AppSpacing.sm), + Expanded( + child: DropdownMenu( + key: ValueKey('$confirmedValue-$_resetNonce'), + initialSelection: confirmedValue, + // Without this, DropdownMenu sizes itself to its + // content width regardless of any parent SizedBox β€” it + // never actually stretched to match the name field + // below it. + expandedInsets: EdgeInsets.zero, + enableFilter: true, + // DropdownMenu.requestFocusOnTap defaults to false on + // mobile platforms (iOS/Android/Fuchsia), which makes + // its internal TextField readOnly β€” type-to-search + // silently does nothing there without this. This + // app's primary targets are iOS and Android (see + // CLAUDE.md), so enableFilter's whole point (Step 7's + // "filters as you type... on a small simulated device + // (e.g. iPhone SE)") requires this explicitly. + requestFocusOnTap: true, + // Formerly a separate label above the field β€” moved + // in as the hint so the field carries its own + // description instead of needing a caption beside it. + hintText: context.l10n.selectFriendLabel, + leadingIcon: const Icon( + Icons.person_search, + color: AppColors.neutral60, + ), + // DropdownMenu has its own dedicated theme slot + // (DropdownMenuThemeData) separate from the app's + // general InputDecorationTheme β€” nothing sets that + // slot anywhere in this app, so without the overrides + // below this field renders in unthemed Material 3 + // defaults instead of matching the name field and + // search bar beside it. + textStyle: const TextStyle( + color: AppColors.white, + fontSize: 16, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surface, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + borderSide: const BorderSide( + color: AppColors.neutral60, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + borderSide: const BorderSide( + color: AppColors.neutral60, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + borderSide: const BorderSide( + color: AppColors.neutral60, + width: 1.5, + ), + ), ), + menuStyle: MenuStyle( + backgroundColor: const WidgetStatePropertyAll( + AppColors.surface, + ), + surfaceTintColor: const WidgetStatePropertyAll( + Colors.transparent, + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.sm), + side: const BorderSide(color: AppColors.neutral60), + ), + ), + ), + dropdownMenuEntries: [ + DropdownMenuEntry( + value: currentUserId, + label: context.l10n.accountOwnerOptionLabel, + style: _entryStyle, + ), + ...sortedFriends.map( + (friend) => DropdownMenuEntry( + value: friend.userId, + label: friend.username, + style: _entryStyle, + ), + ), + ], + // Clearing an existing link now happens via the X + // button in the name field (PlayerIdentityPanel), not + // from this dropdown β€” every remaining entry is a real + // account, so `value` is never null here in practice. + onSelected: (value) { + if (value == null) return; + if (value == currentUserId) { + context.read().add( + OwnerSelected(userId: currentUserId), + ); + } else { + final friend = sortedFriends.firstWhere( + (f) => f.userId == value, + ); + _showPinDialog(context, friend); + } + }, ), + ), ], ), - const SizedBox(height: AppSpacing.xs), - // Friend list - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 160), - child: ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - itemCount: friends.length, - separatorBuilder: (_, _) => - const SizedBox(height: AppSpacing.sm), - itemBuilder: (context, index) { - final friend = friends[index]; - final isSelected = isLinked && - customState.selectedFriend?.userId == friend.userId; - return _FriendTile( - friend: friend, - isSelected: isSelected, - onTap: isSelected - ? null - : () => _showPinDialog(context, friend), - ); - }, - ), - ), const SizedBox(height: AppSpacing.sm), ], ), ); } + String? _pinErrorText(BuildContext context, PlayerCustomizationState state) { + final l10n = context.l10n; + return switch (state.pinFlowError) { + PinFlowError.none => null, + PinFlowError.incorrect => + l10n.pinIncorrectError(state.pinAttemptsRemaining), + PinFlowError.lockedOut => l10n.pinLockedOutError( + state.pinLockedUntil == null + ? 15 + : (state.pinLockedUntil! + .difference(DateTime.now()) + .inSeconds / + 60) + .ceil() + .clamp(1, 15), + ), + PinFlowError.unavailable => l10n.pinUnavailableError, + PinFlowError.notSet => l10n.pinNotSetError, + }; + } + void _showPinDialog(BuildContext context, FriendModel friend) { final pinController = TextEditingController(); final bloc = context.read(); final l10n = context.l10n; + // Clear any stale error/lockout state left over from a previous + // friend's dialog before this one opens. + bloc.add(const ResetPinFlow()); + unawaited( showDialog( context: context, @@ -307,19 +494,21 @@ class _FriendSection extends StatelessWidget { PlayerCustomizationState>( listenWhen: (previous, current) => previous.pinValidated != current.pinValidated || - previous.pinError != current.pinError, + previous.pinFlowError != current.pinFlowError, listener: (listenerContext, state) { if (state.pinValidated) { - // PIN succeeded β€” select friend, populate name, close + // PIN succeeded β€” select friend, close. The page-level + // BlocListener in CustomizePlayerView.build() populates + // the name field once selectedFriend changes. bloc.add(SelectFriend(friend: friend)); - nameController.text = friend.username; Navigator.pop(listenerContext); } - // pinError is shown reactively via the StatefulBuilder below + // pinFlowError is shown reactively via the BlocBuilder below }, child: StatefulBuilder( builder: (dialogContext, setDialogState) { return AlertDialog( + scrollable: true, backgroundColor: AppColors.surface, title: Text( l10n.verifyFriendTitle(friend.username), @@ -337,7 +526,11 @@ class _FriendSection extends StatelessWidget { BlocBuilder( buildWhen: (previous, current) => - previous.pinError != current.pinError, + previous.pinFlowError != current.pinFlowError || + previous.pinAttemptsRemaining != + current.pinAttemptsRemaining || + previous.pinLockedUntil != + current.pinLockedUntil, builder: (context, state) { return TextField( controller: pinController, @@ -377,9 +570,7 @@ class _FriendSection extends StatelessWidget { color: AppColors.red, ), ), - errorText: state.pinError.isNotEmpty - ? state.pinError - : null, + errorText: _pinErrorText(context, state), errorStyle: const TextStyle( color: AppColors.red, ), @@ -391,26 +582,57 @@ class _FriendSection extends StatelessWidget { ], ), actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text( - l10n.cancelTextButton, - style: - const TextStyle(color: AppColors.neutral60), - ), + BlocBuilder( + buildWhen: (previous, current) => + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + return TextButton( + onPressed: state.isPinValidating + ? null + : () => Navigator.pop(dialogContext), + child: Text( + l10n.cancelTextButton, + style: + const TextStyle(color: AppColors.neutral60), + ), + ); + }, ), - FilledButton( - onPressed: pinController.text.length == 4 - ? () { - bloc.add( - ValidatePin( - pin: pinController.text, - friendUserId: friend.userId, - ), - ); - } - : null, - child: Text(l10n.verifyButtonText), + BlocBuilder( + buildWhen: (previous, current) => + previous.pinFlowError != current.pinFlowError || + previous.isPinValidating != current.isPinValidating, + builder: (context, state) { + final isLockedOut = + state.pinFlowError == PinFlowError.lockedOut; + final canSubmit = pinController.text.length == 4 && + !isLockedOut && + !state.isPinValidating; + return FilledButton( + onPressed: canSubmit + ? () { + bloc.add( + ValidatePin( + pin: pinController.text, + friendUserId: friend.userId, + ), + ); + } + : null, + child: state.isPinValidating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.white, + ), + ) + : Text(l10n.verifyButtonText), + ); + }, ), ], ); @@ -419,85 +641,14 @@ class _FriendSection extends StatelessWidget { ), ); }, - ), - ); - } -} - -class _FriendTile extends StatelessWidget { - const _FriendTile({ - required this.friend, - required this.isSelected, - required this.onTap, - }); - - final FriendModel friend; - final bool isSelected; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppSpacing.md), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppColors.surface.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(AppSpacing.md), - border: Border.all( - color: isSelected - ? AppColors.tertiary - : AppColors.neutral60.withValues(alpha: 0.3), - width: isSelected ? 2 : 1, - ), - ), - child: Row( - children: [ - // Profile picture or first-letter fallback - if (friend.profilePictureUrl.isNotEmpty) - CircleAvatar( - radius: 18, - backgroundColor: AppColors.tertiary, - backgroundImage: NetworkImage(friend.profilePictureUrl), - onBackgroundImageError: (_, _) {}, - ) - else - CircleAvatar( - radius: 18, - backgroundColor: AppColors.tertiary, - child: Text( - friend.username.isNotEmpty - ? friend.username[0].toUpperCase() - : '?', - style: const TextStyle( - color: AppColors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - friend.username, - style: const TextStyle( - color: AppColors.white, - fontSize: 16, - ), - ), - ), - if (isSelected) - const Icon( - Icons.check_circle, - color: AppColors.green, - size: 20, - ), - ], - ), - ), + ).then((_) { + final confirmed = + bloc.state.selectedFriend?.userId == friend.userId && + bloc.state.pinValidated; + if (!confirmed) { + _forceReset(); + } + }), ); } } diff --git a/lib/player/view/widgets/player_identity_panel.dart b/lib/player/view/widgets/player_identity_panel.dart index 9cdf614..10d390e 100644 --- a/lib/player/view/widgets/player_identity_panel.dart +++ b/lib/player/view/widgets/player_identity_panel.dart @@ -1,6 +1,7 @@ import 'package:app_ui/app_ui.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:magic_yeti/l10n/l10n.dart'; import 'package:magic_yeti/player/view/bloc/player_customization_bloc.dart'; import 'package:magic_yeti/player/view/widgets/tracking_preview.dart'; @@ -22,8 +23,8 @@ class PlayerIdentityPanel extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - final isLinked = - state.selectedFriend != null && state.pinValidated; + final isLinked = state.isAccountOwner || + (state.selectedFriend != null && state.pinValidated); return Padding( padding: const EdgeInsets.all(AppSpacing.md), child: Column( @@ -55,6 +56,18 @@ class PlayerIdentityPanel extends StatelessWidget { color: isLinked ? AppColors.green : AppColors.neutral60, ), + suffixIcon: isLinked + ? IconButton( + icon: const Icon( + Icons.cancel, + color: AppColors.neutral60, + ), + tooltip: context.l10n.clearButtonText, + onPressed: () => context + .read() + .add(const LinkCleared()), + ) + : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppSpacing.sm), ), @@ -173,16 +186,15 @@ class _FriendLinkRow extends StatelessWidget { const SizedBox(width: AppSpacing.sm), Expanded( child: Text( - 'Linked to ${state.selectedFriend?.username ?? ''}', + context.l10n.linkedToFriend( + state.isAccountOwner + ? (state.ownerUsername ?? + context.l10n.accountOwnerOptionLabel) + : (state.selectedFriend?.username ?? ''), + ), style: const TextStyle(color: AppColors.white, fontSize: 13), ), ), - TextButton( - onPressed: () => context - .read() - .add(const ClearFriend()), - child: const Text('Unlink'), - ), ], ), ); diff --git a/lib/profile/bloc/profile_bloc.dart b/lib/profile/bloc/profile_bloc.dart index 8a6f635..2974890 100644 --- a/lib/profile/bloc/profile_bloc.dart +++ b/lib/profile/bloc/profile_bloc.dart @@ -14,21 +14,44 @@ class ProfileBloc extends Bloc { required User userProfile, }) : _firebaseDatabaseRepository = firebaseDatabaseRepository, _userRepository = userRepository, - _userProfile = userProfile, - super(ProfileState(userProfile: userProfile)) { + super(ProfileState(user: userProfile)) { + on(_onLoadRequested); on(_onEditingToggled); on(_onUsernameChanged); - on(_onFirstNameChanged); - on(_onLastNameChanged); - on(_onEmailChanged); on(_onBioChanged); on(_onSubmitted); + on(_onPinChanged); + on(_onPinSubmitted); on(_onDeleteProfile); } final FirebaseDatabaseRepository _firebaseDatabaseRepository; final UserRepository _userRepository; - final User _userProfile; + + Future _onLoadRequested( + ProfileLoadRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ProfileStatus.loading)); + try { + final profile = + await _firebaseDatabaseRepository.getUserProfileOnce(event.userId); + if (profile == null) { + // A missing profile doc is a failure, not an empty success β€” the + // page would otherwise render a silent blank with no retry. + emit(state.copyWith(status: ProfileStatus.failure)); + return; + } + emit( + state.copyWith( + status: ProfileStatus.loaded, + profile: profile, + ), + ); + } catch (_) { + emit(state.copyWith(status: ProfileStatus.failure)); + } + } void _onEditingToggled( ProfileEditingToggled event, @@ -54,60 +77,78 @@ class ProfileBloc extends Bloc { ); } - void _onFirstNameChanged( - ProfileFirstNameChanged event, + void _onBioChanged( + ProfileBioChanged event, Emitter emit, ) { - emit(state.copyWith(firstName: event.firstName)); + emit(state.copyWith(bio: event.bio)); } - void _onLastNameChanged( - ProfileLastNameChanged event, + Future _onSubmitted( + ProfileSubmitted event, Emitter emit, - ) { - emit(state.copyWith(lastName: event.lastName)); - } + ) async { + final loaded = state.profile; + if (loaded == null) return; - void _onEmailChanged( - ProfileEmailChanged event, - Emitter emit, - ) { - emit(state.copyWith(email: event.email)); + // A present-but-invalid username (e.g. cleared to empty) must never + // reach updateUserProfile: saving `username: ''` would flip + // UserProfileModel.isComplete false, bouncing the user back into + // onboarding on the next auth event. + if (state.username != null && !Formz.validate([state.username!])) { + emit(state.copyWith(status: ProfileStatus.usernameInvalid)); + return; + } + + emit(state.copyWith(status: ProfileStatus.loading)); + + try { + // Build the save model FROM the loaded profile via copyWith so + // fields the profile form never touches (pin/hasPin/friendCode/ + // onboardingComplete/imageUrl) are carried over automatically. + // Constructing a fresh UserProfileModel(...) here instead would + // silently drop those fields on save (the Fix-2-class regression + // this bloc must never reintroduce). + final updatedProfile = loaded.copyWith( + username: state.username?.value.trim() ?? loaded.username, + bio: state.bio ?? loaded.bio, + ); + + await _firebaseDatabaseRepository.updateUserProfile( + state.user.id, + updatedProfile, + ); + emit( + state.copyWith( + status: ProfileStatus.success, + isEditing: false, + profile: updatedProfile, + ), + ); + } catch (_) { + emit(state.copyWith(status: ProfileStatus.failure)); + } } - void _onBioChanged( - ProfileBioChanged event, + void _onPinChanged( + ProfilePinChanged event, Emitter emit, ) { - emit(state.copyWith(bio: event.bio)); + emit(state.copyWith(pin: Pin.dirty(event.pin))); } - Future _onSubmitted( - ProfileSubmitted event, + Future _onPinSubmitted( + ProfilePinSubmitted event, Emitter emit, ) async { - emit(state.copyWith(status: ProfileStatus.loading)); + if (!state.pin.isValid) return; + emit(state.copyWith(status: ProfileStatus.loading)); try { - // final updatedProfile = _userProfile.copyWith( - // username: state.username?.value, - // firstName: state.firstName, - // lastName: state.lastName, - // email: state.email, - // bio: state.bio, - // ); - - // await _firebaseDatabaseRepository.updateUserProfile( - // _userProfile.id, - // updatedProfile, - // ); - // emit( - // state.copyWith( - // status: ProfileStatus.success, - // isEditing: false, - // userProfile: updatedProfile, - // ), - // ); + // Decision #5: changing the PIN from the profile page does not + // require re-entering the old PIN first. + await _firebaseDatabaseRepository.setPin(state.user.id, state.pin.value); + emit(state.copyWith(status: ProfileStatus.pinSaved)); } catch (_) { emit(state.copyWith(status: ProfileStatus.failure)); } @@ -119,6 +160,10 @@ class ProfileBloc extends Bloc { ) async { emit(state.copyWith(status: ProfileStatus.loading)); try { + // Account deletion itself is client-driven here; the Firestore-side + // cleanup of friends/requests/blocks tied to this account now runs + // server-side (Task 1's trigger), so no client-side fan-out cleanup + // is needed before/after this call. await _userRepository.deleteAccount(); emit(state.copyWith(status: ProfileStatus.success)); } catch (_) { diff --git a/lib/profile/bloc/profile_event.dart b/lib/profile/bloc/profile_event.dart index 87c5c42..566f675 100644 --- a/lib/profile/bloc/profile_event.dart +++ b/lib/profile/bloc/profile_event.dart @@ -7,6 +7,16 @@ abstract class ProfileEvent extends Equatable { List get props => []; } +/// Triggers the initial profile load via `getUserProfileOnce`. +class ProfileLoadRequested extends ProfileEvent { + const ProfileLoadRequested(this.userId); + + final String userId; + + @override + List get props => [userId]; +} + class ProfileEditingToggled extends ProfileEvent { const ProfileEditingToggled(); } @@ -20,44 +30,34 @@ class ProfileUsernameChanged extends ProfileEvent { List get props => [username]; } -class ProfileFirstNameChanged extends ProfileEvent { - const ProfileFirstNameChanged(this.firstName); - - final String firstName; - - @override - List get props => [firstName]; -} - -class ProfileLastNameChanged extends ProfileEvent { - const ProfileLastNameChanged(this.lastName); +class ProfileBioChanged extends ProfileEvent { + const ProfileBioChanged(this.bio); - final String lastName; + final String bio; @override - List get props => [lastName]; + List get props => [bio]; } -class ProfileEmailChanged extends ProfileEvent { - const ProfileEmailChanged(this.email); - - final String email; - - @override - List get props => [email]; +class ProfileSubmitted extends ProfileEvent { + const ProfileSubmitted(); } -class ProfileBioChanged extends ProfileEvent { - const ProfileBioChanged(this.bio); +/// New-PIN field changed on the profile page. Reuses the shared [Pin] +/// formz input. +class ProfilePinChanged extends ProfileEvent { + const ProfilePinChanged(this.pin); - final String bio; + final String pin; @override - List get props => [bio]; + List get props => [pin]; } -class ProfileSubmitted extends ProfileEvent { - const ProfileSubmitted(); +/// Submits the entered PIN via `setPin`. Decision #5: no old-PIN prompt +/// is required to change the PIN from the profile page. +class ProfilePinSubmitted extends ProfileEvent { + const ProfilePinSubmitted(); } class ProfileDeleted extends ProfileEvent { diff --git a/lib/profile/bloc/profile_state.dart b/lib/profile/bloc/profile_state.dart index 6944c3c..6a5585d 100644 --- a/lib/profile/bloc/profile_state.dart +++ b/lib/profile/bloc/profile_state.dart @@ -1,64 +1,73 @@ part of 'profile_bloc.dart'; -enum ProfileStatus { initial, loading, success, failure } +enum ProfileStatus { + initial, + loading, + loaded, + success, + failure, + pinSaved, + usernameInvalid, +} class ProfileState extends Equatable { const ProfileState({ - required this.userProfile, + required this.user, this.status = ProfileStatus.initial, + this.profile, this.isEditing = false, this.username, - this.firstName, - this.lastName, - this.email, this.bio, this.isValid = false, + this.pin = const Pin.pure(), }); final ProfileStatus status; + + /// The authenticated user; kept only for id/email display. Profile + /// fields (username/name/bio/pin/friendCode) live on [profile]. + final User user; + + /// The full profile document, loaded via `getUserProfileOnce`. Null + /// until the initial load completes. + final UserProfileModel? profile; final bool isEditing; final Username? username; - final String? firstName; - final String? lastName; - final String? email; final String? bio; final bool isValid; - final User userProfile; + final Pin pin; ProfileState copyWith({ ProfileStatus? status, + User? user, + UserProfileModel? profile, bool? isEditing, Username? username, - String? firstName, - String? lastName, - String? email, String? bio, bool? isValid, - User? userProfile, + Pin? pin, }) { return ProfileState( status: status ?? this.status, + user: user ?? this.user, + profile: profile ?? this.profile, isEditing: isEditing ?? this.isEditing, username: username ?? this.username, - firstName: firstName ?? this.firstName, - lastName: lastName ?? this.lastName, - email: email ?? this.email, bio: bio ?? this.bio, isValid: isValid ?? this.isValid, - userProfile: userProfile ?? this.userProfile, + pin: pin ?? this.pin, ); } @override List get props => [ status, + user, + profile, isEditing, username, - firstName, - lastName, - email, bio, isValid, - userProfile, + pin, ]; } diff --git a/lib/profile/view/profile_page.dart b/lib/profile/view/profile_page.dart index 166a0e3..303606f 100644 --- a/lib/profile/view/profile_page.dart +++ b/lib/profile/view/profile_page.dart @@ -2,6 +2,7 @@ import 'package:firebase_database_repository/firebase_database_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:form_inputs/form_inputs.dart'; import 'package:go_router/go_router.dart'; import 'package:hold_to_confirm_button/hold_to_confirm_button.dart'; import 'package:magic_yeti/app/bloc/app_bloc.dart'; @@ -29,7 +30,7 @@ class ProfilePage extends StatelessWidget { firebaseDatabaseRepository: context.read(), userRepository: context.read(), userProfile: user, - ), + )..add(ProfileLoadRequested(user.id)), child: const ProfileView(), ); } @@ -47,19 +48,36 @@ class ProfileView extends StatelessWidget { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - const SnackBar(content: Text('Profile Updated Successfully')), + SnackBar(content: Text(context.l10n.profileSavedMessage)), + ); + } + if (state.status == ProfileStatus.usernameInvalid) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.usernameInvalidMessage), + backgroundColor: Colors.red, + ), ); } if (state.status == ProfileStatus.failure) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - const SnackBar( - content: Text('Failed to update profile'), + SnackBar( + content: Text(context.l10n.profileSaveFailedMessage), backgroundColor: Colors.red, ), ); } + if (state.status == ProfileStatus.pinSaved) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(context.l10n.pinChangedMessage)), + ); + } }, child: Scaffold( appBar: AppBar( @@ -79,34 +97,43 @@ class ProfileView extends StatelessWidget { width: 500, child: BlocBuilder( buildWhen: (previous, current) => - previous.status != current.status, + previous.status != current.status || + previous.isEditing != current.isEditing, builder: (context, state) { - if (state.status == ProfileStatus.loading) { + if (state.status == ProfileStatus.loading && + state.profile == null) { return const Center(child: CircularProgressIndicator()); } + + final profile = state.profile; + if (profile == null) { + // Load hasn't produced a profile yet (e.g. failure + // before any successful load) β€” nothing to render. + return const SizedBox.shrink(); + } + return Form( child: Column( children: [ - if (state.userProfile.photo != null && - state.userProfile.photo!.isNotEmpty) + if (profile.imageUrl != null && + profile.imageUrl!.isNotEmpty) Center( child: CircleAvatar( radius: 75, backgroundImage: - NetworkImage(state.userProfile.photo!), + NetworkImage(profile.imageUrl!), ), ), - if (state.userProfile.photo == null || - state.userProfile.photo!.isEmpty) + if (profile.imageUrl == null || + profile.imageUrl!.isEmpty) Center( child: CircleAvatar( radius: 75, backgroundColor: Theme.of(context).primaryColor, child: Text( - (state.userProfile.name ?? '').isNotEmpty - ? state.userProfile.name![0] - .toUpperCase() + (profile.username ?? '').isNotEmpty + ? profile.username![0].toUpperCase() : '', style: const TextStyle( fontSize: 40, @@ -117,43 +144,47 @@ class ProfileView extends StatelessWidget { ), const SizedBox(height: 50), _ProfileField( - label: 'Username', - initialValue: state.userProfile.email ?? '', + label: context.l10n.usernameLabel, + initialValue: profile.username ?? '', + helperText: context.l10n.usernameHelperText, + errorTextBuilder: (context, state) => + switch (state.username?.displayError) { + UsernameValidationError.empty => + context.l10n.usernameRequiredError, + UsernameValidationError.tooShort => + context.l10n.usernameTooShortError, + UsernameValidationError.tooLong => + context.l10n.usernameTooLongError, + null => null, + }, onChanged: (value) => context .read() .add(ProfileUsernameChanged(value)), ), _ProfileField( - label: 'Name', - initialValue: state.userProfile.name ?? '', + label: context.l10n.bioLabel, + initialValue: profile.bio ?? '', onChanged: (value) => context .read() - .add(ProfileFirstNameChanged(value)), + .add(ProfileBioChanged(value)), ), - // _ProfileField( - // label: 'Last Name', - // initialValue: state.userProfile.name ?? '', - // onChanged: (value) => context - // .read() - // .add(ProfileLastNameChanged(value)), - // ), - _ProfileField( - label: 'Email', - initialValue: state.userProfile.email ?? '', - onChanged: (value) => context - .read() - .add(ProfileEmailChanged(value)), + // Email is auth-managed and read-only here β€” + // there is no ProfileEmailChanged event/pathway + // anymore; edit via the auth provider instead. + _ReadOnlyProfileField( + label: context.l10n.emailLabel, + value: profile.email ?? '', ), const SizedBox(height: 20), - _FriendCodeSection( - userId: state.userProfile.id, - ), + _FriendCodeSection(friendCode: profile.friendCode), + const SizedBox(height: 20), + const _ChangePinSection(), const SizedBox(height: 20), + _EditProfileButton(), + const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // _EditProfileButton(), - //const SizedBox(width: 16), _SignOutButton(), const SizedBox(width: 16), _DeleteProfileButton(), @@ -194,7 +225,11 @@ class _EditProfileButton extends StatelessWidget { .add(const ProfileEditingToggled()); } }, - child: Text(state.isEditing ? 'Save Profile' : 'Edit Profile'), + child: Text( + state.isEditing + ? context.l10n.saveProfileButton + : context.l10n.editProfileButton, + ), ); }, ); @@ -240,6 +275,10 @@ class _DeleteProfileButton extends StatelessWidget { HoldToConfirmButton( child: const Text('Delete Profile'), onProgressCompleted: () { + // Server-side cleanup (friends/requests/blocks tied + // to this account) now runs via a Firestore trigger + // on account deletion (see Task 1), so there is no + // client-side fan-out cleanup to trigger here. context .read() .add(const AppUserAccountDeleted()); @@ -276,64 +315,142 @@ class _SignOutButton extends StatelessWidget { } class _FriendCodeSection extends StatelessWidget { - const _FriendCodeSection({required this.userId}); + const _FriendCodeSection({required this.friendCode}); - final String userId; + final String? friendCode; @override Widget build(BuildContext context) { - final db = context.read(); - return StreamBuilder( - stream: db.getUserProfile(userId), - builder: (context, snapshot) { - final friendCode = snapshot.data?.friendCode; - if (friendCode == null) return const SizedBox.shrink(); + final code = friendCode; + if (code == null) return const SizedBox.shrink(); - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.badge_outlined), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.friendCodeLabel, - style: Theme.of(context).textTheme.labelMedium, + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.badge_outlined), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.friendCodeLabel, + style: Theme.of(context).textTheme.labelMedium, + ), + Text( + code, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 4), + Text( + context.l10n.friendCodeHelperText, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + // share_plus is not a dependency of the root app (checked + // pubspec.yaml/pubspec.lock) β€” keeping copy-only rather than + // adding a new dependency for this. If share_plus is added + // later, wire a share icon here via Share.share(code). + IconButton( + icon: const Icon(Icons.copy), + tooltip: context.l10n.copyFriendCodeTooltip, + onPressed: () { + Clipboard.setData(ClipboardData(text: code)); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(context.l10n.friendCodeCopiedMessage), ), - Text( - friendCode, - style: - Theme.of(context).textTheme.headlineSmall, + ); + }, + ), + ], + ), + ), + ); + } +} + +class _ChangePinSection extends StatefulWidget { + const _ChangePinSection(); + + @override + State<_ChangePinSection> createState() => _ChangePinSectionState(); +} + +class _ChangePinSectionState extends State<_ChangePinSection> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.changePinTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + context.l10n.changePinDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + key: const Key('profile_pin_field'), + controller: _controller, + keyboardType: TextInputType.number, + maxLength: 4, + obscureText: true, + decoration: InputDecoration( + labelText: context.l10n.newPinLabel, + counterText: '', ), - ], + onChanged: (value) => context + .read() + .add(ProfilePinChanged(value)), + ), ), - const Spacer(), - IconButton( - icon: const Icon(Icons.copy), - tooltip: context.l10n.copyFriendCodeTooltip, - onPressed: () { - Clipboard.setData( - ClipboardData(text: friendCode), + const SizedBox(width: 16), + BlocBuilder( + buildWhen: (previous, current) => + previous.pin != current.pin || + previous.status != current.status, + builder: (context, state) { + return ElevatedButton( + key: const Key('profile_pin_submit_button'), + onPressed: state.pin.isValid && + state.status != ProfileStatus.loading + ? () => context + .read() + .add(const ProfilePinSubmitted()) + : null, + child: Text(context.l10n.saveButtonText), ); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - context.l10n.friendCodeCopiedMessage, - ), - ), - ); }, ), ], ), - ), - ); - }, + ], + ), + ), ); } } @@ -343,40 +460,71 @@ class _ProfileField extends StatelessWidget { required this.label, required this.initialValue, required this.onChanged, + this.helperText, + this.errorTextBuilder, }); final String label; final String initialValue; final void Function(String) onChanged; + final String? helperText; + + /// Builds live inline error copy from bloc state; null for fields + /// without validation. The parent builder only rebuilds on + /// status/isEditing changes, so the error must flow through this + /// widget's own BlocBuilder. + final String? Function(BuildContext context, ProfileState state)? + errorTextBuilder; @override Widget build(BuildContext context) { return BlocBuilder( - buildWhen: (previous, current) => previous.isEditing != current.isEditing, + buildWhen: (previous, current) => + previous.isEditing != current.isEditing || + previous.username != current.username, builder: (context, state) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 100, - child: Text( - label, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - const SizedBox(width: 16), - Expanded( - child: state.isEditing - ? TextFormField( - initialValue: initialValue, - decoration: InputDecoration( - hintText: 'Enter $label', - ), - onChanged: onChanged, - ) - : Text(initialValue.isEmpty ? 'Not set' : initialValue), + Row( + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(width: 16), + Expanded( + child: state.isEditing + ? TextFormField( + initialValue: initialValue, + decoration: InputDecoration( + hintText: 'Enter $label', + errorText: + errorTextBuilder?.call(context, state), + ), + onChanged: onChanged, + ) + : Text( + initialValue.isEmpty + ? context.l10n.notSetLabel + : initialValue, + ), + ), + ], ), + if (helperText != null) + Padding( + padding: const EdgeInsets.only(left: 116, top: 2), + child: Text( + helperText!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), ], ), ); @@ -384,3 +532,35 @@ class _ProfileField extends StatelessWidget { ); } } + +class _ReadOnlyProfileField extends StatelessWidget { + const _ReadOnlyProfileField({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text(value.isEmpty ? context.l10n.notSetLabel : value), + ), + ], + ), + ); + } +} diff --git a/packages/authentication_client/authentication_client/lib/src/models/user.dart b/packages/authentication_client/authentication_client/lib/src/models/user.dart index a0343dc..f490706 100644 --- a/packages/authentication_client/authentication_client/lib/src/models/user.dart +++ b/packages/authentication_client/authentication_client/lib/src/models/user.dart @@ -13,7 +13,6 @@ class User extends Equatable { const User({ required this.id, this.email, - this.name, this.photo, this.isNewUser = false, this.isAnonymous = false, @@ -25,9 +24,6 @@ class User extends Equatable { /// The current user's id. final String id; - /// The current user's name (display name). - final String? name; - /// Url for the current user's photo. final String? photo; @@ -41,7 +37,6 @@ class User extends Equatable { User copyWith({ String? email, String? id, - String? name, String? photo, bool? isNewUser, bool? isAnonymous, @@ -49,7 +44,6 @@ class User extends Equatable { User( email: email ?? this.email, id: id ?? this.id, - name: name ?? this.name, photo: photo ?? this.photo, isNewUser: isNewUser ?? this.isNewUser, isAnonymous: isAnonymous ?? this.isAnonymous, @@ -59,5 +53,5 @@ class User extends Equatable { static const unauthenticated = User(id: ''); @override - List get props => [email, id, name, photo, isNewUser, isAnonymous]; + List get props => [email, id, photo, isNewUser, isAnonymous]; } diff --git a/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart b/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart index 8abe5d9..b36655e 100644 --- a/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart +++ b/packages/authentication_client/firebase_authentication_client/lib/src/firebase_authentication_client.dart @@ -141,7 +141,6 @@ class FirebaseAuthenticationClient implements AuthenticationClient { final appleIdCredential = await _getAppleCredentials( scopes: [ AppleIDAuthorizationScopes.email, - AppleIDAuthorizationScopes.fullName, ], ); @@ -334,7 +333,6 @@ extension on firebase_auth.User { return User( id: uid, email: email, - name: displayName, photo: photoURL, isAnonymous: email == null || email == '', ); diff --git a/packages/authentication_client/firebase_authentication_client/test/src/firebase_authentication_client_test.dart b/packages/authentication_client/firebase_authentication_client/test/src/firebase_authentication_client_test.dart index a450af9..6809e20 100644 --- a/packages/authentication_client/firebase_authentication_client/test/src/firebase_authentication_client_test.dart +++ b/packages/authentication_client/firebase_authentication_client/test/src/firebase_authentication_client_test.dart @@ -118,7 +118,6 @@ void main() { expect(getAppleCredentialsCalls, [ [ AppleIDAuthorizationScopes.email, - AppleIDAuthorizationScopes.fullName, ] ]); }); diff --git a/packages/firebase_database_repository/lib/models/blocked_user_model.dart b/packages/firebase_database_repository/lib/models/blocked_user_model.dart new file mode 100644 index 0000000..bbef9f5 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/blocked_user_model.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/helpers/timestamp_converter.dart'; +import 'package:json_annotation/json_annotation.dart'; +// ignore: directives_ordering +import 'package:cloud_firestore/cloud_firestore.dart'; + +part 'blocked_user_model.g.dart'; + +/// {@template blocked_user_model} +/// This model represents a user that the current user has blocked, +/// denormalizing enough profile data to render a "Blocked users" list +/// without extra reads. +/// {@endtemplate} +@JsonSerializable(explicitToJson: true, includeIfNull: false) +@TimestampConverter() +class BlockedUserModel extends Equatable { + /// {@macro blocked_user_model} + const BlockedUserModel({ + required this.userId, + required this.username, + required this.imageUrl, + this.friendCode, + this.blockedAt, + }); + + /// Converts a Firestore document snapshot to a BlockedUserModel. + factory BlockedUserModel.fromJson(Map json) => + _$BlockedUserModelFromJson(json); + + /// Converts the BlockedUserModel to a Map for Firestore storage. + Map toJson() => _$BlockedUserModelToJson(this); + + /// The unique identifier of the blocked user. + final String userId; + + /// The blocked user's username, denormalized at block time. + final String username; + + /// The blocked user's profile image URL, denormalized at block time. + final String imageUrl; + + /// The blocked user's unique friend code, denormalized at block time β€” + /// since [username] has no uniqueness guarantee, this is the only + /// reliable way to tell two same-named blocked users apart. Null for + /// blocks created before this field existed. + final String? friendCode; + + /// When the block was created. Null until the server timestamp resolves. + @TimestampConverter() + final DateTime? blockedAt; + + @override + List get props => + [userId, username, imageUrl, friendCode, blockedAt]; +} diff --git a/packages/firebase_database_repository/lib/models/blocked_user_model.g.dart b/packages/firebase_database_repository/lib/models/blocked_user_model.g.dart new file mode 100644 index 0000000..f30a660 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/blocked_user_model.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'blocked_user_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +BlockedUserModel _$BlockedUserModelFromJson(Map json) => + BlockedUserModel( + userId: json['userId'] as String, + username: json['username'] as String, + imageUrl: json['imageUrl'] as String, + friendCode: json['friendCode'] as String?, + blockedAt: _$JsonConverterFromJson( + json['blockedAt'], + const TimestampConverter().fromJson, + ), + ); + +Map _$BlockedUserModelToJson(BlockedUserModel instance) => + { + 'userId': instance.userId, + 'username': instance.username, + 'imageUrl': instance.imageUrl, + 'friendCode': ?instance.friendCode, + 'blockedAt': ?_$JsonConverterToJson( + instance.blockedAt, + const TimestampConverter().toJson, + ), + }; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); + +Json? _$JsonConverterToJson( + Value? value, + Json? Function(Value value) toJson, +) => value == null ? null : toJson(value); diff --git a/packages/firebase_database_repository/lib/models/friend_model.dart b/packages/firebase_database_repository/lib/models/friend_model.dart index c42d80a..a616d08 100644 --- a/packages/firebase_database_repository/lib/models/friend_model.dart +++ b/packages/firebase_database_repository/lib/models/friend_model.dart @@ -17,7 +17,7 @@ part 'friend_model.g.dart'; /// @notes /// - Ensure that all fields are properly validated before using this model /// {@endtemplate} -@JsonSerializable(explicitToJson: true) +@JsonSerializable(explicitToJson: true, includeIfNull: false) class FriendModel extends Equatable { /// Constructor for FriendModel. /// @@ -47,7 +47,7 @@ class FriendModel extends Equatable { /// The URL of the friend's profile picture. final String profilePictureUrl; - /// The friend's unique friend code (e.g. "YETI-A3F9") + /// The friend's unique friend code (e.g. "A3F9K2XQ") final String? friendCode; @override diff --git a/packages/firebase_database_repository/lib/models/friend_model.g.dart b/packages/firebase_database_repository/lib/models/friend_model.g.dart index b58b917..d5eae09 100644 --- a/packages/firebase_database_repository/lib/models/friend_model.g.dart +++ b/packages/firebase_database_repository/lib/models/friend_model.g.dart @@ -7,16 +7,16 @@ part of 'friend_model.dart'; // ************************************************************************** FriendModel _$FriendModelFromJson(Map json) => FriendModel( - userId: json['userId'] as String, - username: json['username'] as String, - profilePictureUrl: json['profilePictureUrl'] as String, - friendCode: json['friendCode'] as String?, - ); + userId: json['userId'] as String, + username: json['username'] as String, + profilePictureUrl: json['profilePictureUrl'] as String, + friendCode: json['friendCode'] as String?, +); Map _$FriendModelToJson(FriendModel instance) => { 'userId': instance.userId, 'username': instance.username, 'profilePictureUrl': instance.profilePictureUrl, - 'friendCode': instance.friendCode, + 'friendCode': ?instance.friendCode, }; diff --git a/packages/firebase_database_repository/lib/models/friend_request_model.dart b/packages/firebase_database_repository/lib/models/friend_request_model.dart index 2aa6d91..30ecd76 100644 --- a/packages/firebase_database_repository/lib/models/friend_request_model.dart +++ b/packages/firebase_database_repository/lib/models/friend_request_model.dart @@ -20,7 +20,7 @@ part 'friend_request_model.g.dart'; /// @notes /// - Ensure that all fields are properly validated before using this model /// {@endtemplate} -@JsonSerializable(explicitToJson: true) +@JsonSerializable(explicitToJson: true, includeIfNull: false) @TimestampConverter() class FriendRequestModel extends Equatable { /// Constructor for FriendRequestModel. @@ -37,6 +37,7 @@ class FriendRequestModel extends Equatable { required this.senderName, required this.status, required this.timestamp, + this.senderFriendCode, }); /// Converts a Firestore document snapshot to a FriendRequestModel. @@ -58,6 +59,12 @@ class FriendRequestModel extends Equatable { /// The name of the user sending the request. final String senderName; + /// The sender's unique friend code, denormalized at request-creation + /// time β€” since [senderName] has no uniqueness guarantee, this is the + /// only reliable way to tell two same-named senders apart. Null for + /// requests created before this field existed. + final String? senderFriendCode; + /// The current status of the friend request (e.g., 'pending', 'accepted'). final String status; @@ -72,6 +79,7 @@ class FriendRequestModel extends Equatable { String? senderId, String? receiverId, String? senderName, + String? senderFriendCode, String? status, DateTime? timestamp, }) { @@ -80,6 +88,7 @@ class FriendRequestModel extends Equatable { senderId: senderId ?? this.senderId, receiverId: receiverId ?? this.receiverId, senderName: senderName ?? this.senderName, + senderFriendCode: senderFriendCode ?? this.senderFriendCode, status: status ?? this.status, timestamp: timestamp ?? this.timestamp, ); @@ -91,6 +100,7 @@ class FriendRequestModel extends Equatable { senderId, receiverId, senderName, + senderFriendCode, status, timestamp, ]; diff --git a/packages/firebase_database_repository/lib/models/friend_request_model.g.dart b/packages/firebase_database_repository/lib/models/friend_request_model.g.dart index c6873ff..8a4c72a 100644 --- a/packages/firebase_database_repository/lib/models/friend_request_model.g.dart +++ b/packages/firebase_database_repository/lib/models/friend_request_model.g.dart @@ -13,8 +13,10 @@ FriendRequestModel _$FriendRequestModelFromJson(Map json) => receiverId: json['receiverId'] as String, senderName: json['senderName'] as String, status: json['status'] as String, - timestamp: - const TimestampConverter().fromJson(json['timestamp'] as Timestamp), + timestamp: const TimestampConverter().fromJson( + json['timestamp'] as Timestamp, + ), + senderFriendCode: json['senderFriendCode'] as String?, ); Map _$FriendRequestModelToJson(FriendRequestModel instance) => @@ -23,6 +25,7 @@ Map _$FriendRequestModelToJson(FriendRequestModel instance) => 'senderId': instance.senderId, 'receiverId': instance.receiverId, 'senderName': instance.senderName, + 'senderFriendCode': ?instance.senderFriendCode, 'status': instance.status, 'timestamp': const TimestampConverter().toJson(instance.timestamp), }; diff --git a/packages/firebase_database_repository/lib/models/friend_search_result.dart b/packages/firebase_database_repository/lib/models/friend_search_result.dart new file mode 100644 index 0000000..dfb13b4 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/friend_search_result.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/models/relationship_status.dart'; +import 'package:firebase_database_repository/models/user_profile_model.dart'; + +/// {@template friend_search_result} +/// Result of the `searchByFriendCode` callable. +/// +/// `found: false` is returned ONLY for a genuine server not-found result, +/// which includes block-hiding (indistinguishable from a true miss by +/// design β€” see the callable). Any other callable error is thrown instead +/// of being folded into a `found: false` result, so offline/server errors +/// surface distinctly from "no such code". +/// {@endtemplate} +class FriendSearchResult extends Equatable { + /// {@macro friend_search_result} + const FriendSearchResult({ + required this.found, + this.user, + this.relationship, + }); + + /// Whether a matching user was found (and not hidden by a block). + final bool found; + + /// The matching user's profile, present only when [found] is true. + final UserProfileModel? user; + + /// The relationship between the caller and [user], present only when + /// [found] is true. + final RelationshipStatus? relationship; + + @override + List get props => [found, user, relationship]; +} diff --git a/packages/firebase_database_repository/lib/models/game_model.dart b/packages/firebase_database_repository/lib/models/game_model.dart index 3a27680..c202d9d 100644 --- a/packages/firebase_database_repository/lib/models/game_model.dart +++ b/packages/firebase_database_repository/lib/models/game_model.dart @@ -7,7 +7,7 @@ part 'game_model.g.dart'; /// {@template game_model} /// Model representing a completed game and its statistics /// {@endtemplate} -@JsonSerializable(explicitToJson: true) +@JsonSerializable(explicitToJson: true, includeIfNull: false) class GameModel extends Equatable { /// {@macro game_model} const GameModel({ diff --git a/packages/firebase_database_repository/lib/models/game_model.g.dart b/packages/firebase_database_repository/lib/models/game_model.g.dart index 74e91cb..bb7d437 100644 --- a/packages/firebase_database_repository/lib/models/game_model.g.dart +++ b/packages/firebase_database_repository/lib/models/game_model.g.dart @@ -1,4 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// language: 3.8 part of 'game_model.dart'; @@ -7,27 +9,27 @@ part of 'game_model.dart'; // ************************************************************************** GameModel _$GameModelFromJson(Map json) => GameModel( - players: (json['players'] as List) - .map((e) => Player.fromJson(e as Map)) - .toList(), - startTime: DateTime.parse(json['startTime'] as String), - endTime: DateTime.parse(json['endTime'] as String), - winnerId: json['winnerId'] as String, - durationInSeconds: (json['durationInSeconds'] as num).toInt(), - hostId: json['hostId'] as String? ?? '', - startingPlayerId: json['startingPlayerId'] as String? ?? '', - roomId: json['roomId'] as String? ?? '', - id: json['id'] as String?, - ); + players: (json['players'] as List) + .map((e) => Player.fromJson(e as Map)) + .toList(), + startTime: DateTime.parse(json['startTime'] as String), + endTime: DateTime.parse(json['endTime'] as String), + winnerId: json['winnerId'] as String, + durationInSeconds: (json['durationInSeconds'] as num).toInt(), + hostId: json['hostId'] as String? ?? '', + startingPlayerId: json['startingPlayerId'] as String? ?? '', + roomId: json['roomId'] as String? ?? '', + id: json['id'] as String?, +); Map _$GameModelToJson(GameModel instance) => { - 'hostId': instance.hostId, - 'id': instance.id, - 'startingPlayerId': instance.startingPlayerId, - 'roomId': instance.roomId, - 'players': instance.players.map((e) => e.toJson()).toList(), - 'startTime': instance.startTime.toIso8601String(), - 'endTime': instance.endTime.toIso8601String(), - 'winnerId': instance.winnerId, - 'durationInSeconds': instance.durationInSeconds, - }; + 'hostId': instance.hostId, + 'id': ?instance.id, + 'startingPlayerId': instance.startingPlayerId, + 'roomId': instance.roomId, + 'players': instance.players.map((e) => e.toJson()).toList(), + 'startTime': instance.startTime.toIso8601String(), + 'endTime': instance.endTime.toIso8601String(), + 'winnerId': instance.winnerId, + 'durationInSeconds': instance.durationInSeconds, +}; diff --git a/packages/firebase_database_repository/lib/models/models.dart b/packages/firebase_database_repository/lib/models/models.dart index f5f0844..5811059 100644 --- a/packages/firebase_database_repository/lib/models/models.dart +++ b/packages/firebase_database_repository/lib/models/models.dart @@ -1,6 +1,10 @@ +export 'blocked_user_model.dart'; export 'friend_model.dart'; export 'friend_request_model.dart'; export 'friend_request_result.dart'; +export 'friend_search_result.dart'; export 'game_model.dart'; +export 'pin_validation_result.dart'; export 'relationship_status.dart'; export 'user_profile_model.dart'; +export 'user_search_match.dart'; diff --git a/packages/firebase_database_repository/lib/models/pin_validation_result.dart b/packages/firebase_database_repository/lib/models/pin_validation_result.dart new file mode 100644 index 0000000..6828f9c --- /dev/null +++ b/packages/firebase_database_repository/lib/models/pin_validation_result.dart @@ -0,0 +1,57 @@ +import 'package:equatable/equatable.dart'; + +/// {@template pin_validation_result} +/// Result of validating a friend's PIN via the `validatePin` callable. +/// {@endtemplate} +sealed class PinValidationResult extends Equatable { + /// {@macro pin_validation_result} + const PinValidationResult(); + + @override + List get props => []; +} + +/// The PIN was correct. +final class PinValid extends PinValidationResult { + /// Creates a valid result. + const PinValid(); +} + +/// The PIN was wrong; [attemptsRemaining] tries left before lockout. +/// +/// The attempt that trips the lockout still returns [PinInvalid] with +/// `attemptsRemaining: 0`; only the next call returns [PinLockedOut]. +final class PinInvalid extends PinValidationResult { + /// Creates an invalid result. + const PinInvalid({required this.attemptsRemaining}); + + /// Attempts left before a lockout is applied. + final int attemptsRemaining; + + @override + List get props => [attemptsRemaining]; +} + +/// Too many failed attempts; retry after [lockedUntil]. +final class PinLockedOut extends PinValidationResult { + /// Creates a locked-out result. + const PinLockedOut({required this.lockedUntil}); + + /// When the lockout expires. + final DateTime lockedUntil; + + @override + List get props => [lockedUntil]; +} + +/// The check could not be performed (offline or server error). +final class PinCheckUnavailable extends PinValidationResult { + /// Creates an unavailable result. + const PinCheckUnavailable(); +} + +/// The target user has not set a PIN yet, so validation cannot run. +final class PinNotSet extends PinValidationResult { + /// Creates a not-set result. + const PinNotSet(); +} diff --git a/packages/firebase_database_repository/lib/models/user_profile_model.dart b/packages/firebase_database_repository/lib/models/user_profile_model.dart index 7de9aef..0f51a77 100644 --- a/packages/firebase_database_repository/lib/models/user_profile_model.dart +++ b/packages/firebase_database_repository/lib/models/user_profile_model.dart @@ -6,7 +6,7 @@ part 'user_profile_model.g.dart'; /// {@template user_profile_model} /// Model representing a user's profile /// {@endtemplate} -@JsonSerializable(explicitToJson: true) +@JsonSerializable(explicitToJson: true, includeIfNull: false) class UserProfileModel extends Equatable { /// / {@macro user_profile_model} const UserProfileModel({ @@ -15,13 +15,13 @@ class UserProfileModel extends Equatable { this.isNewUser = false, this.isAnonymous = false, this.username, - this.firstName, - this.lastName, + this.usernameLower, this.bio, this.imageUrl, this.friendCode, this.pin, this.onboardingComplete = false, + this.hasPin = false, }); /// Factory constructor for a [UserProfileModel] from a JSON map @@ -46,11 +46,10 @@ class UserProfileModel extends Equatable { /// Username of the user final String? username; - /// First name of the user - final String? firstName; - - /// Last name of the user - final String? lastName; + /// Lowercase copy of [username], kept in sync by the repository on every + /// profile write. Powers the server-side `searchByUsername` prefix + /// search β€” do not set this directly. + final String? usernameLower; /// Bio of the user final String? bio; @@ -58,12 +57,16 @@ class UserProfileModel extends Equatable { /// Image URL of the user final String? imageUrl; - /// Unique friend code for discovery (e.g. "YETI-A3F9") + /// Unique friend code for discovery (e.g. "A3F9K2XQ") final String? friendCode; /// SHA-256 hashed 4-digit PIN for identity verification final String? pin; + /// Whether the user has a PIN set (hash lives in the private + /// credentials subcollection, so only this flag is public). + final bool hasPin; + /// Whether the user has completed the onboarding flow final bool onboardingComplete; @@ -77,20 +80,19 @@ class UserProfileModel extends Equatable { bool? isNewUser, bool? isAnonymous, String? username, - String? firstName, - String? lastName, + String? usernameLower, String? bio, String? imageUrl, String? friendCode, String? pin, bool? onboardingComplete, + bool? hasPin, }) => UserProfileModel( id: id ?? this.id, email: email ?? this.email, username: username ?? this.username, - firstName: firstName ?? this.firstName, - lastName: lastName ?? this.lastName, + usernameLower: usernameLower ?? this.usernameLower, bio: bio ?? this.bio, imageUrl: imageUrl ?? this.imageUrl, isNewUser: isNewUser ?? this.isNewUser, @@ -98,8 +100,16 @@ class UserProfileModel extends Equatable { friendCode: friendCode ?? this.friendCode, pin: pin ?? this.pin, onboardingComplete: onboardingComplete ?? this.onboardingComplete, + hasPin: hasPin ?? this.hasPin, ); + /// Whether the profile satisfies the friends-feature requirements: + /// onboarded, has a username, and has a PIN (new flag or legacy field). + bool get isComplete => + onboardingComplete && + (username?.isNotEmpty ?? false) && + (hasPin || (pin?.isNotEmpty ?? false)); + @override List get props => [ id, @@ -107,12 +117,12 @@ class UserProfileModel extends Equatable { isNewUser, isAnonymous, username, - firstName, - lastName, + usernameLower, bio, imageUrl, friendCode, pin, onboardingComplete, + hasPin, ]; } diff --git a/packages/firebase_database_repository/lib/models/user_profile_model.g.dart b/packages/firebase_database_repository/lib/models/user_profile_model.g.dart index 8bd2add..19e72b6 100644 --- a/packages/firebase_database_repository/lib/models/user_profile_model.g.dart +++ b/packages/firebase_database_repository/lib/models/user_profile_model.g.dart @@ -13,27 +13,27 @@ UserProfileModel _$UserProfileModelFromJson(Map json) => isNewUser: json['isNewUser'] as bool? ?? false, isAnonymous: json['isAnonymous'] as bool? ?? false, username: json['username'] as String?, - firstName: json['firstName'] as String?, - lastName: json['lastName'] as String?, + usernameLower: json['usernameLower'] as String?, bio: json['bio'] as String?, imageUrl: json['imageUrl'] as String?, friendCode: json['friendCode'] as String?, pin: json['pin'] as String?, onboardingComplete: json['onboardingComplete'] as bool? ?? false, + hasPin: json['hasPin'] as bool? ?? false, ); Map _$UserProfileModelToJson(UserProfileModel instance) => { 'id': instance.id, - 'email': instance.email, + 'email': ?instance.email, 'isNewUser': instance.isNewUser, 'isAnonymous': instance.isAnonymous, - 'username': instance.username, - 'firstName': instance.firstName, - 'lastName': instance.lastName, - 'bio': instance.bio, - 'imageUrl': instance.imageUrl, - 'friendCode': instance.friendCode, - 'pin': instance.pin, + 'username': ?instance.username, + 'usernameLower': ?instance.usernameLower, + 'bio': ?instance.bio, + 'imageUrl': ?instance.imageUrl, + 'friendCode': ?instance.friendCode, + 'pin': ?instance.pin, + 'hasPin': instance.hasPin, 'onboardingComplete': instance.onboardingComplete, }; diff --git a/packages/firebase_database_repository/lib/models/user_search_match.dart b/packages/firebase_database_repository/lib/models/user_search_match.dart new file mode 100644 index 0000000..6fbfd11 --- /dev/null +++ b/packages/firebase_database_repository/lib/models/user_search_match.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:firebase_database_repository/models/relationship_status.dart'; +import 'package:firebase_database_repository/models/user_profile_model.dart'; + +/// {@template user_search_match} +/// A single match from the `searchByUsername` callable: a user profile +/// paired with the caller's relationship to them. +/// {@endtemplate} +class UserSearchMatch extends Equatable { + /// {@macro user_search_match} + const UserSearchMatch({ + required this.user, + required this.relationship, + }); + + /// The matching user's profile. + final UserProfileModel user; + + /// The relationship between the caller and [user]. + final RelationshipStatus relationship; + + @override + List get props => [user, relationship]; +} diff --git a/packages/firebase_database_repository/lib/src/firebase_database_repository.dart b/packages/firebase_database_repository/lib/src/firebase_database_repository.dart index dc1c8f5..12e7bdd 100644 --- a/packages/firebase_database_repository/lib/src/firebase_database_repository.dart +++ b/packages/firebase_database_repository/lib/src/firebase_database_repository.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_database_repository/models/models.dart'; import 'package:firebase_storage/firebase_storage.dart'; @@ -144,15 +145,44 @@ class GetUserProfileException implements Exception { final Object stackTrace; } +/// {@template legacy_friend_request_exception} +/// Exception thrown when accepting a friend request fails because the +/// rules layer denies it with `permission-denied`. This happens for +/// requests created before the current friend/block rules shipped (e.g. a +/// legacy random-id doc that doesn't satisfy the deterministic-id +/// constraints the rules now require), so the accepting user is asked to +/// have the sender re-send the request instead of seeing a generic failure. +/// {@endtemplate} +class LegacyFriendRequestException implements Exception { + /// {@macro legacy_friend_request_exception} + const LegacyFriendRequestException({ + required this.message, + required this.stackTrace, + }); + + /// A description of the error. + final String message; + + /// The stack trace for the exception. + final Object stackTrace; +} + /// {@template firebase_database_repository} /// Firebase database package /// {@endtemplate} class FirebaseDatabaseRepository { /// {@macro firebase_database_repository} - const FirebaseDatabaseRepository({required FirebaseFirestore firebase}) - : _firebase = firebase; + FirebaseDatabaseRepository({ + required FirebaseFirestore firebase, + FirebaseFunctions? functions, + }) : _firebase = firebase, + _functionsOverride = functions; final FirebaseFirestore _firebase; + final FirebaseFunctions? _functionsOverride; + + FirebaseFunctions get _functions => + _functionsOverride ?? FirebaseFunctions.instance; CollectionReference get _friendCollection => _firebase.collection('friendRequests'); @@ -293,13 +323,28 @@ class FirebaseDatabaseRepository { } } - /// Update a user's profile + /// Update a user's profile. + /// + /// `usernameLower` is always derived from [UserProfileModel.username] + /// here β€” the single choke point for every profile write β€” so it can + /// never drift from whatever a caller happened to set (or forget to + /// set) on the model. It powers the server-side `searchByUsername` + /// prefix search. Operates on the JSON map directly (rather than + /// `copyWith`, whose `??` pattern can't force a field back to null) so a + /// null username always clears any stale `usernameLower` too. Future updateUserProfile( String userId, UserProfileModel userProfile, ) async { try { - await _firebase.collection('users').doc(userId).set(userProfile.toJson()); + final json = userProfile.toJson(); + final usernameLower = userProfile.username?.toLowerCase(); + if (usernameLower != null) { + json['usernameLower'] = usernameLower; + } else { + json.remove('usernameLower'); + } + await _firebase.collection('users').doc(userId).set(json); } on Exception catch (error, stackTrace) { throw UpdateUserProfileException( message: error.toString(), @@ -328,8 +373,7 @@ class FirebaseDatabaseRepository { /// Returns null if the document does not exist. Future getUserProfileOnce(String userId) async { try { - final doc = - await _firebase.collection('users').doc(userId).get(); + final doc = await _firebase.collection('users').doc(userId).get(); if (!doc.exists || doc.data() == null) return null; return UserProfileModel.fromJson(doc.data()!); } on Exception catch (error, stackTrace) { @@ -365,9 +409,29 @@ class FirebaseDatabaseRepository { } } + /// Computes the deterministic `friendRequests` document id for a + /// request from [senderId] to [receiverId]. + /// + /// Requests are keyed by `{senderId}_{receiverId}` (rather than a random + /// id) so that Firestore security rules can enforce the doc id shape on + /// create, and so the sender/reverse-sender lookups below can read a + /// single doc instead of running a query. + static String friendRequestDocId(String senderId, String receiverId) => + '${senderId}_$receiverId'; + + // Legacy-pending note: reverse-direction LEGACY (random-id) pendings won't + // be found by the deterministic reverse read, so no auto-accept for them β€” + // the sender simply creates a new deterministic request and the receiver + // now has two pendings, one legacy (declinable) and one acceptable. + // Acceptable drain path. /// Adds a friend request with guards against duplicates, self-requests, /// and existing friendships. /// + /// [senderFriendCode] is denormalized onto the request doc so the + /// receiver can tell the sender apart from anyone else sharing the same + /// (non-unique) [senderName] β€” pass the sender's own profile friend + /// code, not the receiver's. + /// /// Returns [FriendRequestResult] indicating what happened: /// - [FriendRequestResult.sent] β€” request created /// - [FriendRequestResult.autoAccepted] β€” mutual request, now friends @@ -377,6 +441,7 @@ class FirebaseDatabaseRepository { Future addFriendRequest( String senderId, String senderName, + String? senderFriendCode, String receiverId, ) async { // Guard: self-request @@ -391,45 +456,68 @@ class FirebaseDatabaseRepository { .get(); if (friendDoc.exists) return FriendRequestResult.alreadyFriends; - // Guard: pending request already sent - final existingSent = await _friendCollection - .where('senderId', isEqualTo: senderId) - .where('receiverId', isEqualTo: receiverId) - .where('status', isEqualTo: 'pending') - .limit(1) - .get(); - if (existingSent.docs.isNotEmpty) { - return FriendRequestResult.alreadyPending; - } - - // Guard: reverse request exists β€” auto-accept - final reverseRequest = await _friendCollection + // Guard: reverse request exists β€” auto-accept. Checked BEFORE the + // own-direction declined/pending short-circuit below: if Alice declined + // Bob's request and Bob later sends Alice one, Alice tapping "Accept" on + // Bob's search-card request re-invokes this method in the alice->bob + // direction. That must auto-accept β€” not fall through to the stale + // declined doc and return a fake silent "sent". A pending reverse doc + // always gates the accept batch's rules-legal disjuncts (same code path + // as the ordinary mutual-request auto-accept), so promoting this check + // above the own-direction guard is safe regardless of the caller's own + // prior history with the receiver. + final reverseSnapshot = await _friendCollection .where('senderId', isEqualTo: receiverId) .where('receiverId', isEqualTo: senderId) .where('status', isEqualTo: 'pending') .limit(1) .get(); - if (reverseRequest.docs.isNotEmpty) { - final reverseDoc = reverseRequest.docs.first; + if (reverseSnapshot.docs.isNotEmpty) { final reverseModel = FriendRequestModel.fromJson( - reverseDoc.data()! as Map, + reverseSnapshot.docs.first.data()! as Map, ); await acceptFriendRequest(reverseModel, senderId); return FriendRequestResult.autoAccepted; } - // All clear β€” create the request - final newRequestRef = _friendCollection.doc(); - final documentId = newRequestRef.id; - await newRequestRef.set({ - 'id': documentId, - 'senderId': senderId, - 'senderName': senderName, - 'receiverId': receiverId, - 'status': 'pending', - 'timestamp': FieldValue.serverTimestamp(), - }); - return FriendRequestResult.sent; + // Guard: pending (or declined) request already sent. Point-gets on + // possibly-missing friendRequests docs are rules-denied β€” a get() on a + // NONEXISTENT doc evaluates `resource` as null, so the participant read + // rule (which reads `resource.data.*`) errors out to DENIED β€” so guards + // must be constraint-proven queries instead of deterministic doc reads. + final ownSnapshot = await _friendCollection + .where('senderId', isEqualTo: senderId) + .where('receiverId', isEqualTo: receiverId) + .limit(1) + .get(); + if (ownSnapshot.docs.isNotEmpty) { + final status = + (ownSnapshot.docs.first.data()! as Map)['status']; + // A declined request stays declined; the sender sees "sent" and the + // receiver never sees it again (silent re-send suppression). + if (status == 'declined') return FriendRequestResult.sent; + return FriendRequestResult.alreadyPending; + } + + // All clear β€” create the request at the deterministic doc id. + try { + final documentId = friendRequestDocId(senderId, receiverId); + await _friendCollection.doc(documentId).set({ + 'id': documentId, + 'senderId': senderId, + 'senderName': senderName, + if (senderFriendCode != null) 'senderFriendCode': senderFriendCode, + 'receiverId': receiverId, + 'status': 'pending', + 'timestamp': FieldValue.serverTimestamp(), + }); + return FriendRequestResult.sent; + } on FirebaseException catch (e) { + // A blocked sender is denied by rules; concealment is deliberate β€” + // they see the same "sent" as everyone else (spec accepted tradeoff). + if (e.code == 'permission-denied') return FriendRequestResult.sent; + rethrow; + } } /// Accepts a friend request by updating its status in the Firestore database. @@ -444,8 +532,7 @@ class FirebaseDatabaseRepository { try { final senderDoc = await _firebase.collection('users').doc(request.senderId).get(); - final receiverDoc = - await _firebase.collection('users').doc(userId).get(); + final receiverDoc = await _firebase.collection('users').doc(userId).get(); final senderData = senderDoc.data(); final receiverData = receiverDoc.data(); @@ -453,18 +540,15 @@ class FirebaseDatabaseRepository { // Build friend models from profile data with safe fallbacks final senderFriend = FriendModel( userId: request.senderId, - username: senderData?['username'] as String? ?? - request.senderName, - profilePictureUrl: - senderData?['imageUrl'] as String? ?? '', + username: senderData?['username'] as String? ?? request.senderName, + profilePictureUrl: senderData?['imageUrl'] as String? ?? '', friendCode: senderData?['friendCode'] as String?, ); final receiverFriend = FriendModel( userId: userId, username: receiverData?['username'] as String? ?? '', - profilePictureUrl: - receiverData?['imageUrl'] as String? ?? '', + profilePictureUrl: receiverData?['imageUrl'] as String? ?? '', friendCode: receiverData?['friendCode'] as String?, ); @@ -492,27 +576,43 @@ class FirebaseDatabaseRepository { batch.delete(_friendCollection.doc(request.id)); await batch.commit(); + } on FirebaseException catch (e, stackTrace) { + if (e.code == 'permission-denied') { + throw LegacyFriendRequestException( + message: 'Failed to accept friend request: $e', + stackTrace: stackTrace, + ); + } + throw Exception('Failed to accept friend request: $e'); } catch (e) { throw Exception('Failed to accept friend request: $e'); } } /// Removes a friend from both users' friend lists. + /// + /// Both edge deletes commit in one batch so a mid-operation failure + /// can't leave a half-alive friendship (one edge deleted, the reverse + /// intact) β€” the only realistic path to a friendship/pending-request + /// state the rules reason about as impossible. Future removeFriend(String userId, String friendId) async { try { - await _firebase - .collection('friends') - .doc(userId) - .collection('friendList') - .doc(friendId) - .delete(); - - await _firebase - .collection('friends') - .doc(friendId) - .collection('friendList') - .doc(userId) - .delete(); + final batch = _firebase.batch() + ..delete( + _firebase + .collection('friends') + .doc(userId) + .collection('friendList') + .doc(friendId), + ) + ..delete( + _firebase + .collection('friends') + .doc(friendId) + .collection('friendList') + .doc(userId), + ); + await batch.commit(); } catch (e) { throw Exception('Failed to remove friend: $e'); } @@ -535,41 +635,6 @@ class FirebaseDatabaseRepository { } } - /// Searches for users by username or email. - /// - /// @param searchTerm The term to search for in usernames or emails. - /// @returns Future>> A list of user data maps. - /// @throws Exception if the search fails. - Future> searchUsers(String searchTerm) async { - try { - final QuerySnapshot usernameSnapshot = await _firebase - .collection('users') - .where('username', isEqualTo: searchTerm) - .get(); - - final QuerySnapshot emailSnapshot = await _firebase - .collection('users') - .where('email', isEqualTo: searchTerm) - .get(); - - final users = []; - - for (final doc in usernameSnapshot.docs) { - users.add( - UserProfileModel.fromJson(doc.data()! as Map)); - } - - for (final doc in emailSnapshot.docs) { - users.add( - UserProfileModel.fromJson(doc.data()! as Map)); - } - - return users; - } catch (e) { - throw Exception('Failed to search users: $e'); - } - } - /// Retrieves all incoming friend requests for a given user. /// /// @param userId The ID of the user whose incoming friend requests are being retrieved. @@ -591,20 +656,29 @@ class FirebaseDatabaseRepository { } } - /// Declines a friend request by removing it from the Firestore database. + /// Declines a friend request by marking its status as declined. + /// + /// The doc is retained (not deleted) so a future re-send from the same + /// sender to the same receiver is silently suppressed β€” see + /// [addFriendRequest]. /// /// @param requestId The ID of the friend request to decline. /// @returns Future - /// @throws Exception if the request cannot be removed. + /// @throws Exception if the request cannot be updated. Future declineFriendRequest(String requestId) async { try { - await _friendCollection.doc(requestId).delete(); + await _friendCollection.doc(requestId).update({'status': 'declined'}); } catch (e) { throw Exception('Failed to decline friend request: $e'); } } - /// Generates a unique friend code in the format "YETI-XXXX". + /// Generates a unique 8-character friend code (e.g. "A3F9K2XQ"). + /// + /// Plain random characters, no prefix β€” a prefix doesn't add any + /// information (every code would carry the same one), and unlike a + /// username-derived code, a fully random one never goes stale if the + /// owner renames themselves. /// /// Checks for uniqueness against existing codes in the database. /// Retries up to 10 times if a collision is found. @@ -612,15 +686,15 @@ class FirebaseDatabaseRepository { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; final random = Random.secure(); const maxAttempts = 10; + const codeLength = 8; for (var attempt = 0; attempt < maxAttempts; attempt++) { - final code = String.fromCharCodes( + final friendCode = String.fromCharCodes( Iterable.generate( - 4, + codeLength, (_) => chars.codeUnitAt(random.nextInt(chars.length)), ), ); - final friendCode = 'YETI-$code'; final existing = await _firebase .collection('users') @@ -637,65 +711,187 @@ class FirebaseDatabaseRepository { 'attempts'); } - /// Searches for a user by their friend code. + /// Searches for a user by their friend code via the block-aware + /// `searchByFriendCode` callable. /// - /// Returns the matching [UserProfileModel] or `null` if not found. - Future searchByFriendCode(String friendCode) async { + /// `found: false` is returned ONLY for a genuine server not-found result + /// (which includes block-hiding β€” indistinguishable by design). An + /// `invalid-argument` response is rethrown as an [ArgumentError]; any + /// other callable failure (offline, internal, etc.) is thrown as a plain + /// [Exception] so callers (e.g. SearchBloc) can surface their existing + /// error state instead of silently reading as "not found". + Future searchByFriendCode(String friendCode) async { try { - final snapshot = await _firebase - .collection('users') - .where('friendCode', isEqualTo: friendCode.toUpperCase().trim()) - .limit(1) - .get(); + final result = await _functions + .httpsCallable('searchByFriendCode') + .call({'code': friendCode}); + final data = Map.from(result.data as Map); - if (snapshot.docs.isEmpty) return null; + if (data['found'] != true) return const FriendSearchResult(found: false); - return UserProfileModel.fromJson(snapshot.docs.first.data()); - } catch (e) { - throw Exception('Failed to search by friend code: $e'); + final userJson = Map.from(data['user'] as Map); + final relationship = _relationshipFromString( + data['relationship'] as String?, + ); + + return FriendSearchResult( + found: true, + user: UserProfileModel.fromJson(userJson), + relationship: relationship, + ); + } on FirebaseFunctionsException catch (e) { + if (e.code == 'invalid-argument') { + throw ArgumentError(e.message ?? 'Invalid friend code'); + } + throw Exception('Friend code search unavailable'); } } - /// Checks the relationship status between two users. - /// - /// Returns [RelationshipStatus] indicating the current state: - /// self, friends, pendingSent, pendingReceived, or none. - Future checkRelationshipStatus( - String currentUserId, - String otherUserId, - ) async { - if (currentUserId == otherUserId) return RelationshipStatus.self; + /// Searches for users by username prefix (case-insensitive) via the + /// block-aware `searchByUsername` callable. Returns an empty list when + /// nothing matches; throws [ArgumentError] for an `invalid-argument` + /// response (e.g. a query shorter than the server's minimum length), and + /// a plain [Exception] for any other callable failure. + Future> searchByUsername(String query) async { + try { + final result = await _functions + .httpsCallable('searchByUsername') + .call({'query': query}); + final data = Map.from(result.data as Map); + final matches = List.from(data['matches'] as List); + + return matches.map((match) { + final matchMap = Map.from(match as Map); + final userJson = Map.from(matchMap['user'] as Map); + return UserSearchMatch( + user: UserProfileModel.fromJson(userJson), + relationship: _relationshipFromString( + matchMap['relationship'] as String?, + ), + ); + }).toList(); + } on FirebaseFunctionsException catch (e) { + if (e.code == 'invalid-argument') { + throw ArgumentError(e.message ?? 'Invalid search query'); + } + throw Exception('Username search unavailable'); + } + } - // Check if already friends - final friendDoc = await _firebase - .collection('friends') - .doc(currentUserId) - .collection('friendList') - .doc(otherUserId) - .get(); - if (friendDoc.exists) return RelationshipStatus.friends; + /// Maps the callable's relationship string onto [RelationshipStatus]. + static RelationshipStatus _relationshipFromString(String? value) { + switch (value) { + case 'friends': + return RelationshipStatus.friends; + case 'pendingSent': + return RelationshipStatus.pendingSent; + case 'pendingReceived': + return RelationshipStatus.pendingReceived; + case 'self': + return RelationshipStatus.self; + case 'none': + default: + return RelationshipStatus.none; + } + } - // Check if current user sent a pending request - final sentRequest = await _friendCollection + /// Blocks [target]: writes the owner-managed block doc (denormalized + /// username/imageUrl + server timestamp), removes both friendship edges, + /// and deletes any pending friend-request docs between the two users. + /// + /// Under the Task 3 rules, `friendRequests` deletes are pending-only β€” + /// deleting a nonexistent doc (null `resource.data`) or a declined doc + /// is denied. Declined docs don't need deleting: they're already invisible + /// to [getFriendRequests] and permanently suppress re-sends. Rather than + /// point-getting the deterministic docs (which is rules-denied when the + /// doc doesn't exist β€” see [addFriendRequest]), this queries both pending + /// directions by sender/receiver id, which matches deterministic-id docs + /// just as well as legacy random-id ones: query results are pending by + /// construction, so no existence/status check is needed for them. + Future blockUser({ + required String currentUserId, + required BlockedUserModel target, + }) async { + final batch = _firebase.batch() + ..set( + _firebase + .collection('users') + .doc(currentUserId) + .collection('blocks') + .doc(target.userId), + { + 'userId': target.userId, + 'username': target.username, + 'imageUrl': target.imageUrl, + if (target.friendCode != null) 'friendCode': target.friendCode, + 'blockedAt': FieldValue.serverTimestamp(), + }, + ) + ..delete( + _firebase + .collection('friends') + .doc(currentUserId) + .collection('friendList') + .doc(target.userId), + ) + ..delete( + _firebase + .collection('friends') + .doc(target.userId) + .collection('friendList') + .doc(currentUserId), + ); + + // Pending requests in both directions (covers deterministic AND legacy + // random-id docs β€” the query matches on sender/receiver id, not doc id). + final legacySent = await _friendCollection .where('senderId', isEqualTo: currentUserId) - .where('receiverId', isEqualTo: otherUserId) + .where('receiverId', isEqualTo: target.userId) .where('status', isEqualTo: 'pending') - .limit(1) .get(); - if (sentRequest.docs.isNotEmpty) return RelationshipStatus.pendingSent; + for (final doc in legacySent.docs) { + batch.delete(doc.reference); + } - // Check if other user sent a pending request - final receivedRequest = await _friendCollection - .where('senderId', isEqualTo: otherUserId) + final legacyReceived = await _friendCollection + .where('senderId', isEqualTo: target.userId) .where('receiverId', isEqualTo: currentUserId) .where('status', isEqualTo: 'pending') - .limit(1) .get(); - if (receivedRequest.docs.isNotEmpty) { - return RelationshipStatus.pendingReceived; + for (final doc in legacyReceived.docs) { + batch.delete(doc.reference); } - return RelationshipStatus.none; + await batch.commit(); + } + + /// Unblocks a user by deleting the block doc only. Friendship edges and + /// requests were already removed at block time and are not restored. + Future unblockUser({ + required String currentUserId, + required String targetUserId, + }) async { + await _firebase + .collection('users') + .doc(currentUserId) + .collection('blocks') + .doc(targetUserId) + .delete(); + } + + /// Streams the current user's blocked users, newest-first. + Stream> getBlockedUsers(String userId) { + return _firebase + .collection('users') + .doc(userId) + .collection('blocks') + .orderBy('blockedAt', descending: true) + .snapshots() + .map( + (snapshot) => snapshot.docs + .map((doc) => BlockedUserModel.fromJson(doc.data())) + .toList(), + ); } /// Hashes a 4-digit PIN using SHA-256. @@ -704,36 +900,59 @@ class FirebaseDatabaseRepository { return sha256.convert(bytes).toString(); } - /// Validates a PIN against a user's stored hash. - /// - /// Returns `true` if the PIN matches. - Future validatePin(String userId, String pin) async { - try { - final doc = await _firebase.collection('users').doc(userId).get(); - if (!doc.exists) return false; - - final storedHash = doc.data()?['pin'] as String?; - if (storedHash == null) return false; + /// Generates a random 16-byte hex salt for PIN hashing. + static String generateSalt() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } - return storedHash == hashPin(pin); - } catch (e) { - throw Exception('Failed to validate PIN: $e'); - } + /// Hashes a PIN with a salt: sha256(salt + pin). + static String saltedPinHash(String pin, String salt) { + final bytes = utf8.encode(salt + pin); + return sha256.convert(bytes).toString(); } - /// Syncs a completed game to all authenticated players' match histories. + /// Gets a reference to a user's private credentials document. + DocumentReference> _credentialsDoc(String userId) => + _firebase.doc('users/$userId/private/credentials'); + + /// Validates a friend's PIN via the `validatePin` Cloud Function. /// - /// Saves the [GameModel] to `users/{firebaseId}/matches/{gameId}` for - /// each unique Firebase ID in [playerFirebaseIds]. - Future syncGameToPlayers( - GameModel game, - List playerFirebaseIds, - ) async { - final uniqueIds = playerFirebaseIds.toSet(); - final futures = uniqueIds.map( - (id) => addMatchToPlayerHistory(game, id), - ); - await Future.wait(futures); + /// The hash never reaches this client; the server enforces the + /// 5-failures / 15-minute lockout and the friends-only precondition. + Future validatePin({ + required String targetUserId, + required String pin, + }) async { + try { + final result = await _functions + .httpsCallable('validatePin') + .call({'targetUserId': targetUserId, 'pin': pin}); + final data = Map.from(result.data as Map); + if (data['valid'] == true) return const PinValid(); + return PinInvalid( + attemptsRemaining: (data['attemptsRemaining'] as num?)?.toInt() ?? 0, + ); + } on FirebaseFunctionsException catch (e) { + if (e.code == 'resource-exhausted') { + final details = e.details; + final millis = details is Map + ? (details['lockedUntilMillis'] as num?)?.toInt() + : null; + return PinLockedOut( + lockedUntil: millis != null + ? DateTime.fromMillisecondsSinceEpoch(millis) + : DateTime.now().add(const Duration(minutes: 15)), + ); + } + if (e.code == 'failed-precondition') { + return const PinNotSet(); + } + return const PinCheckUnavailable(); + } catch (_) { + return const PinCheckUnavailable(); + } } /// Ensures a user profile document exists with a friend code. @@ -743,8 +962,7 @@ class FirebaseDatabaseRepository { /// generates and adds one. Returns the current friend code. Future ensureUserProfile(UserProfileModel profile) async { try { - final doc = - await _firebase.collection('users').doc(profile.id).get(); + final doc = await _firebase.collection('users').doc(profile.id).get(); if (!doc.exists) { // No profile at all β€” create a full one with friend code @@ -771,26 +989,100 @@ class FirebaseDatabaseRepository { } } - /// Checks whether a user has set their PIN. - Future hasPin(String userId) async { + /// Sets the user's PIN: salted hash into the private credentials doc, + /// `hasPin` flag onto the profile, legacy `pin` field removed. + Future setPin(String userId, String pin) async { try { - final doc = await _firebase.collection('users').doc(userId).get(); - if (!doc.exists) return false; - return doc.data()?['pin'] != null; + final salt = generateSalt(); + final batch = _firebase.batch() + ..set(_credentialsDoc(userId), { + 'pinHash': saltedPinHash(pin, salt), + 'salt': salt, + 'updatedAt': FieldValue.serverTimestamp(), + }) + ..set( + _firebase.collection('users').doc(userId), + {'hasPin': true, 'pin': FieldValue.delete()}, + SetOptions(merge: true), + ); + await batch.commit(); } catch (e) { - return false; + throw Exception('Failed to set PIN: $e'); } } - /// Sets a user's PIN (hashed). - Future setPin(String userId, String pin) async { + /// Moves a legacy profile-doc PIN hash into the private credentials + /// doc. Safe to call on every login; no-ops when nothing to migrate. + /// + /// Note: if a pre-update client changes the legacy PIN on a device that + /// hasn't picked up this migration, and that change lands after this + /// migration already ran, the change is silently discarded β€” the + /// credentials doc wins and the next `migrateLegacyPin` call simply + /// deletes the new legacy hash again. This is accepted behavior; there + /// is no cross-device coordination for legacy-field writes. + Future migrateLegacyPin(String userId) async { try { - await _firebase.collection('users').doc(userId).set( - {'pin': hashPin(pin)}, + final profileRef = _firebase.collection('users').doc(userId); + final profile = await profileRef.get(); + if (!profile.exists) return; + final legacyHash = profile.data()?['pin'] as String?; + if (legacyHash == null || legacyHash.isEmpty) { + // Self-heal: an old-version client's full-doc profile write can + // wipe the hasPin flag after migration already ran. If the + // credentials doc exists but the flag is missing, repair it so + // the completeness gate does not bounce a PIN-holding user back + // into onboarding. + if (profile.data()?['hasPin'] != true) { + final credentials = await _credentialsDoc(userId).get(); + if (credentials.exists) { + unawaited( + profileRef + .set({'hasPin': true}, SetOptions(merge: true)) + .catchError((Object _) {}), + ); + } + } + return; + } + + final credentials = await _credentialsDoc(userId).get(); + final batch = _firebase.batch(); + if (!credentials.exists) { + batch.set(_credentialsDoc(userId), { + 'pinHash': legacyHash, + 'salt': null, + 'updatedAt': FieldValue.serverTimestamp(), + }); + } + batch.set( + profileRef, + {'hasPin': true, 'pin': FieldValue.delete()}, SetOptions(merge: true), ); + // Fire-and-forget: offline, commit() never completes (Firestore + // queues it locally and syncs later); awaiting it here would wedge + // AppBloc's sequential auth-event queue. Local reads already + // reflect the pending write, and the callable's legacy fallback + // covers the gap until it syncs. + unawaited( + batch.commit().catchError((Object _) {}), + ); + } catch (_) { + // Migration is best-effort on login; the callable's legacy + // fallback keeps validation working until it succeeds. + } + } + + /// Checks whether a user has set their PIN (new flag or legacy field). + Future hasPin(String userId) async { + try { + final doc = await _firebase.collection('users').doc(userId).get(); + if (!doc.exists) return false; + final data = doc.data()!; + final legacy = data['pin'] as String?; + return data['hasPin'] == true || (legacy != null && legacy.isNotEmpty); } catch (e) { - throw Exception('Failed to set PIN: $e'); + return false; } } } diff --git a/packages/firebase_database_repository/pubspec.yaml b/packages/firebase_database_repository/pubspec.yaml index 5eb7cf9..f5bd901 100644 --- a/packages/firebase_database_repository/pubspec.yaml +++ b/packages/firebase_database_repository/pubspec.yaml @@ -4,10 +4,11 @@ version: 0.1.0+1 publish_to: none environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.8.0 <4.0.0" dev_dependencies: build_runner: ^2.4.7 + fake_cloud_firestore: ^3.1.0 json_serializable: ^6.9.5 mocktail: ^1.0.0 test: ^1.19.2 @@ -15,6 +16,7 @@ dev_dependencies: dependencies: cloud_firestore: ^5.5.0 + cloud_functions: ^5.2.0 crypto: ^3.0.3 equatable: ^2.0.5 firebase_storage: ^12.3.7 diff --git a/packages/firebase_database_repository/test/models/pin_validation_result_test.dart b/packages/firebase_database_repository/test/models/pin_validation_result_test.dart new file mode 100644 index 0000000..15d677c --- /dev/null +++ b/packages/firebase_database_repository/test/models/pin_validation_result_test.dart @@ -0,0 +1,40 @@ +import 'package:firebase_database_repository/models/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('PinValidationResult', () { + test('value equality', () { + expect(const PinValid(), const PinValid()); + expect( + const PinInvalid(attemptsRemaining: 3), + const PinInvalid(attemptsRemaining: 3), + ); + expect( + const PinInvalid(attemptsRemaining: 3), + isNot(const PinInvalid(attemptsRemaining: 2)), + ); + expect( + PinLockedOut(lockedUntil: DateTime.utc(2026, 7, 3)), + PinLockedOut(lockedUntil: DateTime.utc(2026, 7, 3)), + ); + expect(const PinCheckUnavailable(), const PinCheckUnavailable()); + expect(const PinNotSet(), const PinNotSet()); + }); + + test('subtypes are exhaustively switchable', () { + String describe(PinValidationResult r) => switch (r) { + PinValid() => 'valid', + PinInvalid(:final attemptsRemaining) => + 'invalid:$attemptsRemaining', + PinLockedOut() => 'locked', + PinCheckUnavailable() => 'unavailable', + PinNotSet() => 'notSet', + }; + expect(describe(const PinValid()), 'valid'); + expect( + describe(const PinInvalid(attemptsRemaining: 2)), + 'invalid:2', + ); + }); + }); +} diff --git a/packages/firebase_database_repository/test/models/user_profile_model_test.dart b/packages/firebase_database_repository/test/models/user_profile_model_test.dart new file mode 100644 index 0000000..a345586 --- /dev/null +++ b/packages/firebase_database_repository/test/models/user_profile_model_test.dart @@ -0,0 +1,75 @@ +import 'package:firebase_database_repository/models/models.dart'; +import 'package:test/test.dart'; + +void main() { + group('UserProfileModel', () { + test('toJson omits null fields entirely (includeIfNull false)', () { + const model = UserProfileModel(id: 'u1', username: 'josh'); + final json = model.toJson(); + expect(json.containsKey('pin'), isFalse); + expect(json.containsKey('bio'), isFalse); + expect(json['username'], 'josh'); + }); + + test('usernameLower omitted when null, round-trips when set', () { + const withUsername = UserProfileModel(id: 'u1', username: 'josh'); + expect(withUsername.toJson().containsKey('usernameLower'), isFalse); + + const withLower = UserProfileModel( + id: 'u1', + username: 'Josh', + usernameLower: 'josh', + ); + final decoded = UserProfileModel.fromJson(withLower.toJson()); + expect(decoded.usernameLower, 'josh'); + }); + + test('hasPin defaults false and round-trips through json', () { + const model = UserProfileModel(id: 'u1', hasPin: true); + final decoded = UserProfileModel.fromJson(model.toJson()); + expect(decoded.hasPin, isTrue); + expect(UserProfileModel.fromJson(const {'id': 'u2'}).hasPin, isFalse); + }); + + group('isComplete', () { + test('true when onboarded with username and hasPin', () { + const m = UserProfileModel( + id: 'u', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ); + expect(m.isComplete, isTrue); + }); + + test('legacy unmigrated pin field counts as having a PIN', () { + const m = UserProfileModel( + id: 'u', + username: 'josh', + pin: 'somelegacyhash', + onboardingComplete: true, + ); + expect(m.isComplete, isTrue); + }); + + test('false when missing username, PIN, or onboarding flag', () { + const base = UserProfileModel( + id: 'u', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ); + expect(base.copyWith(username: '').isComplete, isFalse); + expect( + const UserProfileModel( + id: 'u', + username: 'josh', + onboardingComplete: true, + ).isComplete, + isFalse, + ); + expect(base.copyWith(onboardingComplete: false).isComplete, isFalse); + }); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/blocking_test.dart b/packages/firebase_database_repository/test/src/blocking_test.dart new file mode 100644 index 0000000..f6b02e2 --- /dev/null +++ b/packages/firebase_database_repository/test/src/blocking_test.dart @@ -0,0 +1,240 @@ +// Coverage note: fake_cloud_firestore has no rules engine, so the actual +// Firestore security rule enforcement (deletes of nonexistent/declined +// friendRequests docs denied; users/{uid}/blocks owner-only) is covered by +// Task 3's rules tests. This file exercises the repository's own guard +// logic: it reads the deterministic request docs first and only includes +// existing-and-pending ones in the delete batch, so it never even attempts +// a delete that the rules would deny. +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + group('blockUser', () { + const target = BlockedUserModel( + userId: 'bob', + username: 'Bob', + imageUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ); + + test('writes the block doc with denormalized fields + blockedAt', () async { + await repository.blockUser(currentUserId: 'alice', target: target); + + final blockDoc = await firestore.doc('users/alice/blocks/bob').get(); + expect(blockDoc.exists, isTrue); + expect(blockDoc.data()!['username'], 'Bob'); + expect(blockDoc.data()!['imageUrl'], 'http://x/bob.png'); + expect(blockDoc.data()!['friendCode'], 'YETI-B0B1'); + expect(blockDoc.data()!['blockedAt'], isA()); + }); + + test('omits friendCode from the block doc when the target has none', + () async { + const noCodeTarget = BlockedUserModel( + userId: 'carol', + username: 'Carol', + imageUrl: 'http://x/carol.png', + ); + await repository.blockUser(currentUserId: 'alice', target: noCodeTarget); + + final blockDoc = await firestore.doc('users/alice/blocks/carol').get(); + expect(blockDoc.data()!.containsKey('friendCode'), isFalse); + }); + + test('removes both friendship edges', () async { + await firestore + .doc('friends/alice/friendList/bob') + .set({'userId': 'bob'}); + await firestore + .doc('friends/bob/friendList/alice') + .set({'userId': 'alice'}); + + await repository.blockUser(currentUserId: 'alice', target: target); + + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isFalse, + ); + expect( + (await firestore.doc('friends/bob/friendList/alice').get()).exists, + isFalse, + ); + }); + + test('deletes both deterministic request docs when pending', () async { + await firestore.doc('friendRequests/alice_bob').set({ + 'id': 'alice_bob', + 'senderId': 'alice', + 'receiverId': 'bob', + 'status': 'pending', + }); + await firestore.doc('friendRequests/bob_alice').set({ + 'id': 'bob_alice', + 'senderId': 'bob', + 'receiverId': 'alice', + 'status': 'pending', + }); + + await repository.blockUser(currentUserId: 'alice', target: target); + + expect( + (await firestore.doc('friendRequests/alice_bob').get()).exists, + isFalse, + ); + expect( + (await firestore.doc('friendRequests/bob_alice').get()).exists, + isFalse, + ); + }); + + test('leaves a declined deterministic doc alone (not deleted)', () async { + await firestore.doc('friendRequests/alice_bob').set({ + 'id': 'alice_bob', + 'senderId': 'alice', + 'receiverId': 'bob', + 'status': 'declined', + }); + + await repository.blockUser(currentUserId: 'alice', target: target); + + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.exists, isTrue); + expect(doc.data()!['status'], 'declined'); + }); + + test('handles nonexistent deterministic docs without error', () async { + await repository.blockUser(currentUserId: 'alice', target: target); + + expect( + (await firestore.doc('friendRequests/alice_bob').get()).exists, + isFalse, + ); + expect( + (await firestore.doc('friendRequests/bob_alice').get()).exists, + isFalse, + ); + }); + + test( + 'deletes a seeded legacy pending request (random id, ' + 'senderId target -> me)', () async { + await firestore + .collection('friendRequests') + .doc('legacyRandomId123') + .set({ + 'id': 'legacyRandomId123', + 'senderId': 'bob', + 'receiverId': 'alice', + 'status': 'pending', + }); + + await repository.blockUser(currentUserId: 'alice', target: target); + + expect( + (await firestore.doc('friendRequests/legacyRandomId123').get()).exists, + isFalse, + ); + }); + + test( + 'deletes a seeded legacy pending request in the other direction ' + '(me -> target)', () async { + await firestore + .collection('friendRequests') + .doc('legacyRandomId456') + .set({ + 'id': 'legacyRandomId456', + 'senderId': 'alice', + 'receiverId': 'bob', + 'status': 'pending', + }); + + await repository.blockUser(currentUserId: 'alice', target: target); + + expect( + (await firestore.doc('friendRequests/legacyRandomId456').get()).exists, + isFalse, + ); + }); + }); + + group('unblockUser', () { + test('removes only the block doc', () async { + await firestore.doc('users/alice/blocks/bob').set({ + 'userId': 'bob', + 'username': 'Bob', + 'imageUrl': '', + 'blockedAt': FieldValue.serverTimestamp(), + }); + + await repository.unblockUser( + currentUserId: 'alice', + targetUserId: 'bob', + ); + + expect( + (await firestore.doc('users/alice/blocks/bob').get()).exists, + isFalse, + ); + }); + + test('does not resurrect the friendship edges', () async { + await firestore.doc('users/alice/blocks/bob').set({ + 'userId': 'bob', + 'username': 'Bob', + 'imageUrl': '', + 'blockedAt': FieldValue.serverTimestamp(), + }); + + await repository.unblockUser( + currentUserId: 'alice', + targetUserId: 'bob', + ); + + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isFalse, + ); + expect( + (await firestore.doc('friends/bob/friendList/alice').get()).exists, + isFalse, + ); + }); + }); + + group('getBlockedUsers', () { + test('streams seeded docs newest-first', () async { + final earlier = Timestamp.fromDate( + DateTime.now().subtract(const Duration(days: 1)), + ); + final later = Timestamp.now(); + + await firestore.doc('users/alice/blocks/bob').set({ + 'userId': 'bob', + 'username': 'Bob', + 'imageUrl': '', + 'blockedAt': earlier, + }); + await firestore.doc('users/alice/blocks/carol').set({ + 'userId': 'carol', + 'username': 'Carol', + 'imageUrl': '', + 'blockedAt': later, + }); + + final result = await repository.getBlockedUsers('alice').first; + + expect(result.map((u) => u.userId), ['carol', 'bob']); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/friend_request_lifecycle_test.dart b/packages/firebase_database_repository/test/src/friend_request_lifecycle_test.dart new file mode 100644 index 0000000..35fba0a --- /dev/null +++ b/packages/firebase_database_repository/test/src/friend_request_lifecycle_test.dart @@ -0,0 +1,142 @@ +// Coverage note: fake_cloud_firestore has no rules engine, so the +// permission-denied path in addFriendRequest (a blocked sender's create is +// denied by Firestore security rules, and the repository silently maps that +// denial to FriendRequestResult.sent for concealment) cannot be exercised +// here by throwing a real rules-layer FirebaseException. That wire-level +// denial is covered by Task 3's rules tests (packages/.../test/firestore +// .rules tests, or equivalent), which assert the rules themselves reject the +// write. The client-side catch/mapping in this file is a 3-line +// `on FirebaseException catch (e) { if (e.code == 'permission-denied') ... }` +// block verified by code review and by the manual/widget flow rather than by +// a unit test that fakes a thrown FirebaseException here. +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + group('addFriendRequest', () { + test('new requests use the deterministic doc id and id field', () async { + final result = + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + expect(result, FriendRequestResult.sent); + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.exists, isTrue); + expect(doc.data()!['id'], 'alice_bob'); + expect(doc.data()!['status'], 'pending'); + expect(doc.data()!['senderId'], 'alice'); + expect(doc.data()!['receiverId'], 'bob'); + }); + + test('re-send onto an existing pending returns alreadyPending', () async { + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + expect( + await repository.addFriendRequest('alice', 'Alice', null, 'bob'), + FriendRequestResult.alreadyPending, + ); + }); + + test('declined doc suppresses re-send silently as sent', () async { + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + await repository.declineFriendRequest('alice_bob'); + final result = + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + expect(result, FriendRequestResult.sent); + // Still declined β€” no new pending doc, receiver never sees it again. + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.data()!['status'], 'declined'); + }); + + test('decline retains the doc with status declined', () async { + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + await repository.declineFriendRequest('alice_bob'); + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.exists, isTrue); + expect(doc.data()!['status'], 'declined'); + }); + + test('reverse pending auto-accepts and removes both request docs', + () async { + await firestore.collection('users').doc('alice').set({'username': 'a'}); + await firestore.collection('users').doc('bob').set({'username': 'b'}); + await repository.addFriendRequest('bob', 'Bob', null, 'alice'); + final result = + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + expect(result, FriendRequestResult.autoAccepted); + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friends/bob/friendList/alice').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friendRequests/bob_alice').get()).exists, + isFalse, + ); + }); + + test( + 'reverse pending wins over declined suppression: ' + 'auto-accepts and leaves the declined doc untouched', () async { + await firestore.collection('users').doc('alice').set({'username': 'a'}); + await firestore.collection('users').doc('bob').set({'username': 'b'}); + // Alice sent Bob a request; Bob declined it. + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + await repository.declineFriendRequest('alice_bob'); + // Bob later sends Alice a request (reverse-direction pending). + await repository.addFriendRequest('bob', 'Bob', null, 'alice'); + // Alice taps Accept on Bob's request from the search card β€” this + // re-invokes addFriendRequest in the alice->bob direction. The + // reverse-pending (bob_alice) must win over the own-doc declined + // short-circuit (alice_bob) and auto-accept. + final result = + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + expect(result, FriendRequestResult.autoAccepted); + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friends/bob/friendList/alice').get()).exists, + isTrue, + ); + expect( + (await firestore.doc('friendRequests/bob_alice').get()).exists, + isFalse, + ); + // The old declined doc is untouched β€” not deleted, not resurrected. + final declinedDoc = + await firestore.doc('friendRequests/alice_bob').get(); + expect(declinedDoc.exists, isTrue); + expect(declinedDoc.data()!['status'], 'declined'); + }); + + test('getFriendRequests still filters to pending only', () async { + await repository.addFriendRequest('alice', 'Alice', null, 'bob'); + await repository.addFriendRequest('carol', 'Carol', null, 'bob'); + await repository.declineFriendRequest('alice_bob'); + final requests = await repository.getFriendRequests('bob'); + expect(requests.map((r) => r.senderId), ['carol']); + }); + + test('denormalizes senderFriendCode when provided, omits it when null', + () async { + await repository.addFriendRequest('alice', 'Alice', 'YETI-A3F9', 'bob'); + final doc = await firestore.doc('friendRequests/alice_bob').get(); + expect(doc.data()!['senderFriendCode'], 'YETI-A3F9'); + + await repository.addFriendRequest('carol', 'Carol', null, 'bob'); + final noCodeDoc = await firestore.doc('friendRequests/carol_bob').get(); + expect(noCodeDoc.data()!.containsKey('senderFriendCode'), isFalse); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/generate_unique_friend_code_test.dart b/packages/firebase_database_repository/test/src/generate_unique_friend_code_test.dart new file mode 100644 index 0000000..7d230f3 --- /dev/null +++ b/packages/firebase_database_repository/test/src/generate_unique_friend_code_test.dart @@ -0,0 +1,25 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + group('generateUniqueFriendCode', () { + test('is an 8-character uppercase alphanumeric code with no prefix', + () async { + final code = await repository.generateUniqueFriendCode(); + + expect(code, hasLength(8)); + expect(code, matches(RegExp(r'^[A-Z0-9]{8}$'))); + expect(code, isNot(contains('-'))); + expect(code, isNot(startsWith('YETI'))); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/legacy_friend_request_test.dart b/packages/firebase_database_repository/test/src/legacy_friend_request_test.dart new file mode 100644 index 0000000..ec865e0 --- /dev/null +++ b/packages/firebase_database_repository/test/src/legacy_friend_request_test.dart @@ -0,0 +1,87 @@ +// Verifies acceptFriendRequest's catch block rethrows a rules-layer +// permission-denied FirebaseException as the typed LegacyFriendRequestException +// instead of the generic wrapped Exception. fake_cloud_firestore has no rules +// engine (see friend_request_lifecycle_test.dart's coverage note), so a mock +// FirebaseFirestore is used here purely to inject a thrown FirebaseException +// at the first read the method performs. CollectionReference/DocumentReference +// are sealed in cloud_firestore, so only the (unsealed) FirebaseFirestore +// itself is mocked β€” `collection('users')` throws directly rather than +// returning a mocked reference chain. +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFirebaseFirestore extends Mock implements FirebaseFirestore {} + +void main() { + group('acceptFriendRequest legacy-request mapping', () { + final request = FriendRequestModel( + id: 'bob_alice', + senderId: 'bob', + senderName: 'Bob', + receiverId: 'alice', + status: 'pending', + timestamp: DateTime(2024), + ); + + test( + 'rethrows FirebaseException permission-denied as ' + 'LegacyFriendRequestException', () async { + final firestore = _MockFirebaseFirestore(); + when(() => firestore.collection('users')).thenThrow( + FirebaseException( + plugin: 'cloud_firestore', + code: 'permission-denied', + ), + ); + + final repository = FirebaseDatabaseRepository(firebase: firestore); + + expect( + () => repository.acceptFriendRequest(request, 'alice'), + throwsA(isA()), + ); + }); + + test('other failures still throw the generic Exception', () async { + final firestore = _MockFirebaseFirestore(); + when(() => firestore.collection('users')).thenThrow( + FirebaseException(plugin: 'cloud_firestore', code: 'unavailable'), + ); + + final repository = FirebaseDatabaseRepository(firebase: firestore); + + await expectLater( + repository.acceptFriendRequest(request, 'alice'), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }); + + test('happy path against fake_cloud_firestore is unaffected', () async { + final firestore = FakeFirebaseFirestore(); + await firestore.collection('users').doc('bob').set({ + 'username': 'Bob', + 'imageUrl': '', + }); + await firestore.collection('users').doc('alice').set({ + 'username': 'Alice', + 'imageUrl': '', + }); + final repository = FirebaseDatabaseRepository(firebase: firestore); + + await repository.acceptFriendRequest(request, 'alice'); + + expect( + (await firestore.doc('friends/alice/friendList/bob').get()).exists, + isTrue, + ); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/pin_storage_test.dart b/packages/firebase_database_repository/test/src/pin_storage_test.dart new file mode 100644 index 0000000..1e3a82c --- /dev/null +++ b/packages/firebase_database_repository/test/src/pin_storage_test.dart @@ -0,0 +1,144 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + group('salt helpers', () { + test('generateSalt returns 32 hex chars and is random', () { + final a = FirebaseDatabaseRepository.generateSalt(); + final b = FirebaseDatabaseRepository.generateSalt(); + expect(a, matches(RegExp(r'^[0-9a-f]{32}$'))); + expect(a, isNot(b)); + }); + + test('saltedPinHash is deterministic and salt-sensitive', () { + final h1 = FirebaseDatabaseRepository.saltedPinHash('0742', 'aa'); + expect(h1, FirebaseDatabaseRepository.saltedPinHash('0742', 'aa')); + expect(h1, isNot(FirebaseDatabaseRepository.saltedPinHash('0742', 'bb'))); + expect(h1, isNot(FirebaseDatabaseRepository.hashPin('0742'))); + }); + }); + + group('setPin', () { + test('writes salted credentials, sets hasPin, removes legacy field', + () async { + await firestore + .collection('users') + .doc('u1') + .set({'username': 'josh', 'pin': 'legacyhash'}); + + await repository.setPin('u1', '0742'); + + final creds = await firestore + .doc('users/u1/private/credentials') + .get(); + final salt = creds.data()!['salt'] as String; + expect( + creds.data()!['pinHash'], + FirebaseDatabaseRepository.saltedPinHash('0742', salt), + ); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + expect(profile.data()!.containsKey('pin'), isFalse); + expect(profile.data()!['username'], 'josh'); + }); + }); + + group('migrateLegacyPin', () { + test('moves legacy hash into private credentials with null salt', + () async { + final legacyHash = FirebaseDatabaseRepository.hashPin('0742'); + await firestore.collection('users').doc('u1').set({'pin': legacyHash}); + + await repository.migrateLegacyPin('u1'); + // migrateLegacyPin fires the batch commit without awaiting it (see + // the offline-hang fix in FirebaseDatabaseRepository), so give the + // microtask queue a turn before asserting on its effects. + await Future.delayed(Duration.zero); + + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.data()!['pinHash'], legacyHash); + expect(creds.data()!['salt'], isNull); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + expect(profile.data()!.containsKey('pin'), isFalse); + }); + + test('no-ops when there is nothing to migrate', () async { + await firestore.collection('users').doc('u1').set({'username': 'j'}); + await repository.migrateLegacyPin('u1'); + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.exists, isFalse); + }); + + test('does not overwrite already-migrated credentials', () async { + await firestore.doc('users/u1/private/credentials').set({ + 'pinHash': 'saltedHash', + 'salt': 'realsalt', + }); + await firestore + .collection('users') + .doc('u1') + .set({'pin': 'staleLegacy'}); + + await repository.migrateLegacyPin('u1'); + // See note above: the batch commit is fire-and-forget. + await Future.delayed(Duration.zero); + + final creds = + await firestore.doc('users/u1/private/credentials').get(); + expect(creds.data()!['pinHash'], 'saltedHash'); + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!.containsKey('pin'), isFalse); + expect(profile.data()!['hasPin'], isTrue); + }); + + test('repairs a wiped hasPin flag when credentials exist', () async { + await firestore.doc('users/u1/private/credentials').set({ + 'pinHash': 'saltedHash', + 'salt': 'realsalt', + }); + // Old-version client full-doc write wiped the flag and has no pin field. + await firestore.collection('users').doc('u1').set({'username': 'j'}); + + await repository.migrateLegacyPin('u1'); + await Future.delayed(Duration.zero); + + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!['hasPin'], isTrue); + }); + + test('does not create credentials or set hasPin for a user with none', + () async { + await firestore.collection('users').doc('u1').set({'username': 'j'}); + await repository.migrateLegacyPin('u1'); + await Future.delayed(Duration.zero); + final profile = await firestore.doc('users/u1').get(); + expect(profile.data()!.containsKey('hasPin'), isFalse); + }); + }); + + group('hasPin', () { + test('true for hasPin flag, true for legacy field, false otherwise', + () async { + await firestore.collection('users').doc('a').set({'hasPin': true}); + await firestore.collection('users').doc('b').set({'pin': 'hash'}); + await firestore.collection('users').doc('c').set({'username': 'x'}); + expect(await repository.hasPin('a'), isTrue); + expect(await repository.hasPin('b'), isTrue); + expect(await repository.hasPin('c'), isFalse); + }); + }); +} diff --git a/packages/firebase_database_repository/test/src/search_by_friend_code_test.dart b/packages/firebase_database_repository/test/src/search_by_friend_code_test.dart new file mode 100644 index 0000000..221ccb2 --- /dev/null +++ b/packages/firebase_database_repository/test/src/search_by_friend_code_test.dart @@ -0,0 +1,125 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFunctions extends Mock implements FirebaseFunctions {} + +class _MockCallable extends Mock implements HttpsCallable {} + +class _MockResult extends Mock implements HttpsCallableResult {} + +void main() { + late _MockFunctions functions; + late _MockCallable callable; + late FirebaseDatabaseRepository repository; + + setUp(() { + functions = _MockFunctions(); + callable = _MockCallable(); + when(() => functions.httpsCallable('searchByFriendCode')) + .thenReturn(callable); + repository = FirebaseDatabaseRepository( + firebase: FakeFirebaseFirestore(), + functions: functions, + ); + }); + + void stubResult(Map data) { + final result = _MockResult(); + when(() => result.data).thenReturn(data); + when(() => callable.call(any>())).thenAnswer((_) async => result); + } + + test( + 'found:true payload maps to FriendSearchResult with ' + 'RelationshipStatus.pendingSent', () async { + stubResult({ + 'found': true, + 'user': { + 'id': 'target', + 'username': 'Target', + 'imageUrl': 'http://x/y.png', + 'friendCode': 'YETI-A3F9', + }, + 'relationship': 'pendingSent', + }); + + final result = await repository.searchByFriendCode('yeti-a3f9'); + + expect(result.found, isTrue); + expect(result.user?.id, 'target'); + expect(result.user?.username, 'Target'); + expect(result.user?.imageUrl, 'http://x/y.png'); + expect(result.user?.friendCode, 'YETI-A3F9'); + expect(result.relationship, RelationshipStatus.pendingSent); + verify( + () => callable.call({'code': 'yeti-a3f9'}), + ).called(1); + }); + + test('found:false payload maps to a not-found FriendSearchResult', () async { + stubResult({'found': false}); + + final result = await repository.searchByFriendCode('YETI-ZZZZ'); + + expect(result, const FriendSearchResult(found: false)); + }); + + test('unavailable throws', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException(code: 'unavailable', message: 'offline'), + ); + + expect( + () => repository.searchByFriendCode('YETI-A3F9'), + throwsA(isA()), + ); + }); + + test('invalid-argument throws ArgumentError', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException( + code: 'invalid-argument', + message: 'code is required', + ), + ); + + expect( + () => repository.searchByFriendCode(''), + throwsA(isA()), + ); + }); + + test('other FirebaseFunctionsException throws (not found:false)', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException( + code: 'internal', + message: 'boom', + ), + ); + + expect( + () => repository.searchByFriendCode('YETI-A3F9'), + throwsA(isA()), + ); + }); + + test('found:true maps friends relationship correctly', () async { + stubResult({ + 'found': true, + 'user': { + 'id': 'target', + 'username': 'Target', + 'imageUrl': '', + 'friendCode': 'YETI-A3F9', + }, + 'relationship': 'friends', + }); + + final result = await repository.searchByFriendCode('YETI-A3F9'); + + expect(result.relationship, RelationshipStatus.friends); + }); +} diff --git a/packages/firebase_database_repository/test/src/search_by_username_test.dart b/packages/firebase_database_repository/test/src/search_by_username_test.dart new file mode 100644 index 0000000..e8fc944 --- /dev/null +++ b/packages/firebase_database_repository/test/src/search_by_username_test.dart @@ -0,0 +1,103 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFunctions extends Mock implements FirebaseFunctions {} + +class _MockCallable extends Mock implements HttpsCallable {} + +class _MockResult extends Mock implements HttpsCallableResult {} + +void main() { + late _MockFunctions functions; + late _MockCallable callable; + late FirebaseDatabaseRepository repository; + + setUp(() { + functions = _MockFunctions(); + callable = _MockCallable(); + when(() => functions.httpsCallable('searchByUsername')) + .thenReturn(callable); + repository = FirebaseDatabaseRepository( + firebase: FakeFirebaseFirestore(), + functions: functions, + ); + }); + + void stubResult(Map data) { + final result = _MockResult(); + when(() => result.data).thenReturn(data); + when(() => callable.call(any>())) + .thenAnswer((_) async => result); + } + + test('maps multiple matches to UserSearchMatch with their relationships', + () async { + stubResult({ + 'matches': [ + { + 'user': { + 'id': 'josh', + 'username': 'Josh', + 'imageUrl': 'http://x/y.png', + 'friendCode': 'YETI-JOSH', + }, + 'relationship': 'none', + }, + { + 'user': { + 'id': 'john', + 'username': 'John', + 'imageUrl': '', + 'friendCode': 'YETI-JOHN', + }, + 'relationship': 'friends', + }, + ], + }); + + final result = await repository.searchByUsername('jo'); + + expect(result, hasLength(2)); + expect(result[0].user.id, 'josh'); + expect(result[0].relationship, RelationshipStatus.none); + expect(result[1].user.id, 'john'); + expect(result[1].relationship, RelationshipStatus.friends); + verify(() => callable.call({'query': 'jo'})).called(1); + }); + + test('empty matches payload maps to an empty list', () async { + stubResult({'matches': []}); + + final result = await repository.searchByUsername('zz'); + + expect(result, isEmpty); + }); + + test('invalid-argument throws ArgumentError', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException( + code: 'invalid-argument', + message: 'query must be at least 2 characters.', + ), + ); + + expect( + () => repository.searchByUsername('j'), + throwsA(isA()), + ); + }); + + test('other FirebaseFunctionsException throws a plain Exception', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException(code: 'internal', message: 'boom'), + ); + + expect( + () => repository.searchByUsername('jo'), + throwsA(isA()), + ); + }); +} diff --git a/packages/firebase_database_repository/test/src/update_user_profile_test.dart b/packages/firebase_database_repository/test/src/update_user_profile_test.dart new file mode 100644 index 0000000..d6e15bd --- /dev/null +++ b/packages/firebase_database_repository/test/src/update_user_profile_test.dart @@ -0,0 +1,37 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late FakeFirebaseFirestore firestore; + late FirebaseDatabaseRepository repository; + + setUp(() { + firestore = FakeFirebaseFirestore(); + repository = FirebaseDatabaseRepository(firebase: firestore); + }); + + test('derives usernameLower from username on write, ignoring any ' + 'usernameLower already on the model', () async { + const profile = UserProfileModel( + id: 'u1', + username: 'Josh', + usernameLower: 'stale-value', + ); + + await repository.updateUserProfile('u1', profile); + + final doc = await firestore.doc('users/u1').get(); + expect(doc.data()!['usernameLower'], 'josh'); + }); + + test('a null username clears any stale usernameLower rather than keeping ' + 'it', () async { + const profile = UserProfileModel(id: 'u1', usernameLower: 'stale-value'); + + await repository.updateUserProfile('u1', profile); + + final doc = await firestore.doc('users/u1').get(); + expect(doc.data()!.containsKey('usernameLower'), isFalse); + }); +} diff --git a/packages/firebase_database_repository/test/src/validate_pin_test.dart b/packages/firebase_database_repository/test/src/validate_pin_test.dart new file mode 100644 index 0000000..08e29c5 --- /dev/null +++ b/packages/firebase_database_repository/test/src/validate_pin_test.dart @@ -0,0 +1,104 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFunctions extends Mock implements FirebaseFunctions {} + +class _MockCallable extends Mock implements HttpsCallable {} + +class _MockResult extends Mock implements HttpsCallableResult {} + +void main() { + late _MockFunctions functions; + late _MockCallable callable; + late FirebaseDatabaseRepository repository; + + setUp(() { + functions = _MockFunctions(); + callable = _MockCallable(); + when(() => functions.httpsCallable('validatePin')).thenReturn(callable); + repository = FirebaseDatabaseRepository( + firebase: FakeFirebaseFirestore(), + functions: functions, + ); + }); + + void stubResult(Map data) { + final result = _MockResult(); + when(() => result.data).thenReturn(data); + when(() => callable.call(any())).thenAnswer((_) async => result); + } + + test('valid response maps to PinValid', () async { + stubResult({'valid': true}); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ); + expect(result, const PinValid()); + verify( + () => callable.call({'targetUserId': 'friend1', 'pin': '0742'}), + ).called(1); + }); + + test('invalid response maps to PinInvalid with attemptsRemaining', () async { + stubResult({'valid': false, 'attemptsRemaining': 3}); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '9999', + ); + expect(result, const PinInvalid(attemptsRemaining: 3)); + }); + + test('resource-exhausted maps to PinLockedOut with lockedUntil', () async { + final until = DateTime.now().add(const Duration(minutes: 15)); + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException( + code: 'resource-exhausted', + message: 'locked', + details: {'lockedUntilMillis': until.millisecondsSinceEpoch}, + ), + ); + final result = await repository.validatePin( + targetUserId: 'friend1', + pin: '0742', + ); + expect(result, isA()); + expect( + (result as PinLockedOut).lockedUntil.millisecondsSinceEpoch, + until.millisecondsSinceEpoch, + ); + }); + + test('unavailable/internal errors map to PinCheckUnavailable', () async { + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException(code: 'unavailable', message: 'offline'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinCheckUnavailable(), + ); + }); + + test('failed-precondition maps to PinNotSet', () async { + when(() => callable.call(any())).thenThrow( + FirebaseFunctionsException(code: 'failed-precondition', message: 'no pin'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinNotSet(), + ); + }); + + test('permission-denied maps to PinCheckUnavailable', () async { + when(() => callable.call(any>())).thenThrow( + FirebaseFunctionsException(code: 'permission-denied', message: 'denied'), + ); + expect( + await repository.validatePin(targetUserId: 'f', pin: '0742'), + const PinCheckUnavailable(), + ); + }); +} diff --git a/packages/form_inputs/lib/src/username.dart b/packages/form_inputs/lib/src/username.dart index 7e0196a..bba7564 100644 --- a/packages/form_inputs/lib/src/username.dart +++ b/packages/form_inputs/lib/src/username.dart @@ -2,12 +2,21 @@ import 'package:formz/formz.dart'; /// Username Form Input Validation Error enum UsernameValidationError { - /// Username is empty (should have at least 1 character) - empty + /// Username is empty or whitespace-only. + empty, + + /// Username is shorter than [Username.minLength] after trimming. + tooShort, + + /// Username is longer than [Username.maxLength] after trimming. + tooLong, } /// {@template username} /// Reusable username form input. +/// +/// Validates the trimmed value; callers persist `value.trim()` so stored +/// usernames never carry edge whitespace. /// {@endtemplate} class Username extends FormzInput { /// {@macro username} @@ -16,8 +25,19 @@ class Username extends FormzInput { /// {@macro username} const Username.dirty([super.value = '']) : super.dirty(); + /// Minimum trimmed length β€” matches the server-side username search's + /// 2-character minimum query, so every valid username is discoverable. + static const minLength = 2; + + /// Maximum trimmed length. + static const maxLength = 30; + @override UsernameValidationError? validator(String value) { - return value.isNotEmpty ? null : UsernameValidationError.empty; + final trimmed = value.trim(); + if (trimmed.isEmpty) return UsernameValidationError.empty; + if (trimmed.length < minLength) return UsernameValidationError.tooShort; + if (trimmed.length > maxLength) return UsernameValidationError.tooLong; + return null; } } diff --git a/packages/form_inputs/test/src/username_test.dart b/packages/form_inputs/test/src/username_test.dart new file mode 100644 index 0000000..28f5549 --- /dev/null +++ b/packages/form_inputs/test/src/username_test.dart @@ -0,0 +1,41 @@ +import 'package:form_inputs/form_inputs.dart'; +import 'package:test/test.dart'; + +void main() { + group('Username', () { + test('pure empty value reports no display error and is not valid', () { + const username = Username.pure(); + expect(username.displayError, isNull); + expect(username.isValid, isFalse); + }); + + test('empty dirty value has the empty error', () { + const username = Username.dirty(); + expect(username.error, UsernameValidationError.empty); + }); + + test('whitespace-only value has the empty error', () { + const username = Username.dirty(' '); + expect(username.error, UsernameValidationError.empty); + }); + + test('one character after trimming is too short', () { + const username = Username.dirty(' a '); + expect(username.error, UsernameValidationError.tooShort); + }); + + test('31 characters after trimming is too long', () { + final username = Username.dirty('a' * 31); + expect(username.error, UsernameValidationError.tooLong); + }); + + test('2 and 30 trimmed characters are valid', () { + expect(const Username.dirty('ab').isValid, isTrue); + expect(Username.dirty(' ${'a' * 30} ').isValid, isTrue); + }); + + test('interior spaces are allowed', () { + expect(const Username.dirty('Cool Name').isValid, isTrue); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 03b1e22..6155ded 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -268,6 +268,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.4.5" + cloud_functions: + dependency: "direct main" + description: + name: cloud_functions + sha256: e7c7333270522ae226b731fbd50465e4f3639cfe55176a582129d7b6cd484f28 + url: "https://pub.dev" + source: hosted + version: "5.3.4" + cloud_functions_platform_interface: + dependency: transitive + description: + name: cloud_functions_platform_interface + sha256: "5520f7cdb89069ba52cd067932dcd0f4aad7bc8d8a183dccc68d34d8014e2206" + url: "https://pub.dev" + source: hosted + version: "5.6.4" + cloud_functions_web: + dependency: transitive + description: + name: cloud_functions_web + sha256: "69a7dc1ca6b8147570503e934013330afa64052baedf2220ec936ece278b0240" + url: "https://pub.dev" + source: hosted + version: "4.10.10" code_builder: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e1ca5dc..faa8a9a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: bloc_concurrency: ^0.3.0 cached_network_image: ^3.3.1 cloud_firestore: ^5.5.0 + cloud_functions: ^5.2.0 equatable: ^2.0.5 fake_app_config_repository: path: packages/app_config_repository/fake_app_config_repository diff --git a/test/app/bloc/app_bloc_test.dart b/test/app/bloc/app_bloc_test.dart new file mode 100644 index 0000000..98053f7 --- /dev/null +++ b/test/app/bloc/app_bloc_test.dart @@ -0,0 +1,189 @@ +import 'package:app_config_repository/app_config_repository.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +class _MockAppConfigRepository extends Mock implements AppConfigRepository {} + +class _MockUserRepository extends Mock implements UserRepository {} + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('AppBloc', () { + late AppConfigRepository appConfigRepository; + late UserRepository userRepository; + late FirebaseDatabaseRepository firebaseDatabaseRepository; + + setUp(() { + appConfigRepository = _MockAppConfigRepository(); + userRepository = _MockUserRepository(); + firebaseDatabaseRepository = _MockFirebaseDatabaseRepository(); + + // Stub user stream to return empty + when(() => userRepository.user).thenAnswer( + (_) => const Stream.empty(), + ); + + // Stub app config streams to return empty + when(() => appConfigRepository.isForceUpgradeRequired()).thenAnswer( + (_) => const Stream.empty(), + ); + when(() => appConfigRepository.isDownForMaintenance()).thenAnswer( + (_) => const Stream.empty(), + ); + + // Default stub for migrateLegacyPin to avoid errors in existing tests + when(() => firebaseDatabaseRepository.migrateLegacyPin(any())) + .thenAnswer((_) async {}); + }); + + AppBloc buildBloc() => AppBloc( + appConfigRepository: appConfigRepository, + userRepository: userRepository, + firebaseDatabaseRepository: firebaseDatabaseRepository, + user: const User(id: 'initial-user-id'), + ); + + group('_onUserChanged', () { + blocTest( + 'migrates legacy PIN before evaluating the profile', + setUp: () { + when(() => firebaseDatabaseRepository.migrateLegacyPin('user1')) + .thenAnswer((_) async {}); + when(() => firebaseDatabaseRepository.getUserProfileOnce('user1')) + .thenAnswer( + (_) async => const UserProfileModel( + id: 'user1', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ), + ); + }, + build: buildBloc, + act: (bloc) => bloc.add( + const AppUserChanged( + User(id: 'user1', email: 'josh@example.com'), + ), + ), + verify: (_) { + verifyInOrder([ + () => firebaseDatabaseRepository.migrateLegacyPin('user1'), + () => firebaseDatabaseRepository.getUserProfileOnce('user1'), + ]); + }, + ); + }); + + group('completeness gate', () { + const authenticatedUser = User(id: 'user1', email: 'josh@example.com'); + + void stubProfile(UserProfileModel? profile) { + when(() => firebaseDatabaseRepository.getUserProfileOnce('user1')) + .thenAnswer((_) async => profile); + } + + blocTest( + 'complete profile emits authenticated', + setUp: () => stubProfile( + const UserProfileModel( + id: 'user1', + username: 'josh', + hasPin: true, + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.authenticated), + ], + ); + + blocTest( + 'onboarded profile missing a PIN emits onboardingRequired', + setUp: () => stubProfile( + const UserProfileModel( + id: 'user1', + username: 'josh', + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.onboardingRequired), + ], + ); + + blocTest( + 'onboarded profile with empty username emits onboardingRequired', + setUp: () => stubProfile( + const UserProfileModel( + id: 'user1', + username: '', + hasPin: true, + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.onboardingRequired), + ], + ); + + blocTest( + 'legacy unmigrated pin field counts as complete', + setUp: () => stubProfile( + const UserProfileModel( + id: 'user1', + username: 'josh', + pin: 'legacyhash', + onboardingComplete: true, + ), + ), + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.authenticated), + ], + ); + + blocTest( + 'missing profile emits onboardingRequired', + setUp: () => stubProfile(null), + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.onboardingRequired), + ], + ); + + blocTest( + 'network failure falls back to authenticated (offline users are ' + 'never locked out by the gate)', + setUp: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('user1')) + .thenThrow(Exception('network unavailable')); + }, + build: buildBloc, + act: (bloc) => bloc.add(const AppUserChanged(authenticatedUser)), + expect: () => [ + isA() + .having((s) => s.status, 'status', AppStatus.authenticated), + ], + ); + }); + }); +} diff --git a/test/friends_list/blocked_users/bloc/blocked_users_bloc_test.dart b/test/friends_list/blocked_users/bloc/blocked_users_bloc_test.dart new file mode 100644 index 0000000..e822aec --- /dev/null +++ b/test/friends_list/blocked_users/bloc/blocked_users_bloc_test.dart @@ -0,0 +1,141 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/friends_list/blocked_users/bloc/blocked_users_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('BlockedUsersBloc', () { + late FirebaseDatabaseRepository repository; + + const bob = BlockedUserModel( + userId: 'bob', + username: 'Bob', + imageUrl: 'http://x/bob.png', + ); + + const carol = BlockedUserModel( + userId: 'carol', + username: 'Carol', + imageUrl: 'http://x/carol.png', + ); + + setUp(() { + repository = _MockFirebaseDatabaseRepository(); + }); + + BlockedUsersBloc buildBloc() => + BlockedUsersBloc(repository: repository); + + test('initial state is BlockedUsersLoading', () { + expect(buildBloc().state, isA()); + }); + + group('LoadBlockedUsers', () { + blocTest( + 'emits [loading, loaded] when getBlockedUsers succeeds', + setUp: () { + when(() => repository.getBlockedUsers('alice')).thenAnswer( + (_) => Stream.value([bob, carol]), + ); + }, + build: buildBloc, + act: (bloc) => bloc.add(const LoadBlockedUsers('alice')), + expect: () => [ + isA(), + isA().having( + (s) => s.blockedUsers, + 'blockedUsers', + [bob, carol], + ), + ], + ); + + blocTest( + 'emits [loading, loaded] again when the stream emits an update', + setUp: () { + when(() => repository.getBlockedUsers('alice')).thenAnswer( + (_) => Stream.fromIterable([ + [bob, carol], + [carol], + ]), + ); + }, + build: buildBloc, + act: (bloc) => bloc.add(const LoadBlockedUsers('alice')), + expect: () => [ + isA(), + isA().having( + (s) => s.blockedUsers, + 'blockedUsers', + [bob, carol], + ), + isA().having( + (s) => s.blockedUsers, + 'blockedUsers', + [carol], + ), + ], + ); + + blocTest( + 'emits [loading, error] when getBlockedUsers stream errors', + setUp: () { + when(() => repository.getBlockedUsers('alice')).thenAnswer( + (_) => Stream.error(Exception('boom')), + ); + }, + build: buildBloc, + act: (bloc) => bloc.add(const LoadBlockedUsers('alice')), + expect: () => [ + isA(), + isA(), + ], + ); + }); + + group('UnblockUser', () { + blocTest( + 'calls repository.unblockUser with currentUserId and targetUserId', + setUp: () { + when( + () => repository.unblockUser( + currentUserId: any(named: 'currentUserId'), + targetUserId: any(named: 'targetUserId'), + ), + ).thenAnswer((_) async {}); + }, + build: buildBloc, + act: (bloc) => + bloc.add(const UnblockUser('alice', 'bob')), + verify: (_) { + verify( + () => repository.unblockUser( + currentUserId: 'alice', + targetUserId: 'bob', + ), + ).called(1); + }, + ); + + blocTest( + 'emits error when unblockUser fails', + setUp: () { + when( + () => repository.unblockUser( + currentUserId: any(named: 'currentUserId'), + targetUserId: any(named: 'targetUserId'), + ), + ).thenThrow(Exception('boom')); + }, + build: buildBloc, + act: (bloc) => + bloc.add(const UnblockUser('alice', 'bob')), + expect: () => [isA()], + ); + }); + }); +} diff --git a/test/friends_list/blocked_users/view/blocked_users_page_test.dart b/test/friends_list/blocked_users/view/blocked_users_page_test.dart new file mode 100644 index 0000000..6811ae1 --- /dev/null +++ b/test/friends_list/blocked_users/view/blocked_users_page_test.dart @@ -0,0 +1,106 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/blocked_users/bloc/blocked_users_bloc.dart'; +import 'package:magic_yeti/friends_list/blocked_users/view/blocked_users_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockBlockedUsersBloc + extends MockBloc + implements BlockedUsersBloc {} + +void main() { + group('BlockedUsersView', () { + late MockAppBloc appBloc; + late MockBlockedUsersBloc blockedUsersBloc; + + const bob = BlockedUserModel( + userId: 'bob', + username: 'Bob', + imageUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ); + const carol = BlockedUserModel( + userId: 'carol', + username: 'Carol', + imageUrl: 'http://x/carol.png', + ); + + setUp(() { + appBloc = MockAppBloc(); + blockedUsersBloc = MockBlockedUsersBloc(); + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + }); + + Future pumpBlockedUsers(WidgetTester tester) async { + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: blockedUsersBloc), + ], + child: const BlockedUsersView(), + ), + ); + await tester.pump(); + } + + testWidgets('renders seeded blocked users', (tester) async { + when(() => blockedUsersBloc.state) + .thenReturn(const BlockedUsersLoaded([bob, carol])); + + await pumpBlockedUsers(tester); + + expect(find.text('Bob'), findsOneWidget); + expect(find.text('Carol'), findsOneWidget); + expect(find.text('YETI-B0B1'), findsOneWidget); + }); + + testWidgets('shows the empty state when there are no blocked users', + (tester) async { + when(() => blockedUsersBloc.state) + .thenReturn(const BlockedUsersLoaded([])); + + await pumpBlockedUsers(tester); + + expect(find.text("You haven't blocked anyone."), findsOneWidget); + }); + + testWidgets('shows a loading indicator while loading', (tester) async { + when(() => blockedUsersBloc.state).thenReturn(BlockedUsersLoading()); + + await pumpBlockedUsers(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('tapping unblock and confirming dispatches UnblockUser', + (tester) async { + when(() => blockedUsersBloc.state) + .thenReturn(const BlockedUsersLoaded([bob])); + + await pumpBlockedUsers(tester); + + await tester.tap(find.text('Unblock')); + await tester.pumpAndSettle(); + + // Confirm in the dialog (second "Unblock", the action button). + await tester.tap(find.text('Unblock').last); + await tester.pumpAndSettle(); + + verify( + () => blockedUsersBloc.add(const UnblockUser('alice', 'bob')), + ).called(1); + }); + }); +} diff --git a/test/friends_list/friends_list/bloc/friend_list_bloc_test.dart b/test/friends_list/friends_list/bloc/friend_list_bloc_test.dart new file mode 100644 index 0000000..a08a1e9 --- /dev/null +++ b/test/friends_list/friends_list/bloc/friend_list_bloc_test.dart @@ -0,0 +1,83 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('FriendBloc', () { + late FirebaseDatabaseRepository repository; + + const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ); + + const target = BlockedUserModel( + userId: 'bob', + username: 'Bob', + imageUrl: 'http://x/bob.png', + ); + + setUpAll(() { + registerFallbackValue( + const BlockedUserModel(userId: '', username: '', imageUrl: ''), + ); + }); + + setUp(() { + repository = _MockFirebaseDatabaseRepository(); + }); + + FriendBloc buildBloc() => FriendBloc(repository: repository); + + group('BlockFriend', () { + blocTest( + 'calls repository.blockUser then reloads friends', + setUp: () { + when( + () => repository.blockUser( + currentUserId: any(named: 'currentUserId'), + target: any(named: 'target'), + ), + ).thenAnswer((_) async {}); + when(() => repository.getFriends('alice')) + .thenAnswer((_) async => []); + }, + build: buildBloc, + seed: () => const FriendsLoaded([bob]), + act: (bloc) => bloc.add(const BlockFriend('alice', target)), + expect: () => [ + isA().having((s) => s.friends, 'friends', isEmpty), + ], + verify: (_) { + verify( + () => repository.blockUser(currentUserId: 'alice', target: target), + ).called(1); + verify(() => repository.getFriends('alice')).called(1); + }, + ); + + blocTest( + 'emits FriendsError when blockUser fails', + setUp: () { + when( + () => repository.blockUser( + currentUserId: any(named: 'currentUserId'), + target: any(named: 'target'), + ), + ).thenThrow(Exception('boom')); + }, + build: buildBloc, + seed: () => const FriendsLoaded([bob]), + act: (bloc) => bloc.add(const BlockFriend('alice', target)), + expect: () => [isA()], + ); + }); + }); +} diff --git a/test/friends_list/friends_list/friends_list_test.dart b/test/friends_list/friends_list/friends_list_test.dart new file mode 100644 index 0000000..8affa54 --- /dev/null +++ b/test/friends_list/friends_list/friends_list_test.dart @@ -0,0 +1,119 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; +import 'package:magic_yeti/friends_list/friends_list/friends_list.dart'; +import 'package:magic_yeti/l10n/arb/app_localizations.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFriendBloc extends MockBloc + implements FriendBloc {} + +void main() { + group('FriendsListView block action', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + + const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ); + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + }); + + Future pumpFriendsList(WidgetTester tester) async { + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + ], + child: const FriendsListView(), + ), + ); + await tester.pump(); + } + + testWidgets('exposes a Block action in the friend card menu', + (tester) async { + await pumpFriendsList(tester); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Block'), findsOneWidget); + }); + + testWidgets( + 'confirming Block dispatches BlockFriend with the built ' + 'BlockedUserModel', (tester) async { + await pumpFriendsList(tester); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Block')); + await tester.pumpAndSettle(); + + // Confirmation dialog action button (also labelled "Block"). + await tester.tap(find.text('Block').last); + await tester.pumpAndSettle(); + + verify( + () => friendBloc.add( + const BlockFriend( + 'alice', + BlockedUserModel( + userId: 'bob', + username: 'Bob', + imageUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ), + ), + ), + ).called(1); + }); + + testWidgets( + 'confirming Remove renders localized dialog copy and dispatches ' + 'RemoveFriend', (tester) async { + await pumpFriendsList(tester); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + + final l10n = AppLocalizations.of( + tester.element(find.byType(FriendsListView)), + ); + expect(find.text(l10n.removeFriendConfirmTitle('Bob')), findsOneWidget); + expect(find.text(l10n.removeFriendConfirmBody), findsOneWidget); + expect(find.text(l10n.cancelTextButton), findsOneWidget); + + // Confirmation dialog action button (also labelled "Remove"). + await tester.tap(find.text('Remove').last); + await tester.pumpAndSettle(); + + verify( + () => friendBloc.add(const RemoveFriend('alice', 'bob')), + ).called(1); + }); + }); +} diff --git a/test/friends_list/requests/bloc/friend_request_bloc_test.dart b/test/friends_list/requests/bloc/friend_request_bloc_test.dart new file mode 100644 index 0000000..336b281 --- /dev/null +++ b/test/friends_list/requests/bloc/friend_request_bloc_test.dart @@ -0,0 +1,95 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/friends_list/requests/bloc/friend_request_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('FriendRequestBloc', () { + late FirebaseDatabaseRepository repository; + + final request = FriendRequestModel( + id: 'bob_alice', + senderId: 'bob', + senderName: 'Bob', + receiverId: 'alice', + status: 'pending', + timestamp: DateTime(2024), + ); + + setUp(() { + repository = _MockFirebaseDatabaseRepository(); + }); + + FriendRequestBloc buildBloc() => + FriendRequestBloc(repository: repository); + + group('AcceptFriendRequest', () { + blocTest( + 'emits FriendRequestLegacyAcceptError then re-emits the prior ' + 'loaded list when the repository throws ' + 'LegacyFriendRequestException, so the page recovers instead of ' + 'showing an empty list', + setUp: () { + when(() => repository.acceptFriendRequest(request, 'alice')) + .thenThrow( + const LegacyFriendRequestException( + message: 'Failed to accept friend request', + stackTrace: 'stack', + ), + ); + }, + build: buildBloc, + seed: () => FriendRequestLoaded([request]), + act: (bloc) => bloc.add(AcceptFriendRequest(request, 'alice')), + expect: () => [ + isA(), + isA().having( + (s) => s.requests, + 'requests', + [request], + ), + ], + ); + + blocTest( + 'emits generic FriendRequestError for other failures', + setUp: () { + when(() => repository.acceptFriendRequest(request, 'alice')) + .thenThrow(Exception('boom')); + }, + build: buildBloc, + seed: () => FriendRequestLoaded([request]), + act: (bloc) => bloc.add(AcceptFriendRequest(request, 'alice')), + expect: () => [ + isA().having( + (s) => s is FriendRequestLegacyAcceptError, + 'isLegacy', + false, + ), + ], + ); + + blocTest( + 'removes the accepted request from the in-memory list on success', + setUp: () { + when(() => repository.acceptFriendRequest(request, 'alice')) + .thenAnswer((_) async {}); + }, + build: buildBloc, + seed: () => FriendRequestLoaded([request]), + act: (bloc) => bloc.add(AcceptFriendRequest(request, 'alice')), + expect: () => [ + isA().having( + (s) => s.requests, + 'requests', + isEmpty, + ), + ], + ); + }); + }); +} diff --git a/test/friends_list/search_user/bloc/search_bloc_test.dart b/test/friends_list/search_user/bloc/search_bloc_test.dart new file mode 100644 index 0000000..664b839 --- /dev/null +++ b/test/friends_list/search_user/bloc/search_bloc_test.dart @@ -0,0 +1,338 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/friends_list/search_user/bloc/search_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + late _MockFirebaseDatabaseRepository repository; + + const target = UserProfileModel( + id: 'target', + username: 'Target', + friendCode: 'YETI-A3F9', + ); + const otherMatch = UserProfileModel( + id: 'other', + username: 'Targetina', + friendCode: 'YETI-ZZZZ', + ); + + SearchBloc buildBloc() => SearchBloc(repository: repository); + + setUp(() { + repository = _MockFirebaseDatabaseRepository(); + }); + + group('SearchSubmitted', () { + blocTest( + 'routes a friend-code-shaped query to searchByFriendCode', + build: () { + when(() => repository.searchByFriendCode('YETI-A3F9')).thenAnswer( + (_) async => const FriendSearchResult( + found: true, + user: target, + relationship: RelationshipStatus.none, + ), + ); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('YETI-A3F9', 'me')), + expect: () => [ + isA(), + isA().having( + (s) => s.matches, + 'matches', + const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.none, + ), + ], + ), + ], + verify: (_) { + verify(() => repository.searchByFriendCode('YETI-A3F9')).called(1); + verifyNever(() => repository.searchByUsername(any())); + }, + ); + + blocTest( + 'accepts a legacy YETI- code typed lowercase with surrounding ' + 'whitespace', + build: () { + when(() => repository.searchByFriendCode(' yeti-a3f9 ')).thenAnswer( + (_) async => const FriendSearchResult( + found: true, + user: target, + relationship: RelationshipStatus.none, + ), + ); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted(' yeti-a3f9 ', 'me')), + expect: () => [isA(), isA()], + verify: (_) { + verify(() => repository.searchByFriendCode(' yeti-a3f9 ')).called(1); + verifyNever(() => repository.searchByUsername(any())); + }, + ); + + blocTest( + 'routes a plain 8-character code-shaped query to searchByFriendCode', + build: () { + when(() => repository.searchByFriendCode('A3F9K2XQ')).thenAnswer( + (_) async => const FriendSearchResult( + found: true, + user: target, + relationship: RelationshipStatus.none, + ), + ); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('A3F9K2XQ', 'me')), + expect: () => [isA(), isA()], + verify: (_) { + verify(() => repository.searchByFriendCode('A3F9K2XQ')).called(1); + verifyNever(() => repository.searchByUsername(any())); + }, + ); + + blocTest( + 'falls back to searchByUsername when a code-shaped query is not a ' + 'real friend code β€” an 8-character string could coincidentally also ' + 'be a real username', + build: () { + when(() => repository.searchByFriendCode('GAMER123')).thenAnswer( + (_) async => const FriendSearchResult(found: false), + ); + when(() => repository.searchByUsername('GAMER123')).thenAnswer( + (_) async => const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.none, + ), + ], + ); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('GAMER123', 'me')), + expect: () => [ + isA(), + isA().having( + (s) => s.matches, + 'matches', + const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.none, + ), + ], + ), + ], + verify: (_) { + verify(() => repository.searchByFriendCode('GAMER123')).called(1); + verify(() => repository.searchByUsername('GAMER123')).called(1); + }, + ); + + blocTest( + 'routes anything else to searchByUsername, preserving match order', + build: () { + when(() => repository.searchByUsername('targ')).thenAnswer( + (_) async => const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.none, + ), + UserSearchMatch( + user: otherMatch, + relationship: RelationshipStatus.pendingReceived, + ), + ], + ); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('targ', 'me')), + expect: () => [ + isA(), + isA().having( + (s) => s.matches, + 'matches', + const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.none, + ), + UserSearchMatch( + user: otherMatch, + relationship: RelationshipStatus.pendingReceived, + ), + ], + ), + ], + verify: (_) { + verify(() => repository.searchByUsername('targ')).called(1); + verifyNever(() => repository.searchByFriendCode(any())); + }, + ); + + blocTest( + 'friend-code search with no result falls back to an equally-empty ' + 'username search', + build: () { + when(() => repository.searchByFriendCode('YETI-ZZZZ')) + .thenAnswer((_) async => const FriendSearchResult(found: false)); + when(() => repository.searchByUsername('YETI-ZZZZ')) + .thenAnswer((_) async => const []); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('YETI-ZZZZ', 'me')), + expect: () => [ + isA(), + isA().having((s) => s.matches, 'matches', isEmpty), + ], + ); + + blocTest( + 'ArgumentError from the repository surfaces as SearchError', + build: () { + when(() => repository.searchByUsername('a')) + .thenThrow(ArgumentError('too short')); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('a', 'me')), + expect: () => [isA(), isA()], + ); + + blocTest( + 'a plain Exception from the repository surfaces as SearchError', + build: () { + when(() => repository.searchByUsername('targ')) + .thenThrow(Exception('unavailable')); + return buildBloc(); + }, + act: (bloc) => bloc.add(const SearchSubmitted('targ', 'me')), + expect: () => [isA(), isA()], + ); + }); + + group('AddFriendRequest', () { + const meProfile = UserProfileModel( + id: 'me', + username: 'Me', + friendCode: 'YETI-ME01', + ); + + blocTest( + "looks up the sender's own profile and denormalizes its username + " + 'friend code onto the request β€” never a display name passed in by ' + 'the caller', + build: () { + when(() => repository.getUserProfileOnce('me')) + .thenAnswer((_) async => meProfile); + when( + () => repository.addFriendRequest( + 'me', + 'Me', + 'YETI-ME01', + 'target', + ), + ).thenAnswer((_) async => FriendRequestResult.sent); + return buildBloc(); + }, + seed: () => const SearchLoaded([ + UserSearchMatch(user: target, relationship: RelationshipStatus.none), + UserSearchMatch( + user: otherMatch, + relationship: RelationshipStatus.pendingReceived, + ), + ]), + act: (bloc) => bloc.add(const AddFriendRequest('me', 'target')), + expect: () => [ + isA() + .having((s) => s.result, 'result', FriendRequestResult.sent) + .having( + (s) => s.matches, + 'matches', + const [ + UserSearchMatch( + user: target, + relationship: RelationshipStatus.pendingSent, + ), + UserSearchMatch( + user: otherMatch, + relationship: RelationshipStatus.pendingReceived, + ), + ], + ), + ], + verify: (_) { + verify( + () => repository.addFriendRequest('me', 'Me', 'YETI-ME01', 'target'), + ).called(1); + }, + ); + + blocTest( + 'a missing sender profile falls back to an empty name and null ' + 'friend code, rather than throwing', + build: () { + when(() => repository.getUserProfileOnce('me')) + .thenAnswer((_) async => null); + when(() => repository.addFriendRequest('me', '', null, 'target')) + .thenAnswer((_) async => FriendRequestResult.sent); + return buildBloc(); + }, + seed: () => const SearchLoaded( + [UserSearchMatch(user: target, relationship: RelationshipStatus.none)], + ), + act: (bloc) => bloc.add(const AddFriendRequest('me', 'target')), + expect: () => [isA()], + verify: (_) { + verify( + () => repository.addFriendRequest('me', '', null, 'target'), + ).called(1); + }, + ); + + blocTest( + 'emits SearchError when addFriendRequest throws', + build: () { + when(() => repository.getUserProfileOnce('me')) + .thenAnswer((_) async => meProfile); + when( + () => repository.addFriendRequest('me', 'Me', 'YETI-ME01', 'target'), + ).thenThrow(Exception('boom')); + return buildBloc(); + }, + seed: () => const SearchLoaded( + [UserSearchMatch(user: target, relationship: RelationshipStatus.none)], + ), + act: (bloc) => bloc.add(const AddFriendRequest('me', 'target')), + expect: () => [isA()], + ); + + blocTest( + 'emits SearchError when the profile lookup itself throws', + build: () { + when(() => repository.getUserProfileOnce('me')) + .thenThrow(Exception('offline')); + return buildBloc(); + }, + seed: () => const SearchLoaded( + [UserSearchMatch(user: target, relationship: RelationshipStatus.none)], + ), + act: (bloc) => bloc.add(const AddFriendRequest('me', 'target')), + expect: () => [isA()], + verify: (_) { + verifyNever( + () => repository.addFriendRequest(any(), any(), any(), any()), + ); + }, + ); + }); +} diff --git a/test/friends_list/search_user/search_user_page_anonymous_test.dart b/test/friends_list/search_user/search_user_page_anonymous_test.dart new file mode 100644 index 0000000..1d20259 --- /dev/null +++ b/test/friends_list/search_user/search_user_page_anonymous_test.dart @@ -0,0 +1,69 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('SearchUserPage anonymous placeholder', () { + late MockAppBloc appBloc; + late MockFirebaseDatabaseRepository databaseRepository; + + setUp(() { + appBloc = MockAppBloc(); + databaseRepository = MockFirebaseDatabaseRepository(); + }); + + Future pumpSearchUserPage(WidgetTester tester) async { + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + ], + child: RepositoryProvider.value( + value: databaseRepository, + child: const SearchUserPage(), + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'shows signInToSearchFriends copy instead of the search field ' + 'when anonymous', (tester) async { + when(() => appBloc.state).thenReturn( + const AppState.anonymous(User(id: 'anon1')), + ); + + await pumpSearchUserPage(tester); + + expect(find.text('Sign in to add friends.'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets( + 'shows the search field (not the anonymous placeholder) ' + 'when authenticated', (tester) async { + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + + await pumpSearchUserPage(tester); + + expect(find.text('Sign in to add friends.'), findsNothing); + expect(find.byType(TextField), findsOneWidget); + }); + }); +} diff --git a/test/friends_list/search_user/search_user_page_error_test.dart b/test/friends_list/search_user/search_user_page_error_test.dart new file mode 100644 index 0000000..766e3a1 --- /dev/null +++ b/test/friends_list/search_user/search_user_page_error_test.dart @@ -0,0 +1,59 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/friends_list/search_user/search_user_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + group('SearchUserPage error state', () { + late MockAppBloc appBloc; + late MockFirebaseDatabaseRepository databaseRepository; + + setUp(() { + appBloc = MockAppBloc(); + databaseRepository = MockFirebaseDatabaseRepository(); + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + }); + + testWidgets('renders localized copy, not the raw exception', + (tester) async { + when(() => databaseRepository.searchByUsername(any())) + .thenThrow(Exception('boom')); + + await tester.pumpApp( + MultiBlocProvider( + providers: [BlocProvider.value(value: appBloc)], + child: RepositoryProvider.value( + value: databaseRepository, + child: const SearchUserPage(), + ), + ), + ); + await tester.pump(); + + await tester.enterText(find.byType(TextField), 'someone'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + find.text('Search failed. Check your connection and try again.'), + findsOneWidget, + ); + expect(find.textContaining('boom'), findsNothing); + expect(find.textContaining('Exception'), findsNothing); + }); + }); +} diff --git a/test/life_counter/bloc/game_over_bloc_test.dart b/test/life_counter/bloc/game_over_bloc_test.dart new file mode 100644 index 0000000..a39cb29 --- /dev/null +++ b/test/life_counter/bloc/game_over_bloc_test.dart @@ -0,0 +1,357 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/life_counter/bloc/game_over_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:player_repository/player_repository.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class _FakeGameModel extends Fake implements GameModel {} + +void main() { + setUpAll(() { + registerFallbackValue(_FakeGameModel()); + }); + + group('GameOverBloc', () { + late FirebaseDatabaseRepository firebaseDatabaseRepository; + + const basePlayer = Player( + id: 'p1', + name: 'Player One', + playerNumber: 1, + lifePoints: 40, + color: 0, + opponents: [], + placement: 1, + ); + + final hostLinkedSlot = basePlayer.copyWith( + id: 'p1', + firebaseId: () => 'host', + placement: const Value(1), + ); + + final unlinkedSlot = basePlayer.copyWith( + id: 'p2', + firebaseId: () => null, + placement: const Value(2), + ); + + final linkedFriendSlot = basePlayer.copyWith( + id: 'p2', + firebaseId: () => 'friend1', + placement: const Value(2), + ); + + final gameModel = GameModel( + players: const [], + startTime: DateTime(2024), + endTime: DateTime(2024), + winnerId: 'p1', + durationInSeconds: 60, + ); + + final hostSelfSlot = basePlayer.copyWith( + id: 'p1', + firebaseId: () => 'host', + placement: const Value(1), + ); + + final otherSlot = basePlayer.copyWith( + id: 'p2', + firebaseId: () => null, + placement: const Value(2), + ); + + setUp(() { + firebaseDatabaseRepository = _MockFirebaseDatabaseRepository(); + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenAnswer((_) async => 'saved-game-id'); + }); + + GameOverBloc buildBloc({ + required List players, + required String currentUserId, + }) { + return GameOverBloc( + players: players, + currentUserId: currentUserId, + firebaseDatabaseRepository: firebaseDatabaseRepository, + ); + } + + test('preselects the slot already linked to the current user', () { + final bloc = buildBloc( + players: [hostLinkedSlot, unlinkedSlot], + currentUserId: 'host', + ); + expect(bloc.state.selectedPlayerId, hostLinkedSlot.id); + }); + + blocTest( + 'never clobbers a slot linked to another account', + build: () => buildBloc( + players: [linkedFriendSlot, unlinkedSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [linkedFriendSlot, unlinkedSlot], + selectedPlayerId: linkedFriendSlot.id, + firstPlayerId: null, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + final slot = saved.players.firstWhere( + (p) => p.id == linkedFriendSlot.id, + ); + expect(slot.firebaseId, 'friend1'); // NOT 'host' + }, + ); + + blocTest( + 'notPlayingId assigns the host uid to no slot', + build: () => buildBloc( + players: [linkedFriendSlot, unlinkedSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [linkedFriendSlot, unlinkedSlot], + selectedPlayerId: GameOverBloc.notPlayingId, + firstPlayerId: null, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + expect(saved.players.every((p) => p.firebaseId != 'host'), isTrue); + }, + ); + + blocTest( + 'does not call client-side fan-out', + build: () => buildBloc( + players: [unlinkedSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [unlinkedSlot], + selectedPlayerId: unlinkedSlot.id, + firstPlayerId: null, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + // syncGameToPlayers no longer exists on the repository; this test + // asserts the ONLY repository call is saveGameStats. + verify(() => firebaseDatabaseRepository.saveGameStats(any())) + .called(1); + verifyNoMoreInteractions(firebaseDatabaseRepository); + }, + ); + + group('props', () { + test('two states differing only in status are unequal', () { + const standings = [basePlayer]; + const loading = GameOverState( + standings: standings, + selectedPlayerId: null, + firstPlayerId: null, + status: GameOverStatus.loading, + ); + final failure = loading.copyWith(status: GameOverStatus.failure); + + expect(loading, isNot(equals(failure))); + }); + + test('two states differing only in gameModel are unequal', () { + const standings = [basePlayer]; + const withoutModel = GameOverState( + standings: standings, + selectedPlayerId: null, + firstPlayerId: null, + ); + final withModel = withoutModel.copyWith(gameModel: gameModel); + + expect(withoutModel, isNot(equals(withModel))); + }); + }); + + group('failure surfacing', () { + blocTest( + 'emits failure status when saveGameStats throws, without ' + 'resetting standings', + setUp: () { + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenThrow(Exception('network error')); + }, + build: () => buildBloc( + players: [hostSelfSlot, otherSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [hostSelfSlot, otherSlot], + selectedPlayerId: hostSelfSlot.id, + firstPlayerId: hostSelfSlot.id, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + GameOverStatus.loading, + ), + isA() + .having((s) => s.status, 'status', GameOverStatus.failure) + .having( + (s) => s.standings, + 'standings', + [hostSelfSlot, otherSlot], + ), + ], + ); + }); + + group('self-slot unlink', () { + blocTest( + 'switching selected slot unlinks the old self slot and links ' + 'the new one', + build: () => buildBloc( + players: [hostSelfSlot, otherSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [hostSelfSlot, otherSlot], + // User re-selected otherSlot instead of hostSelfSlot. + selectedPlayerId: otherSlot.id, + firstPlayerId: otherSlot.id, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + + final oldSelfSlot = saved.players.firstWhere( + (p) => p.id == hostSelfSlot.id, + ); + final newSelfSlot = saved.players.firstWhere( + (p) => p.id == otherSlot.id, + ); + + expect(oldSelfSlot.firebaseId, isNull); + expect(newSelfSlot.firebaseId, 'host'); + }, + ); + + blocTest( + 'selecting notPlaying unlinks the self slot and no slot has ' + 'the uid', + build: () => buildBloc( + players: [hostSelfSlot, otherSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [hostSelfSlot, otherSlot], + selectedPlayerId: GameOverBloc.notPlayingId, + firstPlayerId: otherSlot.id, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + + expect(saved.players.every((p) => p.firebaseId != 'host'), isTrue); + final oldSelfSlot = saved.players.firstWhere( + (p) => p.id == hostSelfSlot.id, + ); + expect(oldSelfSlot.firebaseId, isNull); + }, + ); + + blocTest( + 'a foreign-linked slot is never unlinked by this logic', + build: () => buildBloc( + players: [linkedFriendSlot, unlinkedSlot], + currentUserId: 'host', + ), + seed: () => GameOverState( + standings: [linkedFriendSlot, unlinkedSlot], + selectedPlayerId: unlinkedSlot.id, + firstPlayerId: unlinkedSlot.id, + ), + act: (bloc) => bloc.add( + SendGameOverStatsEvent(gameModel: gameModel, userId: 'host'), + ), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.saveGameStats(captureAny()), + ).captured.single as GameModel; + + final friendSlot = saved.players.firstWhere( + (p) => p.id == linkedFriendSlot.id, + ); + expect(friendSlot.firebaseId, 'friend1'); + }, + ); + + blocTest( + 'concurrent submit events save exactly once', + build: () { + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 20)); + return 'doc1'; + }); + return buildBloc( + players: [hostSelfSlot, otherSlot], + currentUserId: 'host', + ); + }, + seed: () => GameOverState( + standings: [hostSelfSlot, otherSlot], + selectedPlayerId: hostSelfSlot.id, + firstPlayerId: hostSelfSlot.id, + ), + act: (bloc) => bloc + ..add( + SendGameOverStatsEvent( + gameModel: gameModel, + userId: 'host', + exitIntent: GameOverExitIntent.home, + ), + ) + ..add( + SendGameOverStatsEvent( + gameModel: gameModel, + userId: 'host', + exitIntent: GameOverExitIntent.playAgain, + ), + ), + wait: const Duration(milliseconds: 100), + verify: (_) { + verify(() => firebaseDatabaseRepository.saveGameStats(any())) + .called(1); + }, + ); + }); + }); +} diff --git a/test/life_counter/view/game_over_page_test.dart b/test/life_counter/view/game_over_page_test.dart new file mode 100644 index 0000000..355eee8 --- /dev/null +++ b/test/life_counter/view/game_over_page_test.dart @@ -0,0 +1,411 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/game/bloc/game_bloc.dart'; +import 'package:magic_yeti/home/home_page.dart'; +import 'package:magic_yeti/l10n/arb/app_localizations.dart'; +import 'package:magic_yeti/life_counter/bloc/game_over_bloc.dart'; +import 'package:magic_yeti/life_counter/view/game_over_page.dart'; +import 'package:magic_yeti/life_counter/view/game_page.dart'; +import 'package:magic_yeti/timer/bloc/timer_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:player_repository/player_repository.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockGameBloc extends MockBloc + implements GameBloc {} + +class MockTimerBloc extends MockBloc + implements TimerBloc {} + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class _FakeGameModel extends Fake implements GameModel {} + +void main() { + setUpAll(() { + registerFallbackValue(_FakeGameModel()); + }); + + group('GameOverView account-owner dropdown', () { + late MockAppBloc appBloc; + late MockGameBloc gameBloc; + late MockTimerBloc timerBloc; + late PlayerRepository playerRepository; + late GameOverBloc gameOverBloc; + + const basePlayer = Player( + id: 'p1', + name: 'Player One', + playerNumber: 1, + lifePoints: 40, + color: 0, + opponents: [], + placement: 1, + ); + + final hostLinkedPlayer = basePlayer.copyWith( + id: 'host-slot', + name: 'Host Player', + firebaseId: () => 'host', + placement: const Value(1), + ); + + final friendLinkedPlayer = basePlayer.copyWith( + id: 'friend-slot', + name: 'Friend Player', + firebaseId: () => 'friend1', + placement: const Value(2), + ); + + final unlinkedPlayer = basePlayer.copyWith( + id: 'unlinked-slot', + name: 'Unlinked Player', + placement: const Value(3), + ); + + final gameModel = GameModel( + players: const [], + startTime: DateTime(2024), + endTime: DateTime(2024), + winnerId: 'host-slot', + durationInSeconds: 60, + ); + + setUp(() { + appBloc = MockAppBloc(); + gameBloc = MockGameBloc(); + timerBloc = MockTimerBloc(); + playerRepository = PlayerRepository(); + + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'host')), + ); + when(() => gameBloc.state).thenReturn( + GameState(gameModel: gameModel), + ); + when(() => timerBloc.state).thenReturn( + const TimerState(elapsedSeconds: 60), + ); + + gameOverBloc = GameOverBloc( + players: [hostLinkedPlayer, friendLinkedPlayer, unlinkedPlayer], + currentUserId: 'host', + firebaseDatabaseRepository: _MockFirebaseDatabaseRepository(), + ); + }); + + Future pumpGameOverView(WidgetTester tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: gameBloc), + BlocProvider.value(value: timerBloc), + BlocProvider.value(value: gameOverBloc), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const GameOverView(), + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'account-owner dropdown excludes friend-linked player, includes ' + 'unlinked/host-linked players and the not-playing option', + (tester) async { + await pumpGameOverView(tester); + + final accountOwnerDropdown = tester.widget>( + find.descendant( + of: find.byKey( + const ValueKey('game_over_account_owner_dropdown'), + ), + matching: find.byType(DropdownButton), + ), + ); + + final itemValues = accountOwnerDropdown.items! + .map((item) => item.value) + .toList(); + + expect(itemValues, isNot(contains(friendLinkedPlayer.id))); + expect(itemValues, contains(unlinkedPlayer.id)); + expect(itemValues, contains(hostLinkedPlayer.id)); + expect(itemValues, contains(GameOverBloc.notPlayingId)); + }, + ); + + testWidgets( + 'friend-linked standings row shows the linked badge icon', + (tester) async { + await pumpGameOverView(tester); + + expect(find.byIcon(Icons.link_rounded), findsAtLeastNWidgets(1)); + }, + ); + + testWidgets( + 'account-owner dropdown preselects the host-linked slot', + (tester) async { + await pumpGameOverView(tester); + + final accountOwnerDropdown = tester.widget>( + find.descendant( + of: find.byKey( + const ValueKey('game_over_account_owner_dropdown'), + ), + matching: find.byType(DropdownButton), + ), + ); + + expect(accountOwnerDropdown.value, hostLinkedPlayer.id); + }, + ); + }); + + group('GameOverView navigation on save', () { + late MockAppBloc appBloc; + late MockGameBloc gameBloc; + late MockTimerBloc timerBloc; + late PlayerRepository playerRepository; + late _MockFirebaseDatabaseRepository firebaseDatabaseRepository; + + const basePlayer = Player( + id: 'p1', + name: 'Player One', + playerNumber: 1, + lifePoints: 40, + color: 0, + opponents: [], + placement: 1, + ); + + final hostLinkedPlayer = basePlayer.copyWith( + id: 'host-slot', + name: 'Host Player', + firebaseId: () => 'host', + placement: const Value(1), + ); + + final otherPlayer = basePlayer.copyWith( + id: 'other-slot', + name: 'Other Player', + placement: const Value(2), + ); + + final gameModel = GameModel( + players: const [], + startTime: DateTime(2024), + endTime: DateTime(2024), + winnerId: 'host-slot', + durationInSeconds: 60, + ); + + setUp(() { + appBloc = MockAppBloc(); + gameBloc = MockGameBloc(); + timerBloc = MockTimerBloc(); + playerRepository = PlayerRepository(); + firebaseDatabaseRepository = _MockFirebaseDatabaseRepository(); + + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'host')), + ); + when(() => gameBloc.state).thenReturn( + GameState(gameModel: gameModel), + ); + when(() => timerBloc.state).thenReturn( + const TimerState(elapsedSeconds: 60), + ); + }); + + GameOverBloc buildGameOverBloc() => GameOverBloc( + players: [hostLinkedPlayer, otherPlayer], + currentUserId: 'host', + firebaseDatabaseRepository: firebaseDatabaseRepository, + ); + + Future pumpRoutedGameOverView( + WidgetTester tester, { + required GameOverBloc bloc, + }) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final router = GoRouter( + initialLocation: GameOverPage.routePath, + routes: [ + GoRoute( + path: GameOverPage.routePath, + name: GameOverPage.routeName, + builder: (context, state) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: gameBloc), + BlocProvider.value(value: timerBloc), + BlocProvider.value(value: bloc), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const GameOverView(), + ), + ), + ), + GoRoute( + path: HomePage.routeName, + name: HomePage.routeName, + builder: (context, state) => + const Scaffold(body: Text('HOME_PAGE_STUB')), + ), + GoRoute( + path: GamePage.routePath, + name: GamePage.routeName, + builder: (context, state) => + const Scaffold(body: Text('GAME_PAGE_STUB')), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MaterialApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ); + await tester.pump(); + } + + testWidgets( + 'tapping return-to-home only dispatches SendGameOverStatsEvent and ' + 'stays on the page while status is loading (no eager navigation)', + (tester) async { + final saveCompleter = Completer(); + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenAnswer((_) => saveCompleter.future); + final bloc = buildGameOverBloc(); + addTearDown(bloc.close); + + await pumpRoutedGameOverView(tester, bloc: bloc); + + bloc.add(const UpdateFirstPlayerEvent('host-slot')); + await tester.pump(); + await tester.pump(); + + await tester.tap(find.text('Return to Home')); + await tester.pump(); + await tester.pump(); + + expect(find.text('HOME_PAGE_STUB'), findsNothing); + expect(bloc.state.status, GameOverStatus.loading); + + saveCompleter.complete('saved-id'); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'on success with home intent, listener navigates to home', + (tester) async { + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenAnswer((_) async => 'saved-id'); + final bloc = buildGameOverBloc(); + addTearDown(bloc.close); + + await pumpRoutedGameOverView(tester, bloc: bloc); + + bloc.add(const UpdateFirstPlayerEvent('host-slot')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('Return to Home')); + await tester.pumpAndSettle(); + + expect(find.text('HOME_PAGE_STUB'), findsOneWidget); + }, + ); + + testWidgets( + 'on success with playAgain intent, listener navigates to game page ' + 'and dispatches GameReset/Timer events', + (tester) async { + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenAnswer((_) async => 'saved-id'); + final bloc = buildGameOverBloc(); + addTearDown(bloc.close); + + await pumpRoutedGameOverView(tester, bloc: bloc); + + bloc.add(const UpdateFirstPlayerEvent('host-slot')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('Play Again')); + await tester.pumpAndSettle(); + + expect(find.text('GAME_PAGE_STUB'), findsOneWidget); + verify(() => gameBloc.add(const GameResetEvent())).called(1); + verify(() => timerBloc.add(const TimerResetEvent())).called(1); + verify(() => timerBloc.add(const TimerStartEvent())).called(1); + }, + ); + + testWidgets( + 'on failure, listener shows a snackbar, re-enables the buttons, and ' + 'does not navigate or reset the game', + (tester) async { + when(() => firebaseDatabaseRepository.saveGameStats(any())) + .thenThrow(Exception('network error')); + final bloc = buildGameOverBloc(); + addTearDown(bloc.close); + + await pumpRoutedGameOverView(tester, bloc: bloc); + + bloc.add(const UpdateFirstPlayerEvent('host-slot')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('Return to Home')); + await tester.pump(); + await tester.pump(); + + expect(find.text('HOME_PAGE_STUB'), findsNothing); + expect(find.text('GAME_PAGE_STUB'), findsNothing); + verifyNever(() => gameBloc.add(const GameResetEvent())); + verifyNever(() => gameBloc.add(const GameRestoreRequested())); + + final l10n = await AppLocalizations.delegate.load(const Locale('en')); + expect(find.text(l10n.gameSaveFailedError), findsOneWidget); + + // Buttons re-enabled after failure. + final returnButton = tester.widget( + find.ancestor( + of: find.text('Return to Home'), + matching: find.byType(OutlinedButton), + ), + ); + expect(returnButton.onPressed, isNotNull); + }, + ); + }); +} diff --git a/test/onboarding/bloc/onboarding_bloc_test.dart b/test/onboarding/bloc/onboarding_bloc_test.dart new file mode 100644 index 0000000..dbf80a4 --- /dev/null +++ b/test/onboarding/bloc/onboarding_bloc_test.dart @@ -0,0 +1,235 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:form_inputs/form_inputs.dart'; +import 'package:magic_yeti/onboarding/bloc/onboarding_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +void main() { + late _MockFirebaseDatabaseRepository firebaseDatabaseRepository; + + OnboardingBloc buildBloc({UserProfileModel? existingProfile}) => + OnboardingBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + existingProfile: existingProfile, + ); + + setUpAll(() { + registerFallbackValue(const UserProfileModel(id: 'fallback')); + }); + + setUp(() { + firebaseDatabaseRepository = _MockFirebaseDatabaseRepository(); + }); + + group('PIN handling', () { + test('empty legacy pin string does NOT count as an existing PIN', () { + final bloc = OnboardingBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + existingProfile: const UserProfileModel(id: 'u1', pin: ''), + ); + expect(bloc.state.hasExistingPin, isFalse); + addTearDown(bloc.close); + }); + + test('hasPin flag counts as an existing PIN', () { + final bloc = OnboardingBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + existingProfile: const UserProfileModel(id: 'u1', hasPin: true), + ); + expect(bloc.state.hasExistingPin, isTrue); + addTearDown(bloc.close); + }); + + blocTest( + 'submit with a new PIN calls setPin and writes hasPin without pin', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => null); + when(() => firebaseDatabaseRepository.generateUniqueFriendCode()) + .thenAnswer((_) async => 'YETI-A3F9'); + when(() => firebaseDatabaseRepository.setPin('u1', '0742')) + .thenAnswer((_) async {}); + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const OnboardingState( + username: Username.dirty('josh'), + pin: Pin.dirty('0742'), + ), + act: (bloc) => bloc.add(const OnboardingSubmitted('u1')), + verify: (_) { + verify(() => firebaseDatabaseRepository.setPin('u1', '0742')) + .called(1); + final profile = verify( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + captureAny(), + ), + ).captured.single as UserProfileModel; + expect(profile.hasPin, isTrue); + expect(profile.pin, isNull); + expect(profile.onboardingComplete, isTrue); + }, + ); + + blocTest( + 'submit with an untouched pin and an existing PIN keeps hasPin true ' + 'without calling setPin', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer( + (_) async => const UserProfileModel(id: 'u1', hasPin: true), + ); + when(() => firebaseDatabaseRepository.generateUniqueFriendCode()) + .thenAnswer((_) async => 'YETI-A3F9'); + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc( + existingProfile: const UserProfileModel(id: 'u1', hasPin: true), + ); + }, + seed: () => const OnboardingState( + username: Username.dirty('josh'), + hasExistingPin: true, + ), + act: (bloc) => bloc.add(const OnboardingSubmitted('u1')), + verify: (_) { + verifyNever(() => firebaseDatabaseRepository.setPin(any(), any())); + final profile = verify( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + captureAny(), + ), + ).captured.single as UserProfileModel; + expect(profile.hasPin, isTrue); + expect(profile.pin, isNull); + }, + ); + + blocTest( + 'submit with an untouched pin and an unmigrated legacy PIN preserves ' + 'the legacy hash without calling setPin', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer( + (_) async => + const UserProfileModel(id: 'u1', pin: 'legacyhash'), + ); + when(() => firebaseDatabaseRepository.generateUniqueFriendCode()) + .thenAnswer((_) async => 'YETI-A3F9'); + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc( + existingProfile: const UserProfileModel(id: 'u1', pin: 'legacyhash'), + ); + }, + seed: () => const OnboardingState( + username: Username.dirty('josh'), + hasExistingPin: true, + ), + act: (bloc) => bloc.add(const OnboardingSubmitted('u1')), + verify: (_) { + verifyNever(() => firebaseDatabaseRepository.setPin(any(), any())); + final profile = verify( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + captureAny(), + ), + ).captured.single as UserProfileModel; + expect(profile.pin, 'legacyhash'); + expect(profile.hasPin, isTrue); + }, + ); + + blocTest( + 'submit fails without saving the profile when setPin throws ' + '(pins the setPin-before-save ordering)', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => null); + when(() => firebaseDatabaseRepository.generateUniqueFriendCode()) + .thenAnswer((_) async => 'YETI-A3F9'); + when(() => firebaseDatabaseRepository.setPin('u1', '0742')) + .thenThrow(Exception('boom')); + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const OnboardingState( + username: Username.dirty('josh'), + pin: Pin.dirty('0742'), + ), + act: (bloc) => bloc.add(const OnboardingSubmitted('u1')), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + FormzSubmissionStatus.inProgress, + ), + isA().having( + (s) => s.status, + 'status', + FormzSubmissionStatus.failure, + ), + ], + verify: (_) { + verifyNever( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ); + }, + ); + }); + + group('username hardening', () { + test('whitespace-only username keeps step 0 invalid', () { + final bloc = buildBloc()..add(const OnboardingUsernameChanged(' ')); + addTearDown(bloc.close); + expect(bloc.state.isStepValid, isFalse); + }); + + blocTest( + 'submit persists the trimmed username', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer( + (_) async => const UserProfileModel(id: 'u1', friendCode: 'ABCD1234'), + ); + when( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + act: (bloc) => bloc + ..add(const OnboardingUsernameChanged(' josh ')) + ..add(const OnboardingSubmitted('u1')), + verify: (_) { + final saved = verify( + () => + firebaseDatabaseRepository.updateUserProfile('u1', captureAny()), + ).captured.single as UserProfileModel; + expect(saved.username, 'josh'); + }, + ); + }); +} diff --git a/test/player/customize_player_page_anonymous_test.dart b/test/player/customize_player_page_anonymous_test.dart new file mode 100644 index 0000000..e3777f7 --- /dev/null +++ b/test/player/customize_player_page_anonymous_test.dart @@ -0,0 +1,144 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/commander_library/commander_library_repository.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; +import 'package:magic_yeti/player/bloc/player_bloc.dart'; +import 'package:magic_yeti/player/view/bloc/player_customization_bloc.dart'; +import 'package:magic_yeti/player/view/customize_player_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:player_repository/player_repository.dart'; +import 'package:scryfall_repository/scryfall_repository.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFriendBloc extends MockBloc + implements FriendBloc {} + +class MockPlayerBloc extends MockBloc + implements PlayerBloc {} + +class MockPlayerRepository extends Mock implements PlayerRepository {} + +class MockScryfallRepository extends Mock implements ScryfallRepository {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class FakeCommanderLibraryRepository implements CommanderLibraryRepository { + @override + Future addRecent(Commander c) async {} + @override + Future> getRecents() async => []; + @override + Future> getFavorites() async => []; + @override + Future isFavorite(Commander c) async => false; + @override + Future toggleFavorite(Commander c) async => false; +} + +void main() { + group('CustomizePlayerView anonymous placeholder', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + + const player = Player( + id: 'p1', + name: 'Sarah', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, + ); + + const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: 'http://x/bob.png', + friendCode: 'YETI-B0B1', + ); + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(player); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: player)); + }); + + Future pumpCustomizePlayer(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: MockFirebaseDatabaseRepository(), + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'shows signInToLinkFriends copy instead of the friend list ' + 'when anonymous', (tester) async { + when(() => appBloc.state).thenReturn( + const AppState.anonymous(User(id: 'anon1')), + ); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + + await pumpCustomizePlayer(tester); + + expect(find.text('Sign in to link friends to players.'), findsOneWidget); + expect(find.text('Bob'), findsNothing); + }); + + testWidgets( + 'shows the friend list (not the anonymous placeholder) ' + 'when authenticated', (tester) async { + when(() => appBloc.state).thenReturn( + const AppState.authenticated(User(id: 'alice')), + ); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + + await pumpCustomizePlayer(tester); + + expect( + find.text('Sign in to link friends to players.'), + findsNothing, + ); + // Bob is a selectable entry in the dropdown, not static page text β€” + // the menu has to actually be open to find it in the tree. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + expect(find.text('Bob'), findsWidgets); + }); + }); +} diff --git a/test/player/customize_player_page_friend_picker_test.dart b/test/player/customize_player_page_friend_picker_test.dart new file mode 100644 index 0000000..d60a102 --- /dev/null +++ b/test/player/customize_player_page_friend_picker_test.dart @@ -0,0 +1,644 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/commander_library/commander_library_repository.dart'; +import 'package:magic_yeti/friends_list/friends_list/bloc/friend_list_bloc.dart'; +import 'package:magic_yeti/player/bloc/player_bloc.dart'; +import 'package:magic_yeti/player/view/bloc/player_customization_bloc.dart'; +import 'package:magic_yeti/player/view/customize_player_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:player_repository/player_repository.dart'; +import 'package:scryfall_repository/scryfall_repository.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockFriendBloc extends MockBloc + implements FriendBloc {} + +class MockPlayerBloc extends MockBloc + implements PlayerBloc {} + +class MockPlayerRepository extends Mock implements PlayerRepository {} + +class MockScryfallRepository extends Mock implements ScryfallRepository {} + +class MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class FakeCommanderLibraryRepository implements CommanderLibraryRepository { + @override + Future addRecent(Commander c) async {} + @override + Future> getRecents() async => []; + @override + Future> getFavorites() async => []; + @override + Future isFavorite(Commander c) async => false; + @override + Future toggleFavorite(Commander c) async => false; +} + +const testPlayer = Player( + id: 'p1', + name: 'Sarah', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, +); + +const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + friendCode: 'YETI-B0B1', +); + +void main() { + group('CustomizePlayerView PIN dialog', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + }); + + Future pumpCustomizePlayer( + WidgetTester tester, { + Size size = const Size(1600, 1200), + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'Verify button shows a spinner while ValidatePin is pending and ' + 'blocks re-submission', (tester) async { + final completer = Completer(); + when(() => db.validatePin(targetUserId: 'bob', pin: '1234')) + .thenAnswer((_) => completer.future); + + await pumpCustomizePlayer(tester); + // The friend tile list was replaced by a searchable DropdownMenu (see + // the 'friend/owner dropdown' group below) β€” open it before picking + // Bob, same as every other test in this file that selects a friend. + // + // Tap the internal TextField, not find.byType(DropdownMenu): + // production wraps the DropdownMenu in SizedBox(width: + // double.infinity) (see _FriendSection), which stretches the + // widget's outer bounds far past its internal TextField's actual + // clickable area. tester.tap(find.byType(DropdownMenu)) taps + // the geometric center of those stretched outer bounds, which lands + // in a dead zone and silently fails to open the menu β€” confirmed via + // an isolated repro (WidgetController.getCenter() on a DropdownMenu + // inside SizedBox(width: double.infinity) always misses the hit + // test, regardless of surrounding layout). Tapping the TextField + // directly opens it reliably. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).last, '1234'); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Verify')); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('Verify'), findsNothing); + + final verifyButton = tester.widget( + find.byType(FilledButton), + ); + expect(verifyButton.onPressed, isNull); + + final cancelButton = tester.widget( + find.widgetWithText(TextButton, 'Cancel'), + ); + expect(cancelButton.onPressed, isNull); + + completer.complete(const PinValid()); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'PIN dialog is scrollable on a small screen with the keyboard open', + (tester) async { + when(() => db.validatePin(targetUserId: 'bob', pin: '1234')) + .thenAnswer((_) async => const PinValid()); + + await pumpCustomizePlayer(tester, size: const Size(375, 667)); + // CustomizePlayerPage's two-panel Row layout is a pre-existing + // landscape-style design that predates this task; several of its + // children (TrackingPreview's two Rows, _FriendSection's header Row, + // CommanderSearchBar) already overflow horizontally at phone width + // with no Expanded/Flexible around non-shrinking content β€” real, + // pre-existing, out-of-scope bugs (flagged separately via a spawned + // follow-up), not caused by the PIN dialog fix under test. + // _expectOnlyKnownOverflow drains and fingerprints them so this test + // still fails loudly on any *different*, unexpected exception. + _expectOnlyKnownOverflow(tester.takeException()); + + // Open the dropdown by tapping its internal TextField, not + // find.byType(DropdownMenu) β€” see the detailed comment on + // the equivalent line in the 'Verify button shows a spinner...' test + // above for why a direct tap on the DropdownMenu itself silently + // fails to open it here. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + _expectOnlyKnownOverflow(tester.takeException()); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + _expectOnlyKnownOverflow(tester.takeException()); + + // Simulate the on-screen keyboard opening once the dialog is already + // up (dialog opens, user focuses the PIN field, the OS keyboard + // slides in) β€” the realistic sequence. + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + _expectOnlyKnownOverflow(tester.takeException()); + + // NOTE on scope: this test only verifies the *structural* fix + // (`scrollable: true` is present on the dialog), not end-to-end + // reachability of the Verify button via tester.tap(). An earlier + // version of this test tried to drive the full flow (tap the friend + // tile, enter the PIN, tap Verify, confirm the dialog dismisses) at + // this same small screen + keyboard-inset size. A reviewer verified + // empirically β€” by stripping `scrollable: true` from the production + // code in an isolated worktree to reproduce the genuine pre-fix bug β€” + // that the elaborate version still passed against the *broken* code: + // at Size(375, 667) with a 300px keyboard inset, the dialog's + // Material card comfortably fits either way in this simulated + // harness, so tester.tap()/hit-testing never actually discriminates + // fixed-vs-broken here. Flutter's widget-test harness doesn't + // faithfully reproduce real on-device keyboard occlusion for button + // hit-testing at these parameters, so driving taps through the flow + // was implying more coverage than it had. `scrollable: true` is the + // correct, standard Flutter fix for "dialog content can be pushed + // off-screen by the keyboard" β€” this test verifies that fix is in + // place; real on-device reachability is verified manually as part of + // this plan's Task 6 (small-simulator manual-verification step). + final dialog = tester.widget(find.byType(AlertDialog)); + expect(dialog.scrollable, isTrue); + }); + }); + + group('CustomizePlayerView name lock', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + testWidgets( + 'locks the name field and shows "Linked to Alice" once the owner ' + 'is confirmed', (tester) async { + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + late PlayerCustomizationBloc customizationBloc; + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) { + return customizationBloc = PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ); + }, + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + + customizationBloc.add(const OwnerSelected(userId: 'alice')); + await tester.pumpAndSettle(); + + final nameField = tester.widget( + find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Player name', + ), + ); + expect(nameField.readOnly, isTrue); + expect(find.text('Linked to Alice'), findsOneWidget); + }); + }); + + group('CustomizePlayerView rehydration', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + const linkedToOwnerPlayer = Player( + id: 'p1', + name: 'Old Name', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, + firebaseId: 'alice', + ); + + const linkedToFriendPlayer = Player( + id: 'p1', + name: 'Bob', + playerNumber: 0, + lifePoints: 40, + color: 0xFF378ADD, + opponents: [], + state: PlayerModelState.active, + firebaseId: 'bob', + ); + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + Future pump(WidgetTester tester) async { + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + } + + testWidgets( + 'reopening an owner-linked seat re-confirms Me and shows the owner ' + 'username, without needing a fresh selection', (tester) async { + when(() => playerRepository.getPlayerById('p1')) + .thenReturn(linkedToOwnerPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: linkedToOwnerPlayer)); + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + + await pump(tester); + await tester.pumpAndSettle(); + + expect(find.text('Linked to Alice'), findsOneWidget); + }); + + testWidgets( + 'reopening a friend-linked seat re-confirms that friend once the ' + 'friend list finishes loading, without a PIN prompt', (tester) async { + when(() => playerRepository.getPlayerById('p1')) + .thenReturn(linkedToFriendPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: linkedToFriendPlayer)); + // Starts loading, then transitions to FriendsLoaded β€” whenListen + // drives both the initial `.state` read and the later stream event + // that the page's BlocListener reacts to, without a manual re-stub. + whenListen( + friendBloc, + Stream.fromIterable([const FriendsLoaded([bob])]), + initialState: FriendsLoading(), + ); + + await pump(tester); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Linked to Bob'), findsOneWidget); + }); + }); + + group('CustomizePlayerView friend/owner dropdown', () { + late MockAppBloc appBloc; + late MockFriendBloc friendBloc; + late MockPlayerBloc playerBloc; + late MockPlayerRepository playerRepository; + late MockFirebaseDatabaseRepository db; + + setUp(() { + appBloc = MockAppBloc(); + friendBloc = MockFriendBloc(); + playerBloc = MockPlayerBloc(); + playerRepository = MockPlayerRepository(); + db = MockFirebaseDatabaseRepository(); + + when(() => playerRepository.getPlayerById('p1')).thenReturn(testPlayer); + when(() => playerBloc.state) + .thenReturn(const PlayerState(player: testPlayer)); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(User(id: 'alice'))); + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + }); + + Future pump(WidgetTester tester, {List? friends}) async { + when(() => friendBloc.state) + .thenReturn(FriendsLoaded(friends ?? const [bob])); + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) => PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ), + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + } + + testWidgets('shows Me as an entry even with zero friends', (tester) async { + await pump(tester, friends: []); + + // Every test below opens the dropdown by tapping its internal + // TextField, not find.byType(DropdownMenu) β€” see the + // detailed comment on the 'Verify button shows a spinner...' test + // near the top of this file for why a direct tap on the + // DropdownMenu itself silently fails to open it. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + + // DropdownMenu renders each visible label at least twice while open + // (an offstage sizing copy inside _DropdownMenuBody, plus the real + // tappable MenuItemButton in the overlay) β€” findsOneWidget is never + // satisfiable here, hence findsWidgets, matching the idiom the very + // next test ('typing filters the entries') already uses for 'Zara'. + expect(find.text('Me'), findsWidgets); + }); + + testWidgets('typing filters the entries', (tester) async { + const zara = FriendModel( + userId: 'zara', + username: 'Zara', + profilePictureUrl: '', + ); + await pump(tester, friends: const [bob, zara]); + + // Open via the internal TextField β€” see the detailed comment on the + // 'Verify button shows a spinner...' test near the top of this file. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, 'Za'); + await tester.pumpAndSettle(); + + expect(find.text('Zara'), findsWidgets); + // DropdownMenu keeps one permanent, invisible copy of every original + // entry's label for internal width measurement (`_initialMenu`, + // built once from the unfiltered entry list and never rebuilt), so + // 'Bob' never fully leaves the widget tree even once filtered out of + // the real, interactive overlay β€” findsNothing is unsatisfiable here. + // Distinguish the real, tappable overlay entries from that + // invisible copy via ExcludeSemantics(excluding: true), which + // DropdownMenu wraps only around the invisible _initialMenu buttons + // (the real, interactive ones use excluding: false, a no-op): after + // filtering to 'Za', 'Bob' should not appear under any + // non-excluded (real) MenuItemButton. + final realMenuItems = find.byWidgetPredicate( + (w) => w is ExcludeSemantics && !w.excluding, + ); + expect( + find.descendant(of: realMenuItems, matching: find.text('Bob')), + findsNothing, + ); + }); + + testWidgets('selecting Me dispatches OwnerSelected with no PIN dialog', + (tester) async { + await pump(tester); + + // Open via the internal TextField β€” see the detailed comment on the + // 'Verify button shows a spinner...' test near the top of this file. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Me').last); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Linked to Alice'), findsOneWidget); + }); + + testWidgets('selecting a friend opens the PIN dialog', (tester) async { + await pump(tester); + + // Open via the internal TextField β€” see the detailed comment on the + // 'Verify button shows a spinner...' test near the top of this file. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('Verify Bob'), findsOneWidget); + }); + + testWidgets('cancelling the PIN dialog reverts the dropdown display', + (tester) async { + await pump(tester); + + // Open via the internal TextField β€” see the detailed comment on the + // 'Verify button shows a spinner...' test near the top of this file. + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Bob').last); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + final field = tester.widget(find.byType(TextField).first); + expect(field.controller?.text, isNot('Bob')); + }); + + testWidgets('tapping the clear icon in the name field clears an existing ' + 'link', (tester) async { + late PlayerCustomizationBloc customizationBloc; + when(() => friendBloc.state).thenReturn(const FriendsLoaded([bob])); + tester.view.physicalSize = const Size(1600, 1200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: friendBloc), + BlocProvider.value(value: playerBloc), + BlocProvider( + create: (context) { + return customizationBloc = PlayerCustomizationBloc( + scryfallRepository: MockScryfallRepository(), + firebaseDatabaseRepository: db, + commanderLibraryRepository: FakeCommanderLibraryRepository(), + ); + }, + ), + ], + child: RepositoryProvider.value( + value: playerRepository, + child: const CustomizePlayerView(playerId: 'p1'), + ), + ), + ); + await tester.pump(); + customizationBloc.add(const OwnerSelected(userId: 'alice')); + await tester.pumpAndSettle(); + expect(customizationBloc.state.isAccountOwner, isTrue); + + // The clear affordance is the X icon in PlayerIdentityPanel's name + // field, not an entry in the friend/owner dropdown. + await tester.tap(find.byIcon(Icons.cancel)); + await tester.pumpAndSettle(); + + expect(customizationBloc.state.isAccountOwner, isFalse); + expect(find.text('Linked to Alice'), findsNothing); + }); + }); +} + +/// Matches the test framework's own synthetic exception, thrown when two or +/// more render/framework errors are caught by [FlutterError.onError] before +/// the first is drained via [WidgetTester.takeException] (see +/// TestWidgetsFlutterBinding's onError override) β€” used below to recognize +/// a *batch* of the same pre-existing overflow errors collapsed into one. +final _multipleExceptionsPattern = RegExp( + r'^Multiple exceptions \(\d+\) were detected during the running of the ' + r'current test, and at least one was unexpected\.$', +); + +/// Asserts that a value taken from [WidgetTester.takeException] is either +/// absent, a single pre-existing RenderFlex-overflow error, or the test +/// framework's own "Multiple exceptions (N)" wrapper for a batch of such +/// errors caught within one pump/pumpAndSettle cycle (see the call-site +/// comment above for the known, pre-existing, out-of-scope culprits in +/// CustomizePlayerPage's underlying layout). The wrapper only ever +/// aggregates FlutterErrorDetails caught by the rendering/framework layer; +/// an `expect()` failure inside this test would instead throw a TestFailure +/// directly through the normal exception path, never through this wrapper β€” +/// so accepting any count here still fails loudly on a genuinely different +/// exception. +void _expectOnlyKnownOverflow(Object? exception) { + if (exception == null) return; + final message = exception.toString(); + if (message.contains('RenderFlex overflowed')) return; + expect( + _multipleExceptionsPattern.hasMatch(message), + isTrue, + reason: 'Expected only the known, pre-existing, out-of-scope overflow ' + 'errors from the underlying page layout (or no exception, or a ' + "single overflow, or the framework's batched-overflow wrapper); " + 'got something else, which could be a real regression: $exception', + ); +} diff --git a/test/player/player_customization_bloc_test.dart b/test/player/player_customization_bloc_test.dart index cabf604..61cacc2 100644 --- a/test/player/player_customization_bloc_test.dart +++ b/test/player/player_customization_bloc_test.dart @@ -196,4 +196,262 @@ void main() { expect(b.state.background, isNull); }, ); + + group('ValidatePin', () { + blocTest( + 'emits isPinValidating true, then pinValidated on PinValid', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinValid()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', true), + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having((s) => s.pinValidated, 'pinValidated', true) + .having((s) => s.pinFlowError, 'pinFlowError', PinFlowError.none), + ], + ); + + blocTest( + 'emits incorrect with attemptsRemaining on PinInvalid', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer((_) async => const PinInvalid(attemptsRemaining: 2)); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having((s) => s.pinValidated, 'pinValidated', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.incorrect, + ) + .having((s) => s.pinAttemptsRemaining, 'attempts', 2), + ], + ); + + blocTest( + 'emits lockedOut with expiry on PinLockedOut', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '9999', + ), + ).thenAnswer( + (_) async => PinLockedOut(lockedUntil: DateTime(2026, 7, 3, 12)), + ); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '9999', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.lockedOut, + ) + .having( + (s) => s.pinLockedUntil, + 'lockedUntil', + DateTime(2026, 7, 3, 12), + ), + ], + ); + + blocTest( + 'emits unavailable on PinCheckUnavailable', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinCheckUnavailable()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.unavailable, + ), + ], + ); + + blocTest( + 'emits notSet on PinNotSet', + build: () { + when( + () => db.validatePin( + targetUserId: 'friend1', + pin: '0742', + ), + ).thenAnswer((_) async => const PinNotSet()); + return build(); + }, + act: (bloc) => + bloc.add(const ValidatePin(pin: '0742', friendUserId: 'friend1')), + skip: 1, + expect: () => [ + isA() + .having((s) => s.isPinValidating, 'isPinValidating', false) + .having( + (s) => s.pinFlowError, + 'pinFlowError', + PinFlowError.notSet, + ), + ], + ); + }); + + group('ResetPinFlow', () { + blocTest( + 'clears error/attempts/lockout but preserves pinValidated', + build: build, + seed: () => PlayerCustomizationState( + pinFlowError: PinFlowError.lockedOut, + pinLockedUntil: DateTime(2026, 7, 3, 12), + pinValidated: true, + ), + act: (bloc) => bloc.add(const ResetPinFlow()), + expect: () => [ + isA() + .having((s) => s.pinFlowError, 'pinFlowError', PinFlowError.none) + .having( + (s) => s.pinAttemptsRemaining, + 'pinAttemptsRemaining', + 0, + ) + .having((s) => s.pinLockedUntil, 'pinLockedUntil', isNull) + .having((s) => s.pinValidated, 'pinValidated', isTrue), + ], + ); + }); + + group('OwnerSelected', () { + blocTest( + 'confirms isAccountOwner and clears any existing friend selection', + build: () { + when(() => db.getUserProfileOnce('alice')) + .thenAnswer((_) async => null); + return build(); + }, + seed: () => const PlayerCustomizationState( + selectedFriend: FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + ), + pinValidated: true, + ), + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isTrue); + expect(bloc.state.selectedFriend, isNull); + expect(bloc.state.pinValidated, isFalse); + }, + ); + + blocTest( + 'fetches and stores the owner username', + build: () { + when(() => db.getUserProfileOnce('alice')).thenAnswer( + (_) async => const UserProfileModel(id: 'alice', username: 'Alice'), + ); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.ownerUsername, 'Alice'); + }, + ); + + blocTest( + 'leaves ownerUsername unset if the profile fetch returns null', + build: () { + when(() => db.getUserProfileOnce('alice')) + .thenAnswer((_) async => null); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.ownerUsername, isNull); + }, + ); + + blocTest( + 'still confirms isAccountOwner if the profile fetch throws', + build: () { + when(() => db.getUserProfileOnce('alice')) + .thenThrow(Exception('offline')); + return build(); + }, + act: (bloc) => bloc.add(const OwnerSelected(userId: 'alice')), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isTrue); + expect(bloc.state.ownerUsername, isNull); + }, + ); + }); + + group('SelectFriend', () { + const bob = FriendModel( + userId: 'bob', + username: 'Bob', + profilePictureUrl: '', + ); + + blocTest( + 'confirms the friend as validated and clears isAccountOwner', + build: build, + seed: () => const PlayerCustomizationState(isAccountOwner: true), + act: (bloc) => bloc.add(const SelectFriend(friend: bob)), + verify: (bloc) { + expect(bloc.state.selectedFriend, bob); + expect(bloc.state.pinValidated, isTrue); + expect(bloc.state.isAccountOwner, isFalse); + }, + ); + }); + + group('LinkCleared', () { + blocTest( + 'clears both a friend link and owner status', + build: build, + seed: () => const PlayerCustomizationState(isAccountOwner: true), + act: (bloc) => bloc.add(const LinkCleared()), + verify: (bloc) { + expect(bloc.state.isAccountOwner, isFalse); + expect(bloc.state.selectedFriend, isNull); + expect(bloc.state.pinValidated, isFalse); + }, + ); + }); } diff --git a/test/profile/bloc/profile_bloc_test.dart b/test/profile/bloc/profile_bloc_test.dart new file mode 100644 index 0000000..a1e7f68 --- /dev/null +++ b/test/profile/bloc/profile_bloc_test.dart @@ -0,0 +1,430 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:form_inputs/form_inputs.dart'; +import 'package:magic_yeti/profile/bloc/profile_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +class _MockFirebaseDatabaseRepository extends Mock + implements FirebaseDatabaseRepository {} + +class _MockUserRepository extends Mock implements UserRepository {} + +void main() { + late _MockFirebaseDatabaseRepository firebaseDatabaseRepository; + late _MockUserRepository userRepository; + + const authUser = User(id: 'u1', email: 'josh@example.com'); + + const loadedProfile = UserProfileModel( + id: 'u1', + email: 'josh@example.com', + username: 'josh', + bio: 'hello', + friendCode: 'YETI-A3F9', + pin: 'legacyhash', + hasPin: true, + onboardingComplete: true, + ); + + ProfileBloc buildBloc() => ProfileBloc( + firebaseDatabaseRepository: firebaseDatabaseRepository, + userRepository: userRepository, + userProfile: authUser, + ); + + setUpAll(() { + registerFallbackValue(const UserProfileModel(id: 'fallback')); + }); + + setUp(() { + firebaseDatabaseRepository = _MockFirebaseDatabaseRepository(); + userRepository = _MockUserRepository(); + }); + + group('ProfileLoadRequested', () { + blocTest( + 'loads the profile and emits loaded status', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => loadedProfile); + return buildBloc(); + }, + act: (bloc) => bloc.add(const ProfileLoadRequested('u1')), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA() + .having((s) => s.status, 'status', ProfileStatus.loaded) + .having((s) => s.profile, 'profile', loadedProfile), + ], + ); + + blocTest( + 'emits failure when the load throws', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenThrow(Exception('boom')); + return buildBloc(); + }, + act: (bloc) => bloc.add(const ProfileLoadRequested('u1')), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.failure, + ), + ], + ); + + blocTest( + 'a missing profile doc emits failure, not an empty loaded state', + build: () { + when(() => firebaseDatabaseRepository.getUserProfileOnce('u1')) + .thenAnswer((_) async => null); + return buildBloc(); + }, + act: (bloc) => bloc.add(const ProfileLoadRequested('u1')), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.failure, + ), + ], + ); + + blocTest( + 'submit before load completes is a no-op (race guard)', + build: buildBloc, + act: (bloc) => bloc.add(const ProfileSubmitted()), + expect: () => [], + verify: (_) { + verifyNever( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ); + }, + ); + }); + + group('ProfileSubmitted', () { + blocTest( + 'builds the save model from the loaded profile via copyWith so ' + 'pin/hasPin/friendCode/onboardingComplete/imageUrl are preserved ' + '(Fix-2-class regression guard)', + build: () { + when( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + any(), + ), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + username: Username.dirty('newname'), + bio: 'new bio', + ), + act: (bloc) => bloc.add(const ProfileSubmitted()), + verify: (_) { + final saved = verify( + () => firebaseDatabaseRepository.updateUserProfile( + 'u1', + captureAny(), + ), + ).captured.single as UserProfileModel; + + // Fields explicitly changed by the form. + expect(saved.username, 'newname'); + expect(saved.bio, 'new bio'); + + // Fields NOT touched by the profile form must carry over from + // the loaded profile untouched. + expect(saved.pin, loadedProfile.pin); + expect(saved.hasPin, loadedProfile.hasPin); + expect(saved.friendCode, loadedProfile.friendCode); + expect(saved.onboardingComplete, loadedProfile.onboardingComplete); + expect(saved.imageUrl, loadedProfile.imageUrl); + expect(saved.email, loadedProfile.email); + }, + ); + + blocTest( + 'flips isEditing off and refreshes the loaded profile on success', + build: () { + when( + () => firebaseDatabaseRepository.updateUserProfile('u1', any()), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + username: Username.dirty('newname'), + ), + act: (bloc) => bloc.add(const ProfileSubmitted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA() + .having((s) => s.status, 'status', ProfileStatus.success) + .having((s) => s.isEditing, 'isEditing', isFalse) + .having( + (s) => s.profile?.username, + 'profile.username', + 'newname', + ), + ], + ); + + blocTest( + 'emits usernameInvalid and does not save when the edited username ' + 'is present but invalid (e.g. cleared to empty) β€” an empty username ' + 'would flip UserProfileModel.isComplete false and bounce the user ' + 'to onboarding on the next auth event', + build: buildBloc, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + username: Username.dirty(' '), + ), + act: (bloc) => bloc.add(const ProfileSubmitted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.usernameInvalid, + ), + ], + verify: (_) { + verifyNever( + () => firebaseDatabaseRepository.updateUserProfile(any(), any()), + ); + }, + ); + + blocTest( + 'submit persists the trimmed username', + build: () { + when( + () => firebaseDatabaseRepository.updateUserProfile('u1', any()), + ).thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + username: Username.dirty(' josh2 '), + ), + act: (bloc) => bloc.add(const ProfileSubmitted()), + verify: (_) { + final saved = verify( + () => + firebaseDatabaseRepository.updateUserProfile('u1', captureAny()), + ).captured.single as UserProfileModel; + expect(saved.username, 'josh2'); + }, + ); + + blocTest( + 'emits failure when updateUserProfile throws', + build: () { + when( + () => firebaseDatabaseRepository.updateUserProfile('u1', any()), + ).thenThrow(Exception('boom')); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + ), + act: (bloc) => bloc.add(const ProfileSubmitted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.failure, + ), + ], + ); + }); + + group('ProfilePinChanged / ProfilePinSubmitted', () { + blocTest( + 'ProfilePinChanged updates the Pin formz input', + build: buildBloc, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + act: (bloc) => bloc.add(const ProfilePinChanged('1234')), + expect: () => [ + isA().having( + (s) => s.pin, + 'pin', + const Pin.dirty('1234'), + ), + ], + ); + + blocTest( + 'ProfilePinSubmitted calls setPin with the entered pin and emits ' + 'pinSaved on success (no old-PIN prompt required β€” decision #5)', + build: () { + when(() => firebaseDatabaseRepository.setPin('u1', '4321')) + .thenAnswer((_) async {}); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + pin: Pin.dirty('4321'), + ), + act: (bloc) => bloc.add(const ProfilePinSubmitted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.pinSaved, + ), + ], + verify: (_) { + verify(() => firebaseDatabaseRepository.setPin('u1', '4321')) + .called(1); + }, + ); + + blocTest( + 'ProfilePinSubmitted emits failure when setPin throws', + build: () { + when(() => firebaseDatabaseRepository.setPin('u1', '4321')) + .thenThrow(Exception('boom')); + return buildBloc(); + }, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + pin: Pin.dirty('4321'), + ), + act: (bloc) => bloc.add(const ProfilePinSubmitted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.failure, + ), + ], + ); + + blocTest( + 'ProfilePinSubmitted does not call setPin when the pin is invalid', + build: buildBloc, + seed: () => const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + pin: Pin.dirty('12'), + ), + act: (bloc) => bloc.add(const ProfilePinSubmitted()), + expect: () => [], + verify: (_) { + verifyNever(() => firebaseDatabaseRepository.setPin(any(), any())); + }, + ); + }); + + group('ProfileDeleted', () { + blocTest( + 'calls deleteAccount and emits success', + build: () { + when(() => userRepository.deleteAccount()).thenAnswer((_) async {}); + return buildBloc(); + }, + act: (bloc) => bloc.add(const ProfileDeleted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.success, + ), + ], + verify: (_) { + verify(() => userRepository.deleteAccount()).called(1); + }, + ); + + blocTest( + 'emits failure when deleteAccount throws', + build: () { + when(() => userRepository.deleteAccount()) + .thenThrow(Exception('boom')); + return buildBloc(); + }, + act: (bloc) => bloc.add(const ProfileDeleted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + ProfileStatus.loading, + ), + isA().having( + (s) => s.status, + 'status', + ProfileStatus.failure, + ), + ], + ); + }); +} diff --git a/test/profile/view/profile_page_test.dart b/test/profile/view/profile_page_test.dart new file mode 100644 index 0000000..9a9a9e6 --- /dev/null +++ b/test/profile/view/profile_page_test.dart @@ -0,0 +1,293 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:firebase_database_repository/firebase_database_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:form_inputs/form_inputs.dart'; +import 'package:magic_yeti/app/bloc/app_bloc.dart'; +import 'package:magic_yeti/profile/bloc/profile_bloc.dart'; +import 'package:magic_yeti/profile/view/profile_page.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:user_repository/user_repository.dart'; + +import '../../helpers/pump_app.dart'; + +class MockAppBloc extends MockBloc implements AppBloc {} + +class MockProfileBloc extends MockBloc + implements ProfileBloc {} + +void main() { + group('ProfileView', () { + late MockAppBloc appBloc; + late MockProfileBloc profileBloc; + + const authUser = User(id: 'u1', email: 'josh@example.com'); + + const loadedProfile = UserProfileModel( + id: 'u1', + email: 'josh@example.com', + username: 'joshy', + bio: 'Commander enjoyer', + friendCode: 'YETI-A3F9', + hasPin: true, + onboardingComplete: true, + ); + + setUp(() { + appBloc = MockAppBloc(); + profileBloc = MockProfileBloc(); + when(() => appBloc.state) + .thenReturn(const AppState.authenticated(authUser)); + }); + + Future pumpProfile(WidgetTester tester) async { + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: appBloc), + BlocProvider.value(value: profileBloc), + ], + child: const ProfileView(), + ), + ); + await tester.pump(); + } + + testWidgets('shows a loading indicator while the profile loads', + (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState(user: authUser, status: ProfileStatus.loading), + ); + + await pumpProfile(tester); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets( + 'renders username/bio from the loaded UserProfileModel, ' + 'not the auth User', (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + + expect(find.text('joshy'), findsOneWidget); + expect(find.text('Commander enjoyer'), findsOneWidget); + }); + + testWidgets('renders the friend code row with the loaded friend code', + (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + + expect(find.text('YETI-A3F9'), findsOneWidget); + expect(find.byIcon(Icons.copy), findsOneWidget); + }); + + testWidgets( + 'explains that username is not unique and friend code is the ' + 'unique identifier', (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + + expect( + find.text( + 'How friends find and recognize you. Not unique β€” others may ' + 'share this name.', + ), + findsOneWidget, + ); + expect( + find.text( + 'Your unique code. Share it so a specific friend can add you ' + 'exactly β€” even if someone else shares your username.', + ), + findsOneWidget, + ); + }); + + testWidgets('renders the PIN change section', (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + + expect(find.text('Change PIN'), findsOneWidget); + expect(find.text('New PIN'), findsOneWidget); + }); + + testWidgets('entering a pin dispatches ProfilePinChanged', (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + + await tester.enterText( + find.byKey(const Key('profile_pin_field')), + '1234', + ); + + verify(() => profileBloc.add(const ProfilePinChanged('1234'))).called(1); + }); + + testWidgets('tapping save with a valid pin dispatches ProfilePinSubmitted', + (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + pin: Pin.dirty('1234'), + ), + ); + + await pumpProfile(tester); + + await tester.ensureVisible( + find.byKey(const Key('profile_pin_submit_button')), + ); + await tester.tap(find.byKey(const Key('profile_pin_submit_button'))); + await tester.pump(); + + verify(() => profileBloc.add(const ProfilePinSubmitted())).called(1); + }); + + testWidgets('shows the pin changed snackbar on pinSaved status', + (tester) async { + whenListen( + profileBloc, + Stream.fromIterable([ + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + const ProfileState( + user: authUser, + status: ProfileStatus.pinSaved, + profile: loadedProfile, + ), + ]), + initialState: const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + await tester.pump(); + + expect(find.text('PIN updated!'), findsOneWidget); + }); + + testWidgets('shows the profile saved snackbar on success status', + (tester) async { + whenListen( + profileBloc, + Stream.fromIterable([ + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + const ProfileState( + user: authUser, + status: ProfileStatus.success, + profile: loadedProfile, + ), + ]), + initialState: const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + await tester.pump(); + + expect(find.text('Profile saved'), findsOneWidget); + }); + + testWidgets('shows the profile save failed snackbar on failure status', + (tester) async { + whenListen( + profileBloc, + Stream.fromIterable([ + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + const ProfileState( + user: authUser, + status: ProfileStatus.failure, + profile: loadedProfile, + ), + ]), + initialState: const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + ), + ); + + await pumpProfile(tester); + await tester.pump(); + + expect(find.text("Couldn't save your profile. Try again."), + findsOneWidget); + }); + + testWidgets('email field is read-only (no ProfileEmailChanged event)', + (tester) async { + when(() => profileBloc.state).thenReturn( + const ProfileState( + user: authUser, + status: ProfileStatus.loaded, + profile: loadedProfile, + isEditing: true, + ), + ); + + await pumpProfile(tester); + + expect(find.text('josh@example.com'), findsOneWidget); + // No editable text field is bound to email; only the username + // and bio fields are editable text fields. + expect(find.byType(TextFormField), findsNWidgets(2)); + }); + }); +}