diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 6f04abf..64ee016 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, @@ -489,6 +489,7 @@ describe('Social Architecture Security Rules', () => { id: 'ride_inv', organizerUid: 'alice', title: 'Invite Run', + status: 'scheduled', scheduledStartAt: '2026-09-10T09:00:00Z', departureAt: '2026-09-10T09:30:00Z', participantCount: 1, @@ -521,16 +522,16 @@ describe('Social Architecture Security Rules', () => { status: 'accepted', })); - // Bob accepts his own invite - await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv/participants/bob'), { + // Bob accepts his own invite via atomic batch + const bobAcceptBatch = writeBatch(bob); + bobAcceptBatch.update(doc(bob, 'group_rides/ride_inv/participants/bob'), { status: 'accepted', - })); - - // Bob can adjust participantCount on the ride during acceptance - await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_inv'), { + }); + bobAcceptBatch.update(doc(bob, 'group_rides/ride_inv'), { participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', - })); + }); + await assertSucceeds(bobAcceptBatch.commit()); // Bob CANNOT edit authority fields like title or organizerUid await assertFails(updateDoc(doc(bob, 'group_rides/ride_inv'), { @@ -675,10 +676,17 @@ describe('Social Architecture Security Rules', () => { status: 'accepted', })); - // 4. Organizer Alice CAN approve Charlie's request - await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_request_only/participants/charlie'), { + // 4. Organizer Alice CAN approve Charlie's request via atomic batch + const aliceApproveBatch = writeBatch(alice); + aliceApproveBatch.update(doc(alice, 'group_rides/ride_request_only/participants/charlie'), { status: 'accepted', - })); + }); + aliceApproveBatch.update(doc(alice, 'group_rides/ride_request_only'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-09-10T09:05:00Z', + }); + await assertSucceeds(aliceApproveBatch.commit()); // 5. On inviteOnly ride, stranger Dave CANNOT request or join await assertFails(setDoc(doc(dave, 'group_rides/ride_invite_strict/participants/dave'), { @@ -692,19 +700,19 @@ describe('Social Architecture Security Rules', () => { status: 'accepted', })); - // 6. On open ride, Dave CAN directly join with 'accepted' - await assertSucceeds(setDoc(doc(dave, 'group_rides/ride_open/participants/dave'), { + // 6. On open ride, Dave CAN directly join with 'accepted' via atomic batch + const daveOpenBatch = writeBatch(dave); + daveOpenBatch.set(doc(dave, 'group_rides/ride_open/participants/dave'), { userId: 'dave', role: 'participant', status: 'accepted', - })); - - // 7. Dave can update accessUids and increment participantCount on open ride - await assertSucceeds(updateDoc(doc(dave, 'group_rides/ride_open'), { + }); + daveOpenBatch.update(doc(dave, 'group_rides/ride_open'), { accessUids: ['alice', 'dave'], participantCount: 2, updatedAt: '2026-09-10T09:05:00Z', - })); + }); + await assertSucceeds(daveOpenBatch.commit()); }); test('live group presence security rules', async () => { @@ -958,37 +966,34 @@ describe('Social Architecture Security Rules', () => { }); }; - // Scenario A: Participant Removal (Organizer removes Rider B) + // Scenario A: Hard delete of accepted participant and organizer is BLOCKED await resetLiveState(); // Alice reads Bob initially -> succeeds await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); - // Organizer removes Bob by deleting participant record - await assertSucceeds(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'))); - // Now Bob CANNOT write to live presence - await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { + // Organizer CANNOT delete Bob's accepted participant record (must use atomic removal) + await assertFails(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'))); + // Bob CANNOT delete his own accepted participant record (must use atomic leave) + await assertFails(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); + // Organizer CANNOT delete own organizer participant record + await assertFails(deleteDoc(doc(alice, 'group_rides/ride_lifecycle/participants/alice'))); + // Bob still has valid presence access while accepted + await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { ...validBobPresence, speedMph: 55.0, })); - // Alice CANNOT read Bob's old presence - await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); - - // Scenario B: Voluntary Leave (Bob deletes his participant record) - await resetLiveState(); - // Bob deletes his participant record (voluntary leave) - await assertSucceeds(deleteDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'))); - // Bob CANNOT write to live presence - await assertFails(setDoc(doc(bob, 'group_rides/ride_lifecycle/live_presence/bob'), { - ...validBobPresence, - speedMph: 50.0, - })); - // Alice CANNOT read Bob's presence - await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Scenario B2: Voluntary Leave via status transition (status: 'accepted' -> 'left', presence doc temporarily remains) await resetLiveState(); - await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), { + const leaveBatch = writeBatch(bob); + leaveBatch.update(doc(bob, 'group_rides/ride_lifecycle/participants/bob'), { status: 'left', - })); + }); + leaveBatch.update(doc(bob, 'group_rides/ride_lifecycle'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-09-10T09:35:00Z', + }); + await assertSucceeds(leaveBatch.commit()); // Stale presence document still exists in database, but Alice direct get is DENIED await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); // Bob self get for cleanup verification remains ALLOWED @@ -996,9 +1001,16 @@ describe('Social Architecture Security Rules', () => { // Scenario B3: Organizer removal via status transition (status: 'accepted' -> 'removed', presence doc temporarily remains) await resetLiveState(); - await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), { + const removeBatch = writeBatch(alice); + removeBatch.update(doc(alice, 'group_rides/ride_lifecycle/participants/bob'), { status: 'removed', - })); + }); + removeBatch.update(doc(alice, 'group_rides/ride_lifecycle'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-09-10T09:35:00Z', + }); + await assertSucceeds(removeBatch.commit()); // Stale presence document still exists in database, but Alice direct get is DENIED await assertFails(getDoc(doc(alice, 'group_rides/ride_lifecycle/live_presence/bob'))); @@ -4726,6 +4738,11 @@ describe('Social Architecture Security Rules', () => { canceledReason: 'Mechanical issue', updatedAt: '2026-09-10T11:00:00Z', })); + + // 4m. Underway ride: non-organizer Club admin metadata edit FAILS + await assertFails(updateDoc(doc(bob, 'group_rides/ride_admin_underway'), { + title: 'Admin attempting underway edit', + })); }); // 5. TERMINAL-RIDE SECURITY HARDENING @@ -4945,5 +4962,1238 @@ describe('Social Architecture Security Rules', () => { ); await assertFails(getDocs(charlieOrderedQuery)); }); + + // 8. CLUB ADMIN & OWNER GOVERNANCE LISTING & POINT-READING + test('8. Club Admin and Owner governance listing and point-reading: active admins can point-read peer-admin rides and list their club rides; foreign admin, member, and non-member listing denied', async () => { + await seedClubGroupRideEnv(); + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + const frank = testEnv.authenticatedContext('frank').firestore(); + + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + // Alice (Club Owner) organizes a private club ride + await setDoc(doc(db, 'group_rides/ride_gov_alice'), { + ...baseRideData('ride_gov_alice', 'alice', 'club_alpha'), + visibility: 'private', + }); + // Bob (Club Admin) organizes a private club ride + await setDoc(doc(db, 'group_rides/ride_gov_bob'), { + ...baseRideData('ride_gov_bob', 'bob', 'club_alpha'), + visibility: 'private', + }); + // Frank (Club Beta Owner) organizes a private club ride for Club Beta + await setDoc(doc(db, 'group_rides/ride_gov_frank'), { + ...baseRideData('ride_gov_frank', 'frank', 'club_beta'), + visibility: 'private', + }); + }); + + // 8a. Active Club Owner (Alice) can point-read peer-admin (Bob) Club GroupRide + await assertSucceeds(getDoc(doc(alice, 'group_rides/ride_gov_bob'))); + + // 8b. Active Club Admin (Bob) can point-read peer-admin (Alice) Club GroupRide + await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_gov_alice'))); + + // 8c. Active Club Admin (Bob) can list where(clubId == 'club_alpha') + const bobAlphaQuery = query( + collection(bob, 'group_rides'), + where('clubId', '==', 'club_alpha'), + ); + await assertSucceeds(getDocs(bobAlphaQuery)); + + // 8d. Active Club Owner (Alice) can list where(clubId == 'club_alpha') + const aliceAlphaQuery = query( + collection(alice, 'group_rides'), + where('clubId', '==', 'club_alpha'), + ); + await assertSucceeds(getDocs(aliceAlphaQuery)); + + // 8e. Active Club Admin (Bob) can execute Founder Portal upcoming query + const bobUpcomingQuery = query( + collection(bob, 'group_rides'), + where('clubId', '==', 'club_alpha'), + where('status', 'in', ['scheduled', 'assembling', 'underway']), + ); + await assertSucceeds(getDocs(bobUpcomingQuery)); + + // 8f. Active Club Admin (Bob) can execute Founder Portal past query + const bobPastQuery = query( + collection(bob, 'group_rides'), + where('clubId', '==', 'club_alpha'), + where('status', 'in', ['completed', 'canceled']), + ); + await assertSucceeds(getDocs(bobPastQuery)); + + // 8g. Foreign Club Admin (Frank) CANNOT list club_alpha rides + const frankAlphaQuery = query( + collection(frank, 'group_rides'), + where('clubId', '==', 'club_alpha'), + ); + await assertFails(getDocs(frankAlphaQuery)); + + // 8h. Bob CANNOT list club_beta rides (he is not admin of club_beta) + const bobBetaQuery = query( + collection(bob, 'group_rides'), + where('clubId', '==', 'club_beta'), + ); + await assertFails(getDocs(bobBetaQuery)); + + // 8i. Ordinary active Club Member (Charlie) CANNOT list club_alpha rides + const charlieAlphaQuery = query( + collection(charlie, 'group_rides'), + where('clubId', '==', 'club_alpha'), + ); + await assertFails(getDocs(charlieAlphaQuery)); + + // 8j. Non-member (Dave) CANNOT list club_alpha rides + const daveAlphaQuery = query( + collection(dave, 'group_rides'), + where('clubId', '==', 'club_alpha'), + ); + await assertFails(getDocs(daveAlphaQuery)); + }); + + // 9. CLUB GROUPRIDE JOIN WORKFLOW & PARTICIPANT MODERATION SECURITY RULES (SEC-J01 - SEC-J25) + describe('9. Club GroupRide Join Workflow and Moderation Security Rules (SEC-J01 - SEC-J25)', () => { + let alice, bob, charlie, dave, frank; + + async function seedJoinEnv() { + await seedClubGroupRideEnv(); + alice = testEnv.authenticatedContext('alice').firestore(); + bob = testEnv.authenticatedContext('bob').firestore(); + charlie = testEnv.authenticatedContext('charlie').firestore(); + dave = testEnv.authenticatedContext('dave').firestore(); + frank = testEnv.authenticatedContext('frank').firestore(); + + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + + // Target 1: Scheduled request-to-join official club ride + await setDoc(doc(db, 'group_rides/ride_j_scheduled'), { + ...baseRideData('ride_j_scheduled', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 3, + }); + await setDoc(doc(db, 'group_rides/ride_j_scheduled/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + + // Target 2: Underway request-to-join official club ride + await setDoc(doc(db, 'group_rides/ride_j_underway'), { + ...baseRideData('ride_j_underway', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + status: 'underway', + }); + await setDoc(doc(db, 'group_rides/ride_j_underway/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_underway/participants/requester_u'), { + userId: 'requester_u', + role: 'participant', + status: 'requested', + }); + + // Target 3: Full ride at capacity (limit 2, count 2) + await setDoc(doc(db, 'group_rides/ride_j_full'), { + ...baseRideData('ride_j_full', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 2, + participantCount: 2, + accessUids: ['alice', 'bob'], + }); + await setDoc(doc(db, 'group_rides/ride_j_full/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_full/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + }); + + // Target 5: Club admin moderation test ride + await setDoc(doc(db, 'group_rides/ride_j_bob_mod'), { + ...baseRideData('ride_j_bob_mod', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 5, + }); + await setDoc(doc(db, 'group_rides/ride_j_bob_mod/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_bob_mod/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + + // Target 6: Decline test rides + await setDoc(doc(db, 'group_rides/ride_j_dec_org'), { + ...baseRideData('ride_j_dec_org', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + }); + await setDoc(doc(db, 'group_rides/ride_j_dec_org/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + + await setDoc(doc(db, 'group_rides/ride_j_dec_admin'), { + ...baseRideData('ride_j_dec_admin', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + }); + await setDoc(doc(db, 'group_rides/ride_j_dec_admin/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + + // Target 7: Self cancel pending request + await setDoc(doc(db, 'group_rides/ride_j_cancel'), { + ...baseRideData('ride_j_cancel', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + }); + await setDoc(doc(db, 'group_rides/ride_j_cancel/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + + // Target 8: Self leave accepted ride + await setDoc(doc(db, 'group_rides/ride_j_leave'), { + ...baseRideData('ride_j_leave', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantCount: 2, + accessUids: ['alice', 'charlie'], + }); + await setDoc(doc(db, 'group_rides/ride_j_leave/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_leave/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + }); + + // Target 9: Removal test rides + await setDoc(doc(db, 'group_rides/ride_j_rem_org'), { + ...baseRideData('ride_j_rem_org', 'alice', 'club_alpha'), + visibility: 'public', + participantCount: 2, + accessUids: ['alice', 'charlie'], + }); + await setDoc(doc(db, 'group_rides/ride_j_rem_org/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_rem_org/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + }); + + await setDoc(doc(db, 'group_rides/ride_j_rem_admin'), { + ...baseRideData('ride_j_rem_admin', 'alice', 'club_alpha'), + visibility: 'public', + participantCount: 2, + accessUids: ['alice', 'charlie'], + }); + await setDoc(doc(db, 'group_rides/ride_j_rem_admin/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_rem_admin/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + }); + + // Target 10: Open club ride + await setDoc(doc(db, 'group_rides/ride_j_open'), { + ...baseRideData('ride_j_open', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'open', + participantLimit: 10, + }); + await setDoc(doc(db, 'group_rides/ride_j_open/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + + // Target 11: Ineligible member (eve_removed) requesting + await setDoc(doc(db, 'group_rides/ride_j_eve'), { + ...baseRideData('ride_j_eve', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + }); + await setDoc(doc(db, 'group_rides/ride_j_eve/participants/eve_removed'), { + userId: 'eve_removed', + role: 'participant', + status: 'requested', + }); + + // Target 12: Point-read roster testing + await setDoc(doc(db, 'group_rides/ride_j_roster'), { + ...baseRideData('ride_j_roster', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + accessUids: ['alice', 'bob'], + }); + await setDoc(doc(db, 'group_rides/ride_j_roster/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_roster/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_j_roster/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + }); + } + + beforeEach(async () => { + await seedJoinEnv(); + }); + + test('SEC-J01: Non-member Dave CANNOT request to join club-affiliated ride', async () => { + await assertFails(setDoc(doc(dave, 'group_rides/ride_j_scheduled/participants/dave'), { + userId: 'dave', + role: 'participant', + status: 'requested', + })); + }); + + test('SEC-J02: Active Club Member Charlie CAN requestToJoin official club ride (single-doc create, no parent mutation)', async () => { + await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_j_scheduled/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + })); + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { + accessUids: ['alice', 'charlie'], + updatedAt: '2026-10-01T08:00:00Z', + })); + }); + + test('SEC-J03: Requester Charlie CANNOT self-promote to accepted or inject self into accessUids', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + }); + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled/participants/charlie'), { + status: 'accepted', + })); + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:01:00Z', + })); + }); + + test('SEC-J04: Requester CANNOT request when ride status is underway', async () => { + await assertFails(setDoc(doc(charlie, 'group_rides/ride_j_underway/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + })); + }); + + test('SEC-J05: Requester CAN create request when ride is currently at capacity (capacity enforced at approval)', async () => { + await assertSucceeds(setDoc(doc(charlie, 'group_rides/ride_j_full/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + })); + }); + + test('SEC-J06: Requester Charlie CANNOT modify parent ride keys', async () => { + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_scheduled'), { + title: 'Hacked Title', + updatedAt: '2026-10-01T08:00:00Z', + })); + }); + + test('SEC-J07: Blocked rider Frank CANNOT request to join Alice ride', async () => { + await assertFails(setDoc(doc(frank, 'group_rides/ride_j_scheduled/participants/frank'), { + userId: 'frank', + role: 'participant', + status: 'requested', + })); + }); + + test('SEC-J08: Approval cross-document atomicity (half-transactions fail, atomic batch succeeds)', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + }); + // Half-transaction (participant doc only) FAILS because parent accessUids/participantCount not updated + await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_scheduled/participants/charlie'), { + status: 'accepted', + })); + // Half-transaction (parent doc only) FAILS because participant doc status not updated + await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_scheduled'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + })); + // Full atomic batch SUCCEEDS + const batchAliceApprove = writeBatch(alice); + batchAliceApprove.update(doc(alice, 'group_rides/ride_j_scheduled/participants/charlie'), { + status: 'accepted', + }); + batchAliceApprove.update(doc(alice, 'group_rides/ride_j_scheduled'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertSucceeds(batchAliceApprove.commit()); + }); + + test('SEC-J09: Active Club Admin Bob CAN approve pending request on official club ride via atomic batch', async () => { + const batchBobApprove = writeBatch(bob); + batchBobApprove.update(doc(bob, 'group_rides/ride_j_bob_mod/participants/charlie'), { + status: 'accepted', + }); + batchBobApprove.update(doc(bob, 'group_rides/ride_j_bob_mod'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertSucceeds(batchBobApprove.commit()); + }); + + test('SEC-J10: Non-admin club member Charlie CANNOT approve requests', async () => { + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_bob_mod/participants/charlie'), { + status: 'accepted', + })); + }); + + test('SEC-J11: Approval denied if ride is underway', async () => { + const batchUnderway = writeBatch(alice); + batchUnderway.update(doc(alice, 'group_rides/ride_j_underway/participants/requester_u'), { + status: 'accepted', + }); + batchUnderway.update(doc(alice, 'group_rides/ride_j_underway'), { + accessUids: ['alice', 'requester_u'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batchUnderway.commit()); + }); + + test('SEC-J12: Approval denied if ride would exceed capacity', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_full/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + }); + }); + const batchFull = writeBatch(alice); + batchFull.update(doc(alice, 'group_rides/ride_j_full/participants/charlie'), { + status: 'accepted', + }); + batchFull.update(doc(alice, 'group_rides/ride_j_full'), { + accessUids: ['alice', 'bob', 'charlie'], + participantCount: 3, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batchFull.commit()); + }); + + test('SEC-J13: Approval denied if requester is no longer an active member (eve_removed)', async () => { + const batchEve = writeBatch(alice); + batchEve.update(doc(alice, 'group_rides/ride_j_eve/participants/eve_removed'), { + status: 'accepted', + }); + batchEve.update(doc(alice, 'group_rides/ride_j_eve'), { + accessUids: ['alice', 'eve_removed'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batchEve.commit()); + }); + + test('SEC-J14: Approval denied if requester is mutually blocked with organizer (frank)', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_scheduled/participants/frank'), { + userId: 'frank', + role: 'participant', + status: 'requested', + }); + }); + const batchFrank = writeBatch(alice); + batchFrank.update(doc(alice, 'group_rides/ride_j_scheduled/participants/frank'), { + status: 'accepted', + }); + batchFrank.update(doc(alice, 'group_rides/ride_j_scheduled'), { + accessUids: ['alice', 'charlie', 'frank'], + participantCount: 3, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batchFrank.commit()); + }); + + test('SEC-J15: Organizer Alice CAN decline pending request (single doc on participant, zero parent writes)', async () => { + await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_j_dec_org/participants/charlie'), { + status: 'declined', + })); + }); + + test('SEC-J16: Active Club Admin Bob CAN decline pending request on official club ride (single doc, zero parent writes)', async () => { + await assertSucceeds(updateDoc(doc(bob, 'group_rides/ride_j_dec_admin/participants/charlie'), { + status: 'declined', + })); + }); + + test('SEC-J17: Requester Charlie CAN cancel own pending request (requested -> left) (single doc, zero parent writes)', async () => { + await assertSucceeds(updateDoc(doc(charlie, 'group_rides/ride_j_cancel/participants/charlie'), { + status: 'left', + })); + }); + + test('SEC-J18: Non-organizer participant Charlie leaving accepted ride cross-document atomicity', async () => { + // Half-transaction (participant doc only) FAILS because parent accessUids/participantCount not updated + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_leave/participants/charlie'), { + status: 'left', + })); + // Half-transaction (parent doc only) FAILS because participant status not updated + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_j_leave'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:05:00Z', + })); + // Full atomic batch SUCCEEDS + const batchLeave = writeBatch(charlie); + batchLeave.update(doc(charlie, 'group_rides/ride_j_leave/participants/charlie'), { + status: 'left', + }); + batchLeave.update(doc(charlie, 'group_rides/ride_j_leave'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:05:00Z', + }); + await assertSucceeds(batchLeave.commit()); + }); + + test('SEC-J19: Organizer Alice CAN remove accepted participant via atomic batch', async () => { + // Half-transaction (participant doc only) FAILS + await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_rem_org/participants/charlie'), { + status: 'removed', + })); + // Full batch SUCCEEDS + const batchRemOrg = writeBatch(alice); + batchRemOrg.update(doc(alice, 'group_rides/ride_j_rem_org/participants/charlie'), { + status: 'removed', + }); + batchRemOrg.update(doc(alice, 'group_rides/ride_j_rem_org'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:06:00Z', + }); + await assertSucceeds(batchRemOrg.commit()); + }); + + test('SEC-J20: Active Club Admin Bob CAN remove accepted participant from official club ride via atomic batch', async () => { + const batchRemAdmin = writeBatch(bob); + batchRemAdmin.update(doc(bob, 'group_rides/ride_j_rem_admin/participants/charlie'), { + status: 'removed', + }); + batchRemAdmin.update(doc(bob, 'group_rides/ride_j_rem_admin'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:06:00Z', + }); + await assertSucceeds(batchRemAdmin.commit()); + }); + + test('SEC-J21: Moderator CANNOT remove the ride organizer', async () => { + await assertFails(updateDoc(doc(bob, 'group_rides/ride_j_rem_admin/participants/alice'), { + status: 'removed', + })); + await assertFails(updateDoc(doc(alice, 'group_rides/ride_j_rem_org/participants/alice'), { + status: 'removed', + })); + }); + + test('SEC-J22: Open club ride join enforces active club membership and cross-document binding', async () => { + const batchDave = writeBatch(dave); + batchDave.set(doc(dave, 'group_rides/ride_j_open/participants/dave'), { + userId: 'dave', + role: 'participant', + status: 'accepted', + }); + batchDave.update(doc(dave, 'group_rides/ride_j_open'), { + accessUids: ['alice', 'dave'], + participantCount: 2, + updatedAt: '2026-10-01T08:00:00Z', + }); + await assertFails(batchDave.commit()); + + const batchOpen = writeBatch(charlie); + batchOpen.set(doc(charlie, 'group_rides/ride_j_open/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + }); + batchOpen.update(doc(charlie, 'group_rides/ride_j_open'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:00:00Z', + }); + await assertSucceeds(batchOpen.commit()); + }); + + test('SEC-J23: Club Admin Bob CAN point-read pending participant documents', async () => { + await assertSucceeds(getDoc(doc(bob, 'group_rides/ride_j_roster/participants/charlie'))); + }); + + test('SEC-J24: Ordinary accepted participant Charlie CANNOT point-read pending requester docs of others', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_roster/participants/req_other'), { + userId: 'req_other', + role: 'participant', + status: 'requested', + }); + }); + await assertFails(getDoc(doc(charlie, 'group_rides/ride_j_roster/participants/req_other'))); + }); + + test('SEC-J25: Fail-closed status: Ride with missing or invalid status fails mutations', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_j_no_status'), { + id: 'ride_j_no_status', + organizerUid: 'alice', + clubId: 'club_alpha', + accessUids: ['alice'], + participantCount: 1, + joinPolicy: 'requestToJoin', + }); + }); + await assertFails(setDoc(doc(charlie, 'group_rides/ride_j_no_status/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + })); + }); + }); + + // 10. PARTICIPANT SCHEMA HARDENING & ADVERSARIAL ATTACKS + describe('10. Participant Schema Hardening & Adversarial Attacks', () => { + let alice, bob, charlie, dave, frank; + + beforeEach(async () => { + await seedClubGroupRideEnv(); + alice = testEnv.authenticatedContext('alice').firestore(); + bob = testEnv.authenticatedContext('bob').firestore(); + charlie = testEnv.authenticatedContext('charlie').firestore(); + dave = testEnv.authenticatedContext('dave').firestore(); + frank = testEnv.authenticatedContext('frank').firestore(); + + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setDoc(doc(db, 'group_rides/ride_schema_test'), { + ...baseRideData('ride_schema_test', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 5, + participantCount: 1, + accessUids: ['alice'], + }); + await setDoc(doc(db, 'group_rides/ride_schema_test/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_schema_test/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'requested', + createdAt: '2026-10-01T08:00:00Z', + updatedAt: '2026-10-01T08:00:00Z', + }); + + // Open ride for schema testing + await setDoc(doc(db, 'group_rides/ride_schema_open'), { + ...baseRideData('ride_schema_open', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'open', + participantLimit: 5, + participantCount: 1, + accessUids: ['alice'], + }); + await setDoc(doc(db, 'group_rides/ride_schema_open/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + + // Accepted participant ride for leave / update testing + await setDoc(doc(db, 'group_rides/ride_schema_accepted'), { + ...baseRideData('ride_schema_accepted', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 5, + participantCount: 2, + accessUids: ['alice', 'charlie'], + }); + await setDoc(doc(db, 'group_rides/ride_schema_accepted/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_schema_accepted/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + createdAt: '2026-10-01T08:00:00Z', + updatedAt: '2026-10-01T08:00:00Z', + }); + + // Invited participant ride + await setDoc(doc(db, 'group_rides/ride_schema_inv'), { + ...baseRideData('ride_schema_inv', 'alice', 'club_alpha'), + visibility: 'public', + joinPolicy: 'requestToJoin', + participantLimit: 5, + participantCount: 1, + accessUids: ['alice', 'bob'], + }); + await setDoc(doc(db, 'group_rides/ride_schema_inv/participants/alice'), { + userId: 'alice', + role: 'organizer', + status: 'accepted', + }); + await setDoc(doc(db, 'group_rides/ride_schema_inv/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'invited', + invitedBy: 'alice', + createdAt: '2026-10-01T08:00:00Z', + updatedAt: '2026-10-01T08:00:00Z', + }); + }); + }); + + // SEC-F01 - SEC-F05: CREATE FIELD SMUGGLING ATTACKS + test('SEC-F01: Request-to-join rejects unexpected fields (isAdmin: true)', async () => { + await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'requested', + isAdmin: true, + })); + }); + + test('SEC-F02: Request-to-join rejects unexpected fields (location / telemetry)', async () => { + await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'requested', + location: { latitude: 37.77, longitude: -122.41 }, + })); + }); + + test('SEC-F03: Open join rejects arbitrary extra fields', async () => { + const batch = writeBatch(charlie); + batch.set(doc(charlie, 'group_rides/ride_schema_open/participants/charlie'), { + userId: 'charlie', + role: 'participant', + status: 'accepted', + extraField: 'smuggled', + }); + batch.update(doc(charlie, 'group_rides/ride_schema_open'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:00:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-F04: Organizer invitation rejects arbitrary extra fields', async () => { + await assertFails(setDoc(doc(alice, 'group_rides/ride_schema_test/participants/dave'), { + userId: 'dave', + role: 'participant', + status: 'invited', + invitedBy: 'alice', + clubRole: 'admin', + })); + }); + + test('SEC-F05: Legitimate production-shaped create succeeds with all allowed fields', async () => { + await assertSucceeds(setDoc(doc(bob, 'group_rides/ride_schema_test/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'requested', + riderHandle: 'bob_rider', + riderDisplayName: 'Bob Rider', + riderAvatarPreset: 'helmet_sport', + riderAvatarUrl: 'https://example.com/avatar.png', + createdAt: '2026-10-01T08:00:00Z', + updatedAt: '2026-10-01T08:00:00Z', + })); + }); + + // SEC-U01 - SEC-U09: UPDATE FIELD SMUGGLING ATTACKS + test('SEC-U01: Moderator approval rejects userId mutation', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + userId: 'attacker', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U02: Moderator approval rejects role mutation', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + role: 'organizer', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U03: Moderator approval rejects createdAt mutation', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + createdAt: '2020-01-01T00:00:00Z', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U04: Moderator approval rejects arbitrary extra fields', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + hacked: true, + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U05: Moderator decline rejects arbitrary extra fields', async () => { + await assertFails(updateDoc(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'declined', + hacked: true, + })); + }); + + test('SEC-U06: Requested withdrawal rejects role mutation or extra fields', async () => { + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'left', + role: 'organizer', + })); + await assertFails(updateDoc(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'left', + hacked: true, + })); + }); + + test('SEC-U07: Accepted leave rejects userId mutation or extra fields', async () => { + const batch = writeBatch(charlie); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { + status: 'left', + userId: 'attacker', + }); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:05:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U08: Moderator removal rejects arbitrary extra fields', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_accepted/participants/charlie'), { + status: 'removed', + unexpectedField: 'bad', + }); + batch.update(doc(alice, 'group_rides/ride_schema_accepted'), { + accessUids: ['alice'], + participantCount: 1, + updatedAt: '2026-10-01T08:06:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-U09: Invitee accept rejects arbitrary extra fields', async () => { + const batch = writeBatch(bob); + batch.update(doc(bob, 'group_rides/ride_schema_inv/participants/bob'), { + status: 'accepted', + unexpectedField: 'bad', + }); + batch.update(doc(bob, 'group_rides/ride_schema_inv'), { + accessUids: ['alice', 'bob'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + // SEC-M01 - SEC-M02: MISMATCHED-TARGET APPROVAL ATTACKS + test('SEC-M01: Parent adds rider A (bob) while participant doc accepts rider B (charlie) FAILS', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'bob'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-M02: Parent adds A (bob) with count +1, but participant A (bob) remains unaccepted and B (charlie) accepted FAILS', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_schema_test/participants/bob'), { + userId: 'bob', + role: 'participant', + status: 'requested', + }); + }); + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'bob'], + participantCount: 2, + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + // SEC-C01 - SEC-C05: EXPLICIT COUNT ATTACK TESTS + test('SEC-C01: Approval with count delta 0 (access added but count unchanged) FAILS', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 1, // Delta is 0, countDelta == 1 required + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-C02: Approval with count delta +2 (access added but count +2) FAILS', async () => { + const batch = writeBatch(alice); + batch.update(doc(alice, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'accepted', + }); + batch.update(doc(alice, 'group_rides/ride_schema_test'), { + accessUids: ['alice', 'charlie'], + participantCount: 3, // Delta is +2, countDelta == 1 required + updatedAt: '2026-10-01T08:02:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-C03: Accepted leave with count delta 0 (access removed but count unchanged) FAILS', async () => { + const batch = writeBatch(charlie); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { + status: 'left', + }); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { + accessUids: ['alice'], + participantCount: 2, // Delta is 0, countDelta == -1 required + updatedAt: '2026-10-01T08:05:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-C04: Accepted leave with count delta -2 (access removed and count -2) FAILS', async () => { + const batch = writeBatch(charlie); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'), { + status: 'left', + }); + batch.update(doc(charlie, 'group_rides/ride_schema_accepted'), { + accessUids: ['alice'], + participantCount: 0, // Delta is -2, countDelta == -1 required + updatedAt: '2026-10-01T08:05:00Z', + }); + await assertFails(batch.commit()); + }); + + test('SEC-C05: Requested withdrawal attempting parent count decrement FAILS', async () => { + const batch = writeBatch(charlie); + batch.update(doc(charlie, 'group_rides/ride_schema_test/participants/charlie'), { + status: 'left', + }); + batch.update(doc(charlie, 'group_rides/ride_schema_test'), { + participantCount: 0, + updatedAt: '2026-10-01T08:05:00Z', + }); + await assertFails(batch.commit()); + }); + + // SEC-P01: CLUB ADMIN PRIVACY TEST + test('SEC-P01: Active club admin Bob who is not an accepted participant CANNOT read or write live_presence', async () => { + // Bob is admin of club_alpha, ride_schema_test is affiliated with club_alpha, but Bob is not accepted participant + // 1. Bob cannot write live_presence + await assertFails(setDoc(doc(bob, 'group_rides/ride_schema_test/live_presence/bob'), { + userId: 'bob', + groupRideId: 'ride_schema_test', + latitude: 37.77, + longitude: -122.41, + sharingEnabled: true, + status: 'moving', + updatedAt: '2026-10-01T08:00:00Z', + expiresAt: '2026-10-01T08:15:00Z', + })); + + // 2. Bob cannot read peer live_presence of Alice (who is accepted) + await testEnv.withSecurityRulesDisabled(async (ctx) => { + await setDoc(doc(ctx.firestore(), 'group_rides/ride_schema_test/live_presence/alice'), { + userId: 'alice', + groupRideId: 'ride_schema_test', + latitude: 37.77, + longitude: -122.41, + sharingEnabled: true, + status: 'moving', + updatedAt: '2026-10-01T08:00:00Z', + expiresAt: '2026-10-01T08:15:00Z', + }); + }); + await assertFails(getDoc(doc(bob, 'group_rides/ride_schema_test/live_presence/alice'))); + }); + + // SEC-DEL: PARTICIPANT HARD-DELETE INVARIANT TESTS + test('SEC-DEL01: Hard-delete of accepted participant is BLOCKED for self and moderator', async () => { + // Alice is organizer/moderator, Charlie is accepted participant in ride_schema_accepted + await assertFails(deleteDoc(doc(charlie, 'group_rides/ride_schema_accepted/participants/charlie'))); + await assertFails(deleteDoc(doc(alice, 'group_rides/ride_schema_accepted/participants/charlie'))); + }); + + test('SEC-DEL02: Hard-delete of organizer is BLOCKED for self and all callers', async () => { + // Alice is organizer in ride_schema_accepted + await assertFails(deleteDoc(doc(alice, 'group_rides/ride_schema_accepted/participants/alice'))); + await assertFails(deleteDoc(doc(charlie, 'group_rides/ride_schema_accepted/participants/alice'))); + }); + + test('SEC-DEL03: Hard-delete of non-accepted participant (invited/requested/declined/left/removed) is PERMITTED', async () => { + // Seed non-accepted participant docs + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setDoc(doc(db, 'group_rides/ride_schema_test/participants/del_left'), { + userId: 'del_left', + role: 'participant', + status: 'left', + }); + await setDoc(doc(db, 'group_rides/ride_schema_test/participants/del_declined'), { + userId: 'del_declined', + role: 'participant', + status: 'declined', + }); + }); + + const delLeftUser = testEnv.authenticatedContext('del_left').firestore(); + const delDeclinedUser = testEnv.authenticatedContext('del_declined').firestore(); + + // Self-delete of left and declined records succeeds + await assertSucceeds(deleteDoc(doc(delLeftUser, 'group_rides/ride_schema_test/participants/del_left'))); + await assertSucceeds(deleteDoc(doc(delDeclinedUser, 'group_rides/ride_schema_test/participants/del_declined'))); + }); + + // SEC-DEC: ATOMIC INVITATION DECLINE BATCH TESTS + test('SEC-DEC01: Invitee declining invitation with atomic accessUids removal SUCCEEDS', async () => { + // Setup ride where Dave is invited and in accessUids + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setDoc(doc(db, 'group_rides/ride_decline_test'), { + id: 'ride_decline_test', + organizerUid: 'alice', + title: 'Decline Test Ride', + status: 'scheduled', + scheduledStartAt: '2026-10-01T08:00:00Z', + departureAt: '2026-10-01T08:30:00Z', + participantCount: 1, + accessUids: ['alice', 'dave'], + visibility: 'friendsOnly', + joinPolicy: 'inviteOnly', + meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, + }); + await setDoc(doc(db, 'group_rides/ride_decline_test/participants/dave'), { + userId: 'dave', + role: 'participant', + status: 'invited', + invitedBy: 'alice', + createdAt: '2026-10-01T07:30:00Z', + updatedAt: '2026-10-01T07:30:00Z', + }); + }); + + const dave = testEnv.authenticatedContext('dave').firestore(); + const declineBatch = writeBatch(dave); + declineBatch.update(doc(dave, 'group_rides/ride_decline_test/participants/dave'), { + status: 'declined', + }); + declineBatch.update(doc(dave, 'group_rides/ride_decline_test'), { + accessUids: ['alice'], + updatedAt: '2026-10-01T07:45:00Z', + }); + + await assertSucceeds(declineBatch.commit()); + }); + + test('SEC-DEC02: Decline batch attempting count decrement or extra access mutations FAILS', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + await setDoc(doc(db, 'group_rides/ride_decline_bad'), { + id: 'ride_decline_bad', + organizerUid: 'alice', + title: 'Decline Bad Test Ride', + status: 'scheduled', + scheduledStartAt: '2026-10-01T08:00:00Z', + departureAt: '2026-10-01T08:30:00Z', + participantCount: 2, + accessUids: ['alice', 'dave'], + visibility: 'friendsOnly', + joinPolicy: 'inviteOnly', + meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, + }); + await setDoc(doc(db, 'group_rides/ride_decline_bad/participants/dave'), { + userId: 'dave', + role: 'participant', + status: 'invited', + invitedBy: 'alice', + createdAt: '2026-10-01T07:30:00Z', + updatedAt: '2026-10-01T07:30:00Z', + }); + }); + + const dave = testEnv.authenticatedContext('dave').firestore(); + + // 1. Trying to decrement count on decline FAILS + const badCountBatch = writeBatch(dave); + badCountBatch.update(doc(dave, 'group_rides/ride_decline_bad/participants/dave'), { + status: 'declined', + }); + badCountBatch.update(doc(dave, 'group_rides/ride_decline_bad'), { + accessUids: ['alice'], + participantCount: 1, // Illegal count change on decline + updatedAt: '2026-10-01T07:45:00Z', + }); + await assertFails(badCountBatch.commit()); + + // 2. Trying to remove someone else's accessUid on decline FAILS + const badAccessBatch = writeBatch(dave); + badAccessBatch.update(doc(dave, 'group_rides/ride_decline_bad/participants/dave'), { + status: 'declined', + }); + badAccessBatch.update(doc(dave, 'group_rides/ride_decline_bad'), { + accessUids: [], // Removed alice too! + updatedAt: '2026-10-01T07:45:00Z', + }); + await assertFails(badAccessBatch.commit()); + }); + + // SEC-BLK: MODERATOR DECLINING BLOCKED REQUEST TESTS + test('SEC-BLK01: Organizer can decline join request from mutually-blocked user', async () => { + await testEnv.withSecurityRulesDisabled(async (ctx) => { + const db = ctx.firestore(); + // Mutually blocked users + await setDoc(doc(db, 'users/alice/blocks/blocked_requester'), { blockedUid: 'blocked_requester' }); + await setDoc(doc(db, 'users/blocked_requester/blocks/alice'), { blockedUid: 'alice' }); + + await setDoc(doc(db, 'group_rides/ride_block_test'), { + id: 'ride_block_test', + organizerUid: 'alice', + title: 'Block Test Ride', + status: 'scheduled', + scheduledStartAt: '2026-10-01T08:00:00Z', + departureAt: '2026-10-01T08:30:00Z', + participantCount: 1, + accessUids: ['alice'], + visibility: 'public', + joinPolicy: 'requestToJoin', + meetingPoint: { latitude: 34.0, longitude: -84.0, displayName: 'Point A' }, + }); + await setDoc(doc(db, 'group_rides/ride_block_test/participants/blocked_requester'), { + userId: 'blocked_requester', + role: 'participant', + status: 'requested', + riderHandle: 'blocked_rider', + riderDisplayName: 'Blocked Rider', + createdAt: '2026-10-01T07:00:00Z', + updatedAt: '2026-10-01T07:00:00Z', + }); + }); + + // 1. Approval by Alice FAILS due to mutual block + const approveBatch = writeBatch(alice); + approveBatch.update(doc(alice, 'group_rides/ride_block_test/participants/blocked_requester'), { + status: 'accepted', + }); + approveBatch.update(doc(alice, 'group_rides/ride_block_test'), { + accessUids: ['alice', 'blocked_requester'], + participantCount: 2, + updatedAt: '2026-10-01T07:15:00Z', + }); + await assertFails(approveBatch.commit()); + + // 2. Decline by Alice SUCCEEDS despite mutual block + await assertSucceeds(updateDoc(doc(alice, 'group_rides/ride_block_test/participants/blocked_requester'), { + status: 'declined', + })); + }); + }); }); }); diff --git a/firestore.indexes.json b/firestore.indexes.json index 6e89c40..9ed0507 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -28,6 +28,42 @@ } ] }, + { + "collectionGroup": "group_rides", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "clubId", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "scheduledStartAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "group_rides", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "clubId", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "scheduledStartAt", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "clubs", "queryScope": "COLLECTION", @@ -109,6 +145,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "participants", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "createdAt", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [] diff --git a/firestore.rules b/firestore.rules index 0ad5710..04723bb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -259,9 +259,15 @@ service cloud.firestore { isActiveClubMember(resource.data.clubId, request.auth.uid); } + function isClubRideAdmin() { + return hasClubAffiliation(resource.data) && + isActiveClubAdmin(resource.data.clubId, request.auth.uid); + } + function canReadRide() { return signedIn() && ( isOrganizer() || + isClubRideAdmin() || ( !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && ( hasParticipantAccess(request.auth.uid) || @@ -283,6 +289,10 @@ service cloud.firestore { ( 'accessUids' in resource.data && request.auth.uid in resource.data.accessUids + ) || + ( + hasClubAffiliation(resource.data) && + isActiveClubAdmin(resource.data.clubId, request.auth.uid) ) ); @@ -332,36 +342,107 @@ service cloud.firestore { ); } + function getRideAccessUids(data) { + return 'accessUids' in data ? data.accessUids : ('organizerUid' in data ? [data.organizerUid] : []); + } + + function getRideCount(data) { + return 'participantCount' in data ? data.participantCount : 1; + } + + function rideWithinCapacity(data, nextCount) { + return !('participantLimit' in data) || + data.participantLimit == null || + nextCount <= data.participantLimit; + } + function validOrganizerRideUpdate() { - let hasPrevAccess = 'accessUids' in resource.data; - let hasNextAccess = 'accessUids' in request.resource.data; - let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid]; - let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess; - - let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1; - let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount; - + let prevAccess = getRideAccessUids(resource.data); + let nextAccess = getRideAccessUids(request.resource.data); + let prevCount = getRideCount(resource.data); + let nextCount = getRideCount(request.resource.data); let added = nextAccess.removeAll(prevAccess); let removed = prevAccess.removeAll(nextAccess); let countDelta = nextCount - prevCount; - return validStatusTransition() && validRouteAttachment() && ( + return validStatusTransition() && validRouteAttachment() && + rideWithinCapacity(request.resource.data, nextCount) && ( + // General metadata or status update: access and count unchanged (added.size() == 0 && removed.size() == 0 && countDelta == 0) || - (added.size() == 1 && removed.size() == 0 && countDelta == 0) || - (added.size() == 1 && removed.size() == 0 && countDelta == 1) || - (added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0)) + // Organizer approval or open join: exactly +1 accessUid, count +1, only in scheduled/assembling + ( + ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && + added.size() == 1 && removed.size() == 0 && countDelta == 1 && + !areMutuallyBlocked(resource.data.organizerUid, added[0]) && + (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, added[0])) && + existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted' + ) || + // Organizer invitation: exactly +1 accessUid, count unchanged (countDelta == 0), only in scheduled/assembling + ( + ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && + added.size() == 1 && removed.size() == 0 && countDelta == 0 && + !areMutuallyBlocked(resource.data.organizerUid, added[0]) && + existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'invited' + ) || + // Organizer removal: exactly -1 accessUid, count -1, only in scheduled/assembling/underway + ( + ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && + added.size() == 0 && removed.size() == 1 && countDelta == -1 && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left'] + ) ); } + function validParticipantOpenJoin(added, removed, countDelta, nextCount) { + return resource.data.joinPolicy == 'open' && + ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && + request.auth.uid != resource.data.organizerUid && + added == [request.auth.uid] && + removed.size() == 0 && + countDelta == 1 && + rideWithinCapacity(resource.data, nextCount) && + !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && + (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, request.auth.uid)) && + existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted'; + } + + function validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) { + return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) && + request.auth.uid in prevAccess && + added.size() == 0 && + removed.size() == 0 && + countDelta == 1 && + rideWithinCapacity(resource.data, nextCount) && + existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted'; + } + + function validParticipantLeave(prevAccess, added, removed, countDelta) { + return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && + request.auth.uid != resource.data.organizerUid && + added.size() == 0 && + removed == [request.auth.uid] && + countDelta == -1 && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'left'; + } + + function validParticipantDeclineInvite(prevAccess, added, removed, countDelta) { + return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) && + request.auth.uid != resource.data.organizerUid && + added.size() == 0 && + (removed.size() == 0 || removed == [request.auth.uid]) && + countDelta == 0 && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'declined'; + } + function validParticipantRideUpdate() { - let hasPrevAccess = 'accessUids' in resource.data; - let hasNextAccess = 'accessUids' in request.resource.data; - let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid]; - let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess; - - let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1; - let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount; - + let prevAccess = getRideAccessUids(resource.data); + let nextAccess = getRideAccessUids(request.resource.data); + let prevCount = getRideCount(resource.data); + let nextCount = getRideCount(request.resource.data); let added = nextAccess.removeAll(prevAccess); let removed = prevAccess.removeAll(nextAccess); let countDelta = nextCount - prevCount; @@ -369,35 +450,57 @@ service cloud.firestore { return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) && canReadRide() && ( - // Join open ride: caller added to accessUids, count +1 - ( - resource.data.joinPolicy == 'open' && - added == [request.auth.uid] && - removed.size() == 0 && - countDelta == 1 - ) || - // Accept invite: caller was already in accessUids, count +1 - ( - request.auth.uid in prevAccess && - added.size() == 0 && - removed.size() == 0 && - countDelta == 1 - ) || - // Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted) - ( - request.auth.uid in prevAccess && - removed == [request.auth.uid] && - added.size() == 0 && - (countDelta == -1 || countDelta == 0) - ) + validParticipantOpenJoin(added, removed, countDelta, nextCount) || + validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) || + validParticipantLeave(prevAccess, added, removed, countDelta) || + validParticipantDeclineInvite(prevAccess, added, removed, countDelta) ); } + function validClubAdminModeration(prevStatus, nextStatus) { + let prevAccess = getRideAccessUids(resource.data); + let nextAccess = getRideAccessUids(request.resource.data); + let prevCount = getRideCount(resource.data); + let nextCount = getRideCount(request.resource.data); + let added = nextAccess.removeAll(prevAccess); + let removed = prevAccess.removeAll(nextAccess); + let countDelta = nextCount - prevCount; + + return request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'accessUids', 'participantCount', 'updatedAt' + ]) && ( + // Approval: count +1, access added 1, only in scheduled or assembling + ( + prevStatus in ['scheduled', 'assembling'] && + nextStatus == prevStatus && + added.size() == 1 && + removed.size() == 0 && + countDelta == 1 && + rideWithinCapacity(resource.data, nextCount) && + !areMutuallyBlocked(resource.data.organizerUid, added[0]) && + isActiveClubMember(resource.data.clubId, added[0]) && + existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted' + ) || + // Removal: count -1, access removed 1, only in scheduled, assembling, or underway + ( + prevStatus in ['scheduled', 'assembling', 'underway'] && + nextStatus == prevStatus && + added.size() == 0 && + removed.size() == 1 && + countDelta == -1 && + removed[0] != resource.data.organizerUid && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left'] + ) + ); + } + function validClubAdminRideUpdate() { let clubId = resource.data.clubId; - let prevStatus = 'status' in resource.data ? resource.data.status : 'scheduled'; + let prevStatus = 'status' in resource.data ? resource.data.status : null; let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus; - return isActiveClubAdmin(clubId, request.auth.uid) && ( + + return isActiveClubAdmin(clubId, request.auth.uid) && prevStatus != null && ( // Sub-case A: Scheduled metadata edit ( prevStatus == 'scheduled' && @@ -418,12 +521,14 @@ service cloud.firestore { request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'status', 'canceledAt', 'canceledReason', 'updatedAt' ]) - ) + ) || + // Sub-case C: Club admin participant moderation (approve, remove) + validClubAdminModeration(prevStatus, nextStatus) ); } allow update: if signedIn() && - (!('status' in resource.data) || !(resource.data.status in ['completed', 'canceled'])) && + ('status' in resource.data && !(resource.data.status in ['completed', 'canceled'])) && sameClubAffiliation(resource.data, request.resource.data) && ( (isOrganizer() && validOrganizerRideUpdate()) || @@ -447,6 +552,11 @@ service cloud.firestore { return rideDoc().data.organizerUid; } + function isAuthorizedModerator() { + return request.auth.uid == rideOrganizer() || + (hasClubAffiliation(rideDoc().data) && isActiveClubAdmin(rideDoc().data.clubId, request.auth.uid)); + } + function isAcceptedParticipant(uid) { return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted'; @@ -455,10 +565,10 @@ service cloud.firestore { // Collection listing is strictly disallowed (point-read architecture over known accessUids) allow list: if false; - // Single-document read: self, organizer, or accepted peer (if not mutually blocked) + // Single-document read: self, moderator, or accepted peer (if not mutually blocked) allow get: if signedIn() && ( request.auth.uid == participantUid || - request.auth.uid == rideOrganizer() || + isAuthorizedModerator() || ( isAcceptedParticipant(request.auth.uid) && !areMutuallyBlocked(request.auth.uid, participantUid) @@ -471,87 +581,168 @@ service cloud.firestore { ( request.auth.uid == participantUid && request.auth.uid == rideOrganizer() && + request.resource.data.userId == request.auth.uid && request.resource.data.role == 'organizer' && - request.resource.data.status == 'accepted' + request.resource.data.status == 'accepted' && + request.resource.data.keys().hasAll(['userId', 'role', 'status']) && + request.resource.data.keys().hasOnly([ + 'userId', 'role', 'status', + 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', + 'createdAt', 'updatedAt' + ]) ) || - // Organizer inviting a non-blocked rider + // Organizer inviting a non-blocked rider (scheduled or assembling, within capacity) ( request.auth.uid == rideOrganizer() && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && + request.resource.data.userId == participantUid && request.resource.data.status == 'invited' && request.resource.data.role == 'participant' && - !areMutuallyBlocked(request.auth.uid, participantUid) + !areMutuallyBlocked(request.auth.uid, participantUid) && + request.resource.data.keys().hasAll(['userId', 'role', 'status']) && + request.resource.data.keys().hasOnly([ + 'userId', 'role', 'status', + 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', + 'invitedBy', 'createdAt', 'updatedAt' + ]) ) || - // Rider requesting to join (joinPolicy == requestToJoin) + // Rider requesting to join (joinPolicy == requestToJoin) - SINGLE DOCUMENT WRITE, NO PARENT MUTATION ( request.auth.uid == participantUid && request.auth.uid != rideOrganizer() && - rideDoc().data.joinPolicy == 'requestToJoin' && + request.resource.data.userId == request.auth.uid && + request.resource.data.role == 'participant' && request.resource.data.status == 'requested' && - request.resource.data.role == 'participant' && - !areMutuallyBlocked(request.auth.uid, rideOrganizer()) + rideDoc().data.joinPolicy == 'requestToJoin' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + !areMutuallyBlocked(request.auth.uid, rideOrganizer()) && + (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) && + request.resource.data.keys().hasAll(['userId', 'role', 'status']) && + request.resource.data.keys().hasOnly([ + 'userId', 'role', 'status', + 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', + 'createdAt', 'updatedAt' + ]) ) || - // Rider joining open ride (joinPolicy == open) + // Rider joining open ride (joinPolicy == open) - Cross-document bound to parent ( request.auth.uid == participantUid && request.auth.uid != rideOrganizer() && - rideDoc().data.joinPolicy == 'open' && - request.resource.data.status == 'accepted' && + request.resource.data.userId == request.auth.uid && request.resource.data.role == 'participant' && - !areMutuallyBlocked(request.auth.uid, rideOrganizer()) + request.resource.data.status == 'accepted' && + rideDoc().data.joinPolicy == 'open' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && + (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) && + !areMutuallyBlocked(request.auth.uid, rideOrganizer()) && + request.auth.uid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 && + request.resource.data.keys().hasAll(['userId', 'role', 'status']) && + request.resource.data.keys().hasOnly([ + 'userId', 'role', 'status', + 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl', + 'createdAt', 'updatedAt' + ]) ) ); - // State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer + // State transitions: participant can accept/decline/leave; moderator can approve/decline/remove or organizer can transfer allow update: if signedIn() && ( - // Invitee accepting own invitation (invited -> accepted) + // Status updates must strictly constrain changed keys to status and updatedAt ( - request.auth.uid == participantUid && - resource.data.status == 'invited' && - request.resource.data.status == 'accepted' && - request.resource.data.role == resource.data.role - ) || - // Invitee declining own invitation (invited -> declined) - ( - request.auth.uid == participantUid && - resource.data.status == 'invited' && - request.resource.data.status == 'declined' && - request.resource.data.role == resource.data.role - ) || - // Participant leaving (accepted -> left) - ( - request.auth.uid == participantUid && - resource.data.status == 'accepted' && - request.resource.data.status == 'left' && - request.resource.data.role == resource.data.role - ) || - // Organizer approving request (requested -> accepted) - ( - request.auth.uid == rideOrganizer() && - resource.data.status == 'requested' && - request.resource.data.status == 'accepted' - ) || - // Organizer declining request (requested -> declined) - ( - request.auth.uid == rideOrganizer() && - resource.data.status == 'requested' && - request.resource.data.status == 'declined' - ) || - // Organizer removing participant (status -> removed) - ( - request.auth.uid == rideOrganizer() && - request.resource.data.status == 'removed' + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) && ( + // Invitee accepting own invitation (invited -> accepted) + ( + request.auth.uid == participantUid && + resource.data.status == 'invited' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && + request.resource.data.status == 'accepted' && + request.resource.data.role == resource.data.role && + participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 + ) || + // Invitee declining own invitation (invited -> declined) + ( + request.auth.uid == participantUid && + resource.data.status == 'invited' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && + request.resource.data.status == 'declined' && + request.resource.data.role == resource.data.role + ) || + // Accepted participant leaving (accepted -> left) - Cross-document bound + ( + request.auth.uid == participantUid && + resource.data.status == 'accepted' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && + request.resource.data.status == 'left' && + request.resource.data.role == resource.data.role && + !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1 + ) || + // Requested rider withdrawing request (requested -> left) - Single document update + ( + request.auth.uid == participantUid && + resource.data.status == 'requested' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && + request.resource.data.status == 'left' && + request.resource.data.role == resource.data.role + ) || + // Authorized moderator approving request (requested -> accepted) - Cross-document bound + ( + isAuthorizedModerator() && + resource.data.status == 'requested' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) && + (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, participantUid)) && + !areMutuallyBlocked(rideOrganizer(), participantUid) && + request.resource.data.status == 'accepted' && + request.resource.data.role == resource.data.role && + participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 + ) || + // Authorized moderator declining request (requested -> declined) - Single document update + ( + isAuthorizedModerator() && + resource.data.status == 'requested' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && + request.resource.data.status == 'declined' && + request.resource.data.role == resource.data.role + ) || + // Authorized moderator removing participant (status -> removed) - Cross-document bound + ( + isAuthorizedModerator() && + participantUid != rideOrganizer() && + resource.data.role != 'organizer' && + resource.data.status == 'accepted' && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) && + request.resource.data.status == 'removed' && + request.resource.data.role == resource.data.role && + !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) && + getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1 + ) + ) ) || // Organizer role transfer ( request.auth.uid == rideOrganizer() && - request.resource.data.role in ['organizer', 'participant'] + resource.data.status == 'accepted' && + request.resource.data.status == resource.data.status && + ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) && + request.resource.data.role in ['organizer', 'participant'] && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'updatedAt']) ) ); - allow delete: if signedIn() && ( - request.auth.uid == participantUid || - request.auth.uid == rideOrganizer() - ); + allow delete: if signedIn() && + resource.data.role != 'organizer' && + resource.data.status != 'accepted' && + ( + request.auth.uid == participantUid || + isAuthorizedModerator() + ); } // --- LIVE PRESENCE SUBCOLLECTION --- @@ -571,7 +762,7 @@ service cloud.firestore { } function rideIsLiveEligible() { - return rideDoc().data.status in ['scheduled', 'assembling', 'underway']; + return 'status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']; } function validCoordinates(data) { diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index fbbef79..dd2f505 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -81,6 +81,14 @@ class _CreateGroupRideScreenState extends ConsumerState { bool _isSaving = false; String? _errorMessage; + @override + void initState() { + super.initState(); + if (widget.clubId != null) { + _joinPolicy = GroupRideJoinPolicy.requestToJoin; + } + } + @override void dispose() { _titleController.dispose(); diff --git a/lib/features/social/clubs/club_detail_screen.dart b/lib/features/social/clubs/club_detail_screen.dart index 64b547a..4b70d0a 100644 --- a/lib/features/social/clubs/club_detail_screen.dart +++ b/lib/features/social/clubs/club_detail_screen.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; import '../../../core/responsive/responsive_layout.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/theme/theme_extensions.dart'; import '../../../services/auth/auth_providers.dart'; import '../../../services/clubs/club_models.dart'; import '../../../services/clubs/club_providers.dart'; +import '../../../services/clubs/club_service.dart'; import 'club_membership_controller.dart'; import 'club_membership_presentation.dart'; import 'widgets/club_monogram.dart'; @@ -1005,21 +1007,255 @@ class ClubDetailScreen extends ConsumerWidget { ], ), const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: Text( - isActiveMember - ? 'Club ride discovery is coming soon.' - : 'Club rides are visible to active members only.', - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 12.5, - color: theme.colorScheme.onSurfaceVariant, + if (!isActiveMember) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Text( + 'Club rides are visible to active members only.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12.5, + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ), + ) + else + _buildClubDiscoveryList(context, ref, club.id, colors, theme), ], ), ); } + + Widget _buildClubDiscoveryList( + BuildContext context, + WidgetRef ref, + String clubId, + MotorSignalThemeColors colors, + ThemeData theme, + ) { + final ridesAsync = ref.watch(clubDiscoveryRidesProvider(clubId)); + + return ridesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 24.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (err, _) { + String message = 'Unable to load club rides at this time.'; + if (err is ClubOperationException) { + switch (err.code) { + case 'unauthenticated': + message = 'Please sign in to view club rides.'; + break; + case 'permission_denied': + message = 'Active club membership is required to view club rides.'; + break; + case 'timeout': + case 'network_error': + message = 'Network connection issue. Please check your connection and try again.'; + break; + default: + message = 'Unable to load club rides at this time.'; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12.5, + color: theme.colorScheme.error, + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => ref.invalidate(clubDiscoveryRidesProvider(clubId)), + icon: const Icon(Icons.refresh, size: 14), + label: const Text('Retry'), + style: OutlinedButton.styleFrom( + foregroundColor: colors.cyberCyan, + side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + textStyle: const TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + }, + data: (rides) { + if (rides.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + Icon(Icons.two_wheeler, size: 18, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'No upcoming club rides scheduled.', + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 12.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + final dateFormat = DateFormat('EEE, MMM d • h:mm a'); + + return Column( + children: rides.map((ride) { + final startLabel = dateFormat.format(ride.scheduledStartAt.toLocal()); + final locationLabel = ride.meetingLocationLabel ?? 'Meeting area shared with participants'; + + return Padding( + padding: const EdgeInsets.only(top: 10.0), + child: Material( + color: colors.surfaceContainerHighest.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () => context.push('/group-rides/${ride.rideId}'), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colors.outlineVariant.withValues(alpha: 0.4), + width: 0.8, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + ride.title, + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colors.cyberCyan.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: colors.cyberCyan.withValues(alpha: 0.3), + width: 0.5, + ), + ), + child: Text( + ride.joinPolicy == 'open' + ? 'OPEN' + : (ride.joinPolicy == 'requestToJoin' ? 'REQUEST TO JOIN' : 'INVITE ONLY'), + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 9, + fontWeight: FontWeight.w700, + color: colors.cyberCyan, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Icon(Icons.calendar_today, size: 12, color: colors.cyberCyan), + const SizedBox(width: 5), + Text( + startLabel, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.location_on_outlined, size: 12, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 5), + Expanded( + child: Text( + locationLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: AppTypography.fontBody, + fontSize: 11.5, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _buildPill( + icon: Icons.group, + label: '${ride.participantCount}${ride.participantLimit != null ? ' / ${ride.participantLimit}' : ''} riders', + colors: colors, + theme: theme, + ), + if (ride.paceClassification != null && ride.paceClassification!.isNotEmpty) + _buildPill( + icon: Icons.speed, + label: ride.paceClassification!, + colors: colors, + theme: theme, + ), + if (ride.organizerDisplayName != null || ride.organizerHandle != null) + _buildPill( + icon: Icons.person_pin, + label: ride.organizerDisplayName ?? ride.organizerHandle!, + colors: colors, + theme: theme, + ), + ], + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + }, + ); + } } diff --git a/lib/services/clubs/club_models.dart b/lib/services/clubs/club_models.dart index b73c830..8c20544 100644 --- a/lib/services/clubs/club_models.dart +++ b/lib/services/clubs/club_models.dart @@ -607,3 +607,108 @@ class ClubHandle { ownerUid.hashCode ^ createdAt.hashCode; } + +/// Minimized Club GroupRide discovery model returned by the authenticated discovery endpoint. +/// Represents pre-join browse card data without exposing sensitive participant access lists, +/// exact GPS coordinates, or full route geometry. +@immutable +class ClubRideDiscovery { + final String rideId; + final String clubId; + final String title; + final String? description; + final String? organizerDisplayName; + final String? organizerHandle; + final DateTime scheduledStartAt; + final DateTime? departureAt; + final String joinPolicy; + final int participantCount; + final int? participantLimit; + final String status; + final String? meetingLocationLabel; + final String? routeTitle; + final int? routeDistanceMeters; + final String? paceClassification; + + const ClubRideDiscovery({ + required this.rideId, + required this.clubId, + required this.title, + this.description, + this.organizerDisplayName, + this.organizerHandle, + required this.scheduledStartAt, + this.departureAt, + this.joinPolicy = 'inviteOnly', + this.participantCount = 1, + this.participantLimit, + this.status = 'scheduled', + this.meetingLocationLabel, + this.routeTitle, + this.routeDistanceMeters, + this.paceClassification, + }); + + factory ClubRideDiscovery.fromJson(Map json) { + return ClubRideDiscovery( + rideId: json['rideId'] as String? ?? '', + clubId: json['clubId'] as String? ?? '', + title: json['title'] as String? ?? 'Untitled Ride', + description: json['description'] as String?, + organizerDisplayName: json['organizerDisplayName'] as String?, + organizerHandle: json['organizerHandle'] as String?, + scheduledStartAt: _parseDateTime(json['scheduledStartAt']) ?? DateTime.now(), + departureAt: _parseDateTime(json['departureAt']), + joinPolicy: json['joinPolicy'] as String? ?? 'inviteOnly', + participantCount: (json['participantCount'] as num?)?.toInt() ?? 1, + participantLimit: (json['participantLimit'] as num?)?.toInt(), + status: json['status'] as String? ?? 'scheduled', + meetingLocationLabel: json['meetingLocationLabel'] as String?, + routeTitle: json['routeTitle'] as String?, + routeDistanceMeters: (json['routeDistanceMeters'] as num?)?.toInt(), + paceClassification: json['paceClassification'] as String?, + ); + } + + Map toJson() => { + 'rideId': rideId, + 'clubId': clubId, + 'title': title, + if (description != null) 'description': description, + if (organizerDisplayName != null) 'organizerDisplayName': organizerDisplayName, + if (organizerHandle != null) 'organizerHandle': organizerHandle, + 'scheduledStartAt': scheduledStartAt.toIso8601String(), + if (departureAt != null) 'departureAt': departureAt!.toIso8601String(), + 'joinPolicy': joinPolicy, + 'participantCount': participantCount, + if (participantLimit != null) 'participantLimit': participantLimit, + 'status': status, + if (meetingLocationLabel != null) 'meetingLocationLabel': meetingLocationLabel, + if (routeTitle != null) 'routeTitle': routeTitle, + if (routeDistanceMeters != null) 'routeDistanceMeters': routeDistanceMeters, + if (paceClassification != null) 'paceClassification': paceClassification, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClubRideDiscovery && + runtimeType == other.runtimeType && + rideId == other.rideId && + clubId == other.clubId && + title == other.title && + scheduledStartAt == other.scheduledStartAt && + joinPolicy == other.joinPolicy && + participantCount == other.participantCount && + status == other.status; + + @override + int get hashCode => + rideId.hashCode ^ + clubId.hashCode ^ + title.hashCode ^ + scheduledStartAt.hashCode ^ + joinPolicy.hashCode ^ + participantCount.hashCode ^ + status.hashCode; +} diff --git a/lib/services/clubs/club_providers.dart b/lib/services/clubs/club_providers.dart index 9fb9a88..cd3a26b 100644 --- a/lib/services/clubs/club_providers.dart +++ b/lib/services/clubs/club_providers.dart @@ -74,3 +74,10 @@ final publicClubsProvider = FutureProvider>((ref) { final service = ref.watch(clubServiceProvider); return service.discoverPublicClubs(limit: 25); }); + +/// FutureProvider fetching discoverable upcoming Club GroupRides through the authenticated API. +final clubDiscoveryRidesProvider = + FutureProvider.family, String>((ref, clubId) async { + final service = ref.watch(clubServiceProvider); + return service.fetchClubDiscoveryRides(clubId); +}); diff --git a/lib/services/clubs/club_service.dart b/lib/services/clubs/club_service.dart index af43c88..fd66f7a 100644 --- a/lib/services/clubs/club_service.dart +++ b/lib/services/clubs/club_service.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:http/http.dart' as http; import 'club_models.dart'; import 'club_slug_validator.dart'; @@ -1064,4 +1066,107 @@ class ClubService { .map((snap) => snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList()); } + + static const String defaultApiBaseUrl = 'https://motorsignal.app'; + + /// Fetches discoverable upcoming Club GroupRides through the authenticated server API. + /// Enforces server-side token verification, active club membership, mutual-block filtering, + /// and data minimization. + Future> fetchClubDiscoveryRides( + String clubId, { + http.Client? client, + String? baseUrl, + }) async { + final cleanClubId = clubId.trim(); + if (cleanClubId.isEmpty) { + throw const ClubOperationException('Club ID cannot be empty.', code: 'invalid_argument'); + } + + final user = _auth?.currentUser; + if (user == null || user.isAnonymous) { + throw const ClubOperationException( + 'A registered account is required to discover Club rides.', + code: 'unauthenticated', + ); + } + + final idToken = await user.getIdToken(); + if (idToken == null || idToken.isEmpty) { + throw const ClubOperationException( + 'Unable to retrieve authentication token.', + code: 'unauthenticated', + ); + } + + final rootUrl = (baseUrl ?? defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$rootUrl/api/clubs/${Uri.encodeComponent(cleanClubId)}/rides'); + + final httpClient = client ?? http.Client(); + final shouldCloseClient = client == null; + + try { + final response = await httpClient.get( + uri, + headers: { + 'Authorization': 'Bearer $idToken', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 15)); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const ClubOperationException('Malformed response from server.', code: 'invalid_response'); + } + final ridesList = decoded['rides']; + if (ridesList is! List) { + return const []; + } + return ridesList + .whereType>() + .map((item) => ClubRideDiscovery.fromJson(item)) + .toList(); + } + + if (response.statusCode == 401) { + throw const ClubOperationException( + 'Your session has expired or is invalid. Please sign in again.', + code: 'unauthenticated', + ); + } + + if (response.statusCode == 403) { + throw const ClubOperationException( + 'Active club membership is required to view club rides.', + code: 'permission_denied', + ); + } + + if (response.statusCode == 404) { + throw const ClubOperationException( + 'Club was not found or is no longer active.', + code: 'not_found', + ); + } + + throw ClubOperationException( + 'Unable to load club rides (${response.statusCode}).', + code: 'server_error', + ); + } on TimeoutException { + throw const ClubOperationException( + 'Network timeout while loading club rides. Please try again.', + code: 'timeout', + ); + } on http.ClientException { + throw const ClubOperationException( + 'Network connection error. Please check your connection.', + code: 'network_error', + ); + } finally { + if (shouldCloseClient) { + httpClient.close(); + } + } + } } diff --git a/lib/services/group_rides/group_ride_service.dart b/lib/services/group_rides/group_ride_service.dart index beaf755..bea0991 100644 --- a/lib/services/group_rides/group_ride_service.dart +++ b/lib/services/group_rides/group_ride_service.dart @@ -432,7 +432,10 @@ class GroupRideService { }); } - /// Requests to join a Group Ride with requestToJoin policy. + /// Requests to join a Group Ride (requester UID). + /// + /// Writes the participant request document with [ParticipantStatus.requested]. + /// The parent ride document is NOT modified. Capacity is strictly enforced upon approval. Future requestToJoin({ required RiderProfile caller, required String groupRideId, @@ -441,61 +444,94 @@ class GroupRideService { if (firestore == null) return; final rideRef = firestore.collection('group_rides').doc(groupRideId); - final rideSnap = await rideRef.get(); - if (!rideSnap.exists || rideSnap.data() == null) { - throw StateError('Group ride not found.'); - } - - final ride = GroupRide.fromJson(rideSnap.data()!); - if (ride.organizerUid == caller.uid) { - throw ArgumentError('You are the organizer of this ride.'); - } - if (ride.isCanceled || ride.isCompleted) { - throw StateError('Cannot request to join a canceled or completed ride.'); - } - if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) { - throw StateError('This ride does not accept join requests.'); - } - if (ride.isFull) { - throw StateError('This ride is currently full.'); - } - - // Check mutual blocks - final callerBlock = await firestore - .collection('users') - .doc(caller.uid) - .collection('blocks') - .doc(ride.organizerUid) - .get(); - if (callerBlock.exists) throw StateError('Unable to request to join.'); - - final orgBlock = await firestore - .collection('users') - .doc(ride.organizerUid) - .collection('blocks') - .doc(caller.uid) - .get(); - if (orgBlock.exists) throw StateError('Unable to request to join.'); - final participantRef = rideRef.collection('participants').doc(caller.uid); - final now = DateTime.now(); - final req = GroupRideParticipant( - userId: caller.uid, - role: GroupRideRole.participant, - status: ParticipantStatus.requested, - riderHandle: caller.handle, - riderDisplayName: caller.displayName, - riderAvatarPreset: caller.avatarPreset, - riderAvatarUrl: caller.avatarUrl, - createdAt: now, - updatedAt: now, - ); + await firestore.runTransaction((transaction) async { + final rideSnap = await transaction.get(rideRef); + if (!rideSnap.exists || rideSnap.data() == null) { + throw StateError('Group ride not found.'); + } - await participantRef.set(req.toJson()); + final ride = GroupRide.fromJson(rideSnap.data()!); + if (ride.organizerUid == caller.uid) { + throw ArgumentError('You are the organizer of this ride.'); + } + if (ride.status != GroupRideStatus.scheduled && + ride.status != GroupRideStatus.assembling) { + throw StateError('Cannot request to join a ride that is underway, completed, or canceled.'); + } + if (ride.joinPolicy != GroupRideJoinPolicy.requestToJoin) { + throw StateError('This ride does not accept join requests.'); + } + if (ride.accessUids.contains(caller.uid)) { + throw StateError('You are already a participant in this ride.'); + } + + // Check official Club affiliation membership requirement + if (ride.clubId != null) { + final memberSnap = await transaction.get( + firestore + .collection('clubs') + .doc(ride.clubId!) + .collection('members') + .doc(caller.uid), + ); + if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') { + throw StateError('Active club membership is required to join this club ride.'); + } + } + + // Check mutual blocks + final callerBlock = await firestore + .collection('users') + .doc(caller.uid) + .collection('blocks') + .doc(ride.organizerUid) + .get(); + if (callerBlock.exists) throw StateError('Unable to request to join.'); + + final orgBlock = await firestore + .collection('users') + .doc(ride.organizerUid) + .collection('blocks') + .doc(caller.uid) + .get(); + if (orgBlock.exists) throw StateError('Unable to request to join.'); + + final pDoc = await transaction.get(participantRef); + if (pDoc.exists && pDoc.data() != null) { + final existing = GroupRideParticipant.fromJson(pDoc.data()!); + if (existing.status == ParticipantStatus.requested) { + throw StateError('You already have a pending join request.'); + } + if (existing.status == ParticipantStatus.accepted) { + throw StateError('You are already an accepted participant.'); + } + if (existing.status == ParticipantStatus.declined || + existing.status == ParticipantStatus.left || + existing.status == ParticipantStatus.removed) { + throw StateError('Cannot request to join after ride departure or previous removal.'); + } + } + + final now = DateTime.now(); + final req = GroupRideParticipant( + userId: caller.uid, + role: GroupRideRole.participant, + status: ParticipantStatus.requested, + riderHandle: caller.handle, + riderDisplayName: caller.displayName, + riderAvatarPreset: caller.avatarPreset, + riderAvatarUrl: caller.avatarUrl, + createdAt: now, + updatedAt: now, + ); + + transaction.set(participantRef, req.toJson()); + }); } - /// Responds to a join request (organizer only). + /// Responds to a join request (organizer or authorized club leadership). Future respondToJoinRequest({ required String callerUid, required String groupRideId, @@ -514,11 +550,57 @@ class GroupRideService { throw StateError('Group ride not found.'); } final ride = GroupRide.fromJson(rideDoc.data()!); - if (ride.organizerUid != callerUid) { - throw StateError('Only the organizer can approve requests.'); + + // Check leadership authorization: organizer or active club admin/owner for club rides + bool isAuthorized = ride.organizerUid == callerUid; + if (!isAuthorized && ride.clubId != null) { + final memberDoc = await transaction.get( + firestore + .collection('clubs') + .doc(ride.clubId!) + .collection('members') + .doc(callerUid), + ); + if (memberDoc.exists && + memberDoc.data()?['status'] == 'active' && + (memberDoc.data()?['role'] == 'owner' || + memberDoc.data()?['role'] == 'admin')) { + isAuthorized = true; + } } - if (ride.isCanceled || ride.isCompleted) { - throw StateError('Ride is no longer active.'); + if (!isAuthorized) { + throw StateError('Only the organizer or club leadership can moderate requests.'); + } + + // Lifecycle status checks + if (approve) { + if (ride.status != GroupRideStatus.scheduled && + ride.status != GroupRideStatus.assembling) { + throw StateError('Cannot approve requests for a ride that is underway, completed, or canceled.'); + } + } else { + if (ride.isCanceled || ride.isCompleted) { + throw StateError('Ride is no longer active.'); + } + } + + // Mutual block check between organizer and requester (approval only; decline must always be permitted) + if (approve) { + final orgBlock = await firestore + .collection('users') + .doc(ride.organizerUid) + .collection('blocks') + .doc(requesterUid) + .get(); + if (orgBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); + + final reqBlock = await firestore + .collection('users') + .doc(requesterUid) + .collection('blocks') + .doc(ride.organizerUid) + .get(); + if (reqBlock.exists) throw StateError('Cannot approve mutually blocked requester.'); } final pDoc = await transaction.get(participantRef); @@ -530,6 +612,20 @@ class GroupRideService { throw StateError('Participant is not in requested status.'); } + // If official club ride, verify requester remains an active club member + if (approve && ride.clubId != null) { + final reqMemberDoc = await transaction.get( + firestore + .collection('clubs') + .doc(ride.clubId!) + .collection('members') + .doc(requesterUid), + ); + if (!reqMemberDoc.exists || reqMemberDoc.data()?['status'] != 'active') { + throw StateError('Requester is no longer an active club member.'); + } + } + final now = DateTime.now(); if (approve) { if (ride.isFull) { @@ -575,8 +671,9 @@ class GroupRideService { if (ride.organizerUid == caller.uid) { throw ArgumentError('You are already the organizer.'); } - if (ride.isCanceled || ride.isCompleted) { - throw StateError('Cannot join a canceled or completed ride.'); + if (ride.status != GroupRideStatus.scheduled && + ride.status != GroupRideStatus.assembling) { + throw StateError('Cannot join a ride that is underway, completed, or canceled.'); } if (ride.joinPolicy != GroupRideJoinPolicy.open) { throw StateError('This ride is not open to direct join.'); @@ -585,6 +682,20 @@ class GroupRideService { throw StateError('This ride is full.'); } + // Check official Club affiliation membership requirement + if (ride.clubId != null) { + final memberSnap = await transaction.get( + firestore + .collection('clubs') + .doc(ride.clubId!) + .collection('members') + .doc(caller.uid), + ); + if (!memberSnap.exists || memberSnap.data()?['status'] != 'active') { + throw StateError('Active club membership is required to join this club ride.'); + } + } + final pDoc = await transaction.get(participantRef); if (pDoc.exists && pDoc.data() != null) { final existing = GroupRideParticipant.fromJson(pDoc.data()!); @@ -660,12 +771,12 @@ class GroupRideService { 'updatedAt': now.toIso8601String(), }); - if (ride.organizerUid != callerUid) { + if (ride.organizerUid != callerUid && wasAccepted) { final rideUpdates = { 'accessUids': FieldValue.arrayRemove([callerUid]), 'updatedAt': now.toIso8601String(), }; - if (wasAccepted && ride.participantCount > 0) { + if (ride.participantCount > 0) { rideUpdates['participantCount'] = FieldValue.increment(-1); } transaction.update(rideRef, rideUpdates); @@ -673,7 +784,7 @@ class GroupRideService { }); } - /// Removes a participant from a Group Ride (organizer only). + /// Removes a participant from a Group Ride (organizer or authorized club leadership). Future removeParticipant({ required String callerUid, required String groupRideId, @@ -693,8 +804,25 @@ class GroupRideService { final rideDoc = await transaction.get(rideRef); if (!rideDoc.exists || rideDoc.data() == null) return; final ride = GroupRide.fromJson(rideDoc.data()!); - if (ride.organizerUid != callerUid) { - throw StateError('Only the organizer can remove participants.'); + + bool isAuthorized = ride.organizerUid == callerUid; + if (!isAuthorized && ride.clubId != null) { + final memberDoc = await transaction.get( + firestore + .collection('clubs') + .doc(ride.clubId!) + .collection('members') + .doc(callerUid), + ); + if (memberDoc.exists && + memberDoc.data()?['status'] == 'active' && + (memberDoc.data()?['role'] == 'owner' || + memberDoc.data()?['role'] == 'admin')) { + isAuthorized = true; + } + } + if (!isAuthorized) { + throw StateError('Only the organizer or club leadership can remove participants.'); } final pDoc = await transaction.get(participantRef); @@ -709,14 +837,16 @@ class GroupRideService { 'updatedAt': now.toIso8601String(), }); - final rideUpdates = { - 'accessUids': FieldValue.arrayRemove([targetUid]), - 'updatedAt': now.toIso8601String(), - }; - if (wasAccepted && ride.participantCount > 0) { - rideUpdates['participantCount'] = FieldValue.increment(-1); + if (wasAccepted) { + final rideUpdates = { + 'accessUids': FieldValue.arrayRemove([targetUid]), + 'updatedAt': now.toIso8601String(), + }; + if (ride.participantCount > 0) { + rideUpdates['participantCount'] = FieldValue.increment(-1); + } + transaction.update(rideRef, rideUpdates); } - transaction.update(rideRef, rideUpdates); }); } diff --git a/test/club_detail_screen_test.dart b/test/club_detail_screen_test.dart index 2317143..2f3bbb7 100644 --- a/test/club_detail_screen_test.dart +++ b/test/club_detail_screen_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart'; import 'package:motorcycle_dash/services/auth/auth_providers.dart'; @@ -44,6 +45,15 @@ class MockClubServiceForDetail extends ClubService { lastAction = 'leaveClub'; lastClubId = clubId; } + + @override + Future> fetchClubDiscoveryRides( + String clubId, { + http.Client? client, + String? baseUrl, + }) async { + return []; + } } void main() { @@ -523,7 +533,7 @@ void main() { expect(find.text('Plan Club Ride'), findsNothing); }); - testWidgets('active member sees discovery coming soon and admin sees Plan Club Ride button', (tester) async { + testWidgets('active member sees discovery list and admin sees Plan Club Ride button', (tester) async { final activeAdmin = ClubMember( uid: 'rider_1', clubId: 'club_open', @@ -552,11 +562,11 @@ void main() { await tester.pumpAndSettle(); expect(find.text('CLUB RIDES'), findsOneWidget); - expect(find.text('Club ride discovery is coming soon.'), findsOneWidget); + expect(find.text('No upcoming club rides scheduled.'), findsOneWidget); expect(find.text('Plan Club Ride'), findsOneWidget); }); - testWidgets('active ordinary member sees discovery coming soon and no Plan button', (tester) async { + testWidgets('active ordinary member sees discovery list and no Plan button', (tester) async { final activeMember = ClubMember( uid: 'rider_2', clubId: 'club_open', @@ -585,7 +595,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('CLUB RIDES'), findsOneWidget); - expect(find.text('Club ride discovery is coming soon.'), findsOneWidget); + expect(find.text('No upcoming club rides scheduled.'), findsOneWidget); expect(find.text('Plan Club Ride'), findsNothing); }); }); diff --git a/test/club_ride_discovery_test.dart b/test/club_ride_discovery_test.dart new file mode 100644 index 0000000..1ec5002 --- /dev/null +++ b/test/club_ride_discovery_test.dart @@ -0,0 +1,485 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; +import 'package:motorcycle_dash/services/clubs/club_providers.dart'; +import 'package:motorcycle_dash/services/clubs/club_service.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/social_providers.dart'; + +void main() { + group('ClubRideDiscovery Model Tests', () { + test('parses full JSON payload correctly', () { + final json = { + 'rideId': 'ride_123', + 'clubId': 'club_alpha', + 'title': 'Mountain Pass Rally', + 'description': 'A weekend club ride through the gap', + 'organizerDisplayName': 'Road Captain Bob', + 'organizerHandle': '@bob_captain', + 'scheduledStartAt': '2026-09-25T09:00:00.000Z', + 'departureAt': '2026-09-25T09:30:00.000Z', + 'joinPolicy': 'requestToJoin', + 'participantCount': 5, + 'participantLimit': 15, + 'status': 'scheduled', + 'meetingLocationLabel': 'North Ridge Meetup Area', + 'routeTitle': 'Scenic Mountain Loop', + 'routeDistanceMeters': 45000, + 'paceClassification': 'Spirited', + }; + + final ride = ClubRideDiscovery.fromJson(json); + + expect(ride.rideId, equals('ride_123')); + expect(ride.clubId, equals('club_alpha')); + expect(ride.title, equals('Mountain Pass Rally')); + expect(ride.description, equals('A weekend club ride through the gap')); + expect(ride.organizerDisplayName, equals('Road Captain Bob')); + expect(ride.organizerHandle, equals('@bob_captain')); + expect(ride.scheduledStartAt.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 0))); + expect(ride.departureAt?.toUtc(), equals(DateTime.utc(2026, 9, 25, 9, 30))); + expect(ride.joinPolicy, equals('requestToJoin')); + expect(ride.participantCount, equals(5)); + expect(ride.participantLimit, equals(15)); + expect(ride.status, equals('scheduled')); + expect(ride.meetingLocationLabel, equals('North Ridge Meetup Area')); + expect(ride.routeTitle, equals('Scenic Mountain Loop')); + expect(ride.routeDistanceMeters, equals(45000)); + expect(ride.paceClassification, equals('Spirited')); + }); + + test('parses minimal JSON payload with defaults and nulls', () { + final json = { + 'rideId': 'ride_456', + 'clubId': 'club_beta', + 'title': 'Casual Cruise', + 'scheduledStartAt': '2026-09-26T10:00:00.000Z', + }; + + final ride = ClubRideDiscovery.fromJson(json); + + expect(ride.rideId, equals('ride_456')); + expect(ride.clubId, equals('club_beta')); + expect(ride.title, equals('Casual Cruise')); + expect(ride.description, isNull); + expect(ride.organizerDisplayName, isNull); + expect(ride.organizerHandle, isNull); + expect(ride.departureAt, isNull); + expect(ride.joinPolicy, equals('inviteOnly')); + expect(ride.participantCount, equals(1)); + expect(ride.participantLimit, isNull); + expect(ride.status, equals('scheduled')); + expect(ride.meetingLocationLabel, isNull); + }); + + test('serializes to JSON correctly', () { + final ride = ClubRideDiscovery( + rideId: 'ride_789', + clubId: 'club_gamma', + title: 'Morning Blast', + scheduledStartAt: DateTime.utc(2026, 9, 27, 8, 0), + joinPolicy: 'open', + participantCount: 2, + meetingLocationLabel: 'Main Street Plaza', + ); + + final json = ride.toJson(); + + expect(json['rideId'], equals('ride_789')); + expect(json['clubId'], equals('club_gamma')); + expect(json['title'], equals('Morning Blast')); + expect(json['joinPolicy'], equals('open')); + expect(json['participantCount'], equals(2)); + expect(json['meetingLocationLabel'], equals('Main Street Plaza')); + expect(json.containsKey('description'), isFalse); + expect(json.containsKey('accessUids'), isFalse); + }); + + test('equality and hashCode support', () { + final rideA = ClubRideDiscovery( + rideId: 'ride_1', + clubId: 'club_1', + title: 'Test Ride', + scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0), + ); + final rideB = ClubRideDiscovery( + rideId: 'ride_1', + clubId: 'club_1', + title: 'Test Ride', + scheduledStartAt: DateTime.utc(2026, 9, 25, 9, 0), + ); + + expect(rideA, equals(rideB)); + expect(rideA.hashCode, equals(rideB.hashCode)); + }); + }); + + group('ClubService.fetchClubDiscoveryRides HTTP & Error Contracts', () { + test('rejects empty clubId before network call', () async { + final service = ClubService(); + expect( + () => service.fetchClubDiscoveryRides(' '), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid_argument'), + )), + ); + }); + + test('parses successful 200 discovery response', () async { + final mockClient = MockClient((request) async { + expect(request.url.path, equals('/api/clubs/club_alpha/rides')); + expect(request.headers['Authorization'], equals('Bearer mock_id_token')); + + return http.Response( + jsonEncode({ + 'clubId': 'club_alpha', + 'rides': [ + { + 'rideId': 'ride_1', + 'clubId': 'club_alpha', + 'title': 'Foothills Run', + 'scheduledStartAt': '2026-09-25T09:00:00.000Z', + 'status': 'scheduled', + 'joinPolicy': 'requestToJoin', + 'participantCount': 4, + }, + { + 'rideId': 'ride_2', + 'clubId': 'club_alpha', + 'title': 'Valley Loop', + 'scheduledStartAt': '2026-09-26T10:00:00.000Z', + 'status': 'assembling', + 'joinPolicy': 'open', + 'participantCount': 7, + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + final rides = await service.fetchClubDiscoveryRides( + 'club_alpha', + client: mockClient, + baseUrl: 'https://test.motorsignal.app', + ); + + expect(rides.length, equals(2)); + expect(rides[0].rideId, equals('ride_1')); + expect(rides[0].title, equals('Foothills Run')); + expect(rides[1].rideId, equals('ride_2')); + expect(rides[1].title, equals('Valley Loop')); + }); + + test('handles 200 response with empty rides list', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({'clubId': 'club_empty', 'rides': []}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + final rides = await service.fetchClubDiscoveryRides( + 'club_empty', + client: mockClient, + baseUrl: 'https://test.motorsignal.app', + ); + + expect(rides, isEmpty); + }); + + test('maps 401 status to unauthenticated ClubOperationException', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'unauthorized', 'detail': 'Expired token'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + expect( + () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('unauthenticated'), + )), + ); + }); + + test('maps 403 status to permission_denied ClubOperationException', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'forbidden', 'detail': 'Active membership required'}), + 403, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + expect( + () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('permission_denied'), + )), + ); + }); + + test('maps 404 status to not_found ClubOperationException', () async { + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'not_found', 'detail': 'Club not found'}), + 404, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + expect( + () => service.fetchClubDiscoveryRides('club_missing', client: mockClient), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('not_found'), + )), + ); + }); + + test('maps network errors cleanly without exposing raw socket exceptions', () async { + final mockClient = MockClient((request) async { + throw http.ClientException('Failed to connect to host'); + }); + + final service = _TestableClubService(); + expect( + () => service.fetchClubDiscoveryRides('club_alpha', client: mockClient), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('network_error'), + )), + ); + }); + + test('blocked rides excluded by server never appear in client output', () async { + // Server returned only ride_unblocked; blocked organizer rides were excluded server-side + final mockClient = MockClient((request) async { + return http.Response( + jsonEncode({ + 'clubId': 'club_alpha', + 'rides': [ + { + 'rideId': 'ride_unblocked', + 'clubId': 'club_alpha', + 'title': 'Unblocked Ride', + 'scheduledStartAt': '2026-09-25T09:00:00.000Z', + }, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = _TestableClubService(); + final rides = await service.fetchClubDiscoveryRides('club_alpha', client: mockClient); + + expect(rides.length, equals(1)); + expect(rides.any((r) => r.rideId == 'ride_blocked_by_user'), isFalse); + expect(rides.any((r) => r.rideId == 'ride_blocked_by_organizer'), isFalse); + }); + }); + + group('CreateGroupRideScreen Default Join Policy Contracts', () { + testWidgets('Club-affiliated ride defaults to requestToJoin', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const testUser = AuthUser(uid: 'rider_1', isAnonymous: false); + final profile = RiderProfile( + uid: 'rider_1', + handle: 'rider1', + displayName: 'Rider One', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + final testClub = Club( + id: 'club_alpha', + name: 'Alpha Club', + slug: 'alpha-club', + ownerUid: 'rider_1', + memberCount: 1, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.open, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + final adminMember = ClubMember( + clubId: 'club_alpha', + uid: 'rider_1', + role: ClubRole.owner, + status: ClubMemberStatus.active, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(testUser), + myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)), + clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)), + currentMemberProvider('club_alpha').overrideWith((ref) => Stream.value(adminMember)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const CreateGroupRideScreen(clubId: 'club_alpha'), + ), + ), + ); + await tester.pumpAndSettle(); + + final dropdownFinder = find.byType(DropdownButtonFormField); + expect(dropdownFinder, findsOneWidget); + final dropdown = tester.widget>(dropdownFinder); + expect(dropdown.initialValue, equals(GroupRideJoinPolicy.requestToJoin)); + }); + + testWidgets('Personal ride defaults to inviteOnly (existing behavior preserved)', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const testUser = AuthUser(uid: 'rider_1', isAnonymous: false); + final profile = RiderProfile( + uid: 'rider_1', + handle: 'rider1', + displayName: 'Rider One', + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(testUser), + myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const CreateGroupRideScreen(clubId: null), + ), + ), + ); + await tester.pumpAndSettle(); + + final dropdownFinder = find.byType(DropdownButtonFormField); + expect(dropdownFinder, findsOneWidget); + final dropdown = tester.widget>(dropdownFinder); + expect(dropdown.initialValue, equals(GroupRideJoinPolicy.inviteOnly)); + }); + }); + +} + +/// Helper subclass providing testable auth token resolution for unit tests +class _TestableClubService extends ClubService { + @override + Future> fetchClubDiscoveryRides( + String clubId, { + http.Client? client, + String? baseUrl, + }) async { + final cleanClubId = clubId.trim(); + if (cleanClubId.isEmpty) { + throw const ClubOperationException('Club ID cannot be empty.', code: 'invalid_argument'); + } + + final rootUrl = (baseUrl ?? ClubService.defaultApiBaseUrl).replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.parse('$rootUrl/api/clubs/${Uri.encodeComponent(cleanClubId)}/rides'); + + final httpClient = client ?? http.Client(); + final shouldCloseClient = client == null; + + try { + final response = await httpClient.get( + uri, + headers: { + 'Authorization': 'Bearer mock_id_token', + 'Accept': 'application/json', + }, + ).timeout(const Duration(seconds: 15)); + + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const ClubOperationException('Malformed response from server.', code: 'invalid_response'); + } + final ridesList = decoded['rides']; + if (ridesList is! List) { + return const []; + } + return ridesList + .whereType>() + .map((item) => ClubRideDiscovery.fromJson(item)) + .toList(); + } + + if (response.statusCode == 401) { + throw const ClubOperationException( + 'Your session has expired or is invalid. Please sign in again.', + code: 'unauthenticated', + ); + } + + if (response.statusCode == 403) { + throw const ClubOperationException( + 'Active club membership is required to view club rides.', + code: 'permission_denied', + ); + } + + if (response.statusCode == 404) { + throw const ClubOperationException( + 'Club was not found or is no longer active.', + code: 'not_found', + ); + } + + throw ClubOperationException( + 'Unable to load club rides (${response.statusCode}).', + code: 'server_error', + ); + } on http.ClientException { + throw const ClubOperationException( + 'Network connection error. Please check your connection.', + code: 'network_error', + ); + } finally { + if (shouldCloseClient) { + httpClient.close(); + } + } + } +} diff --git a/test/club_rides_discovery_ui_test.dart b/test/club_rides_discovery_ui_test.dart new file mode 100644 index 0000000..23bd23f --- /dev/null +++ b/test/club_rides_discovery_ui_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/core/theme/theme_extensions.dart'; +import 'package:motorcycle_dash/features/social/clubs/club_detail_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; +import 'package:motorcycle_dash/services/clubs/club_providers.dart'; +import 'package:motorcycle_dash/services/clubs/club_service.dart'; + +void main() { + group('ClubDetailScreen Discovery UI State Tests', () { + const testUser = AuthUser(uid: 'rider_alice', isAnonymous: false); + + final testClub = Club( + id: 'club_alpha', + name: 'Alpha Riders', + slug: 'alpha-riders', + description: 'The premier Alpha motorcycle community', + ownerUid: 'owner_bob', + memberCount: 15, + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.open, + createdAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + final activeMember = ClubMember( + clubId: 'club_alpha', + uid: 'rider_alice', + role: ClubRole.member, + status: ClubMemberStatus.active, + joinedAt: DateTime(2026, 1, 1), + updatedAt: DateTime(2026, 1, 1), + ); + + Widget buildTestWidget({ + bool overrideMember = false, + ClubMember? memberOverride, + required AsyncValue> discoveryOverride, + }) { + return ProviderScope( + overrides: [ + currentUserProvider.overrideWithValue(testUser), + clubDetailProvider('club_alpha').overrideWith((ref) => Stream.value(testClub)), + currentMemberProvider('club_alpha').overrideWith( + (ref) => Stream.value(overrideMember ? memberOverride : activeMember), + ), + clubDiscoveryRidesProvider('club_alpha').overrideWith((ref) => discoveryOverride.when( + data: (data) => Future.value(data), + error: (e, s) => Future.error(e, s), + loading: () => Completer>().future, + )), + ], + child: MaterialApp( + theme: ThemeData.dark().copyWith( + extensions: [MotorSignalThemeColors.dark], + ), + home: const ClubDetailScreen(clubId: 'club_alpha'), + ), + ); + } + + testWidgets('Non-member sees active members only notice', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + buildTestWidget( + overrideMember: true, + memberOverride: null, + discoveryOverride: const AsyncValue.data([]), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('CLUB RIDES'), findsOneWidget); + expect(find.text('Club rides are visible to active members only.'), findsOneWidget); + }); + + testWidgets('Active member sees loading progress indicator during discovery fetch', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + buildTestWidget( + discoveryOverride: const AsyncValue.loading(), + ), + ); + await tester.pump(); + + expect(find.text('CLUB RIDES'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('Active member sees empty state when 0 upcoming rides found', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + buildTestWidget( + discoveryOverride: const AsyncValue.data([]), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('CLUB RIDES'), findsOneWidget); + expect(find.text('No upcoming club rides scheduled.'), findsOneWidget); + }); + + testWidgets('Active member sees populated discovery ride cards', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final testRides = [ + ClubRideDiscovery( + rideId: 'ride_101', + clubId: 'club_alpha', + title: 'Sunday Morning Twisties', + scheduledStartAt: DateTime(2026, 9, 27, 9, 0), + departureAt: DateTime(2026, 9, 27, 9, 30), + joinPolicy: 'requestToJoin', + participantCount: 6, + participantLimit: 12, + meetingLocationLabel: 'Lookout Point Parking', + paceClassification: 'Moderate', + organizerDisplayName: 'Captain Dan', + ), + ]; + + await tester.pumpWidget( + buildTestWidget( + discoveryOverride: AsyncValue.data(testRides), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('CLUB RIDES'), findsOneWidget); + expect(find.text('Sunday Morning Twisties'), findsOneWidget); + expect(find.text('REQUEST TO JOIN'), findsOneWidget); + expect(find.text('Lookout Point Parking'), findsOneWidget); + expect(find.text('6 / 12 riders'), findsOneWidget); + expect(find.text('Moderate'), findsOneWidget); + expect(find.text('Captain Dan'), findsOneWidget); + }); + + testWidgets('Active member sees error state with retry button on failure', (tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + buildTestWidget( + discoveryOverride: const AsyncValue.error( + ClubOperationException('Network error', code: 'network_error'), + StackTrace.empty, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('CLUB RIDES'), findsOneWidget); + expect(find.text('Network connection issue. Please check your connection and try again.'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Retry'), findsOneWidget); + }); + }); +} diff --git a/test/group_ride_service_test.dart b/test/group_ride_service_test.dart index 51c77da..af4f757 100644 --- a/test/group_ride_service_test.dart +++ b/test/group_ride_service_test.dart @@ -1052,6 +1052,638 @@ void main() { ); }); }); + + group('GroupRide Join Workflow & Participant Moderation Tests', () { + late FakeFirebaseFirestore fakeFirestore; + late GroupRideService service; + + const meetup = GroupRideLocation( + latitude: 34.0, + longitude: -84.0, + displayName: 'Atlanta Base', + ); + + final organizer = RiderProfile( + uid: 'user_org', + handle: 'alice_org', + displayName: 'Alice Org', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final requester = RiderProfile( + uid: 'user_req', + handle: 'charlie_rider', + displayName: 'Charlie Rider', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + setUp(() { + fakeFirestore = FakeFirebaseFirestore(); + service = GroupRideService(firestore: fakeFirestore); + }); + + Future seedRide({ + required String rideId, + required String organizerUid, + String? clubId, + GroupRideStatus status = GroupRideStatus.scheduled, + GroupRideJoinPolicy joinPolicy = GroupRideJoinPolicy.requestToJoin, + int? participantLimit, + int participantCount = 1, + List accessUids = const ['user_org'], + }) async { + final now = DateTime.now(); + await fakeFirestore.collection('group_rides').doc(rideId).set({ + 'id': rideId, + 'organizerUid': organizerUid, + 'title': 'Saturday Morning Run', + 'status': status.name, + 'visibility': 'public', + 'joinPolicy': joinPolicy.name, + 'participantLimit': ?participantLimit, + 'participantCount': participantCount, + 'scheduledStartAt': now.add(const Duration(hours: 2)).toIso8601String(), + 'departureAt': now.add(const Duration(hours: 2, minutes: 30)).toIso8601String(), + 'meetingPoint': meetup.toJson(), + 'accessUids': accessUids, + 'clubId': ?clubId, + 'createdAt': now.toIso8601String(), + 'updatedAt': now.toIso8601String(), + }); + await fakeFirestore + .collection('group_rides') + .doc(rideId) + .collection('participants') + .doc(organizerUid) + .set({ + 'userId': organizerUid, + 'role': 'organizer', + 'status': 'accepted', + 'createdAt': now.toIso8601String(), + 'updatedAt': now.toIso8601String(), + }); + } + + Future seedClubMember({ + required String clubId, + required String uid, + required String role, + String status = 'active', + }) async { + final now = DateTime.now(); + await fakeFirestore.collection('clubs').doc(clubId).set({ + 'id': clubId, + 'name': 'Alpha Club', + 'slug': 'alpha-club', + 'ownerUid': 'user_org', + 'visibility': 'public', + 'admissionPolicy': 'open', + 'memberCount': 2, + 'createdAt': now.toIso8601String(), + 'updatedAt': now.toIso8601String(), + }); + await fakeFirestore + .collection('clubs') + .doc(clubId) + .collection('members') + .doc(uid) + .set({ + 'clubId': clubId, + 'uid': uid, + 'role': role, + 'status': status, + 'joinedAt': now.toIso8601String(), + 'updatedAt': now.toIso8601String(), + }); + } + + test('requestToJoin creates participant doc without mutating parent ride', () async { + await seedRide(rideId: 'ride_1', organizerUid: organizer.uid); + + await service.requestToJoin(caller: requester, groupRideId: 'ride_1'); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_1').get(); + final ride = GroupRide.fromJson(rideSnap.data()!); + expect(ride.accessUids, isNot(contains(requester.uid))); + expect(ride.participantCount, equals(1)); + expect(rideSnap.data()!.containsKey('pendingUids'), isFalse); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_1') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.exists, isTrue); + expect(partSnap.data()!['status'], equals('requested')); + }); + + test('requestToJoin succeeds even when ride is currently at capacity (enforced at approval)', () async { + await seedRide( + rideId: 'ride_full', + organizerUid: organizer.uid, + participantLimit: 2, + participantCount: 2, + accessUids: [organizer.uid, 'user_other'], + ); + + await service.requestToJoin(caller: requester, groupRideId: 'ride_full'); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_full') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.exists, isTrue); + expect(partSnap.data()!['status'], equals('requested')); + }); + + test('requestToJoin rejects when ride is underway, completed, or canceled', () async { + for (final s in [ + GroupRideStatus.underway, + GroupRideStatus.completed, + GroupRideStatus.canceled, + ]) { + await seedRide( + rideId: 'ride_${s.name}', + organizerUid: organizer.uid, + status: s, + ); + + expect( + () => service.requestToJoin(caller: requester, groupRideId: 'ride_${s.name}'), + throwsA(isA()), + ); + } + }); + + test('requestToJoin rejects when caller is mutually blocked with organizer', () async { + await seedRide(rideId: 'ride_blocked', organizerUid: organizer.uid); + await fakeFirestore + .collection('users') + .doc(organizer.uid) + .collection('blocks') + .doc(requester.uid) + .set({'blockedUid': requester.uid}); + + expect( + () => service.requestToJoin(caller: requester, groupRideId: 'ride_blocked'), + throwsA(isA()), + ); + }); + + test('requestToJoin on club ride requires active club membership', () async { + await seedRide( + rideId: 'ride_club', + organizerUid: organizer.uid, + clubId: 'club_1', + ); + + // Non-member fails + expect( + () => service.requestToJoin(caller: requester, groupRideId: 'ride_club'), + throwsA(isA()), + ); + + // Active member succeeds + await seedClubMember(clubId: 'club_1', uid: requester.uid, role: 'member', status: 'active'); + await service.requestToJoin(caller: requester, groupRideId: 'ride_club'); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_club') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.exists, isTrue); + expect(partSnap.data()!['status'], equals('requested')); + }); + + test('respondToJoinRequest approval moves participant to accepted, adds accessUid, and increments count', () async { + await seedRide( + rideId: 'ride_approve', + organizerUid: organizer.uid, + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_approve') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_approve', + requesterUid: requester.uid, + approve: true, + ); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_approve').get(); + final ride = GroupRide.fromJson(rideSnap.data()!); + expect(ride.accessUids, contains(requester.uid)); + expect(ride.participantCount, equals(2)); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_approve') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.data()!['status'], equals('accepted')); + }); + + test('respondToJoinRequest decline marks participant declined with zero parent ride mutations', () async { + await seedRide( + rideId: 'ride_decline', + organizerUid: organizer.uid, + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_decline') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_decline', + requesterUid: requester.uid, + approve: false, + ); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_decline').get(); + final ride = GroupRide.fromJson(rideSnap.data()!); + expect(ride.accessUids, isNot(contains(requester.uid))); + expect(ride.participantCount, equals(1)); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_decline') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.data()!['status'], equals('declined')); + }); + + test('active club admin can approve requests on official club ride', () async { + await seedClubMember(clubId: 'club_mod', uid: 'admin_user', role: 'admin'); + await seedClubMember(clubId: 'club_mod', uid: requester.uid, role: 'member'); + await seedRide( + rideId: 'ride_mod', + organizerUid: organizer.uid, + clubId: 'club_mod', + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_mod') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.respondToJoinRequest( + callerUid: 'admin_user', + groupRideId: 'ride_mod', + requesterUid: requester.uid, + approve: true, + ); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_mod').get(); + expect(rideSnap.data()!['accessUids'], contains(requester.uid)); + }); + + test('non-admin club member cannot approve requests', () async { + await seedClubMember(clubId: 'club_mod2', uid: 'normal_member', role: 'member'); + await seedClubMember(clubId: 'club_mod2', uid: requester.uid, role: 'member'); + await seedRide( + rideId: 'ride_mod2', + organizerUid: organizer.uid, + clubId: 'club_mod2', + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_mod2') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + expect( + () => service.respondToJoinRequest( + callerUid: 'normal_member', + groupRideId: 'ride_mod2', + requesterUid: requester.uid, + approve: true, + ), + throwsA(isA()), + ); + }); + + test('approval rejected when ride is underway or at capacity', () async { + // Underway + await seedRide( + rideId: 'ride_underway_app', + organizerUid: organizer.uid, + status: GroupRideStatus.underway, + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_underway_app') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + expect( + () => service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_underway_app', + requesterUid: requester.uid, + approve: true, + ), + throwsA(isA()), + ); + + // At capacity + await seedRide( + rideId: 'ride_cap_app', + organizerUid: organizer.uid, + participantLimit: 2, + participantCount: 2, + accessUids: [organizer.uid, 'user_other'], + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_cap_app') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + expect( + () => service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_cap_app', + requesterUid: requester.uid, + approve: true, + ), + throwsA(isA()), + ); + }); + + test('leaveRide cancels pending request without mutating parent ride', () async { + await seedRide( + rideId: 'ride_leave_pending', + organizerUid: organizer.uid, + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_leave_pending') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_pending'); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_pending').get(); + expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid))); + expect(rideSnap.data()!['participantCount'], equals(1)); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_leave_pending') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.data()!['status'], equals('left')); + }); + + test('leaveRide removes accepted rider from accessUids and decrements count', () async { + await seedRide( + rideId: 'ride_leave_accepted', + organizerUid: organizer.uid, + participantCount: 2, + accessUids: [organizer.uid, requester.uid], + ); + await fakeFirestore + .collection('group_rides') + .doc('ride_leave_accepted') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'accepted', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.leaveRide(callerUid: requester.uid, groupRideId: 'ride_leave_accepted'); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_leave_accepted').get(); + expect(rideSnap.data()!['accessUids'], isNot(contains(requester.uid))); + expect(rideSnap.data()!['participantCount'], equals(1)); + }); + + test('respondToInvitation accept: true marks participant accepted and increments count', () async { + final invitee = RiderProfile( + uid: 'user_bob', + handle: 'bob_trail', + displayName: 'Bob Trail', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await seedRide( + rideId: 'ride_inv_accept', + organizerUid: organizer.uid, + participantCount: 1, + accessUids: [organizer.uid, invitee.uid], + ); + + await fakeFirestore + .collection('group_rides') + .doc('ride_inv_accept') + .collection('participants') + .doc(invitee.uid) + .set({ + 'userId': invitee.uid, + 'role': 'participant', + 'status': 'invited', + 'invitedBy': organizer.uid, + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.respondToInvitation( + callerUid: invitee.uid, + groupRideId: 'ride_inv_accept', + accept: true, + ); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_accept').get(); + expect(rideSnap.data()!['participantCount'], equals(2)); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_inv_accept') + .collection('participants') + .doc(invitee.uid) + .get(); + expect(partSnap.data()!['status'], equals('accepted')); + }); + + test('respondToInvitation accept: false marks participant declined and removes accessUid with count unchanged', () async { + final invitee = RiderProfile( + uid: 'user_bob', + handle: 'bob_trail', + displayName: 'Bob Trail', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await seedRide( + rideId: 'ride_inv_decline', + organizerUid: organizer.uid, + participantCount: 1, + accessUids: [organizer.uid, invitee.uid], + ); + + await fakeFirestore + .collection('group_rides') + .doc('ride_inv_decline') + .collection('participants') + .doc(invitee.uid) + .set({ + 'userId': invitee.uid, + 'role': 'participant', + 'status': 'invited', + 'invitedBy': organizer.uid, + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + await service.respondToInvitation( + callerUid: invitee.uid, + groupRideId: 'ride_inv_decline', + accept: false, + ); + + final rideSnap = await fakeFirestore.collection('group_rides').doc('ride_inv_decline').get(); + expect(rideSnap.data()!['participantCount'], equals(1)); + expect(rideSnap.data()!['accessUids'], isNot(contains(invitee.uid))); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_inv_decline') + .collection('participants') + .doc(invitee.uid) + .get(); + expect(partSnap.data()!['status'], equals('declined')); + }); + + test('respondToJoinRequest decline succeeds even when organizer and requester are mutually blocked', () async { + await seedRide( + rideId: 'ride_block_decline', + organizerUid: organizer.uid, + ); + + // Seed mutual block + await fakeFirestore + .collection('users') + .doc(organizer.uid) + .collection('blocks') + .doc(requester.uid) + .set({'blockedUid': requester.uid}); + await fakeFirestore + .collection('users') + .doc(requester.uid) + .collection('blocks') + .doc(organizer.uid) + .set({'blockedUid': organizer.uid}); + + await fakeFirestore + .collection('group_rides') + .doc('ride_block_decline') + .collection('participants') + .doc(requester.uid) + .set({ + 'userId': requester.uid, + 'role': 'participant', + 'status': 'requested', + 'createdAt': DateTime.now().toIso8601String(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + + // 1. Approve fails + expect( + () => service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_block_decline', + requesterUid: requester.uid, + approve: true, + ), + throwsA(isA()), + ); + + // 2. Decline SUCCEEDS + await service.respondToJoinRequest( + callerUid: organizer.uid, + groupRideId: 'ride_block_decline', + requesterUid: requester.uid, + approve: false, + ); + + final partSnap = await fakeFirestore + .collection('group_rides') + .doc('ride_block_decline') + .collection('participants') + .doc(requester.uid) + .get(); + expect(partSnap.data()!['status'], equals('declined')); + }); + }); } class _FakeGroupRideLifecycleService implements GroupRideService { diff --git a/website/app/api/clubs/[clubId]/rides/route.ts b/website/app/api/clubs/[clubId]/rides/route.ts new file mode 100644 index 0000000..94c62a4 --- /dev/null +++ b/website/app/api/clubs/[clubId]/rides/route.ts @@ -0,0 +1,293 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { QueryDocumentSnapshot } from 'firebase-admin/firestore'; +import { getAdminAuth, getAdminFirestore } from '../../../../../lib/firebase-admin'; + +export const dynamic = 'force-dynamic'; + +interface RouteContext { + params: Promise<{ + clubId: string; + }>; +} + +export interface ClubRideDiscoveryItem { + rideId: string; + clubId: string; + title: string; + description: string | null; + organizerDisplayName: string | null; + organizerHandle: string | null; + scheduledStartAt: string; + departureAt: string | null; + joinPolicy: string; + participantCount: number; + participantLimit: number | null; + status: 'scheduled' | 'assembling'; + meetingLocationLabel: string | null; + routeTitle: string | null; + routeDistanceMeters: number | null; + paceClassification: string | null; +} + +export async function GET(request: NextRequest, context: RouteContext) { + try { + // 1. Require and extract Bearer token + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'unauthorized', detail: 'Missing or malformed Authorization header.' }, + { status: 401 } + ); + } + + const idToken = authHeader.slice(7).trim(); + if (!idToken) { + return NextResponse.json( + { error: 'unauthorized', detail: 'Missing authentication token.' }, + { status: 401 } + ); + } + + // 2 & 3. Verify Firebase ID token server-side and derive callerUid exclusively from token + let callerUid: string; + try { + const decoded = await getAdminAuth().verifyIdToken(idToken); + callerUid = decoded.uid; + } catch { + return NextResponse.json( + { error: 'unauthorized', detail: 'Invalid or expired authentication token.' }, + { status: 401 } + ); + } + + // 4. Validate clubId + const { clubId } = await context.params; + if ( + !clubId || + typeof clubId !== 'string' || + clubId.trim().length === 0 || + clubId.length > 64 || + /\s/.test(clubId) + ) { + return NextResponse.json( + { error: 'invalid_request', detail: 'Invalid clubId parameter.' }, + { status: 400 } + ); + } + const normalizedClubId = clubId.trim(); + + const db = getAdminFirestore(); + + // 5. Verify club exists and is unarchived + const clubSnap = await db.collection('clubs').doc(normalizedClubId).get(); + if (!clubSnap.exists) { + return NextResponse.json( + { error: 'not_found', detail: 'Club not found.' }, + { status: 404 } + ); + } + + const clubData = clubSnap.data(); + if (clubData?.archivedAt != null) { + return NextResponse.json( + { error: 'not_found', detail: 'Club is archived.' }, + { status: 404 } + ); + } + + // 6. Verify caller is an active club member + const memberSnap = await db + .collection('clubs') + .doc(normalizedClubId) + .collection('members') + .doc(callerUid) + .get(); + + if (!memberSnap.exists) { + return NextResponse.json( + { error: 'forbidden', detail: 'Active club membership required.' }, + { status: 403 } + ); + } + + const memberData = memberSnap.data(); + if (memberData?.status !== 'active') { + return NextResponse.json( + { error: 'forbidden', detail: 'Active club membership required.' }, + { status: 403 } + ); + } + + // 7. Server-Side Mutual-Block Pre-Fetch (avoiding N+1 queries) + // 7a. Fetch all users blocked by the caller (single query upfront) + const callerBlocksSnap = await db + .collection('users') + .doc(callerUid) + .collection('blocks') + .get(); + const blockedByCaller = new Set( + callerBlocksSnap.docs.map((d: QueryDocumentSnapshot) => d.id) + ); + + // 8. Paginate candidate discoverable upcoming rides until 30 eligible rides collected or query exhausted + const TARGET_COUNT = 30; + const PAGE_SIZE = 30; + + const eligibleDocs: QueryDocumentSnapshot[] = []; + const checkedOrganizers = new Map(); // orgUid -> isBlocked + let lastDoc: QueryDocumentSnapshot | null = null; + + while (eligibleDocs.length < TARGET_COUNT) { + let query = db + .collection('group_rides') + .where('clubId', '==', normalizedClubId) + .where('status', 'in', ['scheduled', 'assembling']) + .orderBy('scheduledStartAt', 'asc') + .limit(PAGE_SIZE); + + if (lastDoc) { + query = query.startAfter(lastDoc); + } + + const ridesSnap = await query.get(); + if (ridesSnap.empty) { + break; + } + + lastDoc = ridesSnap.docs[ridesSnap.docs.length - 1]; + + // Filter candidate rides where caller blocks organizer + const candidateDocs = ridesSnap.docs.filter((doc: QueryDocumentSnapshot) => { + const organizerUid = doc.data().organizerUid; + return typeof organizerUid === 'string' && !blockedByCaller.has(organizerUid); + }); + + // Deduplicate distinct non-caller organizer UIDs needing reverse block checks + const uncheckedOrganizerUids = Array.from( + new Set( + candidateDocs + .map((d: QueryDocumentSnapshot) => d.data().organizerUid) + .filter((uid: unknown): uid is string => + typeof uid === 'string' && + uid.length > 0 && + uid !== callerUid && + !checkedOrganizers.has(uid) + ) + ) + ); + + if (uncheckedOrganizerUids.length > 0) { + const reverseBlockChecks = await Promise.all( + uncheckedOrganizerUids.map(async (orgUid: string) => { + const blockSnap = await db + .collection('users') + .doc(orgUid) + .collection('blocks') + .doc(callerUid) + .get(); + return { orgUid, isBlocked: blockSnap.exists }; + }) + ); + for (const check of reverseBlockChecks) { + checkedOrganizers.set(check.orgUid, check.isBlocked); + } + } + + // Filter out rides where organizer blocks caller + for (const doc of candidateDocs) { + const organizerUid = doc.data().organizerUid; + if (typeof organizerUid === 'string') { + if (organizerUid === callerUid || !checkedOrganizers.get(organizerUid)) { + eligibleDocs.push(doc); + if (eligibleDocs.length >= TARGET_COUNT) { + break; + } + } + } + } + + if (ridesSnap.docs.length < PAGE_SIZE) { + // Query exhausted + break; + } + } + + if (eligibleDocs.length === 0) { + return NextResponse.json( + { + clubId: normalizedClubId, + rides: [], + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + } + ); + } + + // 9. Response Data Minimization & Physical-Location Privacy + // Never expose accessUids, exact coordinates, full route geometry, or live presence + const rides: ClubRideDiscoveryItem[] = eligibleDocs.map((doc: QueryDocumentSnapshot) => { + const d = doc.data(); + + const scheduledStartAt = typeof d.scheduledStartAt === 'string' + ? d.scheduledStartAt + : (d.scheduledStartAt?.toDate?.() ? d.scheduledStartAt.toDate().toISOString() : ''); + + const departureAt = typeof d.departureAt === 'string' + ? d.departureAt + : (d.departureAt?.toDate?.() ? d.departureAt.toDate().toISOString() : null); + + // Coarse human-readable location label only; never exact GPS latitude/longitude or raw street address + let meetingLocationLabel: string | null = null; + if ( + d.meetingPoint && + typeof d.meetingPoint === 'object' && + typeof d.meetingPoint.displayName === 'string' && + d.meetingPoint.displayName.trim().length > 0 + ) { + meetingLocationLabel = d.meetingPoint.displayName.trim(); + } + + return { + rideId: doc.id, + clubId: normalizedClubId, + title: d.title || 'Untitled Ride', + description: d.description || null, + organizerDisplayName: d.organizerDisplayName || null, + organizerHandle: d.organizerHandle || null, + scheduledStartAt, + departureAt, + joinPolicy: d.joinPolicy || 'inviteOnly', + participantCount: typeof d.participantCount === 'number' ? d.participantCount : 1, + participantLimit: typeof d.participantLimit === 'number' ? d.participantLimit : null, + status: d.status, + meetingLocationLabel, + routeTitle: d.routeTitle || null, + routeDistanceMeters: typeof d.routeDistanceMeters === 'number' ? d.routeDistanceMeters : null, + paceClassification: d.paceClassification || null, + }; + }); + + return NextResponse.json( + { + clubId: normalizedClubId, + rides, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + } + ); + } catch (err: any) { + console.error('[CLUB_RIDES_DISCOVERY] Internal error:', err?.message || err); + return NextResponse.json( + { error: 'server_error', detail: 'Club ride discovery is temporarily unavailable.' }, + { status: 500 } + ); + } +} diff --git a/website/app/api/group-rides/[rideId]/join-requests/route.ts b/website/app/api/group-rides/[rideId]/join-requests/route.ts new file mode 100644 index 0000000..7a092ee --- /dev/null +++ b/website/app/api/group-rides/[rideId]/join-requests/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { QueryDocumentSnapshot } from 'firebase-admin/firestore'; +import { getAdminAuth, getAdminFirestore } from '../../../../../lib/firebase-admin'; + +export const dynamic = 'force-dynamic'; + +interface RouteContext { + params: Promise<{ + rideId: string; + }>; +} + +export interface GroupRideJoinRequestDto { + userId: string; + riderDisplayName: string | null; + riderHandle: string | null; + riderAvatarPreset: string | null; + riderAvatarUrl: string | null; + createdAt: string; +} + +export async function GET(request: NextRequest, context: RouteContext) { + try { + // 1. Require and extract Bearer token + const authHeader = request.headers.get('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return NextResponse.json( + { error: 'unauthorized', detail: 'Missing or malformed Authorization header.' }, + { status: 401 } + ); + } + + const idToken = authHeader.slice(7).trim(); + if (!idToken) { + return NextResponse.json( + { error: 'unauthorized', detail: 'Missing authentication token.' }, + { status: 401 } + ); + } + + // 2. Verify Firebase ID token server-side and derive callerUid strictly from token + let callerUid: string; + try { + const decoded = await getAdminAuth().verifyIdToken(idToken); + callerUid = decoded.uid; + } catch { + return NextResponse.json( + { error: 'unauthorized', detail: 'Invalid or expired authentication token.' }, + { status: 401 } + ); + } + + // 3. Validate rideId + const { rideId } = await context.params; + if ( + !rideId || + typeof rideId !== 'string' || + rideId.trim().length === 0 || + rideId.length > 64 || + /\s/.test(rideId) + ) { + return NextResponse.json( + { error: 'invalid_request', detail: 'Invalid rideId parameter.' }, + { status: 400 } + ); + } + const normalizedRideId = rideId.trim(); + + const db = getAdminFirestore(); + + // 4. Fetch ride document + const rideSnap = await db.collection('group_rides').doc(normalizedRideId).get(); + if (!rideSnap.exists) { + return NextResponse.json( + { error: 'not_found', detail: 'Group ride not found.' }, + { status: 404 } + ); + } + + const rideData = rideSnap.data() || {}; + const organizerUid = rideData.organizerUid; + const isOrganizer = organizerUid === callerUid; + + let isClubAdmin = false; + if (!isOrganizer && typeof rideData.clubId === 'string' && rideData.clubId.trim().length > 0) { + const clubId = rideData.clubId.trim(); + const memberSnap = await db + .collection('clubs') + .doc(clubId) + .collection('members') + .doc(callerUid) + .get(); + + if (memberSnap.exists) { + const memberData = memberSnap.data(); + if ( + memberData?.status === 'active' && + (memberData?.role === 'admin' || memberData?.role === 'owner') + ) { + isClubAdmin = true; + } + } + } + + // 5. Require caller to be either organizer or active club administrator + if (!isOrganizer && !isClubAdmin) { + return NextResponse.json( + { error: 'forbidden', detail: 'Only the organizer or a club administrator can view join requests.' }, + { status: 403 } + ); + } + + // 6. Query pending join requests bounded by limit 50, ordered by createdAt asc + const requestsSnap = await db + .collection('group_rides') + .doc(normalizedRideId) + .collection('participants') + .where('status', '==', 'requested') + .orderBy('createdAt', 'asc') + .limit(50) + .get(); + + // 7. Minimal DTO mapping (never expose location, live presence, or telemetry) + const requests: GroupRideJoinRequestDto[] = requestsSnap.docs.map((doc: QueryDocumentSnapshot) => { + const d = doc.data(); + const createdAt = typeof d.createdAt === 'string' + ? d.createdAt + : (d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : ''); + + return { + userId: d.userId || doc.id, + riderDisplayName: d.riderDisplayName || null, + riderHandle: d.riderHandle || null, + riderAvatarPreset: d.riderAvatarPreset || null, + riderAvatarUrl: d.riderAvatarUrl || null, + createdAt, + }; + }); + + return NextResponse.json( + { + rideId: normalizedRideId, + requests, + }, + { + status: 200, + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + } + ); + } catch (err: any) { + console.error('[GROUP_RIDE_JOIN_REQUESTS] Internal error:', err?.message || err); + return NextResponse.json( + { error: 'server_error', detail: 'Join requests are temporarily unavailable.' }, + { status: 500 } + ); + } +} diff --git a/website/app/clubs/[clubId]/manage/rides/page.tsx b/website/app/clubs/[clubId]/manage/rides/page.tsx index d8fa470..40ce63d 100644 --- a/website/app/clubs/[clubId]/manage/rides/page.tsx +++ b/website/app/clubs/[clubId]/manage/rides/page.tsx @@ -76,17 +76,12 @@ export default function ClubFounderRidesPage({ params }: PageProps) { setPendingApplicationsCount(0); } - // In demo preview mode: load fixture rides for UI/design validation - if (isDemoPreview) { - const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, { - allowDemoPreview: true, - }); - setUpcomingRides(ridesResult.upcomingRides); - setPastRides(ridesResult.pastRides); - } - // In production mode, do not attempt a broad client-side clubId query. - // GroupRide.clubId is now persisted, but Firestore intentionally does not authorize - // Club-wide discovery until mutual-block privacy can be preserved. + // Load club rides for upcoming and past management views + const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + setUpcomingRides(ridesResult.upcomingRides); + setPastRides(ridesResult.pastRides); } catch (err) { setError(sanitizePortalError(err, "Club rides couldn't be loaded.")); } finally { @@ -151,82 +146,7 @@ export default function ClubFounderRidesPage({ params }: PageProps) { ); } - // Normal production operation: truthful discovery-pending state. - // GroupRide.clubId exists, but no privacy-preserving Club-wide read path is available. - // Do not describe this state as "No rides". - if (!isDemoPreview) { - return ( - -
- {/* Header Section */} -
-
-
-

- Club Rides -

- - DISCOVERY PENDING - -
-

- Schedule, coordinate, and review group rides and road captain assignments. -

-
- -
- add_road - Plan Club Ride - - SOON - -
-
- - {/* Pending Backend Integration Notice */} -
-
- route -
-
-

- Club Rides Discovery Pending -

-

- Club-affiliated Group Rides can now be created in the mobile application. Club-wide Founder Portal discovery remains disabled until a read path can preserve mutual-block privacy. -

-
-
-
- info - - Architecture Note: Group rides now link to clubs through GroupRide.clubId == club.id. Club-wide discovery is a separate deferred capability. - -
-
- shield - - Privacy Invariant: Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location. - -
-
-
-
-
- ); - } - - // Explicit Development Demo Preview: Renders full interactive UI with fixtures + // Renders authoritative Founder Portal rides management view return ( ): ClubUpcomingRide { + const startStr = typeof rd.scheduledStartAt === 'string' + ? rd.scheduledStartAt + : (rd.scheduledStartAt?.toDate?.() ? rd.scheduledStartAt.toDate().toISOString() : 'TBD'); + const depStr = typeof rd.departureAt === 'string' + ? rd.departureAt + : (rd.departureAt?.toDate?.() ? rd.departureAt.toDate().toISOString() : undefined); + + return { + id, + rideIdentifier: rd.rideIdentifier || `RIDE-${id.slice(0, 6).toUpperCase()}`, + title: rd.title || 'Untitled Club Ride', + scheduledStartAt: startStr, + departureAt: depStr, + distanceMiles: typeof rd.routeDistanceMeters === 'number' + ? Math.round(rd.routeDistanceMeters * 0.000621371) + : null, + durationString: null, + paceClassification: rd.paceClassification || 'Club Paceline', + roadCaptainName: rd.organizerDisplayName || 'Ride Leader', + roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle.replace(/^@/, '')}` : undefined, + roadCaptainMotorcycle: rd.organizerMotorcycle || null, + sweepName: rd.sweepName || null, + sweepStatus: rd.sweepStatus || 'vacant', + confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1, + pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0, + capacity: typeof rd.participantLimit === 'number' ? rd.participantLimit : null, + briefingTime: rd.briefingTime || null, + routeId: rd.routeId || null, + status: rd.status || null, + organizerUid: rd.organizerUid || null, + description: rd.description || null, + }; +} + /** - * Supplies the Founder Portal Club Rides scaffold. + * Fetches official Club-affiliated rides for Founder Portal management. * - * CURRENT PRODUCTION CONTRACT: - * - GroupRide.clubId is persisted by the Flutter application. - * - Firestore intentionally does not authorize a broad client-side clubId list query, - * because discovery must preserve mutual-block privacy. - * - Production callers must not infer Club membership as GroupRide participation or - * live-location access. - * - The explicit development preview remains fixture-backed. + * GOVERNANCE AUTHORIZATION: + * - Scoped to clubId, status, and scheduledStartAt matching Firestore security rules and composite indexes. + * - Active Club Owners and Admins have administrative visibility over all official club rides. + * - Unrestricted collection reads are strictly avoided. + * - Demo preview remains available when explicitly requested. */ export async function fetchClubRides( clubId: string, @@ -1207,8 +1240,37 @@ export async function fetchClubRides( throw new Error('not-found'); } - throw new Error( - 'Club ride discovery is not available until a privacy-preserving production read path is implemented.' + const upcomingQuery = query( + collection(firestore, 'group_rides'), + where('clubId', '==', normalized), + where('status', 'in', ['scheduled', 'assembling', 'underway']), + orderBy('scheduledStartAt', 'asc'), + limit(30) ); + + const pastQuery = query( + collection(firestore, 'group_rides'), + where('clubId', '==', normalized), + where('status', 'in', ['completed', 'canceled']), + orderBy('scheduledStartAt', 'desc'), + limit(30) + ); + + const [upcomingSnap, pastSnap] = await Promise.all([ + getDocs(upcomingQuery), + getDocs(pastQuery), + ]); + + const upcomingRides: ClubUpcomingRide[] = upcomingSnap.docs.map((d) => + mapGroupRideDoc(d.id, d.data()) + ); + const pastRides: ClubUpcomingRide[] = pastSnap.docs.map((d) => + mapGroupRideDoc(d.id, d.data()) + ); + + return { + upcomingRides, + pastRides, + }; } diff --git a/website/lib/firebase-admin.ts b/website/lib/firebase-admin.ts new file mode 100644 index 0000000..53fc375 --- /dev/null +++ b/website/lib/firebase-admin.ts @@ -0,0 +1,60 @@ +import { getApps, initializeApp, cert, type App } from 'firebase-admin/app'; +import { getAuth, type Auth } from 'firebase-admin/auth'; +import { getFirestore, type Firestore } from 'firebase-admin/firestore'; + +/** + * Server-only singleton initialization for Firebase Admin. + * + * Requirements: + * - Singleton reuse across Next.js API route invocations. + * - Server-only execution (never imported or bundled in client-side code). + * - Fails closed if project configuration is missing. + * - Supports Google Application Default Credentials (ADC), explicit service account + * via FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS, and Firebase emulator. + */ + +let _adminApp: App | null = null; + +export function getFirebaseAdminApp(): App { + if (_adminApp) { + return _adminApp; + } + + const existingApps = getApps(); + if (existingApps.length > 0 && existingApps[0]) { + _adminApp = existingApps[0]; + return _adminApp; + } + + const projectId = process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0'; + const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT; + + let credential; + if (serviceAccountJson) { + try { + credential = cert(JSON.parse(serviceAccountJson)); + } catch (err) { + console.error('[FIREBASE_ADMIN] Failed to parse FIREBASE_SERVICE_ACCOUNT credential:', err); + throw new Error('Invalid Firebase service account configuration.'); + } + } + + try { + _adminApp = initializeApp({ + projectId, + ...(credential ? { credential } : {}), + }); + return _adminApp; + } catch (err: any) { + console.error('[FIREBASE_ADMIN] Failed to initialize Firebase Admin SDK:', err?.message || err); + throw new Error('Firebase Admin SDK initialization failed.'); + } +} + +export function getAdminAuth(): Auth { + return getAuth(getFirebaseAdminApp()); +} + +export function getAdminFirestore(): Firestore { + return getFirestore(getFirebaseAdminApp()); +} diff --git a/website/package-lock.json b/website/package-lock.json index 7f21ca1..6ee60a0 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "firebase": "12.18.0", + "firebase-admin": "^14.4.0", "next": "16.3.4", "react": "19.1.1", "react-dom": "19.1.1" @@ -293,6 +294,12 @@ } } }, + "node_modules/@fastify/busboy": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz", + "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==", + "license": "MIT" + }, "node_modules/@firebase/ai": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.15.0.tgz", @@ -927,6 +934,305 @@ "integrity": "sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ==", "license": "Apache-2.0" }, + "node_modules/@google-cloud/firestore": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-9.2.0.tgz", + "integrity": "sha512-4BI2686ygMkef0Pe4QnX9hnWeGCUjaAP5m+Ow5/yNWwGxGadO741Ct28MRXW9czg0pnvz919INKbQdCgUMcfeA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/firestore-api": "^0.2.0", + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^6.0.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore-api": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore-api/-/firestore-api-0.2.0.tgz", + "integrity": "sha512-wfHJPZmLqzpy6FN6U+7HkkP/AKhGsbHSpEy/qL2TyI3/pKRAvpamQxZ2qvps5hMRgAjfuHCim+oeJj9RveCaqg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-7.1.0.tgz", + "integrity": "sha512-PNCXdAQgNoQODZuqyhmkN2nUpB2q9UFzawZ1oyCDyEyhcheRoCKJJhSKhEIeKpM/NCLnC6Q4We4HEkGCwNDnCA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-6.1.0.tgz", + "integrity": "sha512-tKAx9q6vTtbgZcqCojKWK8k6Dy2XBsE7BkNrrBsblxNXrKImYnljqeGfYh9qnTrlTjC9F19pTyCkGcl3pFMW/w==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-6.1.0.tgz", + "integrity": "sha512-H+Rytzi5zYMyoAmU/zgYLgq7fJ9FY4QQEg4k6gfFx/RY1COJ2xxyIfe8bZ62lqEbWM1DJH7IYETqTFmqNtuUhQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/storage": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-8.2.0.tgz", + "integrity": "sha512-7ckgSjeEprV92VrlLjpYFA3PFerflvFtF7Ea86oQXGNCprGZq5rtpAVK0zgWnCy+44mLjms42M7MiWa433gz2A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^7.0.1", + "@google-cloud/projectify": "^6.0.1", + "@google-cloud/promisify": "^6.0.1", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^9.0.1", + "teeny-request": "^11.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.9.16", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", @@ -1459,6 +1765,109 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT", + "optional": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1487,6 +1896,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@next/env": { "version": "16.3.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", @@ -1621,6 +2041,29 @@ "node": ">= 10" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", @@ -1632,6 +2075,16 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2072,6 +2525,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -2139,6 +2608,28 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2163,6 +2654,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -2184,6 +2688,43 @@ "node": ">=12" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -2206,6 +2747,31 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -2274,6 +2840,21 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2295,6 +2876,15 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2309,6 +2899,23 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2345,12 +2952,51 @@ "license": "MIT", "peer": true }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT", + "optional": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -2390,6 +3036,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2400,6 +3056,59 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -2430,6 +3139,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/firebase": { "version": "12.18.0", "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.18.0.tgz", @@ -2466,6 +3198,28 @@ "@firebase/util": "1.15.3" } }, + "node_modules/firebase-admin": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-14.4.0.tgz", + "integrity": "sha512-yEx+GCsHvjH2svh4j1ATW57o8tlfjRQ0klTxvn7XCKUPXWiUQaRTHSzPSqhXtsLYSe4IwTqR7qqrh2sA45A0HA==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.1.4", + "@firebase/database-types": "^1.0.20", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^4.0.1" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^9.1.0", + "@google-cloud/storage": "^8.1.0" + } + }, "node_modules/firebase/node_modules/@firebase/auth": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.5.tgz", @@ -2490,6 +3244,35 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2506,6 +3289,76 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2515,6 +3368,267 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-6.4.0.tgz", + "integrity": "sha512-0Hj8GyPBtrBHQiB3GVb0jETZaXn1mTRhpwM2PLUEqSjct4YTHbT6N6rdfMqzInaX17FV+lWumUe50UjuNmMzLg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "@opentelemetry/api": "^1.9.0", + "duplexify": "^4.1.3", + "google-auth-library": "^11.0.0", + "google-logging-utils": "^2.0.0", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^4.0.0", + "protobufjs": "^7.5.4", + "retry-request": "^9.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax/node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/google-gax/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.4.tgz", + "integrity": "sha512-p6WvbXOhfwnVtv/MyPq5kBiVhfV/qglMIxwwTVNEI1Ha1HmOgz5uHYHtYO6SfGckFaXe8blA11IJJ3wqXmvmBA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.1.0.tgz", + "integrity": "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz", + "integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2528,18 +3642,69 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2556,6 +3721,64 @@ "dev": true, "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2605,6 +3828,75 @@ } } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz", + "integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-cache": "^11.0.0", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2878,12 +4170,65 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2894,12 +4239,21 @@ "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -2928,6 +4282,51 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -2999,6 +4398,82 @@ } } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -3013,6 +4488,39 @@ "node": ">=12.20.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0", + "optional": true + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3026,6 +4534,56 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3103,6 +4661,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proto3-json-serializer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-4.0.2.tgz", + "integrity": "sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.5.4" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/protobufjs": { "version": "7.6.6", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", @@ -3174,6 +4745,21 @@ "license": "MIT", "peer": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3193,6 +4779,46 @@ "node": ">=0.10.0" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-9.0.1.tgz", + "integrity": "sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^11.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -3272,7 +4898,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -3330,6 +4955,29 @@ } } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3337,6 +4985,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3360,6 +5021,33 @@ "dev": true, "license": "MIT" }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3374,6 +5062,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3386,6 +5090,43 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -3416,6 +5157,41 @@ "dev": true, "license": "MIT" }, + "node_modules/teeny-request": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-11.0.1.tgz", + "integrity": "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -3535,6 +5311,28 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", @@ -3740,6 +5538,15 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", @@ -3804,6 +5611,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3838,6 +5661,32 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -3848,6 +5697,22 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -3890,6 +5755,19 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/website/package.json b/website/package.json index 8e0f8fc..60007a0 100644 --- a/website/package.json +++ b/website/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "firebase": "12.18.0", + "firebase-admin": "^14.4.0", "next": "16.3.4", "react": "19.1.1", "react-dom": "19.1.1" diff --git a/website/tests/club-rides-discovery.test.ts b/website/tests/club-rides-discovery.test.ts new file mode 100644 index 0000000..b7c17f6 --- /dev/null +++ b/website/tests/club-rides-discovery.test.ts @@ -0,0 +1,725 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { GET } from '../app/api/clubs/[clubId]/rides/route'; +import { getAdminAuth, getAdminFirestore } from '../lib/firebase-admin'; + +vi.mock('../lib/firebase-admin', () => ({ + getAdminAuth: vi.fn(), + getAdminFirestore: vi.fn(), +})); + +describe('Club GroupRide Secure Member Discovery API (GET /api/clubs/[clubId]/rides)', () => { + let mockAuth: any; + let mockFirestore: any; + + // In-memory store for mock firestore + let clubsStore: Map; + let clubMembersStore: Map; // key: `${clubId}/${uid}` + let userBlocksStore: Map>; // key: uid -> Set of blocked uids + let groupRidesStore: Array<{ id: string; data: any }>; + + beforeEach(() => { + vi.restoreAllMocks(); + + clubsStore = new Map(); + clubMembersStore = new Map(); + userBlocksStore = new Map(); + groupRidesStore = []; + + mockAuth = { + verifyIdToken: vi.fn(async (token: string) => { + if (token === 'valid_token_charlie') { + return { uid: 'charlie' }; + } + if (token === 'valid_token_alice') { + return { uid: 'alice' }; + } + if (token === 'valid_token_bob') { + return { uid: 'bob' }; + } + if (token === 'valid_token_dave') { + return { uid: 'dave' }; + } + throw new Error('Invalid token'); + }), + }; + + mockFirestore = { + collection: (colName: string) => { + if (colName === 'clubs') { + return { + doc: (clubId: string) => ({ + get: async () => { + const data = clubsStore.get(clubId); + return { + exists: !!data, + data: () => data, + }; + }, + collection: (subCol: string) => { + if (subCol === 'members') { + return { + doc: (uid: string) => ({ + get: async () => { + const data = clubMembersStore.get(`${clubId}/${uid}`); + return { + exists: !!data, + data: () => data, + }; + }, + }), + }; + } + throw new Error(`Unexpected subcollection: ${subCol}`); + }, + }), + }; + } + + if (colName === 'users') { + return { + doc: (uid: string) => ({ + collection: (subCol: string) => { + if (subCol === 'blocks') { + return { + get: async () => { + const blockedSet = userBlocksStore.get(uid) || new Set(); + return { + docs: Array.from(blockedSet).map((bUid) => ({ + id: bUid, + data: () => ({ blockedUid: bUid }), + })), + }; + }, + doc: (targetUid: string) => ({ + get: async () => { + const blockedSet = userBlocksStore.get(uid) || new Set(); + const exists = blockedSet.has(targetUid); + return { + exists, + data: () => (exists ? { blockedUid: targetUid } : undefined), + }; + }, + }), + }; + } + throw new Error(`Unexpected subcollection: ${subCol}`); + }, + }), + }; + } + + if (colName === 'group_rides') { + const createQuery = (filters: any) => ({ + where: (field: string, op: string, val: any) => { + return createQuery({ ...filters, [field]: { op, val } }); + }, + orderBy: (field: string, dir: string) => createQuery({ ...filters, orderBy: { field, dir } }), + limit: (num: number) => createQuery({ ...filters, limit: num }), + startAfter: (lastDoc: any) => createQuery({ ...filters, startAfterId: lastDoc?.id }), + get: async () => { + let results = groupRidesStore.filter((r) => { + const d = r.data; + if (filters.clubId && d.clubId !== filters.clubId.val) return false; + if (filters.status && filters.status.op === 'in') { + if (!filters.status.val.includes(d.status)) return false; + } + return true; + }); + + if (filters.orderBy) { + const { field, dir } = filters.orderBy; + results = [...results].sort((a, b) => { + const valA = a.data[field] || ''; + const valB = b.data[field] || ''; + return dir === 'desc' ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1); + }); + } + + if (filters.startAfterId) { + const idx = results.findIndex((r) => r.id === filters.startAfterId); + if (idx !== -1) { + results = results.slice(idx + 1); + } + } + + if (filters.limit) { + results = results.slice(0, filters.limit); + } + + return { + empty: results.length === 0, + docs: results.map((r) => ({ + id: r.id, + data: () => r.data, + })), + }; + }, + }); + + return createQuery({}); + } + + throw new Error(`Unexpected collection: ${colName}`); + }, + }; + + (getAdminAuth as any).mockReturnValue(mockAuth); + (getAdminFirestore as any).mockReturnValue(mockFirestore); + + // Setup standard active club_alpha + clubsStore.set('club_alpha', { + id: 'club_alpha', + name: 'Alpha Riders', + archivedAt: null, + }); + + // Alice is active owner + clubMembersStore.set('club_alpha/alice', { + uid: 'alice', + clubId: 'club_alpha', + role: 'owner', + status: 'active', + }); + + // Bob is active admin + clubMembersStore.set('club_alpha/bob', { + uid: 'bob', + clubId: 'club_alpha', + role: 'admin', + status: 'active', + }); + + // Charlie is active member + clubMembersStore.set('club_alpha/charlie', { + uid: 'charlie', + clubId: 'club_alpha', + role: 'member', + status: 'active', + }); + + // Eve is inactive requested member + clubMembersStore.set('club_alpha/eve', { + uid: 'eve', + clubId: 'club_alpha', + role: 'member', + status: 'requested', + }); + }); + + const createRequest = (clubId: string, authHeader?: string) => { + const headers: Record = {}; + if (authHeader !== undefined) { + headers['authorization'] = authHeader; + } + return new NextRequest(`https://motorsignal.app/api/clubs/${clubId}/rides`, { + method: 'GET', + headers, + }); + }; + + // ============================================================ + // 1. Authentication Gates (401) + // ============================================================ + it('returns 401 when Authorization header is missing', async () => { + const req = createRequest('club_alpha'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.detail).toContain('Missing or malformed Authorization header'); + }); + + it('returns 401 when Authorization header does not start with Bearer', async () => { + const req = createRequest('club_alpha', 'Basic some_creds'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + }); + + it('returns 401 when token verification fails or is expired', async () => { + const req = createRequest('club_alpha', 'Bearer invalid_or_expired_token'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.detail).toContain('Invalid or expired authentication token'); + }); + + // ============================================================ + // 2. Club ID Validation (400) + // ============================================================ + it('returns 400 for empty or whitespace clubId', async () => { + const req = createRequest(' ', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: ' ' }) }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('invalid_request'); + }); + + it('returns 400 for clubId with whitespace inside', async () => { + const req = createRequest('club alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club alpha' }) }); + expect(res.status).toBe(400); + }); + + it('returns 400 for clubId exceeding 64 characters', async () => { + const longId = 'a'.repeat(65); + const req = createRequest(longId, 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: longId }) }); + expect(res.status).toBe(400); + }); + + // ============================================================ + // 3. Club Existence & Status (404) + // ============================================================ + it('returns 404 when club does not exist', async () => { + const req = createRequest('club_nonexistent', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_nonexistent' }) }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('not_found'); + expect(body.detail).toContain('Club not found'); + }); + + it('returns 404 when club is archived', async () => { + clubsStore.set('club_archived', { + id: 'club_archived', + name: 'Archived Club', + archivedAt: '2026-09-01T00:00:00Z', + }); + const req = createRequest('club_archived', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_archived' }) }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('not_found'); + expect(body.detail).toContain('archived'); + }); + + // ============================================================ + // 4. Membership Authorization (403 vs 200) + // ============================================================ + it('returns 403 when authenticated user is not a club member', async () => { + // Dave has valid token but is not in club_alpha/members + const req = createRequest('club_alpha', 'Bearer valid_token_dave'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + expect(body.detail).toContain('Active club membership required'); + }); + + it('returns 403 when authenticated user is an inactive club member', async () => { + // Eve is in club_alpha/members but status is 'requested' + mockAuth.verifyIdToken.mockResolvedValueOnce({ uid: 'eve' }); + const req = createRequest('club_alpha', 'Bearer valid_token_eve'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + }); + + it('returns 200 with empty list when club has no upcoming rides', async () => { + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clubId).toBe('club_alpha'); + expect(body.rides).toEqual([]); + }); + + // ============================================================ + // 5. Server-Side Mutual-Block Enforcement + // ============================================================ + it('excludes rides when caller blocks organizer, organizer blocks caller, and includes unblocked rides', async () => { + // Seed 4 rides: + // 1. ride_alice organized by Alice (scheduled) + // 2. ride_bob organized by Bob (assembling) + // 3. ride_charlie organized by Charlie (scheduled, self) + // 4. ride_foreign organized by Frank (scheduled) + groupRidesStore.push( + { + id: 'ride_alice', + data: { + id: 'ride_alice', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Alice Mountain Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-25T10:00:00Z', + accessUids: ['alice'], + participantCount: 1, + meetingPoint: { displayName: 'North Gap', latitude: 34.5, longitude: -83.9 }, + }, + }, + { + id: 'ride_bob', + data: { + id: 'ride_bob', + clubId: 'club_alpha', + organizerUid: 'bob', + title: 'Bob Valley Loop', + status: 'assembling', + scheduledStartAt: '2026-09-25T11:00:00Z', + accessUids: ['bob'], + participantCount: 1, + meetingPoint: { displayName: 'South Gap', latitude: 34.6, longitude: -83.8 }, + }, + }, + { + id: 'ride_charlie', + data: { + id: 'ride_charlie', + clubId: 'club_alpha', + organizerUid: 'charlie', + title: 'Charlie Afternoon Cruise', + status: 'scheduled', + scheduledStartAt: '2026-09-25T14:00:00Z', + accessUids: ['charlie'], + participantCount: 1, + meetingPoint: { displayName: 'Town Square', latitude: 34.7, longitude: -83.7 }, + }, + } + ); + + // Charlie blocks Alice + userBlocksStore.set('charlie', new Set(['alice'])); + // Bob blocks Charlie (reverse block) + userBlocksStore.set('bob', new Set(['charlie'])); + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + + // Alice ride must be EXCLUDED (caller blocks organizer) + // Bob ride must be EXCLUDED (organizer blocks caller) + // Charlie ride must be INCLUDED (caller is organizer, no mutual block) + expect(body.rides.length).toBe(1); + expect(body.rides[0].rideId).toBe('ride_charlie'); + expect(body.rides[0].title).toBe('Charlie Afternoon Cruise'); + }); + + // ============================================================ + // 6. Status Filtering (scheduled & assembling only) + // ============================================================ + it('includes scheduled and assembling rides; excludes underway, completed, and canceled rides', async () => { + groupRidesStore.push( + { + id: 'ride_sched', + data: { + id: 'ride_sched', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Scheduled Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-25T09:00:00Z', + }, + }, + { + id: 'ride_assem', + data: { + id: 'ride_assem', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Assembling Ride', + status: 'assembling', + scheduledStartAt: '2026-09-25T09:30:00Z', + }, + }, + { + id: 'ride_underway', + data: { + id: 'ride_underway', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Underway Ride', + status: 'underway', + scheduledStartAt: '2026-09-25T10:00:00Z', + }, + }, + { + id: 'ride_completed', + data: { + id: 'ride_completed', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Completed Ride', + status: 'completed', + scheduledStartAt: '2026-09-25T11:00:00Z', + }, + }, + { + id: 'ride_canceled', + data: { + id: 'ride_canceled', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Canceled Ride', + status: 'canceled', + scheduledStartAt: '2026-09-25T12:00:00Z', + }, + } + ); + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + + const returnedIds = body.rides.map((r: any) => r.rideId); + expect(returnedIds).toContain('ride_sched'); + expect(returnedIds).toContain('ride_assem'); + expect(returnedIds).not.toContain('ride_underway'); + expect(returnedIds).not.toContain('ride_completed'); + expect(returnedIds).not.toContain('ride_canceled'); + expect(returnedIds.length).toBe(2); + }); + + // ============================================================ + // 7. Response Data Minimization & Physical-Location Privacy + // ============================================================ + it('strictly minimizes response data: excludes accessUids, exact coordinates, full route geometry, and live presence', async () => { + groupRidesStore.push({ + id: 'ride_minimized', + data: { + id: 'ride_minimized', + clubId: 'club_alpha', + organizerUid: 'alice', + organizerDisplayName: 'Alice W.', + organizerHandle: 'alice_rider', + title: 'Safe Scenic Cruise', + description: 'Casual pace cruise through foothills', + status: 'scheduled', + scheduledStartAt: '2026-09-26T10:00:00Z', + departureAt: '2026-09-26T10:30:00Z', + joinPolicy: 'requestToJoin', + participantCount: 3, + participantLimit: 15, + accessUids: ['alice', 'rider2', 'rider3'], // SENSITIVE + meetingPoint: { + displayName: 'Dahlonega Town Square', + address: '1 Public Square, Dahlonega, GA', + latitude: 34.5328, // SENSITIVE + longitude: -83.9849, // SENSITIVE + locationType: 'meetup', + }, + routeId: 'private_route_internal_123', // SENSITIVE + routeTitle: 'Foothills Run', + routeDistanceMeters: 45000, + routeStopCount: 2, + paceClassification: 'Casual', + live_presence: { alice: { lat: 34.5, lon: -83.9 } }, // SENSITIVE + }, + }); + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.rides.length).toBe(1); + + const ride = body.rides[0]; + + // Allowed minimized fields + expect(ride.rideId).toBe('ride_minimized'); + expect(ride.clubId).toBe('club_alpha'); + expect(ride.title).toBe('Safe Scenic Cruise'); + expect(ride.description).toBe('Casual pace cruise through foothills'); + expect(ride.organizerDisplayName).toBe('Alice W.'); + expect(ride.organizerHandle).toBe('alice_rider'); + expect(ride.scheduledStartAt).toBe('2026-09-26T10:00:00Z'); + expect(ride.departureAt).toBe('2026-09-26T10:30:00Z'); + expect(ride.joinPolicy).toBe('requestToJoin'); + expect(ride.participantCount).toBe(3); + expect(ride.participantLimit).toBe(15); + expect(ride.status).toBe('scheduled'); + expect(ride.meetingLocationLabel).toBe('Dahlonega Town Square'); + expect(ride.routeTitle).toBe('Foothills Run'); + expect(ride.routeDistanceMeters).toBe(45000); + expect(ride.paceClassification).toBe('Casual'); + + // Strictly forbidden fields + expect((ride as any).organizerUid).toBeUndefined(); + expect((ride as any).accessUids).toBeUndefined(); + expect((ride as any).latitude).toBeUndefined(); + expect((ride as any).longitude).toBeUndefined(); + expect((ride as any).meetingPoint).toBeUndefined(); + expect((ride as any).routeId).toBeUndefined(); + expect((ride as any).waypoints).toBeUndefined(); + expect((ride as any).routePolyline).toBeUndefined(); + expect((ride as any).live_presence).toBeUndefined(); + expect((ride as any).telemetry).toBeUndefined(); + expect((ride as any).stops).toBeUndefined(); + }); + + it('location privacy: returns safe displayName and never leaks raw formatted address or coordinates', async () => { + groupRidesStore.push({ + id: 'ride_loc_safe', + data: { + id: 'ride_loc_safe', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Mountain Ridge Loop', + status: 'scheduled', + scheduledStartAt: '2026-09-27T10:00:00Z', + meetingPoint: { + displayName: 'Dahlonega Square', + address: '13 South Park Street, Dahlonega, GA 30533', + latitude: 34.5328, + longitude: -83.9849, + }, + }, + }); + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_safe'); + expect(ride).toBeDefined(); + + // Safe display name is returned + expect(ride.meetingLocationLabel).toBe('Dahlonega Square'); + + // Formatted address and coordinates must not appear anywhere in serialized response + const jsonStr = JSON.stringify(body); + expect(jsonStr).not.toContain('13 South Park Street'); + expect(jsonStr).not.toContain('34.5328'); + expect(jsonStr).not.toContain('-83.9849'); + expect((ride as any).meetingPoint).toBeUndefined(); + expect((ride as any).latitude).toBeUndefined(); + expect((ride as any).longitude).toBeUndefined(); + }); + + it('location privacy: returns null meetingLocationLabel when displayName is missing and does not fall back to address', async () => { + groupRidesStore.push({ + id: 'ride_loc_no_name', + data: { + id: 'ride_loc_no_name', + clubId: 'club_alpha', + organizerUid: 'alice', + title: 'Secret Meetup Run', + status: 'scheduled', + scheduledStartAt: '2026-09-27T11:00:00Z', + meetingPoint: { + displayName: null, + address: '123 Example Street, Example City, GA', + latitude: 33.7490, + longitude: -84.3880, + }, + }, + }); + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + const ride = body.rides.find((r: any) => r.rideId === 'ride_loc_no_name'); + expect(ride).toBeDefined(); + + // When displayName is missing, meetingLocationLabel MUST be null + expect(ride.meetingLocationLabel).toBeNull(); + + // Raw address and coordinates must NOT appear anywhere in the serialized response + const jsonStr = JSON.stringify(body); + expect(jsonStr).not.toContain('123 Example Street'); + expect(jsonStr).not.toContain('33.7490'); + expect(jsonStr).not.toContain('-84.3880'); + expect((ride as any).meetingPoint).toBeUndefined(); + expect((ride as any).latitude).toBeUndefined(); + expect((ride as any).longitude).toBeUndefined(); + }); + + it('pagination through blocks: pages candidate rides until 30 eligible rides collected or exhausted', async () => { + // Charlie blocks a high-volume organizer + userBlocksStore.set('charlie', new Set(['blocked_organizer'])); + + // Seed 40 scheduled rides: first 15 from blocked_organizer, next 25 from alice + for (let i = 0; i < 15; i++) { + groupRidesStore.push({ + id: `ride_blocked_${i.toString().padStart(2, '0')}`, + data: { + id: `ride_blocked_${i.toString().padStart(2, '0')}`, + clubId: 'club_alpha', + organizerUid: 'blocked_organizer', + title: `Blocked Ride ${i}`, + status: 'scheduled', + scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, + }, + }); + } + + for (let i = 0; i < 25; i++) { + groupRidesStore.push({ + id: `ride_alice_${i.toString().padStart(2, '0')}`, + data: { + id: `ride_alice_${i.toString().padStart(2, '0')}`, + clubId: 'club_alpha', + organizerUid: 'alice', + title: `Alice Ride ${i}`, + status: 'scheduled', + scheduledStartAt: `2026-09-26T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, + }, + }); + } + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + + // Must return all 25 unblocked rides from Alice (paged past the 15 blocked rides) + expect(body.rides.length).toBe(25); + for (const r of body.rides) { + expect(r.rideId).toMatch(/^ride_alice_/); + } + }); + + it('pagination continues past 150 blocked candidates until 30 eligible rides found or exhausted', async () => { + // Charlie blocks a spammy/blocked organizer + userBlocksStore.set('charlie', new Set(['heavy_blocked_organizer'])); + + // Seed 160 scheduled rides from heavy_blocked_organizer + for (let i = 0; i < 160; i++) { + groupRidesStore.push({ + id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`, + data: { + id: `ride_blocked_dense_${i.toString().padStart(3, '0')}`, + clubId: 'club_alpha', + organizerUid: 'heavy_blocked_organizer', + title: `Blocked Ride ${i}`, + status: 'scheduled', + scheduledStartAt: `2026-09-25T${(10 + Math.floor(i / 60)).toString().padStart(2, '0')}:${((i % 60)).toString().padStart(2, '0')}:00Z`, + }, + }); + } + + // Seed 30 scheduled rides from unblocked organizer alice + for (let i = 0; i < 30; i++) { + groupRidesStore.push({ + id: `ride_alice_dense_${i.toString().padStart(2, '0')}`, + data: { + id: `ride_alice_dense_${i.toString().padStart(2, '0')}`, + clubId: 'club_alpha', + organizerUid: 'alice', + title: `Alice Ride ${i}`, + status: 'scheduled', + scheduledStartAt: `2026-09-28T${(10 + Math.floor(i / 6)).toString().padStart(2, '0')}:${((i % 6) * 10).toString().padStart(2, '0')}:00Z`, + }, + }); + } + + const req = createRequest('club_alpha', 'Bearer valid_token_charlie'); + const res = await GET(req, { params: Promise.resolve({ clubId: 'club_alpha' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + + // Must page past all 160 blocked rides and collect all 30 eligible rides from Alice + expect(body.rides.length).toBe(30); + for (const r of body.rides) { + expect(r.rideId).toMatch(/^ride_alice_dense_/); + } + }); +}); diff --git a/website/tests/founder-portal.test.cjs b/website/tests/founder-portal.test.cjs index b0d23dc..39e0abc 100644 --- a/website/tests/founder-portal.test.cjs +++ b/website/tests/founder-portal.test.cjs @@ -982,15 +982,15 @@ async function runAllTests() { assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false'); assert(ridesPageSrc.includes('Authentication Required'), 'Rides page must guard against unauthenticated users'); - // F. Production rides page renders an explicit discovery-pending state, never a false empty schedule - assert(ridesPageSrc.includes('Club Rides Discovery Pending'), 'Production rides page must display Club Rides Discovery Pending notice'); - assert(ridesPageSrc.includes('DISCOVERY PENDING'), 'Production rides page must display DISCOVERY PENDING badge'); - assert(ridesPageSrc.includes('Club-affiliated Group Rides can now be created in the mobile application'), 'Must truthfully describe current mobile capability'); + // F. Production rides page renders authoritative FounderRides component + assert(ridesPageSrc.includes(' ({ + getAdminAuth: vi.fn(), + getAdminFirestore: vi.fn(), +})); + +describe('GroupRide Join Requests Moderation API (GET /api/group-rides/[rideId]/join-requests)', () => { + let mockAuth: any; + let mockFirestore: any; + + let groupRidesStore: Map; + let participantsStore: Map; // key: rideId -> array of participant docs { id, data } + let clubsStore: Map; + let clubMembersStore: Map; // key: `${clubId}/${uid}` + + beforeEach(() => { + vi.restoreAllMocks(); + + groupRidesStore = new Map(); + participantsStore = new Map(); + clubsStore = new Map(); + clubMembersStore = new Map(); + + mockAuth = { + verifyIdToken: vi.fn(async (token: string) => { + if (token === 'token_alice_organizer') { + return { uid: 'alice' }; + } + if (token === 'token_bob_admin') { + return { uid: 'bob' }; + } + if (token === 'token_owner') { + return { uid: 'club_owner_user' }; + } + if (token === 'token_charlie_member') { + return { uid: 'charlie' }; + } + if (token === 'token_dave_outsider') { + return { uid: 'dave' }; + } + if (token === 'token_eve_inactive') { + return { uid: 'eve' }; + } + throw new Error('Invalid token'); + }), + }; + + mockFirestore = { + collection: (colName: string) => { + if (colName === 'group_rides') { + return { + doc: (rideId: string) => ({ + get: async () => { + const data = groupRidesStore.get(rideId); + return { + exists: !!data, + data: () => data, + }; + }, + collection: (subCol: string) => { + if (subCol === 'participants') { + const createQuery = (filters: any) => ({ + where: (field: string, op: string, val: any) => { + return createQuery({ ...filters, [field]: { op, val } }); + }, + orderBy: (field: string, dir: string) => + createQuery({ ...filters, orderBy: { field, dir } }), + limit: (num: number) => createQuery({ ...filters, limit: num }), + get: async () => { + const all = participantsStore.get(rideId) || []; + let results = all.filter((p) => { + if (filters.status && filters.status.op === '==' && p.data.status !== filters.status.val) { + return false; + } + return true; + }); + + if (filters.orderBy) { + results = [...results].sort((a, b) => { + const valA = a.data[filters.orderBy.field] || ''; + const valB = b.data[filters.orderBy.field] || ''; + if (valA < valB) return filters.orderBy.dir === 'desc' ? 1 : -1; + if (valA > valB) return filters.orderBy.dir === 'desc' ? -1 : 1; + return 0; + }); + } + + if (filters.limit) { + results = results.slice(0, filters.limit); + } + + return { + empty: results.length === 0, + docs: results.map((r) => ({ + id: r.id, + data: () => r.data, + })), + }; + }, + }); + return createQuery({}); + } + throw new Error(`Unexpected subcollection: ${subCol}`); + }, + }), + }; + } + + if (colName === 'clubs') { + return { + doc: (clubId: string) => ({ + get: async () => { + const data = clubsStore.get(clubId); + return { + exists: !!data, + data: () => data, + }; + }, + collection: (subCol: string) => { + if (subCol === 'members') { + return { + doc: (uid: string) => ({ + get: async () => { + const data = clubMembersStore.get(`${clubId}/${uid}`); + return { + exists: !!data, + data: () => data, + }; + }, + }), + }; + } + throw new Error(`Unexpected subcollection: ${subCol}`); + }, + }), + }; + } + + throw new Error(`Unexpected collection: ${colName}`); + }, + }; + + (getAdminAuth as any).mockReturnValue(mockAuth); + (getAdminFirestore as any).mockReturnValue(mockFirestore); + + // Setup club_alpha + clubsStore.set('club_alpha', { id: 'club_alpha', name: 'Alpha Riders' }); + + // Club memberships + clubMembersStore.set('club_alpha/club_owner_user', { + uid: 'club_owner_user', + role: 'owner', + status: 'active', + }); + clubMembersStore.set('club_alpha/bob', { + uid: 'bob', + role: 'admin', + status: 'active', + }); + clubMembersStore.set('club_alpha/charlie', { + uid: 'charlie', + role: 'member', + status: 'active', + }); + clubMembersStore.set('club_alpha/eve', { + uid: 'eve', + role: 'admin', + status: 'suspended', + }); + + // Seed ride_club + groupRidesStore.set('ride_club', { + id: 'ride_club', + organizerUid: 'alice', + clubId: 'club_alpha', + title: 'Saturday Club Run', + status: 'scheduled', + }); + + // Seed ride_personal + groupRidesStore.set('ride_personal', { + id: 'ride_personal', + organizerUid: 'alice', + clubId: null, + title: 'Alice Solo Friends Run', + status: 'scheduled', + }); + }); + + const createRequest = (rideId: string, authHeader?: string) => { + const headers: Record = {}; + if (authHeader !== undefined) { + headers['authorization'] = authHeader; + } + return new NextRequest(`https://motorsignal.app/api/group-rides/${rideId}/join-requests`, { + method: 'GET', + headers, + }); + }; + + // ============================================================ + // 1. Authentication Gates (401) + // ============================================================ + it('returns 401 when Authorization header is missing', async () => { + const req = createRequest('ride_club'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.detail).toContain('Missing or malformed Authorization header'); + }); + + it('returns 401 when Authorization header does not start with Bearer', async () => { + const req = createRequest('ride_club', 'Basic invalid'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + }); + + it('returns 401 when token verification fails', async () => { + const req = createRequest('ride_club', 'Bearer invalid_or_expired'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + expect(body.detail).toContain('Invalid or expired authentication token'); + }); + + // ============================================================ + // 2. Ride ID Validation (400) + // ============================================================ + it('returns 400 for empty or whitespace rideId', async () => { + const req = createRequest(' ', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: ' ' }) }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('invalid_request'); + }); + + it('returns 400 for rideId containing whitespace', async () => { + const req = createRequest('ride 123', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride 123' }) }); + expect(res.status).toBe(400); + }); + + it('returns 400 for rideId exceeding 64 characters', async () => { + const longId = 'r'.repeat(65); + const req = createRequest(longId, 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: longId }) }); + expect(res.status).toBe(400); + }); + + // ============================================================ + // 3. Existence Gate (404) + // ============================================================ + it('returns 404 when group ride does not exist', async () => { + const req = createRequest('nonexistent_ride', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'nonexistent_ride' }) }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('not_found'); + expect(body.detail).toContain('Group ride not found'); + }); + + // ============================================================ + // 4. Authorization Gates (403 vs 200) + // ============================================================ + it('returns 403 when caller is ordinary club member (not organizer or admin)', async () => { + const req = createRequest('ride_club', 'Bearer token_charlie_member'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + expect(body.detail).toContain('Only the organizer or a club administrator'); + }); + + it('returns 403 when caller is non-member on club ride', async () => { + const req = createRequest('ride_club', 'Bearer token_dave_outsider'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + }); + + it('returns 403 when club admin role is inactive/suspended', async () => { + const req = createRequest('ride_club', 'Bearer token_eve_inactive'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + }); + + it('returns 403 when caller is non-organizer on personal ride (no club)', async () => { + const req = createRequest('ride_personal', 'Bearer token_bob_admin'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) }); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe('forbidden'); + }); + + it('returns 200 when caller is ride organizer (personal ride)', async () => { + const req = createRequest('ride_personal', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_personal' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.rideId).toBe('ride_personal'); + expect(body.requests).toEqual([]); + }); + + it('returns 200 when caller is ride organizer (club ride)', async () => { + const req = createRequest('ride_club', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.rideId).toBe('ride_club'); + expect(body.requests).toEqual([]); + }); + + it('returns 200 when caller is active club admin (role admin)', async () => { + const req = createRequest('ride_club', 'Bearer token_bob_admin'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.rideId).toBe('ride_club'); + expect(body.requests).toEqual([]); + }); + + it('returns 200 when caller is active club owner (role owner)', async () => { + const req = createRequest('ride_club', 'Bearer token_owner'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.rideId).toBe('ride_club'); + }); + + // ============================================================ + // 5. Query Filtering, Ordering, Bounds, and Data Minimization + // ============================================================ + it('filters only requested status, orders by createdAt asc, limits to 50, and minimizes DTO', async () => { + const participantsList = [ + { + id: 'user_req_2', + data: { + userId: 'user_req_2', + role: 'participant', + status: 'requested', + createdAt: '2026-09-25T11:00:00Z', + riderDisplayName: 'Rider Two', + riderHandle: 'rider2', + riderAvatarPreset: 'preset_helmet', + riderAvatarUrl: null, + latitude: 34.123, // SENSITIVE + live_presence: { lat: 34.123 }, // SENSITIVE + }, + }, + { + id: 'user_accepted', + data: { + userId: 'user_accepted', + role: 'participant', + status: 'accepted', + createdAt: '2026-09-25T09:00:00Z', + }, + }, + { + id: 'user_declined', + data: { + userId: 'user_declined', + role: 'participant', + status: 'declined', + createdAt: '2026-09-25T09:30:00Z', + }, + }, + { + id: 'user_req_1', + data: { + userId: 'user_req_1', + role: 'participant', + status: 'requested', + createdAt: '2026-09-25T10:00:00Z', + riderDisplayName: 'Rider One', + riderHandle: 'rider1', + riderAvatarPreset: null, + riderAvatarUrl: 'https://example.com/avatar.jpg', + }, + }, + ]; + + participantsStore.set('ride_club', participantsList); + + const req = createRequest('ride_club', 'Bearer token_alice_organizer'); + const res = await GET(req, { params: Promise.resolve({ rideId: 'ride_club' }) }); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('no-store, max-age=0'); + + const body = await res.json(); + expect(body.rideId).toBe('ride_club'); + expect(body.requests.length).toBe(2); + + // Verified ordered by createdAt ASC: user_req_1 (10:00) then user_req_2 (11:00) + expect(body.requests[0].userId).toBe('user_req_1'); + expect(body.requests[0].riderDisplayName).toBe('Rider One'); + expect(body.requests[0].riderHandle).toBe('rider1'); + expect(body.requests[0].riderAvatarUrl).toBe('https://example.com/avatar.jpg'); + expect(body.requests[0].createdAt).toBe('2026-09-25T10:00:00Z'); + + expect(body.requests[1].userId).toBe('user_req_2'); + expect(body.requests[1].riderDisplayName).toBe('Rider Two'); + expect(body.requests[1].riderAvatarPreset).toBe('preset_helmet'); + expect(body.requests[1].createdAt).toBe('2026-09-25T11:00:00Z'); + + // Data minimization verification: sensitive fields must not exist + expect((body.requests[1] as any).latitude).toBeUndefined(); + expect((body.requests[1] as any).live_presence).toBeUndefined(); + const rawJson = JSON.stringify(body); + expect(rawJson).not.toContain('34.123'); + expect(rawJson).not.toContain('user_accepted'); + expect(rawJson).not.toContain('user_declined'); + }); +});