Skip to content

feat(phase-1): add identity spine infrastructure#41

Merged
Exc1D merged 14 commits intomainfrom
feature/phase-1-identity-spine
Apr 17, 2026
Merged

feat(phase-1): add identity spine infrastructure#41
Exc1D merged 14 commits intomainfrom
feature/phase-1-identity-spine

Conversation

@Exc1D
Copy link
Copy Markdown
Owner

@Exc1D Exc1D commented Apr 17, 2026

Summary

Implements Phase 1 Infrastructure and Identity Spine as specified in the implementation plan.

Identity Contracts and Schemas

  • Added Zod validators for Phase 1 auth schemas (activeAccountSchema, claimRevocationSchema, setStaffClaimsInputSchema, suspendStaffAccountInputSchema)
  • Added config and alert schemas (minAppVersionSchema, alertSchema)
  • Created TypeScript interfaces in packages/shared-types

Firebase Web Bootstrap Package

  • Created Firebase web bootstrap helpers (createFirebaseWebApp, createAppCheck, ensurePseudonymousSignIn, subscribeAlerts, subscribeMinAppVersion)
  • Added session timeout ladder per role
  • Error handlers for Firestore subscriptions

Phase 1 Firestore Rules

  • Implemented rule surface: isAuthed(), isSuperadmin(), isActivePrivileged()
  • Alerts: authenticated read, deny write
  • system_config: authenticated read, superadmin+active write
  • active_accounts/claim_revocations: self-only read

Claims and Revocation Functions

  • setStaffClaims: superadmin callable to set custom claims
  • suspendStaffAccount: superadmin callable to suspend accounts
  • Atomic Firestore batch writes for data consistency
  • Bootstrap seed script for Phase 1 data

Citizen PWA Shell

  • Pseudonymous Firebase sign-in
  • App Check initialization
  • Real-time alerts subscription
  • Min app version display

Test Commands

# Shared tests
pnpm test

# Functions unit tests (no emulator)
pnpm --filter @bantayog/functions test:unit

# Functions rules tests (requires Firebase emulator)
pnpm --filter @bantayog/functions test:rules

# Citizen PWA tests
cd apps/citizen-pwa && pnpm test

# Build verification
pnpm typecheck && pnpm build

Verification Results

Step Check Result
1 pnpm test PASS
2 pnpm --filter @bantayog/functions test:unit PASS
3 pnpm --filter @bantayog/functions test:rules Requires emulator
4 pnpm typecheck && pnpm build PASS

Note

Track A (Terraform staging infrastructure, Firebase wiring, seed execution) requires staging project access and is not included in this PR.

Summary by Sourcery

Introduce Phase 1 identity spine and citizen shell, including shared auth/config schemas, Firebase web bootstrap helpers, Cloud Functions for staff lifecycle, and basic alert + app version wiring across backend and PWA.

New Features:

  • Add shared TypeScript types and Zod schemas for identity documents, staff claim lifecycle, alerts, and minimum app versions.
  • Implement shared Firebase web helpers for app initialization, App Check, auth, Firestore access, and role-based session timeout configuration.
  • Expose Cloud Functions for setting staff custom claims and suspending staff accounts backed by active account and claim revocation documents.
  • Bootstrap a Phase 1 citizen PWA shell that signs users in pseudonymously, initializes App Check, and renders a live alert feed with min app version metadata.

Bug Fixes:

  • Align account status enums and validation with the Phase 1 model by replacing the pending_verification state with disabled.
  • Fix citizen PWA test setup by configuring Vitest with globals, a DOM-like environment, and proper @testing-library/jest-dom integration.

Enhancements:

  • Document Phase 1 infrastructure progress, verification steps, and local Firebase environment requirements in the README and progress notes.
  • Add a Firestore rules test suite and seed data helpers to validate Phase 1 security rules for alerts, system configuration, and active accounts.
  • Tighten ESLint configuration by excluding function scripts from linting and adjusting TypeScript safety rules for the functions codebase.

Build:

  • Extend functions and citizen PWA package scripts and dependencies to support targeted Vitest runs, Firestore rules testing, and a Phase 1 bootstrap script.
  • Configure per-package Vitest setups for shared validators, shared Firebase, functions, and the citizen PWA, replacing the previous workspace-level config.

Tests:

  • Introduce unit tests for Phase 1 auth logic, Firestore security rules, and shared validator/auth schemas, along with dedicated Vitest configs for each package.

Summary by CodeRabbit

  • New Features
    • Phase 1 identity & account management, live alert feed, and dynamic app status/auth indicators.
  • Style
    • Redesigned app layout with centered card panel, layered gradient background, refined typography, and alert/error styles.
  • Tests
    • Added unit and security-rule test suites, per-package test configs, and test helpers.
  • Documentation
    • Expanded README and progress docs with local env guidance and a Phase 1 verification checklist.
  • Chores
    • Added test/build scripts, bootstrap tooling, and package dependency updates.

claude and others added 7 commits April 17, 2026 17:31
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Replace workspace config with explicit include/exclude
- Add explicit vitest imports to App.test.tsx
- Disable @typescript-eslint/no-unsafe-call for test files
- Note: citizen-pwa tests run separately via cd apps/citizen-pwa && pnpm test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 17, 2026

Reviewer's Guide

Implements Phase 1 "identity spine" across shared types/validators, Cloud Functions, Firestore rules tests, and the citizen PWA shell, including Firebase web bootstrap utilities, staff claim lifecycle functions, and a minimal alert + config surface wired end-to-end.

Sequence diagram for citizen PWA Phase 1 shell bootstrap

sequenceDiagram
  actor Citizen as Citizen
  participant CitizenPWA as "CitizenPWA_App"
  participant UseCitizenShell as "useCitizenShell_hook"
  participant SharedFirebase as "@bantayog_shared_firebase"
  participant FirebaseAuth as "Firebase_Auth"
  participant FirebaseAppCheck as "Firebase_AppCheck"
  participant Firestore as "Firestore_DB"

  Citizen->>CitizenPWA: Load / open app
  CitizenPWA->>UseCitizenShell: Call useCitizenShell()
  UseCitizenShell->>SharedFirebase: parseFirebaseWebEnv(import_meta_env)
  SharedFirebase-->>UseCitizenShell: FirebaseWebEnv

  UseCitizenShell->>SharedFirebase: createFirebaseWebApp(env)
  SharedFirebase-->>UseCitizenShell: FirebaseApp

  UseCitizenShell->>SharedFirebase: getFirebaseAuth(app)
  SharedFirebase-->>UseCitizenShell: Auth
  UseCitizenShell->>SharedFirebase: getFirebaseDb(app)
  SharedFirebase-->>UseCitizenShell: Firestore

  UseCitizenShell->>SharedFirebase: createAppCheck(app, env)
  SharedFirebase-->>FirebaseAppCheck: initializeAppCheck
  FirebaseAppCheck-->>UseCitizenShell: AppCheck active
  UseCitizenShell->>UseCitizenShell: state.appCheckState = active

  UseCitizenShell->>SharedFirebase: ensurePseudonymousSignIn(auth)
  SharedFirebase->>FirebaseAuth: signInAnonymously()
  FirebaseAuth-->>SharedFirebase: User
  SharedFirebase-->>UseCitizenShell: User
  UseCitizenShell->>UseCitizenShell: state.authState = signed-in

  UseCitizenShell->>SharedFirebase: subscribeMinAppVersion(db, callback)
  SharedFirebase->>Firestore: onSnapshot(system_config/min_app_version)
  Firestore-->>SharedFirebase: MinAppVersionDoc
  SharedFirebase-->>UseCitizenShell: callback(minAppVersion)
  UseCitizenShell->>UseCitizenShell: state.minAppVersion = doc

  UseCitizenShell->>SharedFirebase: subscribeAlerts(db, callback)
  SharedFirebase->>Firestore: onSnapshot(alerts orderBy publishedAt desc limit 5)
  Firestore-->>SharedFirebase: AlertDoc[]
  SharedFirebase-->>UseCitizenShell: callback(alerts)
  UseCitizenShell->>UseCitizenShell: state.alerts = alerts
  UseCitizenShell->>UseCitizenShell: state.status = ready

  CitizenPWA-->>Citizen: Render status, auth, app check, min version, alerts
Loading

Sequence diagram for staff claim lifecycle callable functions

sequenceDiagram
  actor Superadmin as Provincial_Superadmin
  participant Client as Admin_Client
  participant Callable as Callable_Endpoint
  participant AccountLifecycle as account_lifecycle_ts
  participant Validators as shared_validators_auth
  participant CustomClaims as custom_claims_ts
  participant AdminAuth as Firebase_AdminAuth
  participant AdminDb as Firestore_AdminDb

  Superadmin->>Client: Initiate setStaffClaims(uid, role, scopes)
  Client->>Callable: HTTPS callable request
  Callable->>AccountLifecycle: setStaffClaims(request)
  AccountLifecycle->>AccountLifecycle: Check request.auth.token.role
  AccountLifecycle->>Validators: setStaffClaimsInputSchema.parse(request.data)
  Validators-->>AccountLifecycle: parsedInput or throw
  AccountLifecycle->>CustomClaims: buildStaffClaims(parsedInput)
  CustomClaims-->>AccountLifecycle: CustomClaims
  AccountLifecycle->>AccountLifecycle: updatedAt = Date.now()
  AccountLifecycle->>AdminAuth: setCustomUserClaims(uid, claims)

  AccountLifecycle->>AdminDb: batch = adminDb.batch()
  AccountLifecycle->>AdminDb: batch.set(active_accounts/uid, buildActiveAccountDoc())
  AccountLifecycle->>AdminDb: batch.set(claim_revocations/uid, buildClaimRevocationDoc(reason=claims_updated))
  AccountLifecycle->>AdminDb: batch.commit()
  AdminDb-->>AccountLifecycle: commit success
  AccountLifecycle-->>Callable: { uid, claims }
  Callable-->>Client: Response

  Superadmin->>Client: Initiate suspendStaffAccount(uid)
  Client->>Callable: HTTPS callable request
  Callable->>AccountLifecycle: suspendStaffAccount(request)
  AccountLifecycle->>AccountLifecycle: Check request.auth.token.role
  AccountLifecycle->>Validators: suspendStaffAccountInputSchema.parse(request.data)
  Validators-->>AccountLifecycle: input
  AccountLifecycle->>AdminDb: get(active_accounts/uid)
  AdminDb-->>AccountLifecycle: snapshot
  AccountLifecycle->>AdminDb: set(active_accounts/uid, accountStatus=suspended, updatedAt, merge)
  AccountLifecycle->>AdminDb: set(claim_revocations/uid, buildClaimRevocationDoc(reason=suspended))
  AdminDb-->>AccountLifecycle: writes committed
  AccountLifecycle-->>Callable: { uid, status: suspended }
  Callable-->>Client: Response
Loading

Entity relationship diagram for Phase 1 identity spine documents

erDiagram
  USER {
    string uid
  }

  ACTIVE_ACCOUNT_DOC {
    string uid
    string role
    string accountStatus
    string municipalityId
    string agencyId
    string[] permittedMunicipalityIds
    boolean mfaEnrolled
    number lastClaimIssuedAt
    number updatedAt
  }

  CLAIM_REVOCATION_DOC {
    string uid
    number revokedAt
    string reason
  }

  MIN_APP_VERSION_DOC {
    string citizen
    string admin
    string responder
    number updatedAt
  }

  ALERT_DOC {
    string title
    string body
    string severity
    number publishedAt
    string publishedBy
  }

  SYSTEM_CONFIG_MIN_APP_VERSION_DOC {
    string id
  }

  ALERT_COLLECTION_DOC {
    string id
  }

  USER ||--|| ACTIVE_ACCOUNT_DOC : has_active_account
  USER ||--o{ CLAIM_REVOCATION_DOC : has_revocations

  SYSTEM_CONFIG_MIN_APP_VERSION_DOC ||--|| MIN_APP_VERSION_DOC : wraps
  ALERT_COLLECTION_DOC ||--|| ALERT_DOC : stores

  USER ||--o{ ALERT_DOC : may_publish
Loading

Class diagram for Phase 1 identity spine types and validators

classDiagram
  class CustomClaims {
    +UserRole role
    +MunicipalityId municipalityId
    +AgencyId agencyId
    +MunicipalityId[] permittedMunicipalityIds
    +AccountStatus accountStatus
    +boolean mfaEnrolled
    +number lastClaimIssuedAt
    +boolean breakGlassSession
  }

  class ActiveAccountDoc {
    +UserUid uid
    +UserRole role
    +AccountStatus accountStatus
    +MunicipalityId municipalityId
    +AgencyId agencyId
    +MunicipalityId[] permittedMunicipalityIds
    +boolean mfaEnrolled
    +number lastClaimIssuedAt
    +number updatedAt
  }

  class ClaimRevocationDoc {
    +UserUid uid
    +number revokedAt
    +string reason
  }

  class MinAppVersionDoc {
    +string citizen
    +string admin
    +string responder
    +number updatedAt
  }

  class AlertDoc {
    +string title
    +string body
    +string severity
    +number publishedAt
    +string publishedBy
  }

  class setStaffClaimsInputSchema {
    +string uid
    +UserRole role
    +string municipalityId
    +string agencyId
    +string[] permittedMunicipalityIds
    +boolean mfaEnrolled
  }

  class suspendStaffAccountInputSchema {
    +string uid
    +string reason
  }

  class activeAccountSchema {
    +string uid
    +UserRole role
    +AccountStatus accountStatus
    +string municipalityId
    +string agencyId
    +string[] permittedMunicipalityIds
    +boolean mfaEnrolled
    +number lastClaimIssuedAt
    +number updatedAt
  }

  class claimRevocationSchema {
    +string uid
    +number revokedAt
    +string reason
  }

  class minAppVersionSchema {
    +string citizen
    +string admin
    +string responder
    +number updatedAt
  }

  class alertSchema {
    +string title
    +string body
    +string severity
    +number publishedAt
    +string publishedBy
  }

  class FirebaseWebEnv {
    +string apiKey
    +string authDomain
    +string projectId
    +string appId
    +string messagingSenderId
    +string storageBucket
    +string appCheckSiteKey
  }

  class FirebaseHelpers {
    +createFirebaseWebApp(env FirebaseWebEnv) FirebaseApp
    +createAppCheck(app FirebaseApp, env FirebaseWebEnv) AppCheck
    +parseFirebaseWebEnv(source Record)
    +getFirebaseAuth(app FirebaseApp) Auth
    +ensurePseudonymousSignIn(auth Auth) User
    +getFirebaseDb(app FirebaseApp) Firestore
    +subscribeMinAppVersion(db Firestore, callback)
    +subscribeAlerts(db Firestore, callback)
    +getSessionTimeoutMs(role UserRole) number
  }

  CustomClaims --> ActiveAccountDoc : shapes
  CustomClaims --> ClaimRevocationDoc : shapes
  ActiveAccountDoc <.. activeAccountSchema : validated_by
  ClaimRevocationDoc <.. claimRevocationSchema : validated_by
  MinAppVersionDoc <.. minAppVersionSchema : validated_by
  AlertDoc <.. alertSchema : validated_by
  setStaffClaimsInputSchema --> CustomClaims : input_to_buildStaffClaims
  suspendStaffAccountInputSchema --> ClaimRevocationDoc : input_for_suspension
  FirebaseWebEnv <.. FirebaseHelpers : used_by
Loading

File-Level Changes

Change Details Files
Introduce shared identity/config types and Zod schemas for Phase 1 documents and callable inputs.
  • Add CustomClaims, ActiveAccountDoc, ClaimRevocationDoc, MinAppVersionDoc, and AlertDoc interfaces and export them from shared-types
  • Define Zod validators for staff claim inputs, account/claim documents, alerts, and min app version config in shared-validators
  • Align AccountStatus enum to use 'disabled' instead of 'revoked' and export new auth/config schemas from the shared-validators barrel
packages/shared-types/src/auth.ts
packages/shared-types/src/config.ts
packages/shared-types/src/enums.ts
packages/shared-types/src/index.ts
packages/shared-validators/src/auth.ts
packages/shared-validators/src/config.ts
packages/shared-validators/src/alerts.ts
packages/shared-validators/src/index.ts
packages/shared-validators/package.json
Add shared Firebase web bootstrap layer for App, Auth, Firestore, App Check, env parsing, and session timeouts, with Vitest configuration.
  • Implement Firebase app initialization and App Check wiring using env-derived configuration
  • Provide helpers for pseudonymous sign-in, auth subscription, Firestore instance access, and subscriptions to alerts and min_app_version docs with error handling
  • Add env parsing for Vite Firebase variables plus role-based session timeout computation
  • Expose these helpers via the shared-firebase barrel and wire up firebase dependency and per-package Vitest config
packages/shared-firebase/src/app.ts
packages/shared-firebase/src/auth.ts
packages/shared-firebase/src/firestore.ts
packages/shared-firebase/src/env.ts
packages/shared-firebase/src/index.ts
packages/shared-firebase/package.json
packages/shared-firebase/vitest.config.ts
packages/shared-firebase/src/env.test.ts
vitest.config.ts
Implement Phase 1 Cloud Functions for staff claims lifecycle and bootstrap seeding, plus Firebase Admin bootstrap.
  • Create firebase-admin singleton exports for Auth and Firestore
  • Implement onCall functions setStaffClaims and suspendStaffAccount with role-based authorization, Zod input validation, and atomic writes to active_accounts and claim_revocations
  • Factor claim-building helpers to construct CustomClaims, active_accounts docs, and claim_revocation docs
  • Add Phase 1 bootstrap seed builder and a CLI script to write initial system_config and alerts, plus npm script to run it
functions/src/firebase-admin.ts
functions/src/auth/account-lifecycle.ts
functions/src/auth/custom-claims.ts
functions/src/bootstrap/phase1-seed.ts
functions/scripts/bootstrap-phase1.ts
functions/src/index.ts
functions/package.json
Add Firestore security rules test coverage and per-package Vitest configs; adjust repo-level ESLint/test setup.
  • Introduce firestore.rules Vitest test suite using @firebase/rules-unit-testing to verify alert reads, self-only active_accounts access, suspended privileged write blocking, and superadmin writes
  • Add Vitest configs for root, functions, shared-firebase, shared-validators, and citizen PWA (with happy-dom environment and jest-dom setup)
  • Wire new test scripts in functions and citizen PWA package.json and enable Vitest globals via tsconfig types
  • Adjust ESLint ignores for functions scripts and relax no-unsafe-call in functions config
infra/firebase/firestore.rules
functions/src/__tests__/firestore.rules.test.ts
functions/vitest.config.ts
functions/package.json
apps/citizen-pwa/vitest.config.ts
apps/citizen-pwa/package.json
apps/citizen-pwa/tsconfig.json
packages/shared-validators/src/phase1-auth.test.ts
functions/src/__tests__/phase1-auth.test.ts
vitest.config.ts
eslint.config.js
Wire the citizen PWA Phase 1 shell to Firebase using the shared bootstrap layer and render a minimal alert feed plus health metadata.
  • Replace the minimal placeholder App with a shell that shows boot/auth/App Check status, UID, min app version, and a list of latest alerts
  • Implement useCitizenShell hook that initializes Firebase, App Check, pseudonymous auth, and subscribes to alerts and min_app_version, with unmount-safe state updates and cleanup
  • Revise App CSS to a centered panel layout with alert list styling and error state coloring
apps/citizen-pwa/src/App.tsx
apps/citizen-pwa/src/useCitizenShell.ts
apps/citizen-pwa/src/App.module.css
Document Phase 1 progress, env requirements, and verification steps for contributors.
  • Extend root README with Citizen PWA Firebase env var requirements and Phase 1 verification command list
  • Add Phase 1 Infrastructure and Identity Spine status, verification checklist, and build notes to docs/progress.md
README.md
docs/progress.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Phase‑1 identity infrastructure: shared Firebase client/helpers, App Check, pseudonymous auth, realtime Firestore subscriptions, Cloud Functions for staff claim lifecycle, Zod validators, tests, a bootstrap script, PWA shell consuming live shell state, updated security rules, and documentation updates.

Changes

Cohort / File(s) Summary
Citizen PWA UI & Shell
apps/citizen-pwa/src/App.tsx, apps/citizen-pwa/src/App.module.css, apps/citizen-pwa/src/useCitizenShell.ts, apps/citizen-pwa/src/App.test.tsx
Replaced static scaffold with runtime-driven shell; added useCitizenShell (env parse, Firebase app/App Check init, pseudonymous sign-in, realtime subscriptions), updated styles, and added rendering test.
PWA Test Config & Package
apps/citizen-pwa/package.json, apps/citizen-pwa/tsconfig.json, apps/citizen-pwa/vitest.config.ts
Added Vitest test script/deps, enabled Vitest globals in tsconfig, and added local Vitest config (happy-dom + setup).
Shared Firebase package
packages/shared-firebase/src/app.ts, .../auth.ts, .../firestore.ts, .../env.ts, .../env.test.ts, .../index.ts, packages/shared-firebase/package.json, packages/shared-firebase/vitest.config.ts
New package: Firebase web app/App Check creators, auth helpers (pseudonymous sign-in, subscribeAuth), Firestore subscriptions, env parsing/session timeouts, tests, and firebase runtime dependency.
Shared Types
packages/shared-types/src/auth.ts, .../config.ts, .../enums.ts, .../index.ts
Added types for claims, active account, revocations, min-app-version, alerts, app surfaces; changed AccountStatus to include disabled; updated barrel exports.
Shared Validators
packages/shared-validators/src/auth.ts, .../config.ts, .../alerts.ts, .../index.ts, .../phase1-auth.test.ts, packages/shared-validators/package.json
Added Zod schemas for staff claim inputs, suspend payloads, active account, revocations, min-app-version, alerts; added tests and zod dependency.
Cloud Functions: auth lifecycle
functions/src/auth/account-lifecycle.ts, functions/src/auth/custom-claims.ts, functions/src/firebase-admin.ts, functions/src/index.ts
Introduced callable functions setStaffClaims and suspendStaffAccount (provincial_superadmin-guarded), claim-building helpers, Admin SDK initializer, Firestore synchronization, and exports from functions entry.
Functions bootstrap & tests
functions/scripts/bootstrap-phase1.ts, functions/src/bootstrap/phase1-seed.ts, functions/src/__tests__/phase1-auth.test.ts, functions/src/__tests__/firestore.rules.test.ts, functions/package.json, functions/vitest.config.ts
Added bootstrap script to seed system_config and alerts, seed generator, unit tests for builders, Firestore rules tests, and package test/bootstrap scripts/config.
Firestore security rules
infra/firebase/firestore.rules
Implemented Phase‑1 rules: helper predicates, document-backed isActivePrivileged(), authenticated reads for alerts, UID‑scoped reads for active_accounts/claim_revocations, superadmin+active writes for system_config, explicit deny for rate_limits, and default deny.
Tooling & config
eslint.config.js, vitest.config.ts, vitest.workspace.ts
Ignored functions/scripts/** in ESLint test overrides; relaxed no-unsafe-call in test overrides; consolidated Vitest root config and removed workspace file; updated include/exclude globs.
Docs & README
README.md, docs/progress.md
Documented required Firebase env vars for local PWA dev and added a Phase‑1 verification checklist with test outcomes and progress notes.
Package manifests
packages/shared-firebase/package.json, packages/shared-validators/package.json, apps/citizen-pwa/package.json, functions/package.json
Added runtime/test dependencies and test/bootstrap scripts across packages to support new features and tests.

Sequence Diagram(s)

sequenceDiagram
    participant App as App Component
    participant Hook as useCitizenShell
    participant Shared as shared-firebase
    participant AppCheck as App Check
    participant Auth as Firebase Auth
    participant DB as Firestore
    participant User as User

    App->>Hook: mount
    Hook->>Shared: parse env & createFirebaseWebApp()
    Shared-->>Hook: FirebaseApp
    Hook->>Shared: getFirebaseAuth(), getFirebaseDb()
    Shared-->>Hook: Auth, Firestore
    Hook->>AppCheck: createAppCheck(app, env)
    AppCheck-->>Hook: active / failed
    Hook->>Auth: ensurePseudonymousSignIn()
    alt has user
        Auth-->>Hook: currentUser
    else
        Auth->>User: signInAnonymously()
        User-->>Auth: credential
        Auth-->>Hook: anon user
    end
    Hook->>DB: subscribeMinAppVersion()
    DB-->>Hook: min version updates
    Hook->>DB: subscribeAlerts()
    DB-->>Hook: alerts list
    Hook-->>App: shell state (status, authState, appCheckState, user, versions, alerts)
    Note over Hook: on unmount -> unsubscribe listeners
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐇 I hopped with keys and tiny seeds,
App Check hums and claims take heed,
A shell awoke with alerts that sing,
Rules and tests all set to spring,
Phase‑one carrots — ready, proceed!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(phase-1): add identity spine infrastructure' directly and specifically describes the primary change: implementing Phase 1 identity spine infrastructure with authentication, authorization, and related Firebase components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/phase-1-identity-spine

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In suspendStaffAccount, the reason field from suspendStaffAccountInputSchema is parsed but not used; buildClaimRevocationDoc is always called with 'suspended', so either remove reason from the input schema or plumb it through to the revocation document.
  • buildStaffClaims currently re-parses its input with setStaffClaimsInputSchema, even though the caller (setStaffClaims) already validated the data; consider changing the function to accept the parsed type (e.g. z.infer<typeof setStaffClaimsInputSchema>) to avoid double validation and make misuse harder.
  • The Zod schemas for active_accounts / claim_revocations and the corresponding TypeScript interfaces (ActiveAccountDoc, ClaimRevocationDoc) encode the same shape separately; consider deriving the TS types from the Zod schemas (or vice versa) to keep them in sync automatically.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `suspendStaffAccount`, the `reason` field from `suspendStaffAccountInputSchema` is parsed but not used; `buildClaimRevocationDoc` is always called with `'suspended'`, so either remove `reason` from the input schema or plumb it through to the revocation document.
- `buildStaffClaims` currently re-parses its `input` with `setStaffClaimsInputSchema`, even though the caller (`setStaffClaims`) already validated the data; consider changing the function to accept the parsed type (e.g. `z.infer<typeof setStaffClaimsInputSchema>`) to avoid double validation and make misuse harder.
- The Zod schemas for `active_accounts` / `claim_revocations` and the corresponding TypeScript interfaces (`ActiveAccountDoc`, `ClaimRevocationDoc`) encode the same shape separately; consider deriving the TS types from the Zod schemas (or vice versa) to keep them in sync automatically.

## Individual Comments

### Comment 1
<location path="apps/citizen-pwa/src/useCitizenShell.ts" line_range="53-56" />
<code_context>
+  useEffect(() => {
+    unmountedRef.current = false
+
+    const env = parseFirebaseWebEnv(import.meta.env)
+    const app = createFirebaseWebApp(env)
+    const db = getFirebaseDb(app)
+    const auth = getFirebaseAuth(app)
+
+    // Capture refs in local variables to avoid exhaustive-deps warning in cleanup
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard parseFirebaseWebEnv / Firebase initialization errors to avoid crashing the component tree.

`parseFirebaseWebEnv` (and possibly `createFirebaseWebApp`) can throw synchronously if env vars are missing or malformed. That exception will currently escape the effect and crash the React tree instead of transitioning this hook to a controlled error state. Please wrap env parsing / app creation in a try/catch (similar to the App Check handling) and set the hook state to `status: 'error'` with a clear message so misconfigurations are surfaced without hard crashes.
</issue_to_address>

### Comment 2
<location path="packages/shared-validators/src/auth.ts" line_range="31-34" />
<code_context>
+    }
+  })
+
+export const suspendStaffAccountInputSchema = z.object({
+  uid: z.string().min(1),
+  reason: z.enum(['suspended', 'claims_updated', 'manual_refresh']),
+})
+
</code_context>
<issue_to_address>
**suggestion:** The `reason` field on suspendStaffAccount input is unused and overly permissive.

The parsed `reason` is never used; `suspendStaffAccount` always writes `reason: 'suspended'` to the revocation doc. This makes the `reason` field in `suspendStaffAccountInputSchema` dead and potentially misleading, especially with values like `'claims_updated'` and `'manual_refresh'`. Please either remove `reason` from the input schema, or, if callers should control it, pass it through to `buildClaimRevocationDoc` and constrain the enum to the actually supported values.

```suggestion
export const suspendStaffAccountInputSchema = z.object({
  uid: z.string().min(1),
})
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread apps/citizen-pwa/src/useCitizenShell.ts Outdated
Comment thread packages/shared-validators/src/auth.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/citizen-pwa/src/App.test.tsx`:
- Line 12: The mocked minAppVersion object in App.test.tsx is missing the
required updatedAt field from the MinAppVersionDoc contract; update the test
mock for minAppVersion to include an updatedAt property (e.g., an ISO timestamp
or Date) so the shape matches MinAppVersionDoc and any validators/type checks,
locating and editing the minAppVersion mock in App.test.tsx.

In `@apps/citizen-pwa/src/App.tsx`:
- Line 43: Replace the unstable key built from
`${alert.title}-${String(alert.publishedAt)}` with a stable unique identifier:
add `id` to the shared AlertDoc type (and any code that constructs/fetches
alerts) and use `alert.id` as the React key for the <article> (the mapping that
renders `alert` in App.tsx). Ensure all places that produce AlertDoc include the
id so list reconciliation uses this stable unique key instead of
title+timestamp.

In `@apps/citizen-pwa/src/useCitizenShell.ts`:
- Around line 103-112: The catch block currently forces appCheckState to
'failed' which can overwrite a prior successful App Check; update the error
handler in the .catch to preserve the current appCheckState instead of forcing
'failed' — use a functional setState update (referencing setState and
unmounted.current) that spreads initialState and then sets status: 'error' and
error: (error instanceof Error ? error.message : 'Pseudonymous sign-in failed')
while copying the existing appCheckState from the previous state
(prev.appCheckState) so the existing App Check result (set earlier around line
65) is not lost.
- Around line 79-101: Both subscription callbacks (subscribeMinAppVersion and
subscribeAlerts) redundantly set shared auth/status/user fields causing extra
re-renders and recreating user each time; update the code so
authentication-related state is set once (e.g., immediately after sign-in where
you currently set authState/user) and modify the subscribeMinAppVersion and
subscribeAlerts callbacks to only call setState to merge their specific slice
(minAppVersion or alerts) without touching status, authState, or user; ensure
cleanup.current.stopVersion and cleanup.current.stopAlerts remain
subscribed/unsubscribed but no longer overwrite the shared auth fields, and
preserve the unmounted.current guard around their specific updates.

In `@docs/progress.md`:
- Around line 7-16: The document incorrectly marks verification as complete
while the checklist shows a failing unit test and skipped emulator-backed rules
test; update the status string "**Status:** Verification complete (see findings
below)" to indicate "Verification incomplete / pending" and add a clear
remediation note referencing the checklist entries that failed/skipped (`pnpm
test` FAIL and `firebase emulators:exec ...` SKIP) requiring: re-running full
test suite until all pass, running Firestore rules tests against the local
emulator, and obtaining explicit staging approval before any prod deployment;
also add a short checklist item or inline instruction reminding authors to
deploy to the dev emulator, run the full suite, and get staging sign-off for
changes touching rules/auth/functions.

In `@functions/package.json`:
- Line 16: The current generic "deploy" npm script is a risky footgun; replace
it with explicit environment-targeted scripts and guards: remove or change the
"deploy" script that runs "firebase deploy --only functions" to instead provide
named scripts like "deploy:dev", "deploy:staging", and "deploy:prod" that
require an explicit project flag or PROJECT env (e.g., --project or
process.env.PROJECT) and add a confirmation step for "deploy:prod"; ensure
"deploy:dev" runs the emulator and the full test suite before deploying, and
make the plain "deploy" either disabled or echo a message instructing to use the
environment-specific scripts so accidental prod deploys are prevented.

In `@functions/scripts/bootstrap-phase1.ts`:
- Line 18: The top-level call to main() lacks explicit error handling; update
the bootstrap entry so any rejection from main() is caught, logged with
structured error details, and the process exits with a non-zero code.
Specifically, wrap the top-level invocation of main() (the symbol main) in a
promise rejection handler or an immediately-invoked async wrapper that catches
errors, calls your logger (e.g., processLogger.error or console.error) with the
error and context, and then calls process.exit(1) to ensure deterministic
failure reporting.
- Around line 8-15: The current seed writes call
adminDb.collection('system_config').doc('min_app_version').set(...) and then
individually await each adminDb.collection('alerts').doc(alert.id).set(alert)
which can leave a partially-applied Phase 1 state if any write fails; change
this to a single atomic batch: create a write batch (e.g., via adminDb.batch()),
add a batch.set for the system_config/min_app_version doc and batch.set for each
alerts doc inside the loop, then call batch.commit() once so all seed writes
succeed or fail together.

In `@functions/src/__tests__/firestore.rules.test.ts`:
- Around line 78-153: Add two tests inside the existing "phase 1 firestore
rules" describe block: (1) a self-read test for the claim_revocations collection
that uses testEnv.authenticatedContext('<uid>', {...}).firestore() and asserts
assertSucceeds(db.collection('claim_revocations').doc('<uid>').get()) and
assertFails for other users, and (2) a write-denial test for alerts that uses
any authenticatedContext or unauthenticatedContext and asserts
assertFails(db.collection('alerts').doc('someId').set({...})); place them
alongside the existing tests so they reference the same testEnv,
assertSucceeds/assertFails helpers and validate the self-only read for
claim_revocations and the rule that denies all writes to alerts.
- Around line 14-19: The path resolution for loading firestore.rules is tied to
process.cwd(); update the test setup that calls initializeTestEnvironment and
the readFileSync/resolve usage to derive the rules file path from the test file
location instead (use __dirname) so the rules file is found regardless of
working directory. Locate the block that calls initializeTestEnvironment and
replace the resolve(process.cwd(), '../infra/firebase/firestore.rules') usage
with a resolution based on __dirname (e.g., resolve(__dirname,
'../infra/firebase/firestore.rules') or path.join(__dirname, '..', 'infra',
'firebase', 'firestore.rules')) so readFileSync reads the correct file robustly.
Ensure the change only affects the path passed to readFileSync; keep
initializeTestEnvironment and readFileSync usage intact.

In `@functions/src/auth/account-lifecycle.ts`:
- Around line 54-61: suspendStaffAccount performs two separate awaits to
adminDb.collection('active_accounts').doc(input.uid).set(...) and
.collection('claim_revocations').doc(input.uid).set(...), which is non-atomic;
change this to use a Firestore write batch: create a batch, call batch.set(...)
for the active_accounts doc (merging current with accountStatus:'suspended' and
updatedAt: revokedAt) and batch.set(...) for the claim_revocations doc using
buildClaimRevocationDoc(input.uid, revokedAt, 'suspended'), then call
batch.commit() and await the single commit so both writes succeed or fail
together.
- Line 18: Replace direct Zod .parse calls with .safeParse for both
setStaffClaimsInputSchema and suspendStaffAccountInputSchema so validation
failures are handled explicitly; if safeParse returns success use the
parsed.data, otherwise throw an HttpsError('invalid-argument', 'Validation
failed', { issues: result.error.format() }) (or similar) to return a descriptive
client error instead of an internal exception—update the code around the
setStaffClaimsInputSchema.parse(...) assignment and the
suspendStaffAccountInputSchema.parse(...) use to perform this conditional check
and throw the HttpsError on validation failure.

In `@functions/src/auth/custom-claims.ts`:
- Around line 5-7: buildStaffClaims currently re-parses input with
setStaffClaimsInputSchema.parse even though setStaffClaims already passes a
parsed value; remove the redundant parse by changing buildStaffClaims to accept
the parsed type produced by setStaffClaimsInputSchema.parse (e.g., rename param
to parsedInput and update its TypeScript type to the schema's output type) and
remove the call to setStaffClaimsInputSchema.parse inside buildStaffClaims, then
update callers (notably setStaffClaims) to pass the parsed value directly;
alternatively, if you need standalone parsing, add a clear JSDoc comment on
buildStaffClaims stating it expects raw input and keep the parse—pick one
approach and apply it consistently.
- Around line 33-45: buildActiveAccountDoc currently always includes
municipalityId and agencyId (and other optional fields) even when undefined;
change the function to only add optional properties (municipalityId, agencyId,
permittedMunicipalityIds if null/undefined, mfaEnrolled, lastClaimIssuedAt) to
the returned object when they are defined to avoid writing undefined fields to
Firestore—construct the base object with required keys (uid, role,
accountStatus, updatedAt) and then conditionally assign each optional property
(using short-circuit spreads or if-blocks) so only present values are included.

In `@functions/src/bootstrap/phase1-seed.ts`:
- Around line 1-22: Annotate the buildPhase1SeedDocs function with an explicit
shared contract return type (e.g., SharedSeedDocs or Phase1SeedContract) by
importing that type and changing the signature to export function
buildPhase1SeedDocs(updatedAt: number): SharedSeedDocs; ensure the returned
object matches the contract shape (systemConfig.min_app_version and alerts array
items conform to the imported type names/fields) and adjust any property
names/structures to satisfy the type so shape drift is caught at compile time.

In `@packages/shared-firebase/src/app.ts`:
- Around line 20-25: createAppCheck should guard against duplicate
initialization the same way createFirebaseWebApp does: attempt to fetch an
existing App Check instance (use getAppCheck(app) in a try/catch or equivalent
existence check) and return it if present, otherwise call
initializeAppCheck(app, { provider: new
ReCaptchaV3Provider(env.appCheckSiteKey), isTokenAutoRefreshEnabled: true });
update createAppCheck to return the existing AppCheck when present to avoid
throwing on double initialization.

In `@packages/shared-firebase/src/env.test.ts`:
- Around line 6-17: The test currently only asserts projectId after calling
parseFirebaseWebEnv; strengthen it by asserting the full normalized output
matches all expected fields. Update the assertion for parseFirebaseWebEnv(...)
to expect the complete object containing apiKey, authDomain, projectId, appId,
messagingSenderId, storageBucket, and appCheckSiteKey (matching the values
passed in VITE_FIREBASE_*), e.g. replace the single-field toMatchObject with a
toEqual (or toMatchObject) asserting all those normalized property names
returned by parseFirebaseWebEnv.

In `@packages/shared-types/src/auth.ts`:
- Around line 15-25: The builder functions buildActiveAccountDoc and
buildClaimRevocationDoc currently accept uid: string but return objects typed as
ActiveAccountDoc/ClaimRevocationDoc which require uid: UserUid; fix by branding
the uid before returning (e.g., wrap the incoming uid with the project’s
branding helper, such as asUserUid(uid)) so the returned objects satisfy the
branded UserUid type, and ensure the helper is imported where used (or change
the function signatures to accept UserUid if callers already pass branded
values).

In `@packages/shared-types/src/config.ts`:
- Around line 10-16: AlertDoc currently lacks the alert identifier; add an id:
string property to the AlertDoc interface so the shape matches the seeded alerts
produced by the seed process (e.g., phase1-seed). Update any producers that
construct AlertDoc (places that create seeded alerts) to include the id and
adjust consumers/usage sites to expect and use AlertDoc.id (for stable UI keys
and type alignment).

In `@packages/shared-validators/src/auth.ts`:
- Around line 31-34: The schema suspendStaffAccountInputSchema defines a reason
but suspendStaffAccount ignores it and always uses 'suspended'; either remove
reason from the schema or pass input.reason into the revocation builder. Update
suspendStaffAccount to call buildClaimRevocationDoc(input.uid, revokedAt,
input.reason) so the validated value is used, or remove the reason property from
suspendStaffAccountInputSchema (z.enum([...])) if the operation will always use
'suspended'; ensure tests and callers are updated accordingly.
- Around line 3-11: Export accountStatusSchema and derive both schemas from the
canonical shared-types enums: import the UserRole and AccountStatus types from
'@bantayog/shared-types', declare const arrays of allowed values (e.g.
USER_ROLES and ACCOUNT_STATUSES) typed to satisfy readonly UserRole[] / readonly
AccountStatus[], then create and export userRoleSchema = z.enum(USER_ROLES) and
export accountStatusSchema = z.enum(ACCOUNT_STATUSES); update references to use
the exported schemas instead of the local literals.

In `@packages/shared-validators/src/config.ts`:
- Around line 3-8: The minAppVersionSchema currently allows any non-empty string
for citizen, admin, and responder which permits invalid tags like "latest";
update the schema for those fields (citizen, admin, responder) to enforce proper
semantic versioning by either using a strict semver regex (e.g.
major.minor.patch with optional prerelease/build) or, preferably, validating
with the semver library (e.g., import semver and use z.string().refine(v =>
semver.valid(v) !== null, { message: 'Invalid semver' })). Keep updatedAt as is
(number().int().nonnegative()) and update error messages accordingly.

In `@packages/shared-validators/src/phase1-auth.test.ts`:
- Around line 41-50: The test title says required fields are enforced but only
validates a valid payload; add explicit negative assertions to verify missing
fields fail: update the describe('claimRevocationSchema') test to include two
additional checks that call claimRevocationSchema.parse (or
claimRevocationSchema.safeParse) with payloads missing revokedAt and missing
reason and assert failure (e.g. expect(() =>
claimRevocationSchema.parse({...})).toThrow() or
expect(claimRevocationSchema.safeParse({...}).success).toBe(false)); keep the
existing valid-case assertion for completeness.

In `@README.md`:
- Around line 47-53: Update the "Phase 1 verification" checklist to add a final
explicit "staging approval" step: after running the listed commands (pnpm test,
pnpm --filter `@bantayog/functions` test:unit, pnpm --filter `@bantayog/functions`
test:rules, lint/typecheck/build) require a documented staging-approval item
that mandates deploying changes to the dev emulator, running the full test
suite, and obtaining explicit staging approval before any production deploy for
changes touching auth, security rules, DB indexes, deployment config, auth
flows, or Cloud Functions; reference the "Phase 1 verification" header and add a
bullet that outlines these exact requirements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 202c89e1-d25a-4963-8dad-ffdbf52dfea5

📥 Commits

Reviewing files that changed from the base of the PR and between 18e713c and 73127f8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (41)
  • README.md
  • apps/citizen-pwa/package.json
  • apps/citizen-pwa/src/App.module.css
  • apps/citizen-pwa/src/App.test.tsx
  • apps/citizen-pwa/src/App.tsx
  • apps/citizen-pwa/src/useCitizenShell.ts
  • apps/citizen-pwa/tsconfig.json
  • apps/citizen-pwa/vitest.config.ts
  • docs/progress.md
  • eslint.config.js
  • functions/package.json
  • functions/scripts/bootstrap-phase1.ts
  • functions/src/__tests__/firestore.rules.test.ts
  • functions/src/__tests__/phase1-auth.test.ts
  • functions/src/auth/account-lifecycle.ts
  • functions/src/auth/custom-claims.ts
  • functions/src/bootstrap/phase1-seed.ts
  • functions/src/firebase-admin.ts
  • functions/src/index.ts
  • functions/vitest.config.ts
  • infra/firebase/firestore.rules
  • packages/shared-firebase/package.json
  • packages/shared-firebase/src/app.ts
  • packages/shared-firebase/src/auth.ts
  • packages/shared-firebase/src/env.test.ts
  • packages/shared-firebase/src/env.ts
  • packages/shared-firebase/src/firestore.ts
  • packages/shared-firebase/src/index.ts
  • packages/shared-firebase/vitest.config.ts
  • packages/shared-types/src/auth.ts
  • packages/shared-types/src/config.ts
  • packages/shared-types/src/enums.ts
  • packages/shared-types/src/index.ts
  • packages/shared-validators/package.json
  • packages/shared-validators/src/alerts.ts
  • packages/shared-validators/src/auth.ts
  • packages/shared-validators/src/config.ts
  • packages/shared-validators/src/index.ts
  • packages/shared-validators/src/phase1-auth.test.ts
  • vitest.config.ts
  • vitest.workspace.ts
💤 Files with no reviewable changes (1)
  • vitest.workspace.ts

Comment thread apps/citizen-pwa/src/App.test.tsx Outdated
Comment thread apps/citizen-pwa/src/App.tsx Outdated

<div className={styles.feed}>
{state.alerts.map((alert) => (
<article key={`${alert.title}-${String(alert.publishedAt)}`} className={styles.alert}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a stable unique alert key instead of title+timestamp concatenation.

Line 43 can collide when two alerts share the same title and publishedAt, which can produce incorrect list reconciliation.

Proposed direction
-<article key={`${alert.title}-${String(alert.publishedAt)}`} className={styles.alert}>
+<article key={alert.id} className={styles.alert}>

(Requires AlertDoc to include id in shared types.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/App.tsx` at line 43, Replace the unstable key built from
`${alert.title}-${String(alert.publishedAt)}` with a stable unique identifier:
add `id` to the shared AlertDoc type (and any code that constructs/fetches
alerts) and use `alert.id` as the React key for the <article> (the mapping that
renders `alert` in App.tsx). Ensure all places that produce AlertDoc include the
id so list reconciliation uses this stable unique key instead of
title+timestamp.

Comment thread apps/citizen-pwa/src/useCitizenShell.ts
Comment thread apps/citizen-pwa/src/useCitizenShell.ts
Comment thread docs/progress.md Outdated
Comment on lines +3 to +11
const userRoleSchema = z.enum([
'citizen',
'responder',
'municipal_admin',
'agency_admin',
'provincial_superadmin',
])

const accountStatusSchema = z.enum(['active', 'suspended', 'disabled'])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider exporting accountStatusSchema and deriving from shared types.

  1. accountStatusSchema is defined but not exported. Other modules may need to validate account status independently.

  2. userRoleSchema duplicates the UserRole type definition in packages/shared-types/src/enums.ts. If the type changes, the schema must be manually kept in sync.

♻️ Proposed improvement for type derivation
import type { UserRole, AccountStatus } from '@bantayog/shared-types'

// Derive enum values from the type (requires listing them, but centralizes the source of truth)
const USER_ROLES = ['citizen', 'responder', 'municipal_admin', 'agency_admin', 'provincial_superadmin'] as const satisfies readonly UserRole[]

export const userRoleSchema = z.enum(USER_ROLES)
export const accountStatusSchema = z.enum(['active', 'suspended', 'disabled'] as const satisfies readonly AccountStatus[])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/auth.ts` around lines 3 - 11, Export
accountStatusSchema and derive both schemas from the canonical shared-types
enums: import the UserRole and AccountStatus types from
'@bantayog/shared-types', declare const arrays of allowed values (e.g.
USER_ROLES and ACCOUNT_STATUSES) typed to satisfy readonly UserRole[] / readonly
AccountStatus[], then create and export userRoleSchema = z.enum(USER_ROLES) and
export accountStatusSchema = z.enum(ACCOUNT_STATUSES); update references to use
the exported schemas instead of the local literals.

Comment thread packages/shared-validators/src/auth.ts
Comment on lines +3 to +8
export const minAppVersionSchema = z.object({
citizen: z.string().min(1),
admin: z.string().min(1),
responder: z.string().min(1),
updatedAt: z.number().int().nonnegative(),
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Tighten version-string validation in the config schema.

Line 4-Line 6 currently allow any non-empty string. That permits malformed versions (e.g., "latest"), which can break version-gating logic later.

Proposed refactor
 import { z } from 'zod'
 
+const semverPattern =
+  /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/
+
 export const minAppVersionSchema = z.object({
-  citizen: z.string().min(1),
-  admin: z.string().min(1),
-  responder: z.string().min(1),
+  citizen: z.string().regex(semverPattern),
+  admin: z.string().regex(semverPattern),
+  responder: z.string().regex(semverPattern),
   updatedAt: z.number().int().nonnegative(),
-})
+}).strict()

As per coding guidelines: “Defensive Programming: Assume external input is malicious/broken. Validate at the boundary.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const minAppVersionSchema = z.object({
citizen: z.string().min(1),
admin: z.string().min(1),
responder: z.string().min(1),
updatedAt: z.number().int().nonnegative(),
})
const semverPattern =
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/
export const minAppVersionSchema = z.object({
citizen: z.string().regex(semverPattern),
admin: z.string().regex(semverPattern),
responder: z.string().regex(semverPattern),
updatedAt: z.number().int().nonnegative(),
}).strict()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/config.ts` around lines 3 - 8, The
minAppVersionSchema currently allows any non-empty string for citizen, admin,
and responder which permits invalid tags like "latest"; update the schema for
those fields (citizen, admin, responder) to enforce proper semantic versioning
by either using a strict semver regex (e.g. major.minor.patch with optional
prerelease/build) or, preferably, validating with the semver library (e.g.,
import semver and use z.string().refine(v => semver.valid(v) !== null, {
message: 'Invalid semver' })). Keep updatedAt as is
(number().int().nonnegative()) and update error messages accordingly.

Comment on lines +41 to +50
describe('claimRevocationSchema', () => {
it('requires a revocation timestamp and reason', () => {
expect(
claimRevocationSchema.parse({
uid: 'admin-1',
revokedAt: 1713350400000,
reason: 'suspended',
}),
).toMatchObject({ reason: 'suspended' })
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

claimRevocationSchema test does not verify the “requires” behavior it claims.

On Line 42, the title says required fields are enforced, but the case only validates a valid payload. Add explicit failure checks for missing revokedAt/reason.

✅ Suggested test completion
 describe('claimRevocationSchema', () => {
   it('requires a revocation timestamp and reason', () => {
-    expect(
-      claimRevocationSchema.parse({
-        uid: 'admin-1',
-        revokedAt: 1713350400000,
-        reason: 'suspended',
-      }),
-    ).toMatchObject({ reason: 'suspended' })
+    expect(
+      claimRevocationSchema.parse({
+        uid: 'admin-1',
+        revokedAt: 1713350400000,
+        reason: 'suspended',
+      }),
+    ).toMatchObject({ reason: 'suspended' })
+
+    expect(() => claimRevocationSchema.parse({ uid: 'admin-1', reason: 'suspended' })).toThrow()
+    expect(() => claimRevocationSchema.parse({ uid: 'admin-1', revokedAt: 1713350400000 })).toThrow()
   })
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('claimRevocationSchema', () => {
it('requires a revocation timestamp and reason', () => {
expect(
claimRevocationSchema.parse({
uid: 'admin-1',
revokedAt: 1713350400000,
reason: 'suspended',
}),
).toMatchObject({ reason: 'suspended' })
})
describe('claimRevocationSchema', () => {
it('requires a revocation timestamp and reason', () => {
expect(
claimRevocationSchema.parse({
uid: 'admin-1',
revokedAt: 1713350400000,
reason: 'suspended',
}),
).toMatchObject({ reason: 'suspended' })
expect(() => claimRevocationSchema.parse({ uid: 'admin-1', reason: 'suspended' })).toThrow()
expect(() => claimRevocationSchema.parse({ uid: 'admin-1', revokedAt: 1713350400000 })).toThrow()
})
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/shared-validators/src/phase1-auth.test.ts` around lines 41 - 50, The
test title says required fields are enforced but only validates a valid payload;
add explicit negative assertions to verify missing fields fail: update the
describe('claimRevocationSchema') test to include two additional checks that
call claimRevocationSchema.parse (or claimRevocationSchema.safeParse) with
payloads missing revokedAt and missing reason and assert failure (e.g. expect(()
=> claimRevocationSchema.parse({...})).toThrow() or
expect(claimRevocationSchema.safeParse({...}).success).toBe(false)); keep the
existing valid-case assertion for completeness.

Comment thread README.md
Comment on lines +47 to +53
## Phase 1 verification

- `pnpm test`
- `pnpm --filter @bantayog/functions test:unit`
- `pnpm --filter @bantayog/functions test:rules`
- `pnpm lint && pnpm typecheck && pnpm build`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add an explicit staging-approval step to this verification checklist.

Given this PR touches auth/function/security surface, include a final checklist item that requires explicit staging approval after emulator + full-suite validation.

Based on learnings: For changes to security rules, DB indexes, deployment config, auth flows, or Cloud Functions with existing traffic: show the diff, deploy to dev emulator first, run full test suite, then request explicit staging approval.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 47 - 53, Update the "Phase 1 verification" checklist
to add a final explicit "staging approval" step: after running the listed
commands (pnpm test, pnpm --filter `@bantayog/functions` test:unit, pnpm --filter
`@bantayog/functions` test:rules, lint/typecheck/build) require a documented
staging-approval item that mandates deploying changes to the dev emulator,
running the full test suite, and obtaining explicit staging approval before any
production deploy for changes touching auth, security rules, DB indexes,
deployment config, auth flows, or Cloud Functions; reference the "Phase 1
verification" header and add a bullet that outlines these exact requirements.

claude added 2 commits April 17, 2026 18:51
Adds @bantayog/shared-firebase, @testing-library/jest-dom, @testing-library/react, tsx

🤖 Generated with [Claude Code](https://claude.com/claude-code)
The explicit @testing-library/jest-dom import was removed during previous fix
but is needed for TypeScript to recognize toBeInTheDocument matcher.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/citizen-pwa/src/App.test.tsx (1)

13-13: ⚠️ Potential issue | 🟡 Minor

Mocked minAppVersion is missing the updatedAt field.

The minAppVersion mock does not fully match the MinAppVersionDoc contract, which requires an updatedAt field. This was previously flagged and remains unaddressed.

🔧 Proposed fix
-    minAppVersion: { citizen: '0.1.0', admin: '0.1.0', responder: '0.1.0' },
+    minAppVersion: {
+      citizen: '0.1.0',
+      admin: '0.1.0',
+      responder: '0.1.0',
+      updatedAt: 1713350400000,
+    },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/App.test.tsx` at line 13, The test mock for
minAppVersion is missing the required updatedAt field per the MinAppVersionDoc
contract; update the mock object named minAppVersion in App.test.tsx to include
an updatedAt property (ISO string or Date) so it matches MinAppVersionDoc shape
used by the app and tests, e.g., add updatedAt: new Date().toISOString() (or the
appropriate fixture timestamp) to the existing object.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/citizen-pwa/src/App.test.tsx`:
- Around line 2-4: The test file currently imports from vitest twice (separate
imports for describe/it/expect and vi); consolidate them into a single import
statement that imports describe, it, expect and vi together (replace the
separate imports for describe/it/expect and vi with one import from 'vitest') to
improve readability and reduce duplicate module imports.
- Around line 31-34: The existing assertions in App.test.tsx use broad regexes
that can produce false positives; update the assertions that check for anon-123,
System online, 0.1.0, and signed-in to use more precise matching (e.g., exact
string matching via getByText with exact:true or exact string match) and/or
query by roles/textContent where appropriate; additionally add tests that mock
useCitizenShell returning status: 'error' with an error message to assert the
error text is rendered, and another test that mocks authState: 'signed-out' to
assert the signed-out UI is shown (use the useCitizenShell mock, render(<App
/>), and screen queries to locate the specific elements).

---

Duplicate comments:
In `@apps/citizen-pwa/src/App.test.tsx`:
- Line 13: The test mock for minAppVersion is missing the required updatedAt
field per the MinAppVersionDoc contract; update the mock object named
minAppVersion in App.test.tsx to include an updatedAt property (ISO string or
Date) so it matches MinAppVersionDoc shape used by the app and tests, e.g., add
updatedAt: new Date().toISOString() (or the appropriate fixture timestamp) to
the existing object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8c57cb41-74e1-4ef2-8681-24c4d0a866c6

📥 Commits

Reviewing files that changed from the base of the PR and between 73127f8 and a1b52c2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • apps/citizen-pwa/src/App.test.tsx

Comment thread apps/citizen-pwa/src/App.test.tsx Outdated
Comment thread apps/citizen-pwa/src/App.test.tsx Outdated
…hell

Wrap parseFirebaseWebEnv, createFirebaseWebApp, getFirebaseDb, getFirebaseAuth
in try-catch to avoid crashing the component tree when env vars are missing
or initialization fails. Now properly sets error state instead of throwing.

Addresses code review feedback: bug_risk issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/citizen-pwa/src/useCitizenShell.ts (1)

121-128: ⚠️ Potential issue | 🟡 Minor

Preserve the existing App Check result on sign-in failure.

If App Check succeeded earlier, Line 126 rewrites it to 'failed' for an unrelated auth error. Keep the current appCheckState when building the error state.

🐛 Proposed fix
       .catch((error: unknown) => {
         if (!unmounted.current) {
-          setState({
-            ...initialState,
-            status: 'error',
-            appCheckState: 'failed',
-            error: error instanceof Error ? error.message : 'Pseudonymous sign-in failed',
-          })
+          setState((current) => ({
+            ...initialState,
+            status: 'error',
+            appCheckState: current.appCheckState,
+            error: error instanceof Error ? error.message : 'Pseudonymous sign-in failed',
+          }))
         }
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/useCitizenShell.ts` around lines 121 - 128, When
handling the sign-in .catch, don't overwrite a previously successful App Check;
preserve the current appCheckState instead of forcing 'failed'. Change the
setState call inside the catch to use a functional update that retains the
existing appCheckState (e.g., derive appCheckState from the previous state or
current state) and only update status and error, while still spreading
initialState and using unmounted.current and initialState as before; reference
the setState call, initialState, and appCheckState to locate where to apply this
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/citizen-pwa/src/useCitizenShell.ts`:
- Around line 95-119: The callbacks passed to subscribeMinAppVersion and
subscribeAlerts currently set status: 'ready' unconditionally, which masks
Firestore error fallbacks that pass null/[]; update the handlers used in
ensurePseudonymousSignIn's then block so they only set status: 'ready' when the
snapshot is a confirmed success (e.g., check minAppVersion !== null before
setting ready in the subscribeMinAppVersion callback and check alerts !== null
(or Array.isArray(alerts) && alerts.length >= 0 but not the error sentinel)
before setting ready in the subscribeAlerts callback), or better: change
subscribeMinAppVersion/subscribeAlerts to surface an explicit success/error flag
and use that to set status:'ready' only on success; keep the user uid assignment
but avoid switching authState/status to 'ready' on error fallbacks and ensure
cleanup.current.stopVersion/stopAlerts remain unchanged.

---

Duplicate comments:
In `@apps/citizen-pwa/src/useCitizenShell.ts`:
- Around line 121-128: When handling the sign-in .catch, don't overwrite a
previously successful App Check; preserve the current appCheckState instead of
forcing 'failed'. Change the setState call inside the catch to use a functional
update that retains the existing appCheckState (e.g., derive appCheckState from
the previous state or current state) and only update status and error, while
still spreading initialState and using unmounted.current and initialState as
before; reference the setState call, initialState, and appCheckState to locate
where to apply this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d662ba79-89a9-4351-b8f0-26a6a22627ea

📥 Commits

Reviewing files that changed from the base of the PR and between a1b52c2 and 18036b7.

📒 Files selected for processing (1)
  • apps/citizen-pwa/src/useCitizenShell.ts

… state once

- Use functional setState in sign-in .catch to preserve existing appCheckState
- Set auth state once after sign-in, not redundantly in both subscription callbacks
- Subscription callbacks now only update their specific slice (minAppVersion or alerts)

Addresses code review feedback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/citizen-pwa/src/useCitizenShell.ts`:
- Around line 95-117: After ensurePseudonymousSignIn resolves, the code
currently registers Firestore listeners unconditionally which can run after the
component unmounted; update the .then() handler in useCitizenShell so you first
check unmounted.current and return early if true (i.e., do not call setState or
create subscriptions), and only after that assign cleanup.current.stopVersion =
subscribeMinAppVersion(db, ...) and cleanup.current.stopAlerts =
subscribeAlerts(db, ...); ensure all setState calls and subscription
registrations remain inside the same unmounted.current guard so subscriptions
are never created after unmount (references: ensurePseudonymousSignIn,
unmounted.current, cleanup.current.stopVersion, cleanup.current.stopAlerts,
subscribeMinAppVersion, subscribeAlerts, setState).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2decfe3b-ee35-48a6-b872-87a1ab982859

📥 Commits

Reviewing files that changed from the base of the PR and between 18036b7 and 7f02941.

📒 Files selected for processing (1)
  • apps/citizen-pwa/src/useCitizenShell.ts

Comment on lines +95 to +117
void ensurePseudonymousSignIn(auth)
.then((user) => {
// Set auth state once immediately after sign-in
if (!unmounted.current) {
setState((current) => ({
...current,
status: 'ready',
authState: 'signed-in',
user: { uid: user.uid },
}))
}

cleanup.current.stopVersion = subscribeMinAppVersion(db, (minAppVersion) => {
if (!unmounted.current) {
setState((current) => ({ ...current, minAppVersion }))
}
})

cleanup.current.stopAlerts = subscribeAlerts(db, (alerts) => {
if (!unmounted.current) {
setState((current) => ({ ...current, alerts }))
}
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="apps/citizen-pwa/src/useCitizenShell.ts"

echo "Inspecting sign-in then-block and subscription registration:"
nl -ba "$file" | sed -n '95,120p'

echo
echo "Expected after fix: early guard 'if (unmounted.current) return' before subscribe* calls."
rg -n "if \\(unmounted\\.current\\) return|subscribeMinAppVersion|subscribeAlerts" "$file" -C2

Repository: Exc1D/bantayog-alert

Length of output: 184


🏁 Script executed:

#!/bin/bash
file="apps/citizen-pwa/src/useCitizenShell.ts"

# Check file exists and get line count
if [ -f "$file" ]; then
  total_lines=$(wc -l < "$file")
  echo "File found. Total lines: $total_lines"
  echo "---"
  
  # Read lines 90-130 to see context around 95-117
  sed -n '90,130p' "$file" | cat -n
else
  echo "File not found"
fi

Repository: Exc1D/bantayog-alert

Length of output: 1588


Avoid creating Firestore subscriptions after unmount.

The .then() handler executes unconditionally when the sign-in promise resolves, even if the component has unmounted. The if (!unmounted.current) guards on lines 99 and 105 only protect the setState calls inside subscription callbacks, not the subscription registration itself. This causes subscribeMinAppVersion() and subscribeAlerts() to create listeners after cleanup has already run, leaking active Firestore subscriptions.

♻️ Proposed fix
 void ensurePseudonymousSignIn(auth)
   .then((user) => {
+    if (unmounted.current) return
+
     // Set auth state once immediately after sign-in
-    if (!unmounted.current) {
-      setState((current) => ({
-        ...current,
-        status: 'ready',
-        authState: 'signed-in',
-        user: { uid: user.uid },
-      }))
-    }
+    setState((current) => ({
+      ...current,
+      status: 'ready',
+      authState: 'signed-in',
+      user: { uid: user.uid },
+    }))

     cleanup.current.stopVersion = subscribeMinAppVersion(db, (minAppVersion) => {
       if (!unmounted.current) {
         setState((current) => ({ ...current, minAppVersion }))
       }
     })

     cleanup.current.stopAlerts = subscribeAlerts(db, (alerts) => {
       if (!unmounted.current) {
         setState((current) => ({ ...current, alerts }))
       }
     })
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/useCitizenShell.ts` around lines 95 - 117, After
ensurePseudonymousSignIn resolves, the code currently registers Firestore
listeners unconditionally which can run after the component unmounted; update
the .then() handler in useCitizenShell so you first check unmounted.current and
return early if true (i.e., do not call setState or create subscriptions), and
only after that assign cleanup.current.stopVersion = subscribeMinAppVersion(db,
...) and cleanup.current.stopAlerts = subscribeAlerts(db, ...); ensure all
setState calls and subscription registrations remain inside the same
unmounted.current guard so subscriptions are never created after unmount
(references: ensurePseudonymousSignIn, unmounted.current,
cleanup.current.stopVersion, cleanup.current.stopAlerts, subscribeMinAppVersion,
subscribeAlerts, setState).

claude and others added 2 commits April 17, 2026 19:12
…-template-expressions

Template literal with number expression in JSX key requires
explicit string conversion to avoid lint error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…properly

- suspendStaffAccount: pass input.reason (not 'suspended') to buildClaimRevocationDoc
- buildStaffClaims: accept typed interface instead of re-parsing input

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (7)
apps/citizen-pwa/src/App.test.tsx (2)

2-4: 🧹 Nitpick | 🔵 Trivial

Consolidate duplicate vitest imports.

Line 2 and Line 4 import from the same module; combine into one import for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/App.test.tsx` around lines 2 - 4, Combine the duplicate
vitest imports into a single import: remove the separate import of vi and merge
it with the existing import that brings in describe, it, and expect so you
import { describe, it, expect, vi } from 'vitest' (update the import(s) where
describe, it, expect, and vi are referenced).

36-39: ⚠️ Potential issue | 🟡 Minor

Use exact text assertions instead of broad regexes.

These regex checks can match unintended strings (especially 0.1.0, where . is wildcard), so this test can pass too easily.

Suggested tightening
-    expect(screen.getByText(/anon-123/)).toBeInTheDocument()
-    expect(screen.getByText(/System online/)).toBeInTheDocument()
-    expect(screen.getByText(/0.1.0/)).toBeInTheDocument()
-    expect(screen.getByText(/signed-in/)).toBeInTheDocument()
+    expect(screen.getByText('anon-123')).toBeInTheDocument()
+    expect(screen.getByText('System online')).toBeInTheDocument()
+    expect(screen.getByText('0.1.0')).toBeInTheDocument()
+    expect(screen.getByText('signed-in')).toBeInTheDocument()

As per coding guidelines: "Write tests that verify the new code is actually invoked, not tests that pass trivially."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/App.test.tsx` around lines 36 - 39, Replace the broad
regex assertions in App.test.tsx with exact string assertions so the test
matches only the intended text; specifically change the four calls that use
screen.getByText(/.../) to use exact string matches (e.g.,
screen.getByText('anon-123'), screen.getByText('System online'),
screen.getByText('0.1.0'), screen.getByText('signed-in')) so the test cannot be
accidentally satisfied by unintended substrings or regex wildcards.
apps/citizen-pwa/src/App.tsx (1)

44-44: ⚠️ Potential issue | 🟡 Minor

Use a truly stable unique key for alert items.

Line 44 can still collide (publishedBy + publishedAt is not guaranteed unique), which risks incorrect list reconciliation. Prefer a dedicated immutable alert.id from the data model and use that as the React key.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/citizen-pwa/src/App.tsx` at line 44, The list key currently uses a
derived key `${alert.publishedBy}-${String(alert.publishedAt)}` which can
collide; update the mapping in App.tsx to use a stable immutable identifier
(e.g., alert.id) instead of the derived string so React can correctly reconcile
the list; locate where the alert is rendered (the element using
key={`${alert.publishedBy}-${String(alert.publishedAt)}`}) and replace that
expression with the dedicated unique field (alert.id), ensuring the data model
provides that id.
functions/src/auth/account-lifecycle.ts (3)

44-44: 🧹 Nitpick | 🔵 Trivial

Same Zod validation concern applies here.

The suspendStaffAccountInputSchema.parse() call has the same issue—validation failures will surface as internal errors rather than invalid-argument errors.

♻️ Proposed fix
-  const input = suspendStaffAccountInputSchema.parse(request.data)
+  const result = suspendStaffAccountInputSchema.safeParse(request.data)
+  if (!result.success) {
+    throw new HttpsError('invalid-argument', result.error.issues[0]?.message ?? 'Invalid input')
+  }
+  const input = result.data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/auth/account-lifecycle.ts` at line 44, The call to
suspendStaffAccountInputSchema.parse(request.data) should be replaced with a
safe parse pattern so Zod validation failures are mapped to an
'invalid-argument' error instead of surfacing as internal errors; use
suspendStaffAccountInputSchema.safeParse(request.data), check result.success,
and when false throw a firebase/functions HttpsError (code 'invalid-argument')
with the validation error details (or a concise message) so the
suspendStaffAccount handler returns a proper client error on bad input.

18-18: 🧹 Nitpick | 🔵 Trivial

Zod validation errors surface as generic internal errors to clients.

When setStaffClaimsInputSchema.parse() throws a ZodError, it propagates as an unhandled exception, resulting in a generic internal error for the caller. Consider using safeParse and throwing a descriptive HttpsError with invalid-argument code.

♻️ Proposed fix
-  const parsed = setStaffClaimsInputSchema.parse(request.data)
+  const result = setStaffClaimsInputSchema.safeParse(request.data)
+  if (!result.success) {
+    throw new HttpsError('invalid-argument', result.error.issues[0]?.message ?? 'Invalid input')
+  }
+  const parsed = result.data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/auth/account-lifecycle.ts` at line 18, Replace the direct call
to setStaffClaimsInputSchema.parse(request.data) with safe parsing: call
setStaffClaimsInputSchema.safeParse(request.data), check the .success flag, and
if false throw an HttpsError with code "invalid-argument" and include the Zod
validation error messages (e.g., join issues) in the error details; keep using
the same variable names (parsed or parsedResult) and ensure downstream code uses
parsedResult.data when success is true so existing logic (in this file's account
lifecycle handler) continues to work.

54-61: ⚠️ Potential issue | 🟠 Major

Non-atomic writes risk data inconsistency.

Unlike setStaffClaims which uses a batch commit, suspendStaffAccount performs two separate writes. If the second write fails, the account will be marked suspended but no revocation record will exist. Apply the same batch pattern for consistency.

🐛 Proposed fix using batch writes
   const current = snapshot.data() ?? {}
   const revokedAt = Date.now()

-  await adminDb
-    .collection('active_accounts')
-    .doc(input.uid)
-    .set({ ...current, accountStatus: 'suspended', updatedAt: revokedAt }, { merge: true })
-  await adminDb
-    .collection('claim_revocations')
-    .doc(input.uid)
-    .set(buildClaimRevocationDoc(input.uid, revokedAt, input.reason))
+  const batch = adminDb.batch()
+  batch.set(
+    adminDb.collection('active_accounts').doc(input.uid),
+    { ...current, accountStatus: 'suspended', updatedAt: revokedAt },
+    { merge: true },
+  )
+  batch.set(
+    adminDb.collection('claim_revocations').doc(input.uid),
+    buildClaimRevocationDoc(input.uid, revokedAt, input.reason),
+  )
+  await batch.commit()

   return { uid: input.uid, status: 'suspended' }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/auth/account-lifecycle.ts` around lines 54 - 61,
suspendStaffAccount currently performs two separate writes which can leave data
inconsistent if the second write fails; change it to use a single Firestore
batch: create a WriteBatch (const batch = adminDb.batch()), call batch.set(...)
for adminDb.collection('active_accounts').doc(input.uid) with the merged payload
({ ...current, accountStatus: 'suspended', updatedAt: revokedAt }, { merge: true
}) and batch.set(...) for adminDb.collection('claim_revocations').doc(input.uid)
with buildClaimRevocationDoc(input.uid, revokedAt, input.reason), then await
batch.commit() so both writes succeed or both fail atomically.
functions/src/auth/custom-claims.ts (1)

40-52: 🧹 Nitpick | 🔵 Trivial

Avoid writing undefined fields to Firestore.

When municipalityId or agencyId are undefined, they're still included in the returned object. While Firestore handles this, it can cause confusion and inconsistent document shapes. Consider conditionally including these fields similar to buildStaffClaims.

♻️ Proposed improvement
 export function buildActiveAccountDoc(uid: string, claims: CustomClaims, updatedAt: number) {
-  return {
+  const doc: Record<string, unknown> = {
     uid,
     role: claims.role,
     accountStatus: claims.accountStatus,
-    municipalityId: claims.municipalityId,
-    agencyId: claims.agencyId,
     permittedMunicipalityIds: claims.permittedMunicipalityIds ?? [],
     mfaEnrolled: claims.mfaEnrolled,
     lastClaimIssuedAt: claims.lastClaimIssuedAt,
     updatedAt,
   }
+  if (claims.municipalityId !== undefined) doc.municipalityId = claims.municipalityId
+  if (claims.agencyId !== undefined) doc.agencyId = claims.agencyId
+  return doc
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/auth/custom-claims.ts` around lines 40 - 52, The returned
object from buildActiveAccountDoc includes municipalityId and agencyId even when
undefined; change buildActiveAccountDoc to conditionally add municipalityId and
agencyId only when they are not undefined (similar to buildStaffClaims), so the
Firestore document doesn't get explicit undefined fields; keep
permittedMunicipalityIds defaulting to [] and still include role, accountStatus,
mfaEnrolled, lastClaimIssuedAt, lastClaimIssuedAt, updatedAt and uid as before,
but merge municipalityId and agencyId into the result object only when
claims.municipalityId !== undefined / claims.agencyId !== undefined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@functions/src/auth/account-lifecycle.ts`:
- Around line 39-64: suspendStaffAccount currently updates Firestore but doesn't
update the user's JWT custom claims, causing token/db state mismatch; call
adminAuth.setCustomUserClaims(input.uid, { accountStatus: 'suspended' }) before
writing to 'active_accounts' and 'claim_revocations' so tokens reflect
suspension, and ensure errors from setCustomUserClaims are handled (fail fast or
log and abort) to avoid partial updates; keep existing use of
buildClaimRevocationDoc and revoke timestamp (revokedAt) when ordering these
operations.

---

Duplicate comments:
In `@apps/citizen-pwa/src/App.test.tsx`:
- Around line 2-4: Combine the duplicate vitest imports into a single import:
remove the separate import of vi and merge it with the existing import that
brings in describe, it, and expect so you import { describe, it, expect, vi }
from 'vitest' (update the import(s) where describe, it, expect, and vi are
referenced).
- Around line 36-39: Replace the broad regex assertions in App.test.tsx with
exact string assertions so the test matches only the intended text; specifically
change the four calls that use screen.getByText(/.../) to use exact string
matches (e.g., screen.getByText('anon-123'), screen.getByText('System online'),
screen.getByText('0.1.0'), screen.getByText('signed-in')) so the test cannot be
accidentally satisfied by unintended substrings or regex wildcards.

In `@apps/citizen-pwa/src/App.tsx`:
- Line 44: The list key currently uses a derived key
`${alert.publishedBy}-${String(alert.publishedAt)}` which can collide; update
the mapping in App.tsx to use a stable immutable identifier (e.g., alert.id)
instead of the derived string so React can correctly reconcile the list; locate
where the alert is rendered (the element using
key={`${alert.publishedBy}-${String(alert.publishedAt)}`}) and replace that
expression with the dedicated unique field (alert.id), ensuring the data model
provides that id.

In `@functions/src/auth/account-lifecycle.ts`:
- Line 44: The call to suspendStaffAccountInputSchema.parse(request.data) should
be replaced with a safe parse pattern so Zod validation failures are mapped to
an 'invalid-argument' error instead of surfacing as internal errors; use
suspendStaffAccountInputSchema.safeParse(request.data), check result.success,
and when false throw a firebase/functions HttpsError (code 'invalid-argument')
with the validation error details (or a concise message) so the
suspendStaffAccount handler returns a proper client error on bad input.
- Line 18: Replace the direct call to
setStaffClaimsInputSchema.parse(request.data) with safe parsing: call
setStaffClaimsInputSchema.safeParse(request.data), check the .success flag, and
if false throw an HttpsError with code "invalid-argument" and include the Zod
validation error messages (e.g., join issues) in the error details; keep using
the same variable names (parsed or parsedResult) and ensure downstream code uses
parsedResult.data when success is true so existing logic (in this file's account
lifecycle handler) continues to work.
- Around line 54-61: suspendStaffAccount currently performs two separate writes
which can leave data inconsistent if the second write fails; change it to use a
single Firestore batch: create a WriteBatch (const batch = adminDb.batch()),
call batch.set(...) for adminDb.collection('active_accounts').doc(input.uid)
with the merged payload ({ ...current, accountStatus: 'suspended', updatedAt:
revokedAt }, { merge: true }) and batch.set(...) for
adminDb.collection('claim_revocations').doc(input.uid) with
buildClaimRevocationDoc(input.uid, revokedAt, input.reason), then await
batch.commit() so both writes succeed or both fail atomically.

In `@functions/src/auth/custom-claims.ts`:
- Around line 40-52: The returned object from buildActiveAccountDoc includes
municipalityId and agencyId even when undefined; change buildActiveAccountDoc to
conditionally add municipalityId and agencyId only when they are not undefined
(similar to buildStaffClaims), so the Firestore document doesn't get explicit
undefined fields; keep permittedMunicipalityIds defaulting to [] and still
include role, accountStatus, mfaEnrolled, lastClaimIssuedAt, lastClaimIssuedAt,
updatedAt and uid as before, but merge municipalityId and agencyId into the
result object only when claims.municipalityId !== undefined / claims.agencyId
!== undefined.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3b0441f0-ea98-48d9-a994-c2c531521b56

📥 Commits

Reviewing files that changed from the base of the PR and between 7f02941 and b68de5c.

📒 Files selected for processing (4)
  • apps/citizen-pwa/src/App.test.tsx
  • apps/citizen-pwa/src/App.tsx
  • functions/src/auth/account-lifecycle.ts
  • functions/src/auth/custom-claims.ts

Comment on lines +39 to +64
export const suspendStaffAccount = onCall(async (request) => {
if (request.auth?.token.role !== 'provincial_superadmin') {
throw new HttpsError('permission-denied', 'Only superadmins can suspend accounts.')
}

const input = suspendStaffAccountInputSchema.parse(request.data)
const snapshot = await adminDb.collection('active_accounts').doc(input.uid).get()

if (!snapshot.exists) {
throw new HttpsError('not-found', 'Active account record not found.')
}

const current = snapshot.data() ?? {}
const revokedAt = Date.now()

await adminDb
.collection('active_accounts')
.doc(input.uid)
.set({ ...current, accountStatus: 'suspended', updatedAt: revokedAt }, { merge: true })
await adminDb
.collection('claim_revocations')
.doc(input.uid)
.set(buildClaimRevocationDoc(input.uid, revokedAt, input.reason))

return { uid: input.uid, status: 'suspended' }
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for other places where accountStatus or setCustomUserClaims are modified
rg -n "accountStatus.*suspended|setCustomUserClaims" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 493


🏁 Script executed:

wc -l functions/src/auth/account-lifecycle.ts

Repository: Exc1D/bantayog-alert

Length of output: 106


🏁 Script executed:

cat -n functions/src/auth/account-lifecycle.ts

Repository: Exc1D/bantayog-alert

Length of output: 2525


🏁 Script executed:

fd -i custom-claims custom-claims.js functions/src/auth/

Repository: Exc1D/bantayog-alert

Length of output: 179


🏁 Script executed:

cat -n functions/src/auth/custom-claims.js

Repository: Exc1D/bantayog-alert

Length of output: 131


🏁 Script executed:

cat -n functions/src/auth/custom-claims.ts

Repository: Exc1D/bantayog-alert

Length of output: 2120


🏁 Script executed:

fd firestore.rules

Repository: Exc1D/bantayog-alert

Length of output: 142


🏁 Script executed:

rg -n "getUser|token.*refresh|accountStatus" --type ts functions/src/ | head -30

Repository: Exc1D/bantayog-alert

Length of output: 1096


🏁 Script executed:

cat -n infra/firebase/firestore.rules

Repository: Exc1D/bantayog-alert

Length of output: 3170


🏁 Script executed:

cat -n functions/src/__tests__/firestore.rules.test.ts | head -150

Repository: Exc1D/bantayog-alert

Length of output: 5307


🏁 Script executed:

rg -n "accountStatus" --type ts functions/src/ | grep -v test | grep -v custom-claims.ts | grep -v account-lifecycle.ts

Repository: Exc1D/bantayog-alert

Length of output: 46


🏁 Script executed:

rg "token\.accountStatus|auth\.token.*accountStatus" --type ts

Repository: Exc1D/bantayog-alert

Length of output: 46


Consider updating JWT claims on account suspension for consistency.

While Firestore security rules independently verify accountStatus from the database document via isActivePrivileged() (which checks the Firestore record, not JWT claims), the user's JWT token will still have accountStatus: 'active' until it expires or is refreshed. This creates a state mismatch between Auth claims and the actual account status in Firestore. Updating the custom claims here would ensure consistency and allow client-side code to rely on token state without fetching Firestore.

The proposed fix to call adminAuth.setCustomUserClaims() with the suspended status before the Firestore updates would be a reasonable quality improvement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@functions/src/auth/account-lifecycle.ts` around lines 39 - 64,
suspendStaffAccount currently updates Firestore but doesn't update the user's
JWT custom claims, causing token/db state mismatch; call
adminAuth.setCustomUserClaims(input.uid, { accountStatus: 'suspended' }) before
writing to 'active_accounts' and 'claim_revocations' so tokens reflect
suspension, and ensure errors from setCustomUserClaims are handled (fail fast or
log and abort) to avoid partial updates; keep existing use of
buildClaimRevocationDoc and revoke timestamp (revokedAt) when ordering these
operations.

- Add id field to AlertDoc, use alert.id as React key in App.tsx
- Fix progress.md: status now reflects incomplete verification with remediation note
- App.test.tsx: single vitest import, exact text matching, added error-state and signed-out tests
- functions/package.json: replace plain deploy with env-specific scripts (dev/staging/prod), plain deploy now prints guidance

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants