From 701f27926d0b07d17e8c775bb5aee00ecf882807 Mon Sep 17 00:00:00 2001 From: Landseer100 <60585684+Landseer100@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:57:01 +0000 Subject: [PATCH 1/3] Add account-backed Rides authentication and normal native presentation Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --- .github/workflows/revyl.yml | 1 + .github/workflows/rides.yml | 2 +- .gitignore | 1 + apps/rides/.revyl/config.yaml | 11 +- apps/rides/App/RidesDemoApp.swift | 12 +- apps/rides/Core/ConvexRideAPI.swift | 39 +- apps/rides/Core/RideAPI.swift | 20 +- apps/rides/Core/RideModels.swift | 34 +- apps/rides/Core/RideStore.swift | 143 +++- .../rides/Features/AccountActivityViews.swift | 67 +- apps/rides/Features/AccountSignInView.swift | 82 +++ apps/rides/Features/BookingFlowView.swift | 25 +- apps/rides/Features/ConnectionView.swift | 86 --- apps/rides/Features/RideDesign.swift | 17 +- apps/rides/Features/RiderHomeView.swift | 12 +- apps/rides/Features/TripViews.swift | 60 +- apps/rides/Tests/RideAccountTests.swift | 643 ++++++++++++++++++ apps/rides/scripts/prepare-revyl-session.py | 20 +- apps/rides/scripts/revyl_accounts.py | 77 +++ apps/rides/scripts/start-session.py | 37 +- apps/rides/scripts/test_revyl_accounts.py | 106 +++ docs/RIDES.md | 94 ++- docs/RIDES_VERIFICATION.md | 12 + scripts/test_revyl_config.py | 41 +- services/ride-convex/README.md | 40 +- .../ride-convex/convex/_generated/api.d.ts | 41 +- services/ride-convex/convex/_generated/api.js | 3 +- .../ride-convex/convex/_generated/server.d.ts | 19 +- .../ride-convex/convex/_generated/server.js | 6 - services/ride-convex/convex/accountActions.ts | 77 +++ services/ride-convex/convex/accountAuth.ts | 26 + services/ride-convex/convex/accountStore.ts | 55 ++ services/ride-convex/convex/accountTypes.ts | 20 + services/ride-convex/convex/accounts.ts | 53 ++ services/ride-convex/convex/dispatch.ts | 126 ++++ services/ride-convex/convex/dispatchState.ts | 38 ++ services/ride-convex/convex/schema.ts | 17 + services/ride-convex/package-lock.json | 18 + services/ride-convex/package.json | 1 + services/ride-convex/tests/accounts.test.ts | 307 +++++++++ services/ride-convex/tests/dispatch.test.ts | 473 +++++++++++++ services/ride-convex/tsconfig.json | 2 +- 42 files changed, 2589 insertions(+), 375 deletions(-) create mode 100644 apps/rides/Features/AccountSignInView.swift delete mode 100644 apps/rides/Features/ConnectionView.swift create mode 100644 apps/rides/Tests/RideAccountTests.swift create mode 100644 apps/rides/scripts/revyl_accounts.py create mode 100644 apps/rides/scripts/test_revyl_accounts.py create mode 100644 services/ride-convex/convex/accountActions.ts create mode 100644 services/ride-convex/convex/accountAuth.ts create mode 100644 services/ride-convex/convex/accountStore.ts create mode 100644 services/ride-convex/convex/accountTypes.ts create mode 100644 services/ride-convex/convex/accounts.ts create mode 100644 services/ride-convex/convex/dispatch.ts create mode 100644 services/ride-convex/convex/dispatchState.ts create mode 100644 services/ride-convex/tests/accounts.test.ts create mode 100644 services/ride-convex/tests/dispatch.test.ts diff --git a/.github/workflows/revyl.yml b/.github/workflows/revyl.yml index 7169d0b..5a9456e 100644 --- a/.github/workflows/revyl.yml +++ b/.github/workflows/revyl.yml @@ -17,3 +17,4 @@ jobs: python-version: '3.12' - run: python -m pip install PyYAML==6.0.3 - run: python scripts/test_revyl_config.py + - run: python -m unittest discover -s apps/rides/scripts -p 'test_*.py' diff --git a/.github/workflows/rides.yml b/.github/workflows/rides.yml index fc424d9..5b64440 100644 --- a/.github/workflows/rides.yml +++ b/.github/workflows/rides.yml @@ -1,4 +1,4 @@ -name: Rides demo +name: Rides on: push: branches: [main] diff --git a/.gitignore b/.gitignore index e39ba2d..c9f1f97 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +__pycache__/ build/ .generated/ DerivedData/ diff --git a/apps/rides/.revyl/config.yaml b/apps/rides/.revyl/config.yaml index 7240daf..7dabe5a 100644 --- a/apps/rides/.revyl/config.yaml +++ b/apps/rides/.revyl/config.yaml @@ -6,7 +6,7 @@ session: script_path: ./scripts/prepare-revyl-session.py timeout_seconds: 30 auth_bypass: - launch_vars: [RIDES_AUTH_BYPASS_ENABLED, RIDES_AUTH_BYPASS_TOKEN] + launch_vars: [RIDES_AUTH_BYPASS_ENABLED, RIDES_AUTH_BYPASS_TOKEN, RIDES_ACCOUNT_SESSION] deep_link: "ridesdemo://revyl-auth?token=${RIDES_AUTH_BYPASS_TOKEN}&role=rider&redirect=/home" build: framework: ios @@ -40,10 +40,9 @@ pr_review: kind: revyl always_verify: - "Scope this proof to the Rides application selected by this project's rides profile. Verify all applicable Rides changes and the checks below, and report any observed Rides defect. Eats has its own root project, build, session, and proof at .revyl/config.yaml. Do not require Eats ordering screens inside the Rides binary; they belong to the separate Eats proof, not an unmet Rides verification requirement." - - A fresh Rides session opens Home with the Revyl sign-in accepted Rider banner, without opening Eats or asking for real authentication. - - The hosted Convex backend is prefilled in Demo connection. Create a new private Rider room, then Continue as Rider. Keep invitations hidden and never include invitation codes, session tokens, or credentials in public evidence. - - Open Where to, select an offline destination, choose a vehicle and demo payment label, and confirm pickup. Verify the hosted request reaches Finding your ride; without a second online driver it must not invent an acceptance or charge. Cancel the requested trip and verify a zero-charge Cancelled history entry. Do not enter real payment information. - - Home, Services, Activity, and Account remain reachable after the cancellation. Open saved places and wallet from Account using only demo data. - - Driver acceptance, two-way chat, arrival, start, completion, receipts, rating, and tips require a second Rides phone joined to the same private room. Do not report those paired behaviors as passed from this single-phone proof. + - A fresh Rides session opens the normal Rider Home signed into a newly registered Convex database account. The session hook supplies its real account session; no connection screen, invitation, role switch, or test banner should be visible. Never publish account session tokens, launch values, passwords, or credentials. + - Open Where to, select a destination, choose a vehicle and payment preference, and confirm pickup. Verify the hosted request reaches Finding your ride; without a second online driver it must not invent an acceptance or charge. Cancel the request and verify a zero-charge Cancelled history entry. Do not enter real payment information. + - Home, Services, Activity, and Account remain reachable after cancellation. Account must show the registered profile. Open saved places and wallet; payments, payouts, live GPS, and driver verification are unavailable and must not be reported as working integrations. + - Driver acceptance, two-way chat, arrival, start, completion, receipts, rating, and tip preferences require a second Rides phone signed into a separate real database driver account in the same isolated service area. Do not report paired behaviors as passed from this single-phone proof. strict_ci_check: build: true diff --git a/apps/rides/App/RidesDemoApp.swift b/apps/rides/App/RidesDemoApp.swift index e7ee756..f23828c 100644 --- a/apps/rides/App/RidesDemoApp.swift +++ b/apps/rides/App/RidesDemoApp.swift @@ -2,7 +2,7 @@ import SwiftUI @main struct RidesDemoApp: App { - @StateObject private var store = RideStore() + @StateObject private var store = RideStore(accountOnly: true) var body: some Scene { WindowGroup { RideRootView() @@ -18,9 +18,9 @@ struct RideRootView: View { @Environment(\.scenePhase) private var scenePhase var body: some View { VStack(spacing: 0) { - if let status = store.revylStatus { - Text(status).font(.caption2).frame(maxWidth: .infinity).padding(5).background(Color.yellow.opacity(0.22)) - } + if store.account == nil || store.showSignIn { + AccountSignInView() + } else { if let error = store.errorMessage { HStack(alignment: .top) { Image(systemName: "exclamationmark.circle.fill") @@ -44,11 +44,11 @@ struct RideRootView: View { NavigationStack { RideActivityView() }.tabItem { Label("Activity", systemImage: "receipt.fill") }.tag(2) NavigationStack { RideAccountView() }.tabItem { Label("Account", systemImage: "person.fill") }.tag(3) }.tint(.black) + } } - .sheet(isPresented: $store.showConnection) { DemoConnectionView().environmentObject(store) } .fullScreenCover(isPresented: $store.showBooking) { BookingFlowView().environmentObject(store) } .task(id: scenePhase == .active ? store.credentials?.token : nil) { - if scenePhase == .active { await store.runUpdates() } + if scenePhase == .active, store.account != nil { await store.runUpdates() } } } } diff --git a/apps/rides/Core/ConvexRideAPI.swift b/apps/rides/Core/ConvexRideAPI.swift index 2cc6bb0..ea78a73 100644 --- a/apps/rides/Core/ConvexRideAPI.swift +++ b/apps/rides/Core/ConvexRideAPI.swift @@ -26,6 +26,31 @@ final class ConvexRideAPIClient: RideAPI { return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } + static func module(for token: String) -> String { token.hasPrefix("user.") ? "dispatch" : "rides" } + + func signUp(_ registration: RideRegistration) async throws -> RideSession { + do { + return try await client.action("accounts:signUp", with: ["name": registration.name, "email": registration.email, "password": registration.password, "role": registration.role.rawValue, "vehicle": registration.vehicle, "plate": registration.plate]) + } catch { throw Self.apiError(error) } + } + + func signIn(email: String, password: String) async throws -> RideSession { + do { return try await client.action("accounts:signIn", with: ["email": email, "password": password]) } + catch { throw Self.apiError(error) } + } + + func resume(token: String) async throws -> RideSession { + do { + for try await session in client.subscribe(to: "accounts:resume", with: ["token": token], yielding: RideSession.self).values { return session } + throw RideAPIError(status: 0, message: "The sign-in connection closed. Try again.") + } catch { throw Self.apiError(error) } + } + + func signOut(token: String) async throws { + do { let _: Bool = try await client.mutation("accounts:signOut", with: ["token": token]) } + catch { throw Self.apiError(error) } + } + func create(name: String) async throws -> RideSession { try await mutate("createRoom", args: ["name": name, "token": Self.freshToken()]) } @@ -36,7 +61,7 @@ final class ConvexRideAPIClient: RideAPI { func state(token: String) async throws -> RideRoomState { do { - for try await state in client.subscribe(to: "rides:state", with: ["token": token], yielding: RideRoomState.self).values { + for try await state in client.subscribe(to: Self.module(for: token) + ":state", with: ["token": token], yielding: RideRoomState.self).values { return state } throw RideAPIError(status: 0, message: "The shared ride connection closed. Try again.") @@ -44,20 +69,20 @@ final class ConvexRideAPIClient: RideAPI { } func availability(token: String, online: Bool) async throws -> RideRoomState { - try await mutate("availability", args: ["token": token, "online": online]) + try await mutate("availability", module: Self.module(for: token), args: ["token": token, "online": online]) } func book(token: String, request: TripRequest) async throws -> RideRoomState { - try await mutate("book", args: ["token": token, "request": request]) + try await mutate("book", module: Self.module(for: token), args: ["token": token, "request": request]) } func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState { - try await mutate("act", args: ["token": token, "tripID": tripID, "action": action]) + try await mutate("act", module: Self.module(for: token), args: ["token": token, "tripID": tripID, "action": action]) } func updates(token: String) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let state = client.subscribe(to: "rides:state", with: ["token": token], yielding: RideRoomState.self) + let state = client.subscribe(to: Self.module(for: token) + ":state", with: ["token": token], yielding: RideRoomState.self) .map(RideSyncEvent.state) let connection = connectionState .map(RideSyncEvent.connection) @@ -74,8 +99,8 @@ final class ConvexRideAPIClient: RideAPI { } } - private func mutate(_ name: String, args: [String: ConvexEncodable?]) async throws -> T { - do { return try await client.mutation("rides:" + name, with: args) } + private func mutate(_ name: String, module: String = "rides", args: [String: ConvexEncodable?]) async throws -> T { + do { return try await client.mutation(module + ":" + name, with: args) } catch { throw Self.apiError(error) } } diff --git a/apps/rides/Core/RideAPI.swift b/apps/rides/Core/RideAPI.swift index ef74276..fd1055b 100644 --- a/apps/rides/Core/RideAPI.swift +++ b/apps/rides/Core/RideAPI.swift @@ -16,6 +16,10 @@ enum RideSyncEvent { protocol RideAPI { var baseURL: URL { get } + func signUp(_ registration: RideRegistration) async throws -> RideSession + func signIn(email: String, password: String) async throws -> RideSession + func resume(token: String) async throws -> RideSession + func signOut(token: String) async throws func create(name: String) async throws -> RideSession func join(code: String, role: DemoRole, name: String) async throws -> RideSession func state(token: String) async throws -> RideRoomState @@ -26,6 +30,18 @@ protocol RideAPI { } extension RideAPI { + func signUp(_ registration: RideRegistration) async throws -> RideSession { + throw RideAPIError(status: 400, message: "Account sign-in is unavailable for this service.") + } + func signIn(email: String, password: String) async throws -> RideSession { + throw RideAPIError(status: 400, message: "Account sign-in is unavailable for this service.") + } + func resume(token: String) async throws -> RideSession { + throw RideAPIError(status: 401, message: "Sign in again to continue.") + } + func signOut(token: String) async throws { + throw RideAPIError(status: 400, message: "Account sign-out is unavailable for this service.") + } func updates(token: String) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task { @@ -68,7 +84,7 @@ struct RideAPIClient: RideAPI { parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, parts.path.isEmpty || parts.path == "/", parts.scheme == "https" || (parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { - throw RideAPIError(status: 0, message: "Enter the HTTPS address of your demo server, without a path. Localhost HTTP is allowed for development.") + throw RideAPIError(status: 0, message: "The service address must use HTTPS without a path. Localhost HTTP is allowed for development.") } return url } @@ -104,7 +120,7 @@ struct RideAPIClient: RideAPI { let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { throw RideAPIError(status: 0, message: "The server didn't return an HTTP response.") } guard (200..<300).contains(response.statusCode) else { - let message = (try? JSONDecoder().decode(Failure.self, from: data).error) ?? "The demo server couldn't complete this request." + let message = (try? JSONDecoder().decode(Failure.self, from: data).error) ?? "The service couldn't complete this request." throw RideAPIError(status: response.statusCode, message: message) } return try JSONDecoder().decode(T.self, from: data) diff --git a/apps/rides/Core/RideModels.swift b/apps/rides/Core/RideModels.swift index f5c292c..e72041e 100644 --- a/apps/rides/Core/RideModels.swift +++ b/apps/rides/Core/RideModels.swift @@ -57,6 +57,14 @@ enum DemoPayment: String, Codable, CaseIterable, Identifiable { case cash = "Demo Cash" case wallet = "Demo Wallet" var id: String { rawValue } + var title: String { + switch self { + case .visa: return "Card" + case .cash: return "Cash" + case .wallet: return "Wallet" + } + } + static func displayName(_ value: String) -> String { DemoPayment(rawValue: value)?.title ?? value } var icon: String { self == .visa ? "creditcard.fill" : self == .cash ? "banknote.fill" : "wallet.pass.fill" } } @@ -70,7 +78,7 @@ enum TripStatus: String, Codable { case .accepted: return "Your driver is on the way" case .arriving: return "Your driver has arrived" case .inProgress: return "You're on your way" - case .completed: return "Thanks for riding, Alex" + case .completed: return "Thanks for riding" case .cancelled: return "Your ride was cancelled" } } @@ -128,6 +136,24 @@ struct RideRoomState: Codable, Equatable { var activeTrip: DemoTrip? { trips.first { $0.status.isActive } } } +struct RideAccount: Codable, Equatable { + let id: String + let name: String + let email: String + let role: DemoRole + let vehicle: String? + let plate: String? +} + +struct RideRegistration { + let name: String + let email: String + let password: String + let role: DemoRole + let vehicle: String? + let plate: String? +} + struct RideSession: Codable, Equatable { let roomCode: String let participantId: String @@ -135,6 +161,8 @@ struct RideSession: Codable, Equatable { let role: DemoRole let name: String let state: RideRoomState + var account: RideAccount? = nil + var expiresAt: Double? = nil } struct StoredRideSession: Codable { @@ -144,6 +172,8 @@ struct StoredRideSession: Codable { let token: String let role: DemoRole let name: String + let account: RideAccount? + let expiresAt: Double? init(_ session: RideSession, baseURL: URL) { self.baseURL = baseURL @@ -152,6 +182,8 @@ struct StoredRideSession: Codable { token = session.token role = session.role name = session.name + account = session.account + expiresAt = session.expiresAt } } diff --git a/apps/rides/Core/RideStore.swift b/apps/rides/Core/RideStore.swift index abff906..4e75124 100644 --- a/apps/rides/Core/RideStore.swift +++ b/apps/rides/Core/RideStore.swift @@ -26,11 +26,11 @@ struct KeychainRideSessionVault: RideSessionVault { let attributes: [String: Any] = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if status == errSecSuccess { return } - guard status == errSecItemNotFound else { throw RideAPIError(status: 0, message: "Couldn't securely save the demo session (Keychain \(status)).") } + guard status == errSecItemNotFound else { throw RideAPIError(status: 0, message: "Couldn't securely save your session (Keychain \(status)).") } var item = query attributes.forEach { item[$0.key] = $0.value } let added = SecItemAdd(item as CFDictionary, nil) - guard added == errSecSuccess else { throw RideAPIError(status: 0, message: "Couldn't securely save the demo session (Keychain \(added)).") } + guard added == errSecSuccess else { throw RideAPIError(status: 0, message: "Couldn't securely save your session (Keychain \(added)).") } } func clear() { SecItemDelete(query as CFDictionary) } } @@ -52,6 +52,7 @@ final class RideStore: ObservableObject { @Published var draft = BookingDraft() @Published var showBooking = false @Published var showConnection = false + @Published var showSignIn = false @Published var focusedTripID: String? @Published var revylStatus: String? @Published var savedPlaces: [DemoPlace] { @@ -68,15 +69,17 @@ final class RideStore: ObservableObject { private let apiFactory: (URL) -> any RideAPI private var api: (any RideAPI)? - init(defaults: UserDefaults = .standard, vault: any RideSessionVault = KeychainRideSessionVault(), apiFactory: @escaping (URL) -> any RideAPI = { RideBackend.client(for: $0) }) { + init(defaults: UserDefaults = .standard, vault: any RideSessionVault = KeychainRideSessionVault(), accountOnly: Bool = false, apiFactory: @escaping (URL) -> any RideAPI = { RideBackend.client(for: $0) }) { self.defaults = defaults self.vault = vault self.apiFactory = apiFactory - let restored = vault.load() + let saved = vault.load() + let restored = accountOnly && saved?.account == nil ? nil : saved + if accountOnly && saved != nil && restored == nil { vault.clear() } credentials = restored role = restored?.role ?? .rider displayName = restored?.name ?? DemoRole.rider.defaultName - serverAddress = restored?.baseURL.absoluteString ?? RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL") ?? defaults.string(forKey: "serverAddress") ?? RideBackend.defaultAddress + serverAddress = restored?.baseURL.absoluteString ?? (accountOnly ? RideBackend.defaultAddress : RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL") ?? defaults.string(forKey: "serverAddress") ?? RideBackend.defaultAddress) savedPlaces = defaults.data(forKey: "savedPlaces").flatMap { try? JSONDecoder().decode([DemoPlace].self, from: $0) } ?? [RideCatalog.places[2], RideCatalog.places[3]] rideReminders = defaults.object(forKey: "rideReminders") as? Bool ?? true defaultPayment = defaults.string(forKey: "defaultPayment").flatMap(DemoPayment.init(rawValue:)) ?? .visa @@ -84,16 +87,90 @@ final class RideStore: ObservableObject { } var connected: Bool { credentials != nil } + var account: RideAccount? { credentials?.account } var isDriverOnline: Bool { state?.onlineDriverIds.contains(credentials?.participantId ?? "") == true } var activeTrip: DemoTrip? { state?.activeTrip } var focusedTrip: DemoTrip? { state?.trips.first { $0.id == focusedTripID } } - var myDriverTrip: DemoTrip? { activeTrip.flatMap { $0.driver?.id == credentials?.participantId ? $0 : nil } } + var myDriverTrip: DemoTrip? { state?.trips.first { $0.status.isActive && $0.driver?.id == credentials?.participantId } } + var ownedActiveTrip: DemoTrip? { role == .driver ? myDriverTrip : activeTrip } var pastTrips: [DemoTrip] { (state?.trips ?? []).filter { !$0.status.isActive } } var earningsCents: Int { (state?.trips ?? []).filter { $0.status == .completed && $0.driver?.id == credentials?.participantId }.reduce(0) { $0 + $1.totalCents } } var syncIsFresh: Bool { syncConnected } + func presentSignIn() { showSignIn = true } + + private func installAccount(_ session: RideSession, client: any RideAPI, expectedRole: DemoRole? = nil) throws { + guard let account = session.account, account.id == session.participantId, account.id == session.roomCode, + account.role == session.role, account.name == session.name, session.state.roomCode == account.id, + expectedRole == nil || expectedRole == account.role, + session.token.hasPrefix("user."), session.token.count >= 48, + let expiresAt = session.expiresAt, expiresAt.isFinite, expiresAt > Date().timeIntervalSince1970 * 1000 else { + throw RideAPIError(status: 401, message: "Couldn't verify your account. Sign in again.") + } + let stored = StoredRideSession(session, baseURL: client.baseURL) + try vault.save(stored) + state = nil + focusedTripID = nil + api = client + credentials = stored + role = account.role + displayName = account.name + serverAddress = client.baseURL.absoluteString + apply(session.state, for: stored) + showSignIn = false + showConnection = false + errorMessage = nil + notice = nil + tab = 0 + } + + func signIn(email: String, password: String, registration: RideRegistration? = nil) async -> Bool { + guard !busy, account == nil else { return false } + busy = true + defer { busy = false } + do { + let client = apiFactory(try RideAPIClient.validatedURL(serverAddress)) + let session: RideSession + if let registration { session = try await client.signUp(registration) } + else { session = try await client.signIn(email: email, password: password) } + do { + try Task.checkCancellation() + try installAccount(session, client: client, expectedRole: registration?.role) + } + catch { try? await client.signOut(token: session.token); throw error } + return true + } catch { + if error is CancellationError { return false } + errorMessage = error.localizedDescription + return false + } + } + + func signOut() async { + guard !busy, let credentials, let api else { return } + busy = true + defer { busy = false } + do { + let incoming = try await api.state(token: credentials.token) + apply(incoming, for: credentials) + guard ownedActiveTrip == nil else { + errorMessage = "Finish or cancel your active ride before signing out." + return + } + if credentials.role == .driver { _ = try await api.availability(token: credentials.token, online: false) } + try await api.signOut(token: credentials.token) + disconnect() + showSignIn = true + errorMessage = nil + } catch { + if (error as? RideAPIError)?.status == 401 { handleSyncError(error, for: credentials) } + else if (error as? RideAPIError)?.status == 409 { await sync(); errorMessage = error.localizedDescription } + else { errorMessage = "Couldn't sign out. Check your connection and try again." } + } + } + func chooseRole(_ value: DemoRole) { guard !connected else { return } role = value @@ -107,7 +184,7 @@ final class RideStore: ObservableObject { do { let url = try RideAPIClient.validatedURL(serverAddress) let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, name.count <= 60 else { throw RideAPIError(status: 0, message: "Enter a demo name of up to 60 characters.") } + guard !name.isEmpty, name.count <= 60 else { throw RideAPIError(status: 0, message: "Enter a name of up to 60 characters.") } guard !createNew || role == .rider else { throw RideAPIError(status: 0, message: "Create the room on the rider phone first.") } let code = roomInput.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard createNew || (!code.isEmpty && code.count <= 32 && code.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) })) else { throw RideAPIError(status: 0, message: "Enter the invitation code from the rider phone.") } @@ -129,7 +206,11 @@ final class RideStore: ObservableObject { } func disconnect() { - guard activeTrip == nil else { errorMessage = "Finish or cancel the active ride before leaving this demo."; return } + guard ownedActiveTrip == nil else { errorMessage = "Finish or cancel your active ride before signing out."; return } + clearSession() + } + + private func clearSession() { credentials = nil api = nil state = nil @@ -137,6 +218,13 @@ final class RideStore: ObservableObject { syncConnected = false focusedTripID = nil roomInput = "" + role = .rider + displayName = "" + tab = 0 + draft = BookingDraft() + showBooking = false + notice = nil + revylStatus = nil vault.clear() } @@ -177,13 +265,10 @@ final class RideStore: ObservableObject { guard !Task.isCancelled, self.credentials?.token == credentials.token else { return } syncConnected = false if let error = error as? RideAPIError, [401, 404].contains(error.status) { - self.credentials = nil - api = nil - state = nil - lastSync = nil - vault.clear() + clearSession() showConnection = true - errorMessage = "This demo session has expired. Create or join a room again." + showSignIn = true + errorMessage = "Your session has expired. Sign in again to continue." } else { errorMessage = "Connection lost. Check your internet connection and tap Retry." } @@ -201,7 +286,7 @@ final class RideStore: ObservableObject { private func perform(_ action: (any RideAPI, StoredRideSession) async throws -> RideRoomState) async -> Bool { guard !busy else { return false } - guard let api, let credentials else { showConnection = true; return false } + guard let api, let credentials else { showSignIn = true; return false } busy = true defer { busy = false } do { @@ -247,6 +332,7 @@ final class RideStore: ObservableObject { draft.payment = defaultPayment draft.destination = destination if reserve { draft.scheduledAt = Date().addingTimeInterval(3600) } + guard account != nil else { presentSignIn(); return } showBooking = true } func handle(_ url: URL, environment: [String: String] = ProcessInfo.processInfo.environment, arguments: [String] = ProcessInfo.processInfo.arguments) async { @@ -255,14 +341,25 @@ final class RideStore: ObservableObject { case .ignored: return case .rejected(let reason): revylStatus = "Revyl sign-in rejected: \(reason)" case .accepted(let role, let tab): - guard credentials == nil || credentials?.role == role else { revylStatus = "Revyl sign-in rejected: Leave the current demo before switching roles"; return } - chooseRole(role) - self.tab = tab - revylStatus = "Revyl sign-in accepted · \(role.title)" - if let value = RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL", environment: environment, arguments: arguments) { serverAddress = value } - if let code = RevylRideSignIn.launchValue("RIDE_DEMO_ROOM_CODE", environment: environment, arguments: arguments), !code.isEmpty { - roomInput = code - await connect(createNew: false) + guard !busy, account == nil else { return } + guard let token = RevylRideSignIn.launchValue("RIDES_ACCOUNT_SESSION", environment: environment, arguments: arguments), token.hasPrefix("user.") else { + revylStatus = "Revyl sign-in rejected: Account session is missing" + return + } + busy = true + defer { busy = false } + do { + let client = apiFactory(try RideAPIClient.validatedURL(serverAddress)) + let session = try await client.resume(token: token) + try Task.checkCancellation() + guard session.token == token else { throw RideAPIError(status: 401, message: "Invalid account session") } + try installAccount(session, client: client, expectedRole: role) + self.tab = tab + revylStatus = "Revyl sign-in accepted · \(role.title)" + } catch { + guard !Task.isCancelled else { return } + revylStatus = "Revyl sign-in rejected: Couldn't verify the account" + errorMessage = "Couldn't sign in. Check your connection and try again." } } #endif diff --git a/apps/rides/Features/AccountActivityViews.swift b/apps/rides/Features/AccountActivityViews.swift index 31acf64..c7b57c7 100644 --- a/apps/rides/Features/AccountActivityViews.swift +++ b/apps/rides/Features/AccountActivityViews.swift @@ -8,7 +8,7 @@ struct RideActivityView: View { VStack(alignment: .leading, spacing: 20) { Text("Activity").font(.system(size: 32, weight: .bold)).padding(.top, 12) Text("Upcoming").font(.title3.bold()) - if let trip = store.activeTrip { + if let trip = store.ownedActiveTrip { NavigationLink { TripTrackingView(tripID: trip.id) } label: { VStack(alignment: .leading, spacing: 10) { Text(trip.destination.name).font(.headline) @@ -34,7 +34,7 @@ struct RideActivityView: View { Menu { Picker("Show trips", selection: $filter) { ForEach(["All", "Completed", "Cancelled"], id: \.self) { Text($0).tag($0) } } } label: { Image(systemName: "line.3.horizontal.decrease").padding(9).background(RideStyle.soft).clipShape(Circle()) }.accessibilityLabel("Filter trips") } let trips = store.pastTrips.filter { filter == "All" || (filter == "Completed" ? $0.status == .completed : $0.status == .cancelled) } - if trips.isEmpty { Text(store.connected ? "Your completed and cancelled demo rides will appear here." : "Connect a demo room to see shared trip history.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 24) } + if trips.isEmpty { Text("Your completed and cancelled rides will appear here.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 24) } ForEach(trips) { trip in VStack(alignment: .leading, spacing: 12) { NavigationLink { TripTrackingView(tripID: trip.id) } label: { @@ -50,7 +50,6 @@ struct RideActivityView: View { } }.padding(14).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.18)) } } - DemoConnectionBadge().padding(.bottom, 15) }.padding(20) }.navigationBarTitleDisplayMode(.inline).tint(.black) } @@ -63,8 +62,9 @@ struct RideAccountView: View { VStack(alignment: .leading, spacing: 24) { HStack { VStack(alignment: .leading, spacing: 10) { - Text(store.displayName).font(.system(size: 30, weight: .bold)) - Label("5.00 · Demo \(store.role.title.lowercased())", systemImage: "star.fill").font(.caption.bold()).padding(7).background(RideStyle.soft).clipShape(Capsule()) + Text(store.account?.name ?? store.displayName).font(.system(size: 30, weight: .bold)) + Text(store.role.title).font(.caption.bold()).padding(7).background(RideStyle.soft).clipShape(Capsule()) + if let email = store.account?.email { Text(email).font(.subheadline).foregroundStyle(.secondary) } } Spacer() Image(systemName: "person.crop.circle.fill").font(.system(size: 62)).foregroundStyle(.gray.opacity(0.5)) @@ -74,25 +74,14 @@ struct RideAccountView: View { NavigationLink { RideWalletView(payment: $store.defaultPayment) } label: { accountTile("Wallet", icon: "wallet.pass.fill") } Button { store.tab = 2 } label: { accountTile("Activity", icon: "receipt.fill") } }.foregroundStyle(.black) - Button { store.showConnection = true } label: { - HStack(spacing: 15) { - Image(systemName: "iphone.gen3.radiowaves.left.and.right").font(.system(size: 26)) - VStack(alignment: .leading, spacing: 6) { - Text("Rider & driver demo").font(.headline) - Text(store.connected ? "Share your room or view connection details" : "Connect two phones to the same room").font(.caption).foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.right").font(.caption) - }.padding(18).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) - }.foregroundStyle(.black) VStack(spacing: 0) { NavigationLink { SavedRidePlacesView() } label: { accountRow("Saved places", icon: "star.fill") } NavigationLink { RideSettingsView() } label: { accountRow("Settings", icon: "gearshape.fill") } - NavigationLink { RideInfoView(title: "Privacy", icon: "hand.raised.fill", text: "Demo names, trip details, and messages are stored within the private invitation room on your configured server. Session credentials stay in the iOS Keychain. Rooms expire after 24 hours; the database stays until its operator deletes it. No device location, contacts, payment details, or audio are collected.") } label: { accountRow("Privacy", icon: "lock.fill") } - NavigationLink { RideInfoView(title: "About this demo", icon: "info.circle.fill", text: "An independent SwiftUI ride demo inspired by Uber's rider workflows; not affiliated with Uber. Rider and driver share booking, acceptance, progress, and messages through a demo server. Maps, vehicles, prices, payment methods, and ratings are sample data. Eats remains a separate native app in this repository.") } label: { accountRow("About", icon: "info.circle.fill") } + NavigationLink { RideInfoView(title: "Privacy", icon: "hand.raised.fill", text: "Your profile, trip details, and messages are associated with your account. Sign-in credentials are stored in the iOS Keychain. Avoid sharing sensitive personal or payment information in messages.") } label: { accountRow("Privacy", icon: "lock.fill") } + NavigationLink { RideInfoView(title: "About Rides", icon: "info.circle.fill", text: "Rides is an independent app for requesting rides and coordinating trip progress. Maps show an Austin route illustration, not live GPS. Driver identity and vehicle details are not independently verified. Payment processing, payouts, phone calls, and emergency response are unavailable. Reservations record a requested pickup time; automatic timed dispatch is unavailable.") } label: { accountRow("About", icon: "info.circle.fill") } }.foregroundStyle(.black) - DemoConnectionBadge() - Text("Rides Demo 0.1 · No real rides or charges").font(.caption2).foregroundStyle(.secondary) + Button("Sign out") { Task { await store.signOut() } }.font(.subheadline.bold()).disabled(store.busy) + Text("Rides 0.1").font(.caption2).foregroundStyle(.secondary) }.padding(20) }.navigationBarTitleDisplayMode(.inline).tint(.black) } @@ -110,19 +99,17 @@ struct RideWalletView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 12) { - Text("Demo Wallet").font(.headline) - Text("$50.00").font(.system(size: 40, weight: .bold)) - Text("Example balance only. No funds are stored, added, or deducted.").font(.caption) + Label("Payments unavailable", systemImage: "creditcard").font(.headline) + Text("No cards are authorized or charged. Funds cannot be added or transferred.").font(.subheadline) }.padding(24).frame(maxWidth: .infinity, alignment: .leading).background(Color(red: 0.86, green: 0.94, blue: 0.89)).clipShape(RoundedRectangle(cornerRadius: 14)) - Text("Payment methods").font(.title2.bold()) + Text("Payment preference").font(.title2.bold()) ForEach(DemoPayment.allCases) { method in Button { payment = method } label: { - HStack(spacing: 15) { Image(systemName: method.icon).frame(width: 25); Text(method.rawValue).font(.subheadline.bold()); Spacer(); Image(systemName: payment == method ? "checkmark.circle.fill" : "circle").font(.system(size: 23)) }.padding(.vertical, 16).foregroundStyle(.black) + HStack(spacing: 15) { Image(systemName: method.icon).frame(width: 25); Text(method.title).font(.subheadline.bold()); Spacer(); Image(systemName: payment == method ? "checkmark.circle.fill" : "circle").font(.system(size: 23)) }.padding(.vertical, 16).foregroundStyle(.black) } Divider() } - Label("Test methods only", systemImage: "lock.shield.fill").font(.headline) - Text("No card entry or real payment processing is enabled. The selected method appears on the demo booking and receipt.").foregroundStyle(.secondary) + Text("Your preference labels your booking and trip details. It does not connect a payment account.").foregroundStyle(.secondary) }.padding(20) }.navigationTitle("Wallet").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) } @@ -133,12 +120,12 @@ struct SavedRidePlacesView: View { @State private var add = false var body: some View { List { - Section("Your saved demo locations") { + Section("Your saved places") { ForEach(store.savedPlaces) { place in RideLocationRow(place: place, icon: "star.fill") } .onDelete { store.savedPlaces.remove(atOffsets: $0) } } Button { add = true } label: { Label("Add a saved place", systemImage: "plus") } - Section { Text("Choose from the offline demo catalog. Swipe to remove a place. Saved locations stay on this phone.").font(.caption).foregroundStyle(.secondary) } + Section { Text("Swipe to remove a place. Saved locations stay on this phone.").font(.caption).foregroundStyle(.secondary) } }.navigationTitle("Saved places").navigationBarTitleDisplayMode(.inline).sheet(isPresented: $add) { PlaceSearchView(title: "Save a place") { store.savePlace($0) }.environmentObject(store) }.tint(.black) } } @@ -147,9 +134,16 @@ struct RideSettingsView: View { @EnvironmentObject private var store: RideStore var body: some View { Form { - Section("Profile") { LabeledContent("Name", value: store.displayName); LabeledContent("Mode", value: store.role.title); Text("Choose your name and mode before joining a room in Demo connection.").font(.caption).foregroundStyle(.secondary) } + Section("Profile") { + LabeledContent("Name", value: store.account?.name ?? store.displayName) + if let email = store.account?.email { LabeledContent("Email", value: email) } + LabeledContent("Account type", value: store.role.title) + if store.role == .driver { + if let vehicle = store.account?.vehicle, !vehicle.isEmpty { LabeledContent("Vehicle", value: vehicle) } + if let plate = store.account?.plate, !plate.isEmpty { LabeledContent("License plate", value: plate) } + } + } Section("Preferences") { Toggle("Show reservation reminder in app", isOn: $store.rideReminders).tint(.black); Text("In-app reminder only. No push notifications are sent.").font(.caption).foregroundStyle(.secondary) } - Section("Demo") { Button("Demo connection & invitation") { store.showConnection = true }; NavigationLink("About the demo") { RideInfoView(title: "Demo limitations", icon: "car.side.fill", text: "Driver movement and travel estimates are simulated. The driver manually marks arrival, start, and completion. Reservations appear immediately, and demo payments never move money. No third-party login, SMS, geocoding, real dispatch, or emergency service is used.") } } }.navigationTitle("Settings").navigationBarTitleDisplayMode(.inline).tint(.black) } } @@ -160,15 +154,14 @@ struct RideHelpView: View { List { Section("Your last trip") { if let trip = store.state?.trips.first { NavigationLink { TripTrackingView(tripID: trip.id) } label: { VStack(alignment: .leading, spacing: 7) { Text(trip.destination.name).font(.headline); Text(RideCatalog.date(trip.createdAt)).font(.caption).foregroundStyle(.secondary) } } } - else { Text("No trips in this demo room yet.").foregroundStyle(.secondary) } + else { Text("No trips yet.").foregroundStyle(.secondary) } } Section("Help topics") { - NavigationLink("Connecting the two phones") { RideInfoView(title: "Connect your demo", icon: "iphone.gen3.radiowaves.left.and.right", text: "Start the shared server at an HTTPS address reachable from both phones. On the rider phone, open Account → Rider & driver demo, enter the server, and create a room. Share that private invitation with the other phone, choose Driver, join the room, and tap GO. A rider booking then appears on the driver phone.") } - NavigationLink("Trip progress and reservations") { RideInfoView(title: "Demo ride progress", icon: "car.side.fill", text: "The driver accepts, marks arrival, starts, and completes the ride. Each committed state appears on the rider phone after its next sync, normally within a second. Nothing auto-accepts or advances a trip. Reserved rides carry their pickup time but can be manually advanced immediately for demos.") } - NavigationLink("Payments and receipts") { RideInfoView(title: "Demo payments", icon: "creditcard.fill", text: "All fares, tips, and payment methods are simulated. A receipt records the chosen method and amount, but no bank or payment provider is charged. Do not enter real card information.") } - NavigationLink("Connection problems") { RideInfoView(title: "Connection problems", icon: "wifi.exclamationmark", text: "Both phones need access to the same running server. If it stops or the preview workspace sleeps, the app shows a connection error and keeps its last synced state. Restart the server and tap Retry. Expired rooms need a new invitation.") } + NavigationLink("Trip progress and reservations") { RideInfoView(title: "Trip progress", icon: "car.side.fill", text: "Your driver accepts the request and updates the ride when they arrive, start, and finish. Maps are route illustrations and do not track live location. Reservations record your requested pickup time but do not guarantee availability or automatically dispatch a driver.") } + NavigationLink("Payments and receipts") { RideInfoView(title: "Payments and receipts", icon: "creditcard.fill", text: "Payments and payouts are unavailable. Trip details record fares, tip preferences, and a payment label, but no payment account is connected, authorized, or charged.") } + NavigationLink("Connection problems") { RideInfoView(title: "Connection problems", icon: "wifi.exclamationmark", text: "Check your internet connection and tap Retry if trip updates stop. If your session has expired, sign in again with your account.") } + NavigationLink("Safety and assistance") { RideInfoView(title: "Safety and assistance", icon: "shield.lefthalf.filled", text: "Emergency response, phone calls, audio recording, and safety monitoring are unavailable. If you need emergency assistance, call your local emergency number outside this app. Driver identity and vehicle details are not independently verified. Support requests and lost-item claims cannot be submitted in the app.") } } - Section { Text("No real support requests, lost-item claims, or emergency calls are submitted from this app.").font(.caption).foregroundStyle(.secondary) } }.navigationTitle("Help").navigationBarTitleDisplayMode(.inline).tint(.black) } } diff --git a/apps/rides/Features/AccountSignInView.swift b/apps/rides/Features/AccountSignInView.swift new file mode 100644 index 0000000..bbf902c --- /dev/null +++ b/apps/rides/Features/AccountSignInView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct AccountSignInView: View { + @EnvironmentObject private var store: RideStore + @State private var creating = false + @State private var name = "" + @State private var email = "" + @State private var password = "" + @State private var role = DemoRole.rider + @State private var vehicle = "" + @State private var plate = "" + + private var ready: Bool { + !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !password.isEmpty && + (!creating || (!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && password.count >= 12 && + (role == .rider || (!vehicle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !plate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)))) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("Rides").font(.system(size: 46, weight: .medium)).tracking(-2).padding(.top, 40) + Image(systemName: "car.side.fill").font(.system(size: 54)).padding(.top, 24).accessibilityHidden(true) + VStack(alignment: .leading, spacing: 10) { + Text(creating ? "Create your account" : "Let's get you moving").font(.system(size: 31, weight: .bold)) + Text(creating ? "Your next ride starts here." : "Sign in to book a ride or get back on the road.").font(.subheadline).foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 16) { + if creating { + field("Full name") { + TextField("Full name", text: $name).textContentType(.name).accessibilityIdentifier("auth.name") + } + } + field("Email") { + TextField("Email address", text: $email).keyboardType(.emailAddress).textContentType(.username).textInputAutocapitalization(.never).autocorrectionDisabled().accessibilityIdentifier("auth.email") + } + field("Password") { + SecureField(creating ? "At least 12 characters" : "Password", text: $password).textContentType(creating ? .newPassword : .password).accessibilityIdentifier("auth.password") + } + if creating { + Picker("Account type", selection: $role) { + Text("Ride").tag(DemoRole.rider) + Text("Drive").tag(DemoRole.driver) + }.pickerStyle(.segmented).accessibilityIdentifier("auth.role") + if role == .driver { + field("Vehicle") { + TextField("Make, model and color", text: $vehicle).accessibilityIdentifier("auth.vehicle") + } + field("License plate") { + TextField("License plate", text: $plate).textInputAutocapitalization(.characters).autocorrectionDisabled().accessibilityIdentifier("auth.plate") + } + } + } + } + if let error = store.errorMessage { + Text(error).font(.subheadline).foregroundStyle(.red).accessibilityIdentifier("auth.error") + } + RidePrimaryButton(title: creating ? "Create account" : "Sign in", busy: store.busy, disabled: !ready) { + Task { + let registration = creating ? RideRegistration(name: name, email: email, password: password, role: role, vehicle: role == .driver ? vehicle : nil, plate: role == .driver ? plate : nil) : nil + if await store.signIn(email: email, password: password, registration: registration) { password = "" } + } + }.accessibilityIdentifier("auth.submit") + Button(creating ? "Already have an account? Sign in" : "New here? Create an account") { + creating.toggle() + password = "" + store.errorMessage = nil + }.font(.subheadline.weight(.semibold)).foregroundStyle(.black).frame(maxWidth: .infinity).disabled(store.busy).accessibilityIdentifier("auth.switchMode") + if creating { + Text("Email recovery and driver verification are currently unavailable.").font(.caption).foregroundStyle(.secondary) + } + }.padding(.horizontal, 24).padding(.bottom, 30).frame(maxWidth: 520).frame(maxWidth: .infinity) + }.background(.white).scrollDismissesKeyboard(.interactively) + } + + private func field(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title).font(.caption.weight(.semibold)) + content().font(.body).padding(16).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 10)) + } + } +} diff --git a/apps/rides/Features/BookingFlowView.swift b/apps/rides/Features/BookingFlowView.swift index 436155b..56b9a64 100644 --- a/apps/rides/Features/BookingFlowView.swift +++ b/apps/rides/Features/BookingFlowView.swift @@ -8,7 +8,6 @@ struct BookingFlowView: View { @State private var placePicker: String? @State private var showSchedule = false @State private var showPayment = false - @State private var showConnect = false @State private var schedule = Date().addingTimeInterval(3600) var body: some View { NavigationStack { @@ -34,7 +33,7 @@ struct BookingFlowView: View { VStack(alignment: .leading, spacing: 24) { Text("When do you want to be picked up?").font(.system(size: 28, weight: .bold)) DatePicker("Pickup time", selection: $schedule, in: Date().addingTimeInterval(60)...Date().addingTimeInterval(30 * 86400), displayedComponents: [.date, .hourAndMinute]).datePickerStyle(.graphical) - Text("Reserve up to 30 days ahead. In this demo, the driver manually advances reservations immediately; there is no timed dispatch.").font(.footnote).foregroundStyle(.secondary) + Text("Choose a pickup time up to 30 days ahead. A reservation does not guarantee driver availability.").font(.footnote).foregroundStyle(.secondary) RidePrimaryButton(title: "Confirm pickup time") { store.draft.scheduledAt = schedule; showSchedule = false } Button("Ride now instead") { store.draft.scheduledAt = nil; showSchedule = false }.frame(maxWidth: .infinity) Spacer() @@ -42,7 +41,6 @@ struct BookingFlowView: View { }.tint(.black) } .sheet(isPresented: $showPayment) { NavigationStack { RideWalletView(payment: $store.draft.payment).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { showPayment = false } } } }.tint(.black) } - .sheet(isPresented: $showConnect) { DemoConnectionView().environmentObject(store) } .sheet(isPresented: Binding(get: { placePicker != nil }, set: { if !$0 { placePicker = nil } })) { PlaceSearchView(title: placePicker == "pickup" ? "Pickup location" : "Add a stop") { place in if placePicker == "pickup" { store.draft.pickup = place } @@ -77,14 +75,13 @@ struct BookingFlowView: View { ForEach(store.draft.stops) { stop in HStack { RideLocationRow(place: stop, icon: "circle"); Button { store.draft.stops.removeAll { $0.id == stop.id } } label: { Image(systemName: "minus.circle.fill") }.accessibilityLabel("Remove \(stop.name)") } } - if !store.draft.stops.isEmpty { Text("Each demo stop adds $3.00 to the fare.").font(.caption).foregroundStyle(.secondary) } + if !store.draft.stops.isEmpty { Text("Each stop adds $3.00 to the estimated fare.").font(.caption).foregroundStyle(.secondary) } Text("Suggested destinations").font(.subheadline.bold()) ForEach(RideCatalog.search(query, saved: store.savedPlaces).filter { $0.id != store.draft.pickup.id && !store.draft.stops.contains($0) }) { place in Button { store.draft.destination = place; step = 1 } label: { RideLocationRow(place: place, icon: store.savedPlaces.contains(place) ? "star.fill" : "mappin.circle.fill") } Divider().padding(.leading, 42) } - if RideCatalog.search(query, saved: store.savedPlaces).isEmpty { Text("No demo locations found. Try Airport, Home, Work, or Coffee.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 20) } - Text("Locations and routes use an offline Austin demo catalog. No live geocoding.").font(.caption).foregroundStyle(.secondary) + if RideCatalog.search(query, saved: store.savedPlaces).isEmpty { Text("No locations found. Try Airport, Home, Work, or Coffee.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 20) } }.padding(20) }.scrollDismissesKeyboard(.interactively) } @@ -99,7 +96,7 @@ struct BookingFlowView: View { RideCarArt(service: service) VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text(service.title).font(.system(size: 17, weight: .semibold)); Label("\(service.seats)", systemImage: "person.fill").font(.system(size: 10)) } - Text(store.draft.scheduledAt == nil ? "4 min away · Demo estimate" : "Scheduled demo pickup").font(.system(size: 11)).foregroundStyle(.secondary) + Text(store.draft.scheduledAt == nil ? "Estimated fare" : "Scheduled pickup").font(.system(size: 11)).foregroundStyle(.secondary) Text(service.detail).font(.system(size: 11)).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, alignment: .leading) Text(RideCatalog.money(service.fareCents + store.draft.stops.count * 300)).font(.system(size: 15, weight: .semibold)) @@ -114,16 +111,15 @@ struct BookingFlowView: View { ScrollView { VStack(alignment: .leading, spacing: 18) { Text("Confirm your pickup").font(.system(size: 25, weight: .bold)) - Text("Meet your driver at the pickup spot. This is a simulated trip, not a real booking.").font(.subheadline).foregroundStyle(.secondary) + Text("Check your pickup location before requesting a ride.").font(.subheadline).foregroundStyle(.secondary) if let destination = store.draft.destination { RideRouteSummary(pickup: store.draft.pickup, destination: destination, stops: store.draft.stops) } if let date = store.draft.scheduledAt { Label(date.formatted(date: .complete, time: .shortened), systemImage: "calendar").font(.subheadline.bold()) - Text("Reservations appear on the driver phone immediately and are manually advanced in this demo.").font(.caption).foregroundStyle(.secondary) } TextField("Add a pickup note (optional)", text: $store.draft.note, axis: .vertical).lineLimit(2...4).padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 10)) - Text("You can cancel before the ride starts. No cancellation fees or real charges apply.").font(.caption).foregroundStyle(.secondary) + Text("You can cancel before the ride starts.").font(.caption).foregroundStyle(.secondary) }.padding(20) }.scrollDismissesKeyboard(.interactively) } @@ -132,15 +128,14 @@ struct BookingFlowView: View { VStack(spacing: 12) { Divider() Button { showPayment = true } label: { - HStack { Image(systemName: store.draft.payment.icon); Text(store.draft.payment.rawValue).font(.system(size: 14, weight: .medium)); Spacer(); Image(systemName: "chevron.right").font(.caption) }.foregroundStyle(.black) + HStack { Image(systemName: store.draft.payment.icon); Text(store.draft.payment.title).font(.system(size: 14, weight: .medium)); Spacer(); Image(systemName: "chevron.right").font(.caption) }.foregroundStyle(.black) } if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red).frame(maxWidth: .infinity, alignment: .leading) } - RidePrimaryButton(title: step == 1 ? "Choose \(store.draft.service.title)" : store.connected ? "Confirm pickup · \(RideCatalog.money(store.draft.fareCents))" : "Connect to request a ride", busy: store.busy, disabled: store.draft.validationError() != nil) { + RidePrimaryButton(title: step == 1 ? "Choose \(store.draft.service.title)" : store.connected ? "Confirm pickup · \(RideCatalog.money(store.draft.fareCents))" : "Sign in to request a ride", busy: store.busy, disabled: store.draft.validationError() != nil) { if step == 1 { step = 2 } - else if !store.connected { showConnect = true } + else if !store.connected { store.presentSignIn() } else { Task { await store.book() } } } - Text("Demo fare · No payment will be collected").font(.system(size: 10)).foregroundStyle(.secondary) }.padding(.horizontal, 20).padding(.bottom, 12).background(.white) } } @@ -155,7 +150,7 @@ struct PlaceSearchView: View { NavigationStack { List(RideCatalog.search(query, saved: store.savedPlaces)) { place in Button { onSelect(place); dismiss() } label: { RideLocationRow(place: place) } - }.listStyle(.plain).searchable(text: $query, prompt: "Search demo locations").navigationTitle(title).navigationBarTitleDisplayMode(.inline).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } + }.listStyle(.plain).searchable(text: $query, prompt: "Search locations").navigationTitle(title).navigationBarTitleDisplayMode(.inline).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } }.tint(.black) } } diff --git a/apps/rides/Features/ConnectionView.swift b/apps/rides/Features/ConnectionView.swift deleted file mode 100644 index 43731bd..0000000 --- a/apps/rides/Features/ConnectionView.swift +++ /dev/null @@ -1,86 +0,0 @@ -import SwiftUI - -struct DemoConnectionView: View { - @EnvironmentObject private var store: RideStore - @Environment(\.dismiss) private var dismiss - @State private var joinExisting = false - @State private var copied = false - @State private var showInvitation = false - @State private var confirmLeave = false - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 22) { - HStack { - Image(systemName: "iphone").font(.system(size: 36)) - Image(systemName: "arrow.left.arrow.right").font(.system(size: 20)) - Image(systemName: "iphone").font(.system(size: 36)) - }.padding(.top, 10) - Text(store.connected ? "Your shared demo" : "One ride. Two phones.").font(.system(size: 31, weight: .bold)) - Text("Create a room on the rider phone. On the driver phone, choose Driver and join with the same server address and invitation code.").font(.subheadline).foregroundStyle(.secondary) - if let session = store.credentials { - VStack(alignment: .leading, spacing: 12) { - Label("\(session.name) · \(session.role.title)", systemImage: session.role == .rider ? "person.fill" : "car.fill").font(.headline) - Text("Invitation code").font(.caption).foregroundStyle(.secondary) - if showInvitation { - Text(session.roomCode).font(.system(size: 24, weight: .bold, design: .monospaced)).textSelection(.enabled).privacySensitive() - } - Button(showInvitation ? "Hide invitation" : "Show private invitation") { showInvitation.toggle() }.font(.subheadline.bold()) - Text(session.baseURL.absoluteString).font(.caption).textSelection(.enabled) - Button(copied ? "Copied invitation" : "Copy invitation") { - UIPasteboard.general.string = "Rides demo\nServer: \(session.baseURL.absoluteString)\nRoom: \(session.roomCode)\nChoose Driver to join. Keep this invitation private." - copied = true - }.font(.subheadline.bold()) - }.padding(18).frame(maxWidth: .infinity, alignment: .leading).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 14)) - RidePrimaryButton(title: "Continue as \(session.role.title)") { dismiss() } - Button("Leave this demo", role: .destructive) { confirmLeave = true }.disabled(store.activeTrip != nil) - if store.activeTrip != nil { Text("Finish or cancel the active ride before leaving.").font(.caption).foregroundStyle(.secondary) } - } else { - HStack(spacing: 12) { - ForEach(DemoRole.allCases) { role in - Button { store.chooseRole(role) } label: { - VStack(spacing: 9) { - Image(systemName: role == .rider ? "person.crop.circle.fill" : "car.side.fill").font(.system(size: 30)) - Text(role.title).font(.headline) - }.frame(maxWidth: .infinity).padding(18).background(store.role == role ? Color.black : RideStyle.soft).foregroundStyle(store.role == role ? Color.white : Color.black).clipShape(RoundedRectangle(cornerRadius: 12)) - }.disabled(store.busy) - } - } - VStack(alignment: .leading, spacing: 7) { - Text("Shared backend").font(.subheadline.bold()) - TextField("https://your-deployment.convex.cloud", text: $store.serverAddress).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled().padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)).accessibilityIdentifier("demo.server") - Text("The hosted Convex backend is prefilled. Use the same address on both phones.").font(.caption).foregroundStyle(.secondary) - if !RideBackend.defaultAddress.isEmpty && store.serverAddress != RideBackend.defaultAddress { - Button("Use hosted Convex backend") { store.serverAddress = RideBackend.defaultAddress }.font(.caption.bold()) - } - } - VStack(alignment: .leading, spacing: 7) { - Text("Your demo name").font(.subheadline.bold()) - TextField("Name", text: $store.displayName).textContentType(.name).padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)) - } - if store.role == .rider { Toggle("Join an existing room", isOn: $joinExisting).tint(.black) } - if store.role == .driver || joinExisting { - TextField("Invitation code", text: $store.roomInput).textInputAutocapitalization(.characters).autocorrectionDisabled().padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)).accessibilityIdentifier("demo.room") - } - if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red) } - RidePrimaryButton(title: store.role == .rider && !joinExisting ? "Create rider room" : "Join as \(store.role.title)", busy: store.busy, disabled: store.serverAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { - Task { - await store.connect(createNew: store.role == .rider && !joinExisting) - if store.connected && (store.role == .driver || joinExisting) { dismiss() } - } - } - } - VStack(alignment: .leading, spacing: 8) { - Label("A shared demo, not a real ride", systemImage: "info.circle").font(.subheadline.bold()) - Text("Trip requests, acceptance, messages, and ride progress sync through your server. Maps, fares, profiles, and payment methods are sample data. Anyone with the private invitation can join this room.").font(.caption).foregroundStyle(.secondary) - }.padding(.bottom, 20) - }.padding(20) - }.scrollDismissesKeyboard(.interactively) - .navigationTitle("Demo connection").navigationBarTitleDisplayMode(.inline) - .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() }.disabled(store.busy) } } - .confirmationDialog("Leave this demo?", isPresented: $confirmLeave, titleVisibility: .visible) { - Button("Leave demo", role: .destructive) { store.disconnect() } - } message: { Text("Room access expires 24 hours after creation. Keep the invitation private to rejoin before then. Expiry does not delete retained history.") } - }.tint(.black) - } -} diff --git a/apps/rides/Features/RideDesign.swift b/apps/rides/Features/RideDesign.swift index eee631a..df58c89 100644 --- a/apps/rides/Features/RideDesign.swift +++ b/apps/rides/Features/RideDesign.swift @@ -92,22 +92,8 @@ struct DemoMapView: View { .font(.system(size: 28)).foregroundStyle(.black) .rotationEffect(.degrees(status == .inProgress ? 100 : 20)) .position(x: geometry.size.width * (status == .arriving ? 0.30 : status == .inProgress ? 0.64 : 0.52), y: geometry.size.height * (status == .arriving ? 0.30 : 0.60)) - VStack { Spacer(); HStack { Spacer(); Text("Simulated map · Austin").font(.system(size: 8)).foregroundStyle(.secondary).padding(6).background(.white.opacity(0.8)) } } } - }.clipped().accessibilityLabel("Simulated Austin map\(route ? " with pickup and destination route" : "")") - } -} - -struct DemoConnectionBadge: View { - @EnvironmentObject private var store: RideStore - var body: some View { - Button { store.showConnection = true } label: { - HStack(spacing: 5) { - Circle().fill(store.connected && store.syncIsFresh ? RideStyle.green : Color.gray).frame(width: 6, height: 6) - Text(store.connected ? "Live demo · \(store.role.title)" : "Connect two-phone demo") - Image(systemName: "slider.horizontal.3") - }.font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary) - }.accessibilityIdentifier("demo.connection") + }.clipped().accessibilityElement(children: .ignore).accessibilityLabel(route ? "Austin route" : "Austin map") } } @@ -149,7 +135,6 @@ struct RideInfoView: View { Image(systemName: icon).font(.system(size: 44)).padding(.top, 20) Text(title).font(.largeTitle.bold()) Text(text).font(.body).foregroundStyle(.secondary) - Text("Independent demo. No real trips, payments, calls, or emergency services are provided.").font(.footnote).padding(16).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) }.frame(maxWidth: .infinity, alignment: .leading).padding(20) }.navigationTitle(title).navigationBarTitleDisplayMode(.inline) } diff --git a/apps/rides/Features/RiderHomeView.swift b/apps/rides/Features/RiderHomeView.swift index 0ff5d35..77959d3 100644 --- a/apps/rides/Features/RiderHomeView.swift +++ b/apps/rides/Features/RiderHomeView.swift @@ -7,10 +7,8 @@ struct RiderHomeView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { HStack(alignment: .firstTextBaseline) { - Text("Uber").font(.system(size: 38, weight: .medium)).tracking(-2) - Text("DEMO").font(.system(size: 9, weight: .bold)).tracking(1).padding(.horizontal, 7).padding(.vertical, 4).background(RideStyle.soft).clipShape(Capsule()) + Text("Rides").font(.system(size: 38, weight: .medium)).tracking(-2) Spacer() - DemoConnectionBadge() }.padding(.top, 6) HStack(spacing: 0) { VStack(spacing: 12) { @@ -69,10 +67,9 @@ struct RiderHomeView: View { Spacer() }.padding(18).frame(maxWidth: .infinity, alignment: .leading).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) } - Text("Independent ride demo · No real rides or charges").font(.caption2).foregroundStyle(.secondary).padding(.bottom, 12) }.padding(.horizontal, 20) }.background(.white) - .alert("Eats is a separate app", isPresented: $eatsInfo) { Button("OK", role: .cancel) {} } message: { Text("The Eats demo is unchanged in this repository. Install the EatsClone target to switch to it from here.") } + .alert("Eats isn't installed", isPresented: $eatsInfo) { Button("OK", role: .cancel) {} } message: { Text("Install Eats to open it from here.") } } private func suggestion(_ title: String, icon: String, action: @escaping () -> Void) -> some View { @@ -92,9 +89,9 @@ struct RideServicesView: View { VStack(alignment: .leading, spacing: 22) { Text(store.role == .driver ? "Earnings" : "Services").font(.system(size: 32, weight: .bold)).padding(.top, 12) if store.role == .driver { - Text("Your demo earnings").font(.headline) + Text("Recent trip totals").font(.headline) Text(RideCatalog.money(store.earningsCents)).font(.system(size: 46, weight: .bold)) - Text("Completed rides assigned to you, including demo tips. No money is transferred.").foregroundStyle(.secondary) + Text("Recorded totals for your completed rides. Payouts are unavailable.").foregroundStyle(.secondary) ForEach(store.pastTrips.filter { $0.status == .completed && $0.driver?.id == store.credentials?.participantId }) { trip in HStack { VStack(alignment: .leading) { Text(trip.destination.name).font(.headline); Text(RideCatalog.date(trip.createdAt)).font(.caption).foregroundStyle(.secondary) }; Spacer(); Text(RideCatalog.money(trip.totalCents)).fontWeight(.semibold) }.padding(.vertical, 10) Divider() @@ -113,7 +110,6 @@ struct RideServicesView: View { } } Button { store.beginBooking(reserve: true) } label: { Label("Reserve a ride", systemImage: "calendar.badge.clock").font(.headline).frame(maxWidth: .infinity, alignment: .leading).padding(20).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundStyle(.black) - Text("Bikes, rentals, group invitations, and deliveries aren't part of this ride demo.").font(.footnote).foregroundStyle(.secondary) } }.padding(20) }.navigationBarTitleDisplayMode(.inline) diff --git a/apps/rides/Features/TripViews.swift b/apps/rides/Features/TripViews.swift index af5b258..f3d8a3c 100644 --- a/apps/rides/Features/TripViews.swift +++ b/apps/rides/Features/TripViews.swift @@ -4,7 +4,6 @@ struct TripTrackingView: View { @EnvironmentObject private var store: RideStore let tripID: String @State private var showCancel = false - @State private var showCall = false var body: some View { if let trip = store.state?.trips.first(where: { $0.id == tripID }) { if trip.status == .completed { RideReceiptView(tripID: tripID) } @@ -13,20 +12,17 @@ struct TripTrackingView: View { VStack(spacing: 0) { ZStack(alignment: .topTrailing) { DemoMapView(status: trip.status) - NavigationLink { RideSafetyView(trip: trip) } label: { Image(systemName: "shield.lefthalf.filled").font(.system(size: 20)).foregroundStyle(.blue).frame(width: 46, height: 46).background(.white).clipShape(Circle()) }.padding(18) + NavigationLink { RideSafetyView(trip: trip) } label: { Image(systemName: "shield.lefthalf.filled").font(.system(size: 20)).foregroundStyle(.blue).frame(width: 46, height: 46).background(.white).clipShape(Circle()) }.padding(18).accessibilityLabel("Safety information") }.frame(height: geometry.size.height * 0.43) ScrollView { VStack(alignment: .leading, spacing: 18) { Capsule().fill(.gray.opacity(0.3)).frame(width: 38, height: 4).frame(maxWidth: .infinity) HStack { Text(trip.status.title).font(.system(size: 24, weight: .bold)); Spacer() } - DemoConnectionBadge() if trip.status == .requested { - HStack(spacing: 12) { ProgressView(); Text(store.state?.driverOnline == true ? "Waiting for the driver phone to accept." : "Your demo driver is offline. Go online on the other phone to accept this ride.").font(.subheadline).foregroundStyle(.secondary) } - Text("The request is shared with your demo room. It won't be accepted automatically.").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 12) { ProgressView(); Text("Waiting for a driver to accept your request.").font(.subheadline).foregroundStyle(.secondary) } } if trip.status == .cancelled { - Text(trip.cancellationReason ?? "Plans changed. No fee was charged.").font(.subheadline).foregroundStyle(.secondary) - Text("No cancellation fee. No payment was collected.").font(.subheadline).foregroundStyle(.secondary) + Text(trip.cancellationReason ?? "Plans changed.").font(.subheadline).foregroundStyle(.secondary) RidePrimaryButton(title: "Back to Home") { store.focusedTripID = nil; store.tab = 0 } } if let driver = trip.driver { @@ -34,7 +30,8 @@ struct TripTrackingView: View { ZStack { Circle().fill(RideStyle.soft); Image(systemName: "person.fill").font(.system(size: 30)).foregroundStyle(.gray) }.frame(width: 58, height: 58) VStack(alignment: .leading, spacing: 5) { Text(driver.name).font(.headline) - Label(String(format: "%.2f", driver.rating), systemImage: "star.fill").font(.caption) + if driver.rating > 0 { Label(String(format: "%.2f", driver.rating), systemImage: "star.fill").font(.caption) } + else { Text("New").font(.caption).foregroundStyle(.secondary) } } Spacer() VStack(alignment: .trailing, spacing: 5) { @@ -43,17 +40,13 @@ struct TripTrackingView: View { } } if trip.canMessage { - HStack(spacing: 12) { - NavigationLink { RideChatView(tripID: trip.id) } label: { Label("Message driver", systemImage: "message.fill").font(.subheadline.bold()).frame(maxWidth: .infinity).padding(15).background(RideStyle.soft).clipShape(Capsule()) } - Button { showCall = true } label: { Image(systemName: "phone.fill").frame(width: 48, height: 48).background(RideStyle.soft).clipShape(Circle()) }.accessibilityLabel("Demo call driver") - }.foregroundStyle(.black) + NavigationLink { RideChatView(tripID: trip.id) } label: { Label("Message driver", systemImage: "message.fill").font(.subheadline.bold()).frame(maxWidth: .infinity).padding(15).background(RideStyle.soft).clipShape(Capsule()) }.foregroundStyle(.black) } } if trip.status.isActive { VStack(alignment: .leading, spacing: 10) { Text(trip.status == .inProgress ? "Enjoy the ride" : "Meet at your pickup spot").font(.headline) Text(trip.status == .inProgress ? trip.destination.name : trip.pickup.name).font(.subheadline) - Text(trip.status == .inProgress ? "Your demo driver will complete the ride from the other phone." : "Your demo driver will mark arrival and start the trip from the other phone.").font(.caption).foregroundStyle(.secondary) }.padding(16).frame(maxWidth: .infinity, alignment: .leading).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.22)) } } RideRouteSummary(pickup: trip.pickup, destination: trip.destination, stops: trip.stops) @@ -61,14 +54,12 @@ struct TripTrackingView: View { if trip.status.isActive, store.rideReminders, let date = trip.scheduledAt { Label("Reservation: \(RideCatalog.date(date))", systemImage: "calendar").font(.caption) } if !trip.note.isEmpty { Label(trip.note, systemImage: "text.bubble").font(.caption) } if trip.status.canCancel { Button("Cancel ride", role: .destructive) { showCancel = true }.font(.subheadline.bold()).frame(maxWidth: .infinity).padding(.vertical, 10).disabled(store.busy) } - Text("Simulated map and travel times · No live GPS or real driver").font(.system(size: 10)).foregroundStyle(.secondary) }.padding(20) } } }.sheet(isPresented: $showCancel) { CancelRideView(trip: trip).environmentObject(store) } - .alert("Demo call", isPresented: $showCall) { Button("OK", role: .cancel) {} } message: { Text("No phone call is placed. Use Message driver to send a real message between the two demo phones.") } } - } else { ContentUnavailableView("Ride not available", systemImage: "car.side", description: Text("Reconnect to the demo server to refresh this ride.")) } + } else { ContentUnavailableView("Ride not available", systemImage: "car.side", description: Text("Check your connection and try again.")) } } } @@ -81,21 +72,19 @@ struct DriverHomeView: View { DemoMapView(route: store.myDriverTrip != nil, status: store.activeTrip?.status ?? .requested) VStack(spacing: 12) { HStack { - Button { store.showConnection = true } label: { Image(systemName: "line.3.horizontal").font(.system(size: 20)).frame(width: 44, height: 44).background(.white).clipShape(Circle()) } + Button { store.tab = 3 } label: { Image(systemName: "line.3.horizontal").font(.system(size: 20)).frame(width: 44, height: 44).background(.white).clipShape(Circle()) }.accessibilityLabel("Account") Spacer() Button { store.tab = 1 } label: { Text(RideCatalog.money(store.earningsCents)).font(.system(size: 22, weight: .bold)).padding(.horizontal, 22).padding(.vertical, 11).background(.black).foregroundStyle(.white).clipShape(Capsule()) } Spacer() Image(systemName: "shield.lefthalf.filled").font(.system(size: 20)).frame(width: 44, height: 44).background(.white).clipShape(Circle()).accessibilityHidden(true) } - DemoConnectionBadge().padding(8).background(.white.opacity(0.94)).clipShape(Capsule()) Spacer() if !store.isDriverOnline && store.myDriverTrip == nil { Button { - if store.connected { Task { await store.setOnline(true) } } - else { store.showConnection = true } + Task { await store.setOnline(true) } } label: { Text("GO").font(.system(size: 27, weight: .bold)).frame(width: 84, height: 84).background(Color(red: 0.15, green: 0.42, blue: 0.96)).foregroundStyle(.white).clipShape(Circle()).overlay { Circle().stroke(.white, lineWidth: 5).padding(5) }.shadow(color: .black.opacity(0.18), radius: 8, y: 3) - }.padding(.bottom, 22).disabled(store.busy).accessibilityIdentifier("driver.goOnline") + }.padding(.bottom, 22).disabled(store.busy).accessibilityLabel("Go online").accessibilityIdentifier("driver.goOnline") } }.padding(18) }.frame(minHeight: 220, maxHeight: .infinity) @@ -120,16 +109,14 @@ struct DriverHomeView: View { if let date = trip.scheduledAt { Label("Reserved: \(RideCatalog.date(date))", systemImage: "calendar").font(.caption) } RidePrimaryButton(title: "Accept ride", busy: store.busy) { Task { await store.act(trip, RideAction(action: "accept")) } } .accessibilityIdentifier("driver.acceptRide") - Text("Accepting here updates the rider phone. No real trip is dispatched.").font(.caption).foregroundStyle(.secondary) } else { HStack { Text(store.isDriverOnline ? "You're online" : "You're offline").font(.system(size: 26, weight: .bold)) Spacer() if store.isDriverOnline { Button("Go offline") { Task { await store.setOnline(false) } }.font(.subheadline.bold()).disabled(store.busy) } } - Text(store.isDriverOnline ? store.activeTrip == nil ? "Waiting for a request from the rider phone." : "Another driver accepted this ride." : "Go online to receive ride requests from your demo room.").font(.subheadline).foregroundStyle(.secondary) - if !store.connected { RidePrimaryButton(title: "Join a rider's demo room") { store.showConnection = true } } - HStack { Image(systemName: "checkmark.shield.fill").foregroundStyle(RideStyle.green); Text("Driver companion demo").font(.caption.bold()); Spacer(); Text("Austin, TX").font(.caption).foregroundStyle(.secondary) }.padding(.top, 8) + Text(store.isDriverOnline ? store.activeTrip == nil ? "Waiting for a ride request." : "Another driver accepted this ride." : "Go online to receive ride requests.").font(.subheadline).foregroundStyle(.secondary) + Text("Austin, TX").font(.caption).foregroundStyle(.secondary).padding(.top, 8) } }.padding(20) }.frame(maxHeight: store.myDriverTrip != nil || (store.activeTrip?.status == .requested && store.isDriverOnline) ? 430 : 200) @@ -146,7 +133,7 @@ struct CancelRideView: View { NavigationStack { VStack(alignment: .leading, spacing: 22) { Text(trip.scheduledAt == nil ? "Cancel your ride?" : "Cancel your reservation?").font(.system(size: 29, weight: .bold)) - Text("You won't be charged a cancellation fee. The other phone will see the ride cancelled.").foregroundStyle(.secondary) + Text("This will cancel the ride for you and the other participant.").foregroundStyle(.secondary) Picker("Reason", selection: $reason) { ForEach(["Changed my mind", "Pickup location is wrong", "Driver is too far away", "Other"], id: \.self) { Text($0).tag($0) } }.pickerStyle(.inline) @@ -168,7 +155,7 @@ struct RideChatView: View { var body: some View { let trip = store.state?.trips.first { $0.id == tripID } VStack(spacing: 0) { - Text("Keep your account safe. Never share personal, payment, or account information in this demo chat.").font(.caption).foregroundStyle(.secondary).padding(14).frame(maxWidth: .infinity).background(RideStyle.soft) + Text("Keep your account safe. Never share sensitive personal, payment, or account information in chat.").font(.caption).foregroundStyle(.secondary).padding(14).frame(maxWidth: .infinity).background(RideStyle.soft) ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 14) { @@ -216,9 +203,10 @@ struct RideReceiptView: View { VStack(alignment: .leading, spacing: 22) { HStack { Spacer(); Image(systemName: "checkmark.circle.fill").font(.system(size: 52)).foregroundStyle(RideStyle.green); Spacer() }.padding(.top, 22) Text("Thanks for riding").font(.system(size: 30, weight: .bold)).frame(maxWidth: .infinity) - Text("Your trip was \(RideCatalog.money(trip.fareCents))").font(.subheadline).foregroundStyle(.secondary).frame(maxWidth: .infinity) + Text("Recorded fare: \(RideCatalog.money(trip.fareCents))").font(.subheadline).foregroundStyle(.secondary).frame(maxWidth: .infinity) if store.role == .rider && (trip.rating == nil || editing) { - Text("Add a tip for \(trip.driver?.name ?? "your driver")").font(.headline).frame(maxWidth: .infinity) + Text("Tip preference for \(trip.driver?.name ?? "your driver")").font(.headline).frame(maxWidth: .infinity) + Text("Payments unavailable. This preference does not send money.").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity) HStack(spacing: 10) { ForEach([0, 100, 300, 500], id: \.self) { amount in Button(amount == 0 ? "No tip" : RideCatalog.money(amount)) { tip = amount }.font(.subheadline.bold()).frame(maxWidth: .infinity).padding(.vertical, 12).background(tip == amount ? .black : RideStyle.soft).foregroundStyle(tip == amount ? .white : .black).clipShape(Capsule()) @@ -240,14 +228,14 @@ struct RideReceiptView: View { Divider() HStack { Text("Trip fare"); Spacer(); Text(RideCatalog.money(trip.fareCents)) } HStack { Text("Tip"); Spacer(); Text(RideCatalog.money(trip.tipCents)) } - HStack { Text("Total").font(.headline); Spacer(); Text(RideCatalog.money(trip.totalCents)).font(.title2.bold()) } - Label(trip.paymentMethod, systemImage: "creditcard.fill").font(.subheadline) + HStack { Text("Recorded total").font(.headline); Spacer(); Text(RideCatalog.money(trip.totalCents)).font(.title2.bold()) } + Label(DemoPayment.displayName(trip.paymentMethod), systemImage: "creditcard.fill").font(.subheadline) if let rating = trip.rating { Label("Your rating: \(rating) of 5", systemImage: "star.fill").font(.subheadline) if store.role == .rider { Button("Edit rating and tip") { self.rating = rating; tip = trip.tipCents; editing = true } } } Text("\(RideCatalog.date(trip.createdAt)) · \(trip.serviceTitle)\nTrip \(trip.shortID)").font(.caption).foregroundStyle(.secondary) - Text("Demo receipt. No money was transferred.").font(.caption).foregroundStyle(.secondary) + Text("Payments unavailable. No payment was processed.").font(.caption).foregroundStyle(.secondary) if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red) } if store.role == .rider && trip.rating != nil { RidePrimaryButton(title: "Ride again") { store.beginBooking(destination: trip.destination); store.draft.pickup = trip.pickup; store.draft.stops = trip.stops; store.draft.service = RideService(rawValue: trip.service) ?? .uberX } @@ -265,10 +253,10 @@ struct RideSafetyView: View { VStack(alignment: .leading, spacing: 24) { Image(systemName: "shield.lefthalf.filled").font(.system(size: 48)).foregroundStyle(.blue) Text("Safety toolkit").font(.system(size: 30, weight: .bold)) - Text("This is a demo, not an emergency service.").font(.headline) - Text("If you need real emergency assistance, contact your local emergency number outside this app. The demo does not call emergency services, record audio, monitor crashes, or track your location.").foregroundStyle(.secondary) - ShareLink(item: "Demo ride \(trip.shortID)\n\(trip.pickup.name) → \(trip.destination.name)\nStatus: \(trip.status.title)\nThis is a simulated ride, not live location sharing.") { Label("Share demo trip details", systemImage: "square.and.arrow.up").font(.headline).padding(18).frame(maxWidth: .infinity).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) } - Text("Before a real ride, verify the driver's photo, vehicle, and plate. No PIN verification or safety monitoring is implemented in this demo.").font(.subheadline).foregroundStyle(.secondary) + Text("Emergency assistance unavailable").font(.headline) + Text("For emergency assistance, call your local emergency number outside this app. Emergency calls, audio recording, crash monitoring, and location tracking are unavailable.").foregroundStyle(.secondary) + ShareLink(item: "Ride \(trip.shortID)\n\(trip.pickup.name) → \(trip.destination.name)\nStatus: \(trip.status.title)\nTrip details only. Location sharing unavailable.") { Label("Share trip details", systemImage: "square.and.arrow.up").font(.headline).padding(18).frame(maxWidth: .infinity).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) } + Text("Check the vehicle and plate before entering a car. Driver identity and vehicle details are not independently verified. PIN verification and safety monitoring are unavailable.").font(.subheadline).foregroundStyle(.secondary) }.padding(24) }.navigationTitle("Safety").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) } diff --git a/apps/rides/Tests/RideAccountTests.swift b/apps/rides/Tests/RideAccountTests.swift new file mode 100644 index 0000000..6876e6f --- /dev/null +++ b/apps/rides/Tests/RideAccountTests.swift @@ -0,0 +1,643 @@ +import Foundation +import XCTest +@testable import RidesDemo + +final class RideAccountModelTests: XCTestCase { + func testAccountTokensUseDispatchAndLegacyTokensKeepTheRoomModule() { + XCTAssertEqual(ConvexRideAPIClient.module(for: AccountFixture.token), "dispatch") + for token in ["legacy-fixture", "", "user", "User.fixture", "prefix.user.fixture"] { + XCTAssertEqual(ConvexRideAPIClient.module(for: token), "rides", token) + } + } + + func testLegacySessionAndStoredSessionDecodeWithoutAccountOrExpiry() throws { + let sessionData = Data(""" + {"roomCode":"LEGACY","participantId":"legacy-rider","token":"legacy-fixture","role":"rider","name":"Legacy fixture","state":{"roomCode":"LEGACY","revision":1,"driverOnline":false,"onlineDriverIds":[],"trips":[]}} + """.utf8) + let session = try JSONDecoder().decode(RideSession.self, from: sessionData) + XCTAssertNil(session.account) + XCTAssertNil(session.expiresAt) + XCTAssertEqual(session.participantId, "legacy-rider") + + let storedData = Data(""" + {"baseURL":"https://fixture.convex.cloud","roomCode":"LEGACY","participantId":"legacy-rider","token":"legacy-fixture","role":"rider","name":"Legacy fixture"} + """.utf8) + let stored = try JSONDecoder().decode(StoredRideSession.self, from: storedData) + XCTAssertNil(stored.account) + XCTAssertNil(stored.expiresAt) + XCTAssertEqual(stored.token, session.token) + } + + func testAccountSessionAndSecureStoredSessionRoundTripTheirProfile() throws { + for role in DemoRole.allCases { + let session = AccountFixture.session(role: role) + let decoded = try JSONDecoder().decode(RideSession.self, from: JSONEncoder().encode(session)) + XCTAssertEqual(decoded, session) + + let stored = StoredRideSession(session, baseURL: AccountFixture.baseURL) + let restored = try JSONDecoder().decode(StoredRideSession.self, from: JSONEncoder().encode(stored)) + XCTAssertEqual(restored.baseURL, AccountFixture.baseURL) + XCTAssertEqual(restored.roomCode, session.roomCode) + XCTAssertEqual(restored.participantId, session.participantId) + XCTAssertEqual(restored.token, session.token) + XCTAssertEqual(restored.role, role) + XCTAssertEqual(restored.name, session.name) + XCTAssertEqual(restored.account, session.account) + XCTAssertEqual(restored.expiresAt, session.expiresAt) + } + } +} + +@MainActor +final class RideAccountStoreTests: XCTestCase { + private var suite: String! + private var defaults: UserDefaults! + private var vault: AccountTestVault! + private var api: AccountTestAPI! + private var store: RideStore! + + override func setUp() async throws { + suite = "RideAccountTests.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + vault = AccountTestVault() + api = AccountTestAPI() + store = RideStore(defaults: defaults, vault: vault, apiFactory: { [api = api!] _ in api }) + store.serverAddress = AccountFixture.baseURL.absoluteString + } + + override func tearDown() async throws { + defaults.removePersistentDomain(forName: suite) + } + + func testSignUpPersistsOnlyTheAccountSessionInTheSecureVault() async throws { + let registration = AccountFixture.registration(role: .driver) + api.session = AccountFixture.session(role: .driver) + api.current = api.session.state + store.showSignIn = true + + let succeeded = await store.signIn(email: "ignored@example.invalid", password: "unused-fixture", registration: registration) + + XCTAssertTrue(succeeded) + XCTAssertEqual(api.registrations.count, 1) + XCTAssertEqual(api.registrations.first?.name, registration.name) + XCTAssertEqual(api.registrations.first?.email, registration.email) + XCTAssertEqual(api.registrations.first?.password, registration.password) + XCTAssertEqual(api.registrations.first?.role, .driver) + XCTAssertEqual(api.registrations.first?.vehicle, registration.vehicle) + XCTAssertEqual(api.registrations.first?.plate, registration.plate) + XCTAssertEqual(api.signInCount, 0) + XCTAssertEqual(store.account, api.session.account) + XCTAssertEqual(store.role, .driver) + XCTAssertFalse(store.showSignIn) + XCTAssertFalse(store.busy) + try assertSecurePersistence(password: registration.password) + assertNoRoomCalls() + } + + func testSignInUsesDatabaseProfileRatherThanTheLocallySelectedRole() async throws { + store.chooseRole(.driver) + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + + XCTAssertTrue(succeeded) + XCTAssertEqual(api.signInCount, 1) + XCTAssertEqual(api.signedInEmail, AccountFixture.email) + XCTAssertEqual(api.signedInPassword, AccountFixture.password) + XCTAssertTrue(api.registrations.isEmpty) + XCTAssertEqual(store.role, .rider) + XCTAssertEqual(store.displayName, api.session.account?.name) + XCTAssertEqual(store.account, api.session.account) + try assertSecurePersistence(password: AccountFixture.password) + assertNoRoomCalls() + } + + func testFailedCredentialsNeverInstallOrPersistASession() async { + api.authenticationFailure = RideAPIError(status: 401, message: "Invalid email or password") + + let succeeded = await store.signIn(email: AccountFixture.email, password: "incorrect-fixture") + + XCTAssertFalse(succeeded) + assertSignedOut() + XCTAssertEqual(vault.saveCount, 0) + XCTAssertTrue(api.revokedTokens.isEmpty) + XCTAssertNotNil(store.errorMessage) + XCTAssertFalse(store.busy) + XCTAssertTrue((defaults.persistentDomain(forName: suite) ?? [:]).isEmpty) + assertNoRoomCalls() + } + + func testInvalidAccountIdentityRoleNameTokenAndExpiryAreRejectedAndRevoked() async { + var missingAccount = AccountFixture.session() + missingAccount.account = nil + var missingExpiry = AccountFixture.session() + missingExpiry.expiresAt = nil + let invalidSessions: [(String, RideSession)] = [ + ("missing account", missingAccount), + ("participant identity", AccountFixture.session(participantID: "other-user")), + ("session identity", AccountFixture.session(roomCode: "other-user")), + ("state identity", AccountFixture.session(stateRoomCode: "other-user")), + ("role", AccountFixture.session(sessionRole: .driver)), + ("name", AccountFixture.session(sessionName: "Different fixture")), + ("legacy token", AccountFixture.session(token: "legacy-fixture")), + ("short account token", AccountFixture.session(token: "user.short")), + ("missing expiry", missingExpiry), + ("expired", AccountFixture.session(expiresAt: 1)), + ("nonfinite expiry", AccountFixture.session(expiresAt: .infinity)), + ("invalid expiry", AccountFixture.session(expiresAt: .nan)) + ] + + for (label, session) in invalidSessions { + api.session = session + let previousRevocations = api.revokedTokens.count + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + + XCTAssertFalse(succeeded, label) + assertSignedOut() + XCTAssertEqual(vault.saveCount, 0, label) + XCTAssertEqual(api.revokedTokens.count, previousRevocations + 1, label) + XCTAssertEqual(api.revokedTokens.last, session.token, label) + XCTAssertNotNil(store.errorMessage, label) + } + assertNoRoomCalls() + } + + func testSignUpCannotInstallAnAccountWithADifferentDatabaseRole() async { + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password, registration: AccountFixture.registration(role: .driver)) + + XCTAssertFalse(succeeded) + assertSignedOut() + XCTAssertEqual(api.revokedTokens, [api.session.token]) + XCTAssertEqual(vault.saveCount, 0) + } + + func testSecureStorageFailureRevokesTheNewSessionWithoutConnecting() async { + vault.failure = RideAPIError(status: 0, message: "Synthetic vault failure") + + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + + XCTAssertFalse(succeeded) + assertSignedOut() + XCTAssertEqual(api.revokedTokens, [api.session.token]) + XCTAssertEqual(vault.saveCount, 1) + } + + func testCancelledSignUpRevokesALateSessionWithoutPersistingIt() async { + let started = expectation(description: "Sign-up request suspended") + api.suspendSignUp = true + api.onSignUpSuspended = { started.fulfill() } + let task = Task { await store.signIn(email: AccountFixture.email, password: AccountFixture.password, registration: AccountFixture.registration()) } + await fulfillment(of: [started], timeout: 2) + + task.cancel() + api.signUpContinuation?.resume(returning: api.session) + api.signUpContinuation = nil + let succeeded = await task.value + + XCTAssertFalse(succeeded) + assertSignedOut() + XCTAssertEqual(vault.saveCount, 0) + XCTAssertEqual(api.revokedTokens, [api.session.token]) + XCTAssertFalse(store.busy) + XCTAssertNil(store.errorMessage) + } + + func testAccountRestoresTheSecureProfileAndFindsItsActiveTripOnSync() async throws { + let trip = AccountFixture.trip(status: .accepted) + let session = AccountFixture.session(trips: [trip]) + try vault.save(StoredRideSession(session, baseURL: api.baseURL)) + api.current = session.state + + let restored = RideStore(defaults: defaults, vault: vault, apiFactory: { [api = api!] _ in api }) + + XCTAssertTrue(restored.connected) + XCTAssertEqual(restored.account, session.account) + XCTAssertEqual(restored.role, session.role) + XCTAssertEqual(restored.displayName, session.name) + XCTAssertEqual(restored.serverAddress, api.baseURL.absoluteString) + await restored.sync() + XCTAssertEqual(restored.activeTrip, trip) + XCTAssertEqual(restored.focusedTripID, trip.id) + XCTAssertEqual(restored.state, session.state) + XCTAssertEqual(api.stateTokens, [session.token]) + XCTAssertEqual(api.signInCount, 0) + XCTAssertTrue(api.resumeTokens.isEmpty) + assertNoRoomCalls() + } + + func testAccountOnlyStartupDiscardsLegacyRoomsAndCustomServerDefaults() throws { + let oldState = RideRoomState(roomCode: "LEGACY", revision: 1, driverOnline: false, onlineDriverIds: [], trips: []) + let oldSession = RideSession(roomCode: "LEGACY", participantId: "legacy-rider", token: "legacy-fixture", role: .rider, name: "Legacy fixture", state: oldState) + try vault.save(StoredRideSession(oldSession, baseURL: URL(string: "https://old-service.example.invalid")!)) + defaults.set("https://old-service.example.invalid", forKey: "serverAddress") + + let upgraded = RideStore(defaults: defaults, vault: vault, accountOnly: true) + + XCTAssertFalse(upgraded.connected) + XCTAssertNil(upgraded.account) + XCTAssertNil(vault.value) + XCTAssertEqual(upgraded.serverAddress, RideBackend.defaultAddress) + } + + func testSignOutRevokesTheSessionAndClearsAccountAndTripState() async { + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + store.roomInput = "STALE" + store.focusedTripID = "stale-trip" + store.draft.destination = RideCatalog.places[1] + store.draft.note = "Synthetic draft" + store.showBooking = true + store.notice = "Synthetic notice" + store.revylStatus = "Synthetic status" + store.tab = 3 + + await store.signOut() + + assertSignedOut() + XCTAssertEqual(api.stateTokens, [api.session.token]) + XCTAssertEqual(api.revokedTokens, [api.session.token]) + XCTAssertEqual(vault.clearCount, 1) + XCTAssertNil(store.lastSync) + XCTAssertFalse(store.syncConnected) + XCTAssertNil(store.focusedTripID) + XCTAssertEqual(store.roomInput, "") + XCTAssertNil(store.draft.destination) + XCTAssertEqual(store.draft.note, "") + XCTAssertFalse(store.showBooking) + XCTAssertNil(store.notice) + XCTAssertNil(store.revylStatus) + XCTAssertTrue(store.showSignIn) + XCTAssertNil(store.errorMessage) + XCTAssertFalse(store.busy) + XCTAssertEqual(store.tab, 0) + XCTAssertEqual(store.role, .rider) + XCTAssertEqual(store.displayName, "") + } + + func testDriverSignOutClearsTheDatabaseProfileAndGoesOfflineBeforeRevocation() async { + api.session = AccountFixture.session(role: .driver) + api.current = api.session.state + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + + await store.signOut() + + assertSignedOut() + XCTAssertEqual(api.events, ["state", "offline", "revoke"]) + XCTAssertEqual(api.availabilityTokens, [api.session.token]) + XCTAssertEqual(store.role, .rider) + XCTAssertEqual(store.displayName, "") + } + + func testRiderCannotSignOutOrDisconnectWhileOwningAnActiveTrip() async { + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + api.current = AccountFixture.session(trips: [AccountFixture.trip(status: .inProgress)]).state + + await store.signOut() + store.disconnect() + + XCTAssertTrue(store.connected) + XCTAssertNotNil(store.ownedActiveTrip) + XCTAssertNotNil(vault.value) + XCTAssertTrue(api.revokedTokens.isEmpty) + XCTAssertEqual(vault.clearCount, 0) + XCTAssertNotNil(store.errorMessage) + XCTAssertFalse(store.busy) + } + + func testDriverCannotSignOutWhileAssignedAnActiveTrip() async { + let driver = AccountFixture.driver() + api.session = AccountFixture.session(role: .driver, trips: [AccountFixture.trip(id: "offer-fixture", status: .requested), AccountFixture.trip(status: .accepted, driver: driver)]) + api.current = api.session.state + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + XCTAssertEqual(store.activeTrip?.id, "offer-fixture") + XCTAssertEqual(store.myDriverTrip?.id, "account-trip-fixture") + + await store.signOut() + + XCTAssertTrue(store.connected) + XCTAssertEqual(store.ownedActiveTrip?.driver?.id, driver.id) + XCTAssertTrue(api.availabilityTokens.isEmpty) + XCTAssertTrue(api.revokedTokens.isEmpty) + XCTAssertNotNil(vault.value) + } + + func testUnassignedDriverOfferDoesNotCountAsAnOwnedActiveTrip() async { + api.session = AccountFixture.session(role: .driver, trips: [AccountFixture.trip(status: .requested)]) + api.current = api.session.state + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + XCTAssertNotNil(store.activeTrip) + XCTAssertNil(store.myDriverTrip) + XCTAssertNil(store.ownedActiveTrip) + + await store.signOut() + + assertSignedOut() + XCTAssertEqual(api.events, ["state", "offline", "revoke"]) + } + + func testTripAssignedToAnotherDriverDoesNotBlockSignOut() async { + let other = DemoDriver(id: "other-driver", name: "Other fixture", vehicle: "Fixture car", plate: "TEST 002", rating: 4.9) + api.session = AccountFixture.session(role: .driver, trips: [AccountFixture.trip(status: .accepted, driver: other)]) + api.current = api.session.state + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + XCTAssertNil(store.ownedActiveTrip) + + await store.signOut() + + assertSignedOut() + XCTAssertEqual(api.revokedTokens, [api.session.token]) + } + + func testRevokedSessionClearsSecureCredentialsEvenWithAnOwnedTrip() async { + api.session = AccountFixture.session(trips: [AccountFixture.trip(status: .inProgress)]) + api.current = api.session.state + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + store.draft.destination = RideCatalog.places[1] + store.showBooking = true + api.stateFailure = RideAPIError(status: 401, message: "Session revoked") + + await store.sync() + + assertSignedOut() + XCTAssertNil(store.lastSync) + XCTAssertNil(store.focusedTripID) + XCTAssertNil(store.draft.destination) + XCTAssertFalse(store.showBooking) + XCTAssertFalse(store.syncConnected) + XCTAssertEqual(store.role, .rider) + XCTAssertEqual(store.displayName, "") + XCTAssertTrue(store.showSignIn) + XCTAssertTrue(store.errorMessage?.contains("expired") == true) + } + + func testSignOutNetworkFailureKeepsTheSessionForRetry() async { + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + api.signOutFailure = URLError(.notConnectedToInternet) + + await store.signOut() + + XCTAssertTrue(store.connected) + XCTAssertNotNil(vault.value) + XCTAssertNotNil(store.errorMessage) + XCTAssertEqual(vault.clearCount, 0) + XCTAssertFalse(store.busy) + } + + func testSignOutConflictRefreshesANewlyAssignedTripAndKeepsTheSession() async { + let succeeded = await store.signIn(email: AccountFixture.email, password: AccountFixture.password) + XCTAssertTrue(succeeded) + let trip = AccountFixture.trip(status: .accepted) + api.stateResponses = [api.session.state, AccountFixture.session(trips: [trip]).state] + api.signOutFailure = RideAPIError(status: 409, message: "Finish the active ride before signing out") + + await store.signOut() + + XCTAssertTrue(store.connected) + XCTAssertEqual(store.ownedActiveTrip, trip) + XCTAssertNotNil(vault.value) + XCTAssertEqual(vault.clearCount, 0) + XCTAssertEqual(api.stateTokens, [api.session.token, api.session.token]) + XCTAssertEqual(store.errorMessage, "Finish the active ride before signing out") + XCTAssertFalse(store.busy) + } + + #if DEBUG + func testRevylGateResumesAnExistingAccountWithoutCreatingOrJoiningRooms() async { + await store.handle(AccountFixture.link(), environment: AccountFixture.environment(), arguments: []) + + XCTAssertTrue(store.connected) + XCTAssertEqual(store.account, api.session.account) + XCTAssertEqual(store.tab, 2) + XCTAssertEqual(api.resumeTokens, [AccountFixture.token]) + XCTAssertEqual(vault.value?.token, AccountFixture.token) + XCTAssertTrue(store.revylStatus?.contains("accepted") == true) + XCTAssertEqual(api.signInCount, 0) + XCTAssertTrue(api.registrations.isEmpty) + assertNoRoomCalls() + } + + func testRevylGateCannotSpoofTheDatabaseAccountRole() async { + await store.handle(AccountFixture.link(role: .driver), environment: AccountFixture.environment(), arguments: []) + + assertSignedOut() + XCTAssertEqual(api.resumeTokens, [AccountFixture.token]) + XCTAssertEqual(vault.saveCount, 0) + XCTAssertTrue(store.revylStatus?.contains("rejected") == true) + assertNoRoomCalls() + } + + func testRevylGateRequiresASeparateAccountSessionToken() async { + for token in [nil, "legacy-fixture"] as [String?] { + await store.handle(AccountFixture.link(), environment: AccountFixture.environment(accountToken: token), arguments: []) + + assertSignedOut() + XCTAssertTrue(api.resumeTokens.isEmpty) + XCTAssertEqual(vault.saveCount, 0) + XCTAssertTrue(store.revylStatus?.contains("rejected") == true) + } + assertNoRoomCalls() + } + + func testRevylGateRejectsAResumedSessionWithADifferentToken() async { + api.session = AccountFixture.session(token: "user." + String(repeating: "b", count: 43)) + + await store.handle(AccountFixture.link(), environment: AccountFixture.environment(), arguments: []) + + assertSignedOut() + XCTAssertEqual(vault.saveCount, 0) + XCTAssertTrue(store.revylStatus?.contains("rejected") == true) + assertNoRoomCalls() + } + + func testRevylGateDoesNotContactAccountsWhenTheGateIsDisabledOrWrong() async { + var disabled = AccountFixture.environment() + disabled["RIDES_AUTH_BYPASS_ENABLED"] = "false" + await store.handle(AccountFixture.link(), environment: disabled, arguments: []) + await store.handle(AccountFixture.link(gateToken: "wrong-fixture"), environment: AccountFixture.environment(), arguments: []) + + assertSignedOut() + XCTAssertTrue(api.resumeTokens.isEmpty) + XCTAssertEqual(vault.saveCount, 0) + assertNoRoomCalls() + } + #else + func testReleaseBuildIgnoresTheRevylAccountGate() async { + await store.handle(AccountFixture.link(), environment: AccountFixture.environment(), arguments: []) + + assertSignedOut() + XCTAssertTrue(api.resumeTokens.isEmpty) + XCTAssertEqual(vault.saveCount, 0) + XCTAssertNil(store.revylStatus) + assertNoRoomCalls() + } + #endif + + private func assertSecurePersistence(password: String, file: StaticString = #filePath, line: UInt = #line) throws { + let stored = try XCTUnwrap(vault.value, file: file, line: line) + XCTAssertEqual(vault.saveCount, 1, file: file, line: line) + XCTAssertEqual(stored.account, api.session.account, file: file, line: line) + XCTAssertEqual(stored.token, api.session.token, file: file, line: line) + let encoded = try JSONEncoder().encode(stored) + let fields = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any], file: file, line: line) + XCTAssertEqual(Set(fields.keys), Set(["baseURL", "roomCode", "participantId", "token", "role", "name", "account", "expiresAt"]), file: file, line: line) + let profile = try XCTUnwrap(fields["account"] as? [String: Any], file: file, line: line) + XCTAssertNil(profile["password"], file: file, line: line) + XCTAssertNil(profile["passwordHash"], file: file, line: line) + XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains(password), file: file, line: line) + XCTAssertTrue((defaults.persistentDomain(forName: suite) ?? [:]).isEmpty, file: file, line: line) + } + + private func assertSignedOut(file: StaticString = #filePath, line: UInt = #line) { + XCTAssertFalse(store.connected, file: file, line: line) + XCTAssertNil(store.credentials, file: file, line: line) + XCTAssertNil(store.account, file: file, line: line) + XCTAssertNil(store.state, file: file, line: line) + XCTAssertNil(vault.value, file: file, line: line) + } + + private func assertNoRoomCalls(file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(api.createCount, 0, file: file, line: line) + XCTAssertEqual(api.joinCount, 0, file: file, line: line) + } +} + +private final class AccountTestVault: RideSessionVault { + var value: StoredRideSession? + var saveCount = 0 + var clearCount = 0 + var failure: Error? + + func load() -> StoredRideSession? { value } + func save(_ session: StoredRideSession) throws { + saveCount += 1 + if let failure { throw failure } + value = session + } + func clear() { + clearCount += 1 + value = nil + } +} + +private final class AccountTestAPI: RideAPI { + let baseURL = AccountFixture.baseURL + var session = AccountFixture.session() + var current = AccountFixture.session().state + var authenticationFailure: Error? + var stateFailure: Error? + var stateResponses: [RideRoomState] = [] + var signOutFailure: Error? + var registrations: [RideRegistration] = [] + var signInCount = 0 + var signedInEmail: String? + var signedInPassword: String? + var resumeTokens: [String] = [] + var revokedTokens: [String] = [] + var stateTokens: [String] = [] + var availabilityTokens: [String] = [] + var events: [String] = [] + var createCount = 0 + var joinCount = 0 + var suspendSignUp = false + var onSignUpSuspended: (() -> Void)? + var signUpContinuation: CheckedContinuation? + + func signUp(_ registration: RideRegistration) async throws -> RideSession { + registrations.append(registration) + if let authenticationFailure { throw authenticationFailure } + if suspendSignUp { + return try await withCheckedThrowingContinuation { continuation in + signUpContinuation = continuation + onSignUpSuspended?() + } + } + return session + } + func signIn(email: String, password: String) async throws -> RideSession { + signInCount += 1 + signedInEmail = email + signedInPassword = password + if let authenticationFailure { throw authenticationFailure } + return session + } + func resume(token: String) async throws -> RideSession { + resumeTokens.append(token) + if let authenticationFailure { throw authenticationFailure } + return session + } + func signOut(token: String) async throws { + events.append("revoke") + revokedTokens.append(token) + if let signOutFailure { throw signOutFailure } + } + func create(name: String) async throws -> RideSession { + createCount += 1 + throw RideAPIError(status: 400, message: "Account tests must not create rooms") + } + func join(code: String, role: DemoRole, name: String) async throws -> RideSession { + joinCount += 1 + throw RideAPIError(status: 400, message: "Account tests must not join rooms") + } + func state(token: String) async throws -> RideRoomState { + stateTokens.append(token) + events.append("state") + if let stateFailure { throw stateFailure } + if !stateResponses.isEmpty { return stateResponses.removeFirst() } + return current + } + func availability(token: String, online: Bool) async throws -> RideRoomState { + availabilityTokens.append(token) + events.append(online ? "online" : "offline") + return current + } + func book(token: String, request: TripRequest) async throws -> RideRoomState { current } + func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState { current } +} + +private enum AccountFixture { + static let baseURL = URL(string: "https://fixture.convex.cloud")! + static let token = "user." + String(repeating: "a", count: 43) + static let email = "account-fixture@example.invalid" + static let password = "Synthetic-test-password-2026!" + static let gateToken = String(repeating: "g", count: 40) + + static func account(role: DemoRole = .rider) -> RideAccount { + RideAccount(id: role == .driver ? "account-driver" : "account-rider", name: role == .driver ? "Driver fixture" : "Rider fixture", email: email, role: role, vehicle: role == .driver ? "Fixture sedan" : nil, plate: role == .driver ? "TEST 001" : nil) + } + + static func registration(role: DemoRole = .rider) -> RideRegistration { + let profile = account(role: role) + return RideRegistration(name: profile.name, email: profile.email, password: password, role: role, vehicle: profile.vehicle, plate: profile.plate) + } + + static func session(role: DemoRole = .rider, trips: [DemoTrip] = [], participantID: String? = nil, roomCode: String? = nil, stateRoomCode: String? = nil, sessionRole: DemoRole? = nil, sessionName: String? = nil, token: String = AccountFixture.token, expiresAt: Double = Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000) -> RideSession { + let profile = account(role: role) + let state = RideRoomState(roomCode: stateRoomCode ?? profile.id, revision: 1, driverOnline: role == .driver, onlineDriverIds: role == .driver ? [profile.id] : [], trips: trips) + return RideSession(roomCode: roomCode ?? profile.id, participantId: participantID ?? profile.id, token: token, role: sessionRole ?? role, name: sessionName ?? profile.name, state: state, account: profile, expiresAt: expiresAt) + } + + static func driver() -> DemoDriver { + let profile = account(role: .driver) + return DemoDriver(id: profile.id, name: profile.name, vehicle: profile.vehicle!, plate: profile.plate!, rating: 4.9) + } + + static func trip(id: String = "account-trip-fixture", status: TripStatus, driver: DemoDriver? = nil) -> DemoTrip { + let rider = account() + return DemoTrip(id: id, riderId: rider.id, riderName: rider.name, pickup: RideCatalog.places[0], destination: RideCatalog.places[1], stops: [], service: "uberX", paymentMethod: "Demo Cash", scheduledAt: nil, note: "", status: status, driver: driver, fareCents: 1840, createdAt: "2026-10-19T12:00:00.000Z", updatedAt: "2026-10-19T12:00:00.000Z", cancellationReason: nil, rating: nil, tipCents: 0, messages: []) + } + + static func link(role: DemoRole = .rider, gateToken: String = AccountFixture.gateToken) -> URL { + URL(string: "ridesdemo://revyl-auth?token=\(gateToken)&role=\(role.rawValue)&redirect=/activity")! + } + + static func environment(accountToken: String? = AccountFixture.token) -> [String: String] { + var result = ["RIDES_AUTH_BYPASS_ENABLED": "true", "RIDES_AUTH_BYPASS_TOKEN": gateToken] + if let accountToken { result["RIDES_ACCOUNT_SESSION"] = accountToken } + return result + } +} diff --git a/apps/rides/scripts/prepare-revyl-session.py b/apps/rides/scripts/prepare-revyl-session.py index c9141fa..7f5eb3e 100755 --- a/apps/rides/scripts/prepare-revyl-session.py +++ b/apps/rides/scripts/prepare-revyl-session.py @@ -1,5 +1,21 @@ #!/usr/bin/env python3 import secrets +import sys -print("RIDES_AUTH_BYPASS_ENABLED=true") -print("RIDES_AUTH_BYPASS_TOKEN=" + secrets.token_urlsafe(32)) +from revyl_accounts import account_for + + +def main(): + try: + session = account_for("rider") + except Exception: + print("Couldn't prepare the Rides account. Check backend availability.", file=sys.stderr) + return 1 + print("RIDES_AUTH_BYPASS_ENABLED=true") + print("RIDES_AUTH_BYPASS_TOKEN=" + secrets.token_urlsafe(32)) + print("RIDES_ACCOUNT_SESSION=" + session["token"]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/rides/scripts/revyl_accounts.py b/apps/rides/scripts/revyl_accounts.py new file mode 100644 index 0000000..fb6c4a5 --- /dev/null +++ b/apps/rides/scripts/revyl_accounts.py @@ -0,0 +1,77 @@ +import json +import os +from pathlib import Path +import secrets +import stat +import time +from urllib.request import Request, urlopen +import uuid + + +BACKEND_URL = "https://harmless-mammoth-742.convex.cloud" + + +def register(role, area): + account = { + "name": "Alex Morgan" if role == "rider" else "Jordan Lee", + "email": role + "-" + uuid.uuid4().hex + "@rides.invalid", + "password": secrets.token_urlsafe(32), + "role": role, + "serviceArea": area, + } + if role == "driver": + account.update(vehicle="Silver Toyota Prius", plate="TX 8248") + request = Request( + BACKEND_URL + "/api/action", + data=json.dumps({"path": "accounts:signUp", "args": account, "format": "json"}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urlopen(request, timeout=20) as response: + envelope = json.load(response) + if envelope.get("status") != "success": + raise ValueError() + session = envelope["value"] + validate_session(session, role) + return session + except Exception: + raise RuntimeError("Couldn't register the Rides test account. Check backend availability.") from None + + +def validate_session(session, role): + profile = session["account"] + if not ( + role in ("rider", "driver") + and session["role"] == profile["role"] == role + and session["participantId"] == session["roomCode"] == profile["id"] + and session["name"] == profile["name"] + and session["token"].startswith("user.") + and len(session["token"]) >= 48 + and session["expiresAt"] > time.time() * 1000 + ): + raise ValueError("Invalid or expired Rides account session") + + +def account_for(role, accounts_file=None): + if not accounts_file: + return register(role, "test-" + uuid.uuid4().hex) + path = Path(accounts_file).expanduser() + if path.exists(): + if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) & 0o077: + raise RuntimeError("The private account file must not be a symlink or readable by other users.") + try: + payload = json.loads(path.read_text()) + if payload["backend"] != BACKEND_URL: + raise ValueError() + validate_session(payload[role], role) + return payload[role] + except Exception: + raise RuntimeError("The private account file is invalid or expired. Use a new file for fresh accounts.") from None + area = "test-" + uuid.uuid4().hex + payload = {"backend": BACKEND_URL, "serviceArea": area, "rider": register("rider", area), "driver": register("driver", area)} + path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as stream: + json.dump(payload, stream) + return payload[role] diff --git a/apps/rides/scripts/start-session.py b/apps/rides/scripts/start-session.py index 7498518..f35fcca 100644 --- a/apps/rides/scripts/start-session.py +++ b/apps/rides/scripts/start-session.py @@ -1,43 +1,46 @@ #!/usr/bin/env python3 import argparse import json +import os import secrets import subprocess import sys from pathlib import Path -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode + +from revyl_accounts import account_for parser = argparse.ArgumentParser(description="Start an isolated Revyl rider or driver session without changing Eats defaults.") parser.add_argument("--role", choices=["rider", "driver"], default="rider") parser.add_argument("--build-version-id") -parser.add_argument("--server") -parser.add_argument("--room") +parser.add_argument("--accounts-file", help="Private JSON file for a freshly registered rider/driver pair; created with mode 0600 if absent") args = parser.parse_args() -if args.server: - parsed = urlparse(args.server) - if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"): - parser.error("--server must be an HTTPS address without a path or credentials") -if args.room and (not args.server or not args.room.isascii() or not args.room.isalnum() or not 8 <= len(args.room) <= 32): - parser.error("--room needs --server and an 8–32 character invitation code") + +root = Path(__file__).resolve().parents[1] +try: + session = account_for(args.role, args.accounts_file) + lines = (root / ".revyl/config.yaml").read_text().splitlines() + app_id = next(line.split(":", 1)[1].strip() for line in lines if line.strip().startswith("app_id:")) +except Exception: + print("Couldn't prepare the Rides account or app configuration. Account data is withheld.", file=sys.stderr) + raise SystemExit(1) token = secrets.token_urlsafe(32) link = "ridesdemo://revyl-auth?" + urlencode({"token": token, "role": args.role, "redirect": "/home"}) command = [ "revyl", "device", "start", "--platform", "ios", - "--app-id", "1f375e45-bf9b-434a-b939-a8892a2297c3", + "--app-id", app_id, "--launch-env", "RIDES_AUTH_BYPASS_ENABLED=true", "--launch-env", "RIDES_AUTH_BYPASS_TOKEN=" + token, + "--launch-env", "RIDES_ACCOUNT_SESSION=" + session["token"], "--app-link", link, "--timeout", "1800", "--open=false", "--json", ] if args.build_version_id: command += ["--build-version-id", args.build_version_id] -if args.server: - command += ["--launch-env", "RIDE_DEMO_BASE_URL=" + args.server] -if args.room: - command += ["--launch-env", "RIDE_DEMO_ROOM_CODE=" + args.room.upper()] - -root = Path(__file__).resolve().parents[1] -result = subprocess.run(command, cwd=root, capture_output=True, text=True) +environment = os.environ.copy() +if "REVYL_UBER_API_KEY" in environment: + environment["REVYL_API_KEY"] = environment["REVYL_UBER_API_KEY"] +result = subprocess.run(command, cwd=root, env=environment, capture_output=True, text=True) if result.returncode: print("Revyl could not start the rides session. Raw output is withheld because it may contain launch credentials. Inspect device availability with `revyl device list`.", file=sys.stderr) raise SystemExit(result.returncode) diff --git a/apps/rides/scripts/test_revyl_accounts.py b/apps/rides/scripts/test_revyl_accounts.py new file mode 100644 index 0000000..7ef6018 --- /dev/null +++ b/apps/rides/scripts/test_revyl_accounts.py @@ -0,0 +1,106 @@ +import contextlib +import io +import json +from pathlib import Path +import runpy +import tempfile +import time +import unittest +from unittest.mock import patch + +import revyl_accounts as accounts + + +def session(role, name="Account fixture"): + profile = {"id": role + "-id", "name": name, "email": role + "@rides.invalid", "role": role, "vehicle": None, "plate": None} + return {"account": profile, "roomCode": profile["id"], "participantId": profile["id"], "name": name, "role": role, "token": "user." + "x" * 43, "expiresAt": (time.time() + 3600) * 1000} + + +class AccountSetupTests(unittest.TestCase): + def response(self, request, timeout): + value = json.loads(request.data) + return io.BytesIO(json.dumps({"status": "success", "value": session(value["args"]["role"], value["args"]["name"])}).encode()) + + def test_registration_uses_public_account_action_and_fresh_credentials(self): + with patch.object(accounts, "urlopen", side_effect=self.response) as request: + accounts.register("rider", "test-area") + accounts.register("rider", "test-area") + values = [json.loads(call.args[0].data) for call in request.call_args_list] + self.assertEqual({value["path"] for value in values}, {"accounts:signUp"}) + self.assertEqual({value["args"]["serviceArea"] for value in values}, {"test-area"}) + self.assertNotEqual(values[0]["args"]["email"], values[1]["args"]["email"]) + self.assertNotEqual(values[0]["args"]["password"], values[1]["args"]["password"]) + self.assertGreaterEqual(len(values[0]["args"]["password"]), 32) + for call in request.call_args_list: + self.assertEqual(call.args[0].full_url, accounts.BACKEND_URL + "/api/action") + self.assertNotIn("Authorization", call.args[0].headers) + + def test_driver_registration_contains_vehicle_details(self): + with patch.object(accounts, "urlopen", side_effect=self.response) as request: + accounts.register("driver", "test-area") + value = json.loads(request.call_args.args[0].data)["args"] + self.assertEqual(value["role"], "driver") + self.assertTrue(value["vehicle"]) + self.assertTrue(value["plate"]) + + def test_registration_errors_do_not_expose_request_or_response(self): + for result in [OSError("private-credential"), io.BytesIO(b'{"status":"error","errorMessage":"private-credential"}')]: + with patch.object(accounts, "urlopen", side_effect=result if isinstance(result, Exception) else None, return_value=result): + with self.assertRaises(RuntimeError) as error: + accounts.register("rider", "test-area") + self.assertNotIn("private-credential", str(error.exception)) + + def test_session_validation_rejects_role_identity_and_expiry_mismatches(self): + for update in [{"role": "driver"}, {"participantId": "other-user"}, {"roomCode": "invitation"}, {"token": "legacy-token"}, {"expiresAt": 0}]: + value = session("rider") + value.update(update) + with self.assertRaises(ValueError): + accounts.validate_session(value, "rider") + + def test_pair_file_registers_distinct_users_in_same_isolated_area_and_reuses_them(self): + with tempfile.TemporaryDirectory() as folder, patch.object(accounts, "urlopen", side_effect=self.response) as request: + path = Path(folder) / "private-pair.json" + rider = accounts.account_for("rider", path) + driver = accounts.account_for("driver", path) + payload = json.loads(path.read_text()) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertNotEqual(rider["account"]["id"], driver["account"]["id"]) + self.assertEqual(request.call_count, 2) + values = [json.loads(call.args[0].data)["args"] for call in request.call_args_list] + self.assertEqual(values[0]["serviceArea"], values[1]["serviceArea"]) + self.assertTrue(payload["serviceArea"].startswith("test-")) + self.assertEqual(payload["backend"], accounts.BACKEND_URL) + self.assertNotIn("password", path.read_text()) + + def test_independent_sessions_register_independent_areas(self): + with patch.object(accounts, "urlopen", side_effect=self.response) as request: + accounts.account_for("rider") + accounts.account_for("rider") + areas = [json.loads(call.args[0].data)["args"]["serviceArea"] for call in request.call_args_list] + self.assertNotEqual(*areas) + + def test_refuses_public_or_symlinked_account_files(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "pair.json" + path.write_text(json.dumps({"backend": accounts.BACKEND_URL, "rider": session("rider")})) + path.chmod(0o644) + with self.assertRaises(RuntimeError): + accounts.account_for("rider", path) + path.chmod(0o600) + link = Path(folder) / "link.json" + link.symlink_to(path) + with self.assertRaises(RuntimeError): + accounts.account_for("rider", link) + + def test_hook_failure_prints_no_partial_launch_values(self): + output, errors = io.StringIO(), io.StringIO() + script = Path(__file__).with_name("prepare-revyl-session.py") + with patch.object(accounts, "account_for", side_effect=RuntimeError("private-credential")), contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors), self.assertRaises(SystemExit) as exit: + runpy.run_path(str(script), run_name="__main__") + self.assertEqual(exit.exception.code, 1) + self.assertEqual(output.getvalue(), "") + self.assertNotIn("private-credential", errors.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/RIDES.md b/docs/RIDES.md index aff4d63..c676928 100644 --- a/docs/RIDES.md +++ b/docs/RIDES.md @@ -1,83 +1,67 @@ -# Native rides and paired-phone demo +# Native Rides -The repository contains two independent iOS 17+ SwiftUI applications. The original `EatsClone` target and its offline ordering behavior are unchanged. `apps/rides/project.yml` generates `RidesDemo.xcodeproj`, scheme `RidesDemo`, bundle `com.revyl.ridesdemo`, display name **Rides**. One Rides binary has rider and driver modes; install it on both phones. +The repository contains two independent iOS 17+ SwiftUI applications. Eats and its offline ordering behavior are unchanged. `apps/rides/project.yml` generates the `RidesDemo` scheme and bundle `com.revyl.ridesdemo`, with display name **Rides**. Internal target names and legacy wire values remain compatible with older builds; the normal app has no pairing screen or test banner. -## Build profiles +## Accounts and trips -The apps share this Git repository, but each has its own Revyl project root, session setup, and automatic PR proof policy: +Create an account with a name, email, and a password of 12–128 characters, or sign in to an existing account. Choose Ride or Drive only at registration; the database fixes that account's role. Drivers provide a vehicle description and license plate. Account details come from the database rather than editable local placeholders. Email verification, password recovery, and independent driver verification are not integrated. -| Config | Profile | Revyl app | Build command and output, relative to its project root | -| --- | --- | --- | --- | -| `.revyl/config.yaml` | `pull-request` | Eats, `ecced7bf-9a43-4a8b-9a26-c072fa7bad74` | `bash scripts/build-ios.sh` → `build/Build/Products/Debug-iphonesimulator/EatsClone.app` | -| `apps/rides/.revyl/config.yaml` | `rides` | Rides, `1f375e45-bf9b-434a-b939-a8892a2297c3` | `bash scripts/build-ios.sh` → `build/Build/Products/Debug-iphonesimulator/RidesDemo.app` | +Both roles use the existing `landseer-enga/uber-clone` Convex production service at `https://harmless-mammoth-742.convex.cloud`. Only that public URL is bundled. No admin credential, invitation, fixed user, or password ships in the app. Passwords use independently salted scrypt; opaque, revocable account sessions expire after 30 days and are stored in the iOS Keychain. Sign out revokes the session and takes a driver offline; an active ride must first be completed or cancelled. -```sh -revyl config validate -revyl -C apps/rides config validate -revyl build --profile pull-request --platform ios -revyl -C apps/rides build --profile rides --platform ios -``` +1. Sign into a rider account on one phone and a separately registered driver account on another. Normal registration uses the Austin service area. +2. The driver taps **GO**. The rider selects a destination, service, payment preference, and pickup, then confirms the request. +3. The online driver accepts the request. Assignment is atomic: competing drivers cannot both accept, and one driver cannot take two riders' active trips. +4. Both users can exchange messages. The assigned driver marks arrival, starts, and completes the ride. The rider can record a rating and tip preference. +5. Cancellation is available before the trip starts and records a zero-charge history entry. Once started, the assigned driver completes the trip. + +Riders see only their own trip history. Drivers see bounded requests in their service area and their own assigned trips, not other riders' completed history or messages. Account history returns the latest 20 trips, retaining an active trip even when it is older; drivers may additionally see up to 20 pending offers. There is no account lifetime trip cap. Request UUIDs remain idempotent after their trip leaves the history window. Area-wide revisions prevent older snapshots from overwriting newer changes. -The root project remains Eats-only; run Rides CLI commands with `-C apps/rides` or from that directory. Revyl selects the nearest config, so each app gets its own sign-in rather than inheriting the Eats hook. Project `b13e33ff-5c8d-490e-bb24-9ef1d4c06bba` is registered at `apps/rides`, with independent preview builds, Revyl-harness Proof of Changes, strict build checks, and triggers for Rides and its shared service. Eats triggers cover its own source and configuration. A change touching both sets of paths is eligible for both projects. +The pinned ConvexMobile 0.8.1 SDK supplies live dispatch subscriptions. Backgrounding cancels observation; foregrounding restores it. Network failures retain the secure session and last state without inventing acceptance, messages, or trip progress. Bookmarks, reminder preferences, and payment preferences remain local to the phone. -The Rides project was published in manual authority so automation can run before the config reaches `main`. Once Revyl reconciles its designated config from `main`, Git becomes authoritative. The existing root project is already Git-managed; its updated published aggregate follows `main`, while PR candidate configuration is validated at the exact PR head. Do not use a force-push of server configuration to bypass Git authority. +## Builds and independent proofs -Both proof policies explicitly scope device verification to their own app rather than expecting the other binary's screens. Rides automatic proof exercises independent Rider sign-in, creation of a private room on the prefilled hosted backend, a real demo request and zero-charge cancellation, tabs, saved places, and wallet. Invitations must stay hidden in public evidence. The single-phone policy does not claim to verify driver acceptance, two-way chat, or completion without a second phone, and neither policy establishes full Uber parity. +| Project root | Profile | Build output | +| --- | --- | --- | +| Repository root | `pull-request` | `build/Build/Products/Debug-iphonesimulator/EatsClone.app` | +| `apps/rides` | `rides` | `build/Build/Products/Debug-iphonesimulator/RidesDemo.app` | -On an Apple Silicon Mac, `cd apps/rides && bash scripts/build-ios.sh` runs the core tests in Debug and Release and builds the iOS app and test bundle. ConvexMobile 0.8.1 supplies arm64 iOS, simulator, and macOS binaries, not Intel binaries. Simulator builds are ad-hoc signed with a simulator-specific Keychain identity; an unsigned simulator app cannot save the shared session. `bash scripts/verify-ios.sh` additionally executes XCTest, including a real Keychain round trip, on an installed iPhone simulator. The cloud build recipe compiles but does not execute iOS XCTest. The `Rides demo` GitHub workflow runs Convex typechecking/tests, legacy server tests, and simulator verification separately, on pull requests and pushes to `main`. CI tests do not deploy or require a Convex admin credential. +Run `revyl config validate` at the repository root and `revyl -C apps/rides config validate` for Rides. Build with `revyl build --profile pull-request --platform ios` or `revyl -C apps/rides build --profile rides --platform ios`. Each root owns its app, session hook, changed-path triggers, and proof policy. A proof never requires the other binary's screens. -## Shared service +The Rides single-phone proof registers a fresh real database rider in a unique test service area. It covers the normal signed-in Home, hosted request, cancellation, history, tabs, and profile. It must not claim driver acceptance, chat, or completion without a second separately registered driver in that same area. Launch variables and account sessions must never appear in public evidence. -The default shared backend is the existing **uber-clone** project in the **landseer-enga** Convex team, at `https://harmless-mammoth-742.convex.cloud`. This public deployment URL is bundled in the Rides app; no account token or deployment key is bundled. The source and deployment instructions are in [`services/ride-convex`](../services/ride-convex/README.md). No workspace process is needed to keep this hosted service running. +The Uber Revyl account migration is separate from native account authentication. Until the GitHub connection and project identities are verified, repository configuration remains on its existing Revyl projects. Do not publish a config combining a Benchmarks project ID with an Uber app ID or bypass Git-managed configuration authority. `REVYL_UBER_API_KEY` is separate from the existing `REVYL_API_KEY`; use only a process-scoped override for Uber commands, and never change the original key or credential store. See [Revyl setup](REVYL.md). -The Swift app uses pinned ConvexMobile 0.8.1 mutations and a live `rides:state` subscription. The backend stores isolated invitation rooms, SHA-256 credential hashes, demo requests, atomic acceptance and transitions, messages, and ratings in Convex tables. A private invitation can grant rider or driver access to that room; it is a demo capability, not verified production identity. Rooms expire after 24 hours, but expiry does not delete retained database documents. Rooms allow 20 lifetime trips, 32 participants, 128 credentials, and 100 messages per trip; create a fresh room when full. There is no real car dispatch or payment settlement. +## Isolated two-user verification -The original dependency-free Node 24 HTTP/SQLite service remains available for optional local or self-hosted development: +The Debug-only Revyl gate resumes an already registered database account; it does not create a room or grant a role. It requires a separate per-launch gate token plus the real account session, then verifies the server's identity, role, name, and expiration. Release builds ignore the gate. ```sh -cd services/ride-demo -npm test -npm start +python3 apps/rides/scripts/start-session.py --role rider --accounts-file ~/.capy/work/rides-pair.json +python3 apps/rides/scripts/start-session.py --role driver --accounts-file ~/.capy/work/rides-pair.json ``` -For that legacy service, run its Docker image behind HTTPS and mount `/data` on durable storage. Enter the same HTTPS origin on both phones; non-Convex origins use the existing HTTP transport. Temporary workspace previews can stop and are not permanent hosting. Use **Use hosted Convex backend** to reset a custom address to the bundled hosted default. Invitations and credentials are never hardcoded. - -## Pair two phones - -1. Install the same Rides build on both iPhones. On the rider, tap **Connect two-phone demo** or **Account → Rider & driver demo**. Choose **Rider**, keep the prefilled Convex address, enter a sample name, and tap **Create rider room**. -2. Keep the invitation private; **Copy invitation** shares the address and code, while **Show private invitation** reveals it only when requested. On the second phone, choose **Driver**, use the same backend address and the rider's room code, and tap **Join as Driver**. Tap **GO** to become available. -3. On the rider, tap **Where to?**, select an offline demo destination, choose a service and payment label, then confirm pickup. The request must appear on the driver phone; nothing accepts it automatically. -4. Tap **Accept ride** on the driver. The rider must change to the assigned driver, vehicle, and plate. Use **Message driver/rider** to send messages between the phones. -5. The driver taps **I've arrived**, **Start**, and **Complete ride**. The rider observes each server-committed state through the live subscription. The rider can submit a rating and demo tip; receipts and the driver's demo earnings update from shared state. -6. For a separate cancellation case, rebook and cancel before the trip starts. Both phones must reflect cancellation. No fee is charged. A ride cannot be cancelled after starting; the assigned driver completes it. - -Each room permits one active trip. Competing online drivers cannot both accept it. Foreground Convex subscriptions reconnect through the SDK; backgrounding cancels observation until the app becomes active again. The optional legacy transport still polls once per second. Both discard older room revisions. Network failures do not locally fake acceptance or advance a trip. Credentials are kept in Keychain; bookmarks, the default payment label, and in-app reminder preferences stay in UserDefaults. Driver identity persists with its saved session; a new driver join creates a different participant. +The first call registers distinct rider/driver users through the public account action in one fresh service area and creates a private `0600` file. The second reuses the other user's real session. No admin key is needed. The file contains sensitive sessions, not passwords: keep it outside Git, do not attach it, and delete it after verification. Use a new path for a fresh pair. Without `--accounts-file`, each launch gets an independent account and area, so unrelated proofs cannot match one another. Pass `--build-version-id` to pin a compatible build. The launcher reads the app ID from the nested config, captures raw CLI output, and prints only safe session metadata. -## Isolated Revyl sign-in +The automatic `prepare-revyl-session.py` hook similarly registers a fresh rider and emits launch values only to Revyl's private hook channel. It fails closed without partial output if registration fails. Checkout-less sessions need compatible private launch values; expired account sessions cannot be used as permanent fixtures. -Each project owns its session hook. The root hook mints an Eats buyer token; `apps/rides/scripts/prepare-revyl-session.py` mints a fresh Rides token. Rides preview sessions without a checkout use the separate `RIDES_AUTH_BYPASS_ENABLED` and encrypted `RIDES_AUTH_BYPASS_TOKEN` organization launch references. They never replace the Eats variables. The automatic Rides proof defaults to Rider. The explicit launcher selects either Rider or Driver from the nested project: +## Verification commands ```sh -python3 apps/rides/scripts/start-session.py --role rider -python3 apps/rides/scripts/start-session.py --role driver +cd services/ride-convex +npm ci && npm run typecheck && npm test && npm audit +cd ../.. +python scripts/test_revyl_config.py +python -m unittest discover -s apps/rides/scripts -p 'test_*.py' ``` -Pass `--build-version-id` to pin a build. Pass the rider's invitation with `--room` to join automatically. The launcher mints an independent token per phone, supplies it at app boot, and uses `ridesdemo://revyl-auth` with the requested allowlisted role. It captures raw CLI output and prints only safe session metadata, never the token or deep link. The fixed fresh-session names are Alex Morgan (rider) and Jordan Lee (driver). The bypass selects a demo role; it is not production authentication or a fixed global backend account. The handler is Debug-only, explicitly enabled and token-gated, with role, route, and parameter allowlists. Session creation can queue if the Revyl workspace has no capacity. +On an Apple Silicon Mac, `cd apps/rides && bash scripts/build-ios.sh` runs Swift core tests in Debug and Release and builds the app/test bundle. `bash scripts/verify-ios.sh` additionally executes iPhone simulator XCTest, including a real Keychain round trip. ConvexMobile 0.8.1 does not include an Intel simulator slice. Simulator builds are ad-hoc signed with the simulator-specific entitlement; do not disable signing or weaken Keychain tests. GitHub's Rides workflow runs Convex, legacy service, and actual simulator tests independently. CI does not deploy or need an admin token. -## Reference coverage and limits +Backend deployment instructions and API contracts are in [`services/ride-convex`](../services/ride-convex/README.md). The older invitation-room Convex API and HTTP/SQLite service remain for older clients and regression tests, but the current UI does not expose them. -The supplied [Uber iOS release](https://mobbin.com/apps/uber-ios-51d7d6f1-1a92-4d18-bb0c-0958f4d14598/d3962942-f984-494a-91bc-1d31f6e974e9/flows) redirects to a login page in this workspace's browser. The connected Mobbin search API supplied indexed Uber iOS flow metadata and screenshots; it did not provide an exhaustive version-pinned inventory. This implementation follows the core rider screen structure, not every Uber product or a claimed pixel-perfect reproduction. The driver dashboard is an original companion design, not a reproduction of Uber Driver screens. +## Scope and limits -| Indexed reference | Demo implementation | -| --- | --- | -| [Booking a ride](https://mobbin.com/flows/c54d8dfb-8f67-4e82-afb6-7943d8886c5b) | Rides/Eats header, destination suggestions, pickup/destination, up to two stops, vehicle tiers, sample fares, payment selection, pickup confirmation, request and tracking states, rating and tips | -| [Reserving a ride](https://mobbin.com/flows/41341864-d3d9-4d31-b37e-840222b27239) | Date/time picker and reservation details; limited to 30 days; visible immediately to the driver and manually advanced, not timed dispatch | -| [Cancel reservation](https://mobbin.com/flows/7476e6b7-36ea-4a61-8da5-7fa55fcb41f0) | Reason selection and explicit keep/cancel actions before trip start; no real fee | -| [Message driver](https://mobbin.com/flows/c447156b-3263-43fb-8801-cb44fdf3beac) | Shared chat, quick replies, privacy warning; no VoIP, SMS, or automatic replies | -| [Activity](https://mobbin.com/flows/c8c8b75b-d49a-430a-9581-3bc669f3ce74) | Active/reserved ride, shared past trips, filters, receipt, and rebook | -| [Account](https://mobbin.com/flows/8b0e61f3-f4e6-4f64-bbf4-d9df7a247326), [Wallet](https://mobbin.com/flows/5d2557e7-3e74-4bfa-b5d0-ad40ef66f371), [Settings](https://mobbin.com/flows/56e8bd16-f5b3-41fc-af23-c5c7064ca6b6) | Demo profile, method selection, example balance, saved locations and preferences; no real card entry or balances | -| [Help](https://mobbin.com/flows/a7808204-ebf4-4030-8ae0-cb0d63b96806), [Safety toolkit](https://mobbin.com/flows/532bd6f4-1a97-4edf-8080-4bf347f3007b) | Demo help, trip detail sharing and explicit safety limitations; no live support, emergency calls, audio, crash detection, or PIN verification | +This is an independent app, not a verified transport or financial service. Maps are original Austin route illustrations, not GPS tracking or routing. Matching connects registered app users; it does not dispatch real vehicles. Driver identities and vehicle details are not independently verified. Payment preferences and fare/tip records do not authorize cards, transfer money, or create payouts. Reservations record a requested time without automatic timed dispatch. Phone calls, emergency response, email recovery, push notifications, and background delivery are unavailable. These limits appear in contextual Help, About, Wallet, and receipt screens rather than global demo warnings. -Not implemented: exhaustive onboarding/auth parity, group invitations and contact switching, rentals, bikes/scooters, memberships, family/business profiles, promotions, real maps/geocoding/GPS, live dispatch or matching, payment settlement, and background/push delivery. Maps and all travel estimates are original offline illustrations. The Eats shortcut opens the separate installed Eats demo; if absent, it explains how to install it. No Mobbin screenshots, Uber logos, or proprietary fonts are bundled. +The rider structure was informed by indexed Uber flow references; exhaustive, version-pinned parity has not been established. The driver dashboard is original. No Mobbin screenshots, Uber logos, or proprietary fonts are bundled. The Eats shortcut opens the separate installed Eats application. -See [verification evidence](RIDES_VERIFICATION.md) for checks actually completed and outstanding gates. +See [verification evidence](RIDES_VERIFICATION.md) for completed checks and outstanding gates. diff --git a/docs/RIDES_VERIFICATION.md b/docs/RIDES_VERIFICATION.md index e54f037..2328ce6 100644 --- a/docs/RIDES_VERIFICATION.md +++ b/docs/RIDES_VERIFICATION.md @@ -2,6 +2,18 @@ No real ride, charge, call, or location tracking is performed. +## Account-backed follow-up — verification in progress + +The current follow-up replaces the invitation UI with database-backed registration, sign-in, persisted profiles, and sign-out. It removes customer-facing demo language, pairing controls, fixed profile ratings and wallet balances, and visible Revyl banners. New account dispatch is separate from the legacy room API described below, which remains compatible for older clients. + +The integrated backend passes 114 tests, including all 45 legacy cases, and npm audit reports no vulnerabilities. The production deploy dry run also passes with Node type declarations explicitly enabled. Six configuration-isolation tests and eight account-session setup tests pass. These are local/transaction checks, not proof of deployed account behavior. + +An isolated source snapshot compiled the new native screens and test bundle on the Uber account's arm64 runner. Attempting simulator execution there failed because that runner has no available iPhone simulator runtime; this does not establish executed iOS XCTest. The follow-up's exact-head simulator CI and native paired-account device verification are still required. The latest upgrade/session regression is newer than that first build snapshot. + +Uber API access and its existing Eats/Rides app containers are verified with the separate `REVYL_UBER_API_KEY`. GitHub repository authorization/project discovery in that account is still blocked; existing repository configs and the original API key have not been migrated or replaced. Do not treat a successful manual app build as completed GitHub automation migration. + +The earlier hosted-room PR at `dc03df4c` has now completed its independent single-phone Revyl proof with no defects found in the exercised request/cancellation/navigation flow. That evidence does not verify the new account follow-up or paired behavior. + ## Hosted Convex deployment — October 19, 2026 The existing `landseer-enga/uber-clone` production deployment is `https://harmless-mammoth-742.convex.cloud`. The CLI deployed the schema, seven indexes, and six public ride functions; inspection confirmed `rooms`, `participants`, `credentials`, and `trips` tables. No replacement project was created and no existing project was deleted. The production service does not depend on a workspace process. diff --git a/scripts/test_revyl_config.py b/scripts/test_revyl_config.py index be808e7..d06f633 100644 --- a/scripts/test_revyl_config.py +++ b/scripts/test_revyl_config.py @@ -14,6 +14,20 @@ ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "apps/rides/scripts")) + +from revyl_accounts import BACKEND_URL + + +def account_response(request, timeout): + value = json.loads(request.data) + registration = value["args"] + if request.full_url != BACKEND_URL + "/api/action" or value["path"] != "accounts:signUp": + raise AssertionError("Account setup must use public registration") + token = "user." + registration["password"] + profile = {"id": "fixture-user", "name": registration["name"], "email": registration["email"], "role": registration["role"], "vehicle": registration.get("vehicle"), "plate": registration.get("plate")} + session = {"account": profile, "roomCode": profile["id"], "participantId": profile["id"], "name": profile["name"], "role": profile["role"], "token": token, "expiresAt": 9000000000000} + return io.BytesIO(json.dumps({"status": "success", "value": session}).encode()) class RevylConfigTests(unittest.TestCase): @@ -45,10 +59,24 @@ def test_session_hooks_are_isolated_and_mint_fresh_tokens(self): self.assertTrue(os.access(hook, os.X_OK)) values = [] for _ in range(2): - result = subprocess.run([sys.executable, str(hook)], capture_output=True, text=True) - self.assertEqual(result.returncode, 0) - value = dict(line.split("=", 1) for line in result.stdout.splitlines()) - self.assertEqual(set(value), {prefix + "_AUTH_BYPASS_ENABLED", prefix + "_AUTH_BYPASS_TOKEN"}) + if prefix == "RIDES": + output = io.StringIO() + with patch("revyl_accounts.urlopen", side_effect=account_response) as request, contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exit: + runpy.run_path(str(hook), run_name="__main__") + self.assertEqual(exit.exception.code, 0) + self.assertEqual(request.call_count, 1) + self.assertEqual(json.loads(request.call_args.args[0].data)["args"]["role"], "rider") + text = output.getvalue() + else: + result = subprocess.run([sys.executable, str(hook)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0) + text = result.stdout + value = dict(line.split("=", 1) for line in text.splitlines()) + expected = {prefix + "_AUTH_BYPASS_ENABLED", prefix + "_AUTH_BYPASS_TOKEN"} + if prefix == "RIDES": + expected.add("RIDES_ACCOUNT_SESSION") + self.assertTrue(value["RIDES_ACCOUNT_SESSION"].startswith("user.")) + self.assertEqual(set(value), expected) self.assertEqual(value[prefix + "_AUTH_BYPASS_ENABLED"], "true") self.assertTrue(len(value[prefix + "_AUTH_BYPASS_TOKEN"]) >= 32) values.append(value) @@ -77,8 +105,10 @@ def test_rides_launcher_uses_nested_project_for_both_roles(self): for role in ["rider", "driver"]: output = io.StringIO() response = subprocess.CompletedProcess([], 0, stdout=json.dumps({"session_id": "test-session", "launch_env": "must-not-be-printed"}), stderr="") - with patch.object(sys, "argv", [str(script), "--role", role]), patch("subprocess.run", return_value=response) as run, contextlib.redirect_stdout(output): + with patch.object(sys, "argv", [str(script), "--role", role]), patch("revyl_accounts.urlopen", side_effect=account_response) as request, patch("subprocess.run", return_value=response) as run, contextlib.redirect_stdout(output): runpy.run_path(str(script), run_name="__main__") + self.assertEqual(request.call_count, 1) + self.assertEqual(json.loads(request.call_args.args[0].data)["args"]["role"], role) self.assertEqual(run.call_args.kwargs["cwd"], ROOT / "apps/rides") command = run.call_args.args[0] link = command[command.index("--app-link") + 1] @@ -86,6 +116,7 @@ def test_rides_launcher_uses_nested_project_for_both_roles(self): self.assertTrue(parse_qs(urlparse(link).query)["role"] == [role]) app = next(iter(self.configs[1]["build"]["profiles"].values()))["ios"]["app_id"] self.assertTrue(command[command.index("--app-id") + 1] == app) + self.assertTrue(any(value.startswith("RIDES_ACCOUNT_SESSION=user.") for value in command)) self.assertEqual(json.loads(output.getvalue()), {"session_id": "test-session", "role": role}) def test_runtime_state_is_ignored_for_both_projects(self): diff --git a/services/ride-convex/README.md b/services/ride-convex/README.md index f4cee47..75301c1 100644 --- a/services/ride-convex/README.md +++ b/services/ride-convex/README.md @@ -12,7 +12,7 @@ npm run typecheck npm test ``` -Tests use `convex-test` and Vitest's edge runtime, with transaction limits enabled. They do not require a Convex account, local deployment, environment variables, or network access after installation. Concurrent test calls exercise the mock backend's serialized transactions; production conflict retries and live subscriptions still require integration verification against a deployment. +Tests use `convex-test` with transaction limits enabled. Legacy room tests use Vitest's edge runtime; account/dispatch tests use Node to exercise the real Node crypto actions. They do not require a Convex account, local deployment, environment variables, or network access after installation. Concurrent test calls exercise the mock backend's serialized transactions; production conflict retries and live subscriptions still require integration verification against a deployment. Static `_generated` bindings are checked in. Convex 1.47.0's ordinary `codegen` command requires an existing deployment selection. The npm script instead uses that version's offline `--system-udfs` code-generation path, with deployment/access-token variables explicitly removed. It generates schema-derived data model types, typed function references, and the standard server bindings without creating or contacting a deployment. The flag is an internal CLI feature pinned to this Convex version. Normal deployment/codegen can regenerate the same bindings later. @@ -29,9 +29,39 @@ npx convex deploy --typecheck enable --yes Selection writes ignored local configuration and chooses the existing project's development deployment; `deploy` targets that project's default production deployment. Confirm that the dry run names `harmless-mammoth-742` before deploying. Deployment regenerates bindings, validates the schema, and publishes functions and indexes. Regular app clients need only the public URL and their own private demo-room session, never this admin token. Eats uses neither this deployment nor its credentials. -## Public API +## Account and dispatch API -All functions live in `convex/rides.ts`. Function paths are `rides:createRoom`, `rides:joinRoom`, `rides:state`, `rides:availability`, `rides:book`, and `rides:act`. +New clients use accounts and dispatch, without invitations, shared identities, or room creation. Existing `rides:*` functions and tables remain compatible with older room clients; their credentials cannot authenticate account functions and account credentials cannot access legacy rooms. + +| Function | Kind | Arguments | Result | +| --- | --- | --- | --- | +| `accounts:signUp` | action | `{ name, email, password, role, vehicle?, plate?, serviceArea? }` | extended `RideSession` | +| `accounts:signIn` | action | `{ email, password }` | extended `RideSession` | +| `accounts:resume` | query | `{ token }` | extended `RideSession` | +| `accounts:profile` | query | `{ token }` | account | +| `accounts:signOut` | mutation | `{ token }` | `true` | +| `dispatch:state` | query | `{ token }` | `RideRoomState` | +| `dispatch:availability` | mutation | `{ token, online }` | `RideRoomState` | +| `dispatch:book` | mutation | exactly the `rides:book` envelope below | `RideRoomState` | +| `dispatch:act` | mutation | exactly the `rides:act` envelope below | `RideRoomState` | + +The extended session is `{ roomCode, participantId, token, role, name, state, account, expiresAt }`, with account `{ id, name, email, role, vehicle, plate }`. `account.id == participantId == roomCode` is the persistent database user ID. The `roomCode` field is only a legacy Swift binding, **not an invitation**. Session role/name match the account and come from the database. `expiresAt` is epoch milliseconds. Tokens are `user.` followed by 32 cryptographically random bytes encoded base64url (43 characters without padding). The prefix lets native clients distinguish account sessions from their older room credentials. `resume` returns the presented token and its original expiry, not a new session. Profile vehicle/plate are null for riders; drivers must supply both when registering. There is no profile or role-changing endpoint. + +Email is trimmed, lowercased, and unique; names are trimmed and limited to 80 characters. Passwords are 12–128 characters, used verbatim without trimming. Driver vehicle and plate are limited to 100 and 20 characters. `serviceArea` defaults to `austin`; supplied areas are trimmed, lowercased, 1–80 characters matching `[a-z0-9][a-z0-9-]*`. A zone is only an offer-discovery boundary: selecting another user's zone never grants access to their assigned trips, histories, messages, or mutations. Normal UI registration should omit it. Parallel tests register a fresh rider **and** driver through `accounts:signUp` in the same `test-` zone, then inject each real returned session into its Debug app or resume it with `accounts:resume`. There are no fixture accounts or administrative seeding secrets. + +Node internal actions generate salts and credentials using `node:crypto.randomBytes`, derive 64-byte password hashes with salted scrypt (`N=32768, r=8, p=3`, random 16-byte salt), and compare with `timingSafeEqual`. The Node actions access the database only through internal mutations; no public password/hash lookup exists. Tables contain salted password hashes and SHA-256 **session** token digests, never plaintext passwords or session tokens. Each login creates a separate 30-day session. Sign-out returns 409 while the account has an active trip; otherwise it atomically takes a driver offline and deletes only the current session. Repeating a validly shaped revoked-token sign-out returns true without changing availability. Revocation and expiry are checked on every authenticated account/dispatch call. Expired rows are not automatically deleted, and subscription caching is not an expiry timer: clients must also clear expired local sessions. + +An expired session may only delete its own expired credential during sign-out; it cannot change account availability or affect newer sessions. Sign-in atomically reserves at most five attempts per registered account per 15-minute window, including successful attempts, without extending the window on failures. Case variants share the same budget. Wrong passwords, unknown accounts, and throttled accounts all return `ConvexError({ status: 401, message: "Invalid email or password" })`; unknown/throttled accounts still perform dummy scrypt work. Unknown addresses do not create throttle records. Malformed envelopes return safe application errors. Argument envelopes use `v.any()` and manual validation to avoid credential echoes from framework validators. Application code does not log credentials; credentials necessarily traverse TLS and action arguments, so platform observability and client logging still require sensitive-data controls. + +Dispatch trusts database identity and immutable role, never caller-supplied IDs or roles. Trips persist actual rider and assigned driver user references. One active-trip pointer per rider and per driver prevents simultaneous bookings and cross-rider double assignment in atomic transactions. Acceptance requires an online driver in the same area. Only the assigned driver advances the trip; only its rider/assigned driver may chat or cancel under the legacy lifecycle rules. Only its rider may rate or tip after completion. Driver snapshots use their registered vehicle/plate and a rating of 0 rather than fabricated reputation; aggregate reputation is not implemented. + +Account snapshots include at most 20 recent history records (replacing the oldest with an older active trip if needed), at most 20 requested offers for drivers, and at most 32 online-driver IDs. An online driver always sees their own ID in that bounded list. All reads use scoped indexes. Riders see only their own trips. Drivers see only requested offers in their area and their own assigned recent/active trips, never other users' assigned histories or chats. An area-wide revision increases atomically on successful dispatch mutations so native clients can reject out-of-order snapshots, including discovery/availability changes from other accounts. Identical booking retries do not increase it. Old rows and rider-scoped request IDs remain stored, so identical booking retries remain idempotent even after a trip leaves the visible recent history; changed payloads return 409. Unlike legacy rooms, accounts have no lifetime trip cap. Fares, booking validation, message bounds, and lifecycle rules below also apply to dispatch. + +This is real database-backed email/password authentication, **not a complete production identity or security system**. Emails are not verified; password recovery, email delivery, driver/license/background verification, identity proofing, payments, global/IP-based abuse protection, and automatic retention cleanup are absent. Driver names, vehicles, and plates are self-reported. Per-account throttling does not stop distributed signup or unknown-email scrypt denial of service, and a targeted account can be temporarily locked out. Production adoption requires independent security review, deployment verification, abuse controls, verified identity/recovery workflows, and a retention policy. No deployment or account provisioning is required for local verification. + +## Legacy room public API + +Legacy room functions live in `convex/rides.ts`. Function paths are `rides:createRoom`, `rides:joinRoom`, `rides:state`, `rides:availability`, `rides:book`, and `rides:act`. | Function | Kind | Arguments | Result | | --- | --- | --- | --- | @@ -48,7 +78,7 @@ Expected failures throw `ConvexError({ status, message })`: 400 for invalid inpu Use a Convex client subscription to `rides:state` for live updates. The query reads the room, every bounded room participant, and every bounded room trip through room-scoped indexes, so all room changes invalidate its result. Revisions increase for joins and successful state-changing mutations; an identical booking retry or an identical existing-token join does not increase the revision. Ordering is newest trip first by Convex insertion order. Expiry is checked on each function execution; a cached subscription is not a timer and clients should clear an expired local session rather than expect an expiry-only push. -## Credentials and privacy boundary +## Legacy credentials and privacy boundary The client must generate a fresh credential from cryptographically secure random bytes before creating or joining a room. Accepted credentials are 32–256 characters, used verbatim without trimming, and hashed with server-side SHA-256. Application tables persist only the hash and a participant reference. No application function logs credentials or stores plaintext credentials in documents or errors. Credentials are necessarily sent as Convex function arguments over TLS; handle client transport, platform access, and observability as sensitive infrastructure. Do not place tokens in URLs, logs, analytics, or repository files. @@ -58,7 +88,7 @@ Invitations are private, case-sensitive, 16-character capabilities using `ABCDEF This is private-capability demo authentication, **not production user authentication**. There is no verified user identity, membership approval, per-IP rate limiter, payment processing, or abuse-prevention service. Production use needs an authenticated identity and invitation-management policy, abuse protection at ingress, and an explicit data-retention policy. The public functions offer no administrative database access. -## Business rules and bounded reads +## Legacy room business rules and bounded reads Each room has one rider, at most 31 distinct drivers, at most 128 credentials, and at most 20 lifetime trips. Each trip has at most 100 messages. Reaching a cap returns 409 rather than deleting history, invalidating old credentials, or weakening request-id idempotency. Start a new room for another demo. These caps keep full-room snapshots bounded even with maximum-length Unicode strings and full chat histories; there is no hidden partial-history pagination. diff --git a/services/ride-convex/convex/_generated/api.d.ts b/services/ride-convex/convex/_generated/api.d.ts index 95e413a..20a1ab7 100644 --- a/services/ride-convex/convex/_generated/api.d.ts +++ b/services/ride-convex/convex/_generated/api.d.ts @@ -8,44 +8,45 @@ * @module */ -import type * as rides from "../rides.js"; -import type * as validation from "../validation.js"; - import type { ApiFromModules, FilterApi, FunctionReference, } from "convex/server"; - -declare const fullApi: ApiFromModules<{ - rides: typeof rides; - validation: typeof validation; -}>; +import type * as accountActions from "../accountActions.js"; +import type * as accountAuth from "../accountAuth.js"; +import type * as accountStore from "../accountStore.js"; +import type * as accountTypes from "../accountTypes.js"; +import type * as accounts from "../accounts.js"; +import type * as dispatch from "../dispatch.js"; +import type * as dispatchState from "../dispatchState.js"; +import type * as rides from "../rides.js"; +import type * as validation from "../validation.js"; /** - * A utility for referencing Convex functions in your app's public API. + * A utility for referencing Convex functions in your app's API. * * Usage: * ```js * const myFunctionReference = api.myModule.myFunction; * ``` */ +declare const fullApi: ApiFromModules<{ + accountActions: typeof accountActions; + accountAuth: typeof accountAuth; + accountStore: typeof accountStore; + accountTypes: typeof accountTypes; + accounts: typeof accounts; + dispatch: typeof dispatch; + dispatchState: typeof dispatchState; + rides: typeof rides; + validation: typeof validation; +}>; export declare const api: FilterApi< typeof fullApi, FunctionReference >; - -/** - * A utility for referencing Convex functions in your app's internal API. - * - * Usage: - * ```js - * const myFunctionReference = internal.myModule.myFunction; - * ``` - */ export declare const internal: FilterApi< typeof fullApi, FunctionReference >; - -export declare const components: {}; diff --git a/services/ride-convex/convex/_generated/api.js b/services/ride-convex/convex/_generated/api.js index 44bf985..3f9c482 100644 --- a/services/ride-convex/convex/_generated/api.js +++ b/services/ride-convex/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi, componentsGeneric } from "convex/server"; +import { anyApi } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. @@ -20,4 +20,3 @@ import { anyApi, componentsGeneric } from "convex/server"; */ export const api = anyApi; export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/services/ride-convex/convex/_generated/server.d.ts b/services/ride-convex/convex/_generated/server.d.ts index f235db4..e22cbd9 100644 --- a/services/ride-convex/convex/_generated/server.d.ts +++ b/services/ride-convex/convex/_generated/server.d.ts @@ -21,17 +21,6 @@ import { } from "convex/server"; import type { DataModel } from "./dataModel.js"; -/** - * Typesafe environment variables. - * - * This includes platform-provided env vars and any variables declared in - * `convex.config.ts`. - */ -type Env = { - readonly CONVEX_CLOUD_URL: string; - readonly CONVEX_SITE_URL: string; -}; - /** * Define a query in this Convex app's public API. * @@ -106,13 +95,7 @@ export declare const internalAction: ActionBuilder; */ export declare const httpAction: HttpActionBuilder; -/** - * Typesafe environment variables. - * - * This includes platform-provided env vars and any variables declared in - * `convex.config.ts`. - */ -export declare const env: Env; +export declare const env: Record; /** * A set of services for use within Convex query functions. diff --git a/services/ride-convex/convex/_generated/server.js b/services/ride-convex/convex/_generated/server.js index 2dbe0db..79c6b77 100644 --- a/services/ride-convex/convex/_generated/server.js +++ b/services/ride-convex/convex/_generated/server.js @@ -92,10 +92,4 @@ export const internalAction = internalActionGeneric; */ export const httpAction = httpActionGeneric; -/** - * Typesafe environment variables. - * - * This includes platform-provided env vars and any variables declared in - * `convex.config.ts`. - */ export const env = process.env; diff --git a/services/ride-convex/convex/accountActions.ts b/services/ride-convex/convex/accountActions.ts new file mode 100644 index 0000000..ddcb0a7 --- /dev/null +++ b/services/ride-convex/convex/accountActions.ts @@ -0,0 +1,77 @@ +"use node"; + +import { createHash, randomBytes, scrypt, timingSafeEqual } from "node:crypto"; +import { v } from "convex/values"; +import { internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { AccountSession, SignIn, SignUp } from "./accountTypes"; +import { fail, object, text } from "./validation"; + +function emailAddress(value: unknown): string { + const email = text(value, 254, "email").toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) fail(400, "Invalid email"); + return email; +} + +function passwordValue(value: unknown): string { + if (typeof value !== "string" || value.length < 12 || value.length > 128) fail(400, "Password must contain 12 to 128 characters"); + return value; +} + +async function passwordDigest(password: string, salt: string): Promise { + return new Promise((resolve, reject) => { + scrypt(password, salt, 64, { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, (error, key) => { + if (error) reject(new Error("Password processing unavailable")); + else resolve(key); + }); + }); +} + +function credential() { + const token = `user.${randomBytes(32).toString("base64url")}`; + return { token, sessionHash: createHash("sha256").update(token).digest("hex") }; +} + +export const signUp = internalAction({ + args: v.any(), + handler: async (ctx, args: SignUp): Promise => { + const input = object(args, "arguments"); + const name = text(input.name, 80, "name"); + const email = emailAddress(input.email); + const password = passwordValue(input.password); + const role = input.role; + if (role !== "rider" && role !== "driver") fail(400, "Invalid role"); + const vehicle = input.vehicle === undefined ? null : text(input.vehicle, 100, "vehicle"); + const plate = input.plate === undefined ? null : text(input.plate, 20, "plate"); + if (role === "driver" && (!vehicle || !plate)) fail(400, "Driver vehicle and plate are required"); + const serviceArea = input.serviceArea === undefined ? "austin" : text(input.serviceArea, 80, "serviceArea").toLowerCase(); + if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(serviceArea)) fail(400, "Invalid serviceArea"); + const passwordSalt = randomBytes(16).toString("hex"); + const passwordHash = (await passwordDigest(password, passwordSalt)).toString("hex"); + const { token, sessionHash } = credential(); + const session = await ctx.runMutation(internal.accountStore.register, { + name, email, role, vehicle: role === "driver" ? vehicle : null, plate: role === "driver" ? plate : null, + serviceArea, passwordSalt, passwordHash, sessionHash, + }); + return { ...session, token }; + }, +}); + +export const signIn = internalAction({ + args: v.any(), + handler: async (ctx, args: SignIn): Promise => { + const input = object(args, "arguments"); + if (typeof input.email !== "string" || input.email.length > 254 || + typeof input.password !== "string" || input.password.length > 128) fail(401, "Invalid email or password"); + const email = input.email.trim().toLowerCase(); + const candidate = await ctx.runMutation(internal.accountStore.beginLogin, { email }); + const digest = await passwordDigest(input.password, candidate?.passwordSalt ?? "00000000000000000000000000000000"); + const expected = Buffer.from(candidate?.passwordHash ?? "00".repeat(64), "hex"); + if (!timingSafeEqual(digest, expected) || !candidate) fail(401, "Invalid email or password"); + const { token, sessionHash } = credential(); + const session = await ctx.runMutation(internal.accountStore.finishLogin, { + userId: candidate.id, passwordHash: candidate.passwordHash, sessionHash, + }); + return { ...session, token }; + }, +}); diff --git a/services/ride-convex/convex/accountAuth.ts b/services/ride-convex/convex/accountAuth.ts new file mode 100644 index 0000000..04bff8e --- /dev/null +++ b/services/ride-convex/convex/accountAuth.ts @@ -0,0 +1,26 @@ +import type { Doc } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import type { Account } from "./accountTypes"; +import { fail, tokenHash } from "./validation"; + +export const sessionLifetime = 30 * 24 * 60 * 60_000; +export const loginWindow = 15 * 60_000; +export const loginLimit = 5; + +export function publicAccount(user: Doc<"users">): Account { + return { id: user._id, name: user.name, email: user.email, role: user.role, + vehicle: user.vehicle, plate: user.plate }; +} + +export async function authenticateSession(ctx: QueryCtx | MutationCtx, token: unknown) { + const hash = await tokenHash(token); + const session = await ctx.db.query("sessions").withIndex("by_hash", q => q.eq("tokenHash", hash)).unique(); + if (!session || Date.now() >= session.expiresAt) fail(401, "Invalid or expired bearer token"); + const user = await ctx.db.get(session.userId); + if (!user) fail(401, "Invalid or expired bearer token"); + return { user, session }; +} + +export async function authenticate(ctx: QueryCtx | MutationCtx, token: unknown): Promise> { + return (await authenticateSession(ctx, token)).user; +} diff --git a/services/ride-convex/convex/accountStore.ts b/services/ride-convex/convex/accountStore.ts new file mode 100644 index 0000000..27712ae --- /dev/null +++ b/services/ride-convex/convex/accountStore.ts @@ -0,0 +1,55 @@ +import { v } from "convex/values"; +import { internalMutation, type MutationCtx } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; +import { role } from "./schema"; +import { loginLimit, loginWindow, publicAccount, sessionLifetime } from "./accountAuth"; +import type { AccountSession } from "./accountTypes"; +import { accountState } from "./dispatchState"; +import { fail } from "./validation"; + +async function createSession(ctx: MutationCtx, user: Doc<"users">, hash: string): Promise> { + const expiresAt = Date.now() + sessionLifetime; + await ctx.db.insert("sessions", { userId: user._id, tokenHash: hash, expiresAt }); + return { roomCode: user._id, participantId: user._id, name: user.name, role: user.role, + state: await accountState(ctx, user), account: publicAccount(user), expiresAt }; +} + +export const register = internalMutation({ + args: { + name: v.string(), email: v.string(), role, passwordSalt: v.string(), passwordHash: v.string(), + vehicle: v.union(v.string(), v.null()), plate: v.union(v.string(), v.null()), serviceArea: v.string(), + sessionHash: v.string(), + }, + handler: async (ctx, args): Promise> => { + if (await ctx.db.query("users").withIndex("by_email", q => q.eq("email", args.email)).unique()) { + fail(409, "Unable to create account"); + } + const { sessionHash, ...profile } = args; + const id = await ctx.db.insert("users", { ...profile, online: false, activeTripId: null, + loginWindowStart: 0, loginAttempts: 0 }); + return createSession(ctx, (await ctx.db.get(id))!, sessionHash); + }, +}); + +export const beginLogin = internalMutation({ + args: { email: v.string() }, + handler: async (ctx, { email }) => { + const user = await ctx.db.query("users").withIndex("by_email", q => q.eq("email", email)).unique(); + if (!user) return null; + const now = Date.now(); + const reset = now >= user.loginWindowStart + loginWindow; + const attempts = reset ? 0 : user.loginAttempts; + if (attempts >= loginLimit) return null; + await ctx.db.patch(user._id, { loginWindowStart: reset ? now : user.loginWindowStart, loginAttempts: attempts + 1 }); + return { id: user._id, passwordSalt: user.passwordSalt, passwordHash: user.passwordHash }; + }, +}); + +export const finishLogin = internalMutation({ + args: { userId: v.id("users"), passwordHash: v.string(), sessionHash: v.string() }, + handler: async (ctx, args): Promise> => { + const user = await ctx.db.get(args.userId); + if (!user || user.passwordHash !== args.passwordHash) fail(401, "Invalid email or password"); + return createSession(ctx, user, args.sessionHash); + }, +}); diff --git a/services/ride-convex/convex/accountTypes.ts b/services/ride-convex/convex/accountTypes.ts new file mode 100644 index 0000000..5faf569 --- /dev/null +++ b/services/ride-convex/convex/accountTypes.ts @@ -0,0 +1,20 @@ +import type { Infer } from "convex/values"; +import type { trip } from "./schema"; + +export type Account = { + id: string; name: string; email: string; role: "rider" | "driver"; + vehicle: string | null; plate: string | null; +}; +export type AccountState = { + roomCode: string; revision: number; driverOnline: boolean; + onlineDriverIds: string[]; trips: Infer[]; +}; +export type AccountSession = { + roomCode: string; participantId: string; token: string; role: "rider" | "driver"; + name: string; state: AccountState; account: Account; expiresAt: number; +}; +export type SignUp = { + name: string; email: string; password: string; role: string; + vehicle?: string; plate?: string; serviceArea?: string; +}; +export type SignIn = { email: string; password: string }; diff --git a/services/ride-convex/convex/accounts.ts b/services/ride-convex/convex/accounts.ts new file mode 100644 index 0000000..082d783 --- /dev/null +++ b/services/ride-convex/convex/accounts.ts @@ -0,0 +1,53 @@ +import { v } from "convex/values"; +import { action, mutation, query } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { authenticate, authenticateSession, publicAccount } from "./accountAuth"; +import { accountState, advanceArea } from "./dispatchState"; +import type { AccountSession, SignIn, SignUp } from "./accountTypes"; +import { fail, object, tokenHash } from "./validation"; + +export const signUp = action({ + args: v.any(), + handler: async (ctx, args: SignUp): Promise => ctx.runAction(internal.accountActions.signUp, args), +}); + +export const signIn = action({ + args: v.any(), + handler: async (ctx, args: SignIn): Promise => ctx.runAction(internal.accountActions.signIn, args), +}); + +export const profile = query({ + args: v.any(), + handler: async (ctx, args: { token: string }) => publicAccount(await authenticate(ctx, object(args, "arguments").token)), +}); + +export const resume = query({ + args: v.any(), + handler: async (ctx, args: { token: string }): Promise => { + const input = object(args, "arguments"); + const { user, session } = await authenticateSession(ctx, input.token); + return { roomCode: user._id, participantId: user._id, token: input.token as string, role: user.role, + name: user.name, account: publicAccount(user), expiresAt: session.expiresAt, state: await accountState(ctx, user) }; + }, +}); + +export const signOut = mutation({ + args: v.any(), + handler: async (ctx, args: { token: string }) => { + const hash = await tokenHash(object(args, "arguments").token); + const session = await ctx.db.query("sessions").withIndex("by_hash", q => q.eq("tokenHash", hash)).unique(); + if (!session) return true; + if (Date.now() >= session.expiresAt) { + await ctx.db.delete(session._id); + return true; + } + const user = await ctx.db.get(session.userId); + if (user?.activeTripId) fail(409, "Cannot sign out during an active trip"); + if (user?.role === "driver" && user.online) { + await ctx.db.patch(user._id, { online: false }); + await advanceArea(ctx, user.serviceArea); + } + await ctx.db.delete(session._id); + return true; + }, +}); diff --git a/services/ride-convex/convex/dispatch.ts b/services/ride-convex/convex/dispatch.ts new file mode 100644 index 0000000..c4ef551 --- /dev/null +++ b/services/ride-convex/convex/dispatch.ts @@ -0,0 +1,126 @@ +import { v } from "convex/values"; +import { mutation, query, type MutationCtx } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; +import { authenticate } from "./accountAuth"; +import { accountState, advanceArea } from "./dispatchState"; +import { booking, fail, fares, limits, object, randomId, text, validSchedule } from "./validation"; + +async function changed(ctx: MutationCtx, user: Doc<"users">) { + await advanceArea(ctx, user.serviceArea); + return accountState(ctx, (await ctx.db.get(user._id))!); +} + +export const state = query({ + args: v.any(), + handler: async (ctx, args: { token: string }) => { + const user = await authenticate(ctx, object(args, "arguments").token); + return accountState(ctx, user); + }, +}); + +export const availability = mutation({ + args: v.any(), + handler: async (ctx, args: { token: string; online: boolean }) => { + const input = object(args, "arguments"); + const user = await authenticate(ctx, input.token); + if (user.role !== "driver") fail(403, "Driver role required"); + if (typeof input.online !== "boolean") fail(400, "online must be boolean"); + if (!input.online && user.activeTripId) fail(409, "Cannot go offline during an active trip"); + await ctx.db.patch(user._id, { online: input.online }); + return changed(ctx, user); + }, +}); + +type Place = { id: string; name: string; address: string; latitude: number; longitude: number }; +type Request = { requestId: string; pickup: Place; destination: Place; stops: Place[]; service: string; + paymentMethod: string; scheduledAt?: string | null; note: string }; + +export const book = mutation({ + args: v.any(), + handler: async (ctx, args: { token: string; request: Request }) => { + const input = object(args, "arguments"); + const user = await authenticate(ctx, input.token); + if (user.role !== "rider") fail(403, "Rider role required"); + const { requestId, payload } = booking(input.request); + const requestPayload = JSON.stringify(payload); + const previous = await ctx.db.query("dispatchTrips").withIndex("by_rider_request", q => + q.eq("riderId", user._id).eq("requestId", requestId)).unique(); + if (previous) { + if (previous.requestPayload !== requestPayload) fail(409, "requestId already used with different payload"); + return accountState(ctx, user); + } + validSchedule(payload.scheduledAt); + if (user.activeTripId) fail(409, "Rider already has an active trip"); + const now = new Date().toISOString(); + const tripId = await ctx.db.insert("dispatchTrips", { + riderId: user._id, driverId: null, serviceArea: user.serviceArea, status: "requested", requestId, requestPayload, + payload: { id: randomId(), riderId: user._id, riderName: user.name, ...payload, + status: "requested", driver: null, fareCents: fares[payload.service] + 300 * payload.stops.length, + createdAt: now, updatedAt: now, cancellationReason: null, rating: null, tipCents: 0, messages: [] }, + }); + await ctx.db.patch(user._id, { activeTripId: tripId }); + return changed(ctx, user); + }, +}); + +type Action = { action: string; reason?: string | null; rating?: number | null; tipCents?: number | null; text?: string | null }; + +export const act = mutation({ + args: v.any(), + handler: async (ctx, args: { token: string; tripID: string; action: Action }) => { + const input = object(args, "arguments"); + const user = await authenticate(ctx, input.token); + const tripID = text(input.tripID, 100, "tripID"); + const actionInput = object(input.action, "action"); + const action = text(actionInput.action, 20, "action"); + const row = await ctx.db.query("dispatchTrips").withIndex("by_trip_id", q => q.eq("payload.id", tripID)).unique(); + if (!row || row.serviceArea !== user.serviceArea || (user.role === "rider" && row.riderId !== user._id)) fail(404, "Trip not found"); + const trip = row.payload; + const owner = user.role === "rider" && row.riderId === user._id; + const assigned = user.role === "driver" && row.driverId === user._id; + if (action === "accept") { + if (user.role !== "driver") fail(403, "Driver role required"); + if (!user.online) fail(409, "Driver must be online"); + const rider = (await ctx.db.get(row.riderId))!; + if (trip.status !== "requested" || rider.activeTripId !== row._id) fail(409, "Trip is no longer available"); + if (user.activeTripId) fail(409, "Driver already has an active trip"); + trip.status = "accepted"; + trip.driver = { id: user._id, name: user.name, vehicle: user.vehicle!, plate: user.plate!, rating: 0 }; + row.driverId = user._id; + await ctx.db.patch(user._id, { activeTripId: row._id }); + } else if (action === "arrive" || action === "start" || action === "complete") { + if (!assigned) fail(403, "Assigned driver required"); + const transition = { arrive: ["accepted", "arriving"], start: ["arriving", "in_progress"], complete: ["in_progress", "completed"] } as const; + if (trip.status !== transition[action][0]) fail(409, `Cannot ${action} trip from ${trip.status}`); + trip.status = transition[action][1]; + } else if (action === "cancel") { + if (!owner && !assigned) fail(403, "Trip owner or assigned driver required"); + if (!["requested", "accepted", "arriving"].includes(trip.status)) fail(409, `Cannot cancel trip from ${trip.status}`); + trip.status = "cancelled"; + trip.cancellationReason = actionInput.reason === undefined || actionInput.reason === null ? null : text(actionInput.reason, 300, "reason", false); + } else if (action === "rate") { + if (!owner) fail(403, "Trip owner rider required"); + if (trip.status !== "completed") fail(409, "Only completed trips can be rated"); + const { rating, tipCents } = actionInput; + if (typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) fail(400, "rating must be an integer from 1 to 5"); + if (typeof tipCents !== "number" || !Number.isInteger(tipCents) || tipCents < 0 || tipCents > 10_000) fail(400, "tipCents must be an integer from 0 to 10000"); + trip.rating = rating; + trip.tipCents = tipCents; + } else if (action === "message") { + if (!owner && !assigned) fail(403, "Trip owner or assigned driver required"); + if (!["accepted", "arriving", "in_progress"].includes(trip.status)) fail(409, "Chat is unavailable for this trip"); + if (trip.messages.length >= limits.messages) fail(409, "Message history is full"); + const messageText = text(actionInput.text, 500, "text"); + trip.messages.push({ id: randomId(), senderId: user._id, senderName: user.name, text: messageText, createdAt: new Date().toISOString() }); + } else fail(400, "Unsupported action"); + trip.updatedAt = new Date().toISOString(); + await ctx.db.patch(row._id, { payload: trip, status: trip.status, driverId: row.driverId }); + if (trip.status === "completed" || trip.status === "cancelled") { + for (const id of [row.riderId, ...(row.driverId ? [row.driverId] : [])]) { + const participant = (await ctx.db.get(id))!; + if (participant.activeTripId === row._id) await ctx.db.patch(id, { activeTripId: null }); + } + } + return changed(ctx, user); + }, +}); diff --git a/services/ride-convex/convex/dispatchState.ts b/services/ride-convex/convex/dispatchState.ts new file mode 100644 index 0000000..7e16ec2 --- /dev/null +++ b/services/ride-convex/convex/dispatchState.ts @@ -0,0 +1,38 @@ +import type { Doc } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { limits } from "./validation"; + +export async function advanceArea(ctx: MutationCtx, name: string) { + const area = await ctx.db.query("dispatchAreas").withIndex("by_name", q => q.eq("name", name)).unique(); + if (area) await ctx.db.patch(area._id, { revision: area.revision + 1 }); + else await ctx.db.insert("dispatchAreas", { name, revision: 1 }); +} + +export async function accountState(ctx: QueryCtx | MutationCtx, user: Doc<"users">) { + const area = await ctx.db.query("dispatchAreas").withIndex("by_name", q => q.eq("name", user.serviceArea)).unique(); + const history = user.role === "rider" + ? await ctx.db.query("dispatchTrips").withIndex("by_rider", q => q.eq("riderId", user._id)).order("desc").take(limits.trips) + : await ctx.db.query("dispatchTrips").withIndex("by_driver", q => q.eq("driverId", user._id)).order("desc").take(limits.trips); + if (user.activeTripId && !history.some(row => row._id === user.activeTripId)) { + const active = await ctx.db.get(user.activeTripId); + if (active && (user.role === "rider" ? active.riderId === user._id : active.driverId === user._id)) { + if (history.length === limits.trips) history.pop(); + history.push(active); + } + } + const offers = user.role === "driver" + ? await ctx.db.query("dispatchTrips").withIndex("by_area_status", q => q.eq("serviceArea", user.serviceArea).eq("status", "requested")) + .order("desc").take(limits.trips) + : []; + const online = await ctx.db.query("users").withIndex("by_area_role_online", q => + q.eq("serviceArea", user.serviceArea).eq("role", "driver").eq("online", true)).take(limits.participants); + const onlineDriverIds = online.map(driver => driver._id); + if (user.role === "driver" && user.online && !onlineDriverIds.includes(user._id)) { + if (onlineDriverIds.length === limits.participants) onlineDriverIds.pop(); + onlineDriverIds.push(user._id); + } + return { + roomCode: user._id, revision: area?.revision ?? 0, driverOnline: onlineDriverIds.length > 0, onlineDriverIds, + trips: [...history, ...offers].sort((a, b) => b._creationTime - a._creationTime).map(row => row.payload), + }; +} diff --git a/services/ride-convex/convex/schema.ts b/services/ride-convex/convex/schema.ts index 289228f..96ac992 100644 --- a/services/ride-convex/convex/schema.ts +++ b/services/ride-convex/convex/schema.ts @@ -26,6 +26,23 @@ export const trip = v.object({ }); export default defineSchema({ + users: defineTable({ + name: v.string(), email: v.string(), role, passwordSalt: v.string(), passwordHash: v.string(), + vehicle: v.union(v.string(), v.null()), plate: v.union(v.string(), v.null()), + serviceArea: v.string(), online: v.boolean(), + activeTripId: v.union(v.id("dispatchTrips"), v.null()), + loginWindowStart: v.number(), loginAttempts: v.number(), + }).index("by_email", ["email"]).index("by_area_role_online", ["serviceArea", "role", "online"]), + sessions: defineTable({ + userId: v.id("users"), tokenHash: v.string(), expiresAt: v.number(), + }).index("by_hash", ["tokenHash"]), + dispatchAreas: defineTable({ name: v.string(), revision: v.number() }).index("by_name", ["name"]), + dispatchTrips: defineTable({ + riderId: v.id("users"), driverId: v.union(v.id("users"), v.null()), serviceArea: v.string(), + status: v.string(), requestId: v.string(), requestPayload: v.string(), payload: trip, + }).index("by_rider", ["riderId"]).index("by_driver", ["driverId"]) + .index("by_area_status", ["serviceArea", "status"]) + .index("by_rider_request", ["riderId", "requestId"]).index("by_trip_id", ["payload.id"]), rooms: defineTable({ code: v.string(), riderId: v.string(), revision: v.number(), expiresAt: v.number(), activeTripId: v.union(v.id("trips"), v.null()), diff --git a/services/ride-convex/package-lock.json b/services/ride-convex/package-lock.json index 2414c8e..da0f84f 100644 --- a/services/ride-convex/package-lock.json +++ b/services/ride-convex/package-lock.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@edge-runtime/vm": "5.0.0", + "@types/node": "24.19.2", "convex-test": "0.0.54", "typescript": "5.9.3", "vitest": "4.1.12" @@ -785,6 +786,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@vitest/expect": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", @@ -1594,6 +1605,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", diff --git a/services/ride-convex/package.json b/services/ride-convex/package.json index e92d17d..5113ad7 100644 --- a/services/ride-convex/package.json +++ b/services/ride-convex/package.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@edge-runtime/vm": "5.0.0", + "@types/node": "24.19.2", "convex-test": "0.0.54", "typescript": "5.9.3", "vitest": "4.1.12" diff --git a/services/ride-convex/tests/accounts.test.ts b/services/ride-convex/tests/accounts.test.ts new file mode 100644 index 0000000..b8e1d7b --- /dev/null +++ b/services/ride-convex/tests/accounts.test.ts @@ -0,0 +1,307 @@ +// @vitest-environment node +import { createHash, scryptSync } from "node:crypto"; +import { convexTest } from "convex-test"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { api } from "../convex/_generated/api"; +import schema from "../convex/schema"; +import { loginLimit, loginWindow, sessionLifetime } from "../convex/accountAuth"; + +const modules = import.meta.glob("../convex/**/*.ts"); +const password = "correct horse battery staple"; +const registration = (overrides = {}) => ({ + name: "Alex", email: `${crypto.randomUUID()}@example.test`, password, role: "rider", ...overrides, +}); +const backend = () => convexTest({ schema, modules, transactionLimits: true }); +const request = () => ({ + requestId: crypto.randomUUID(), + pickup: { id: "pickup", name: "Pickup", address: "1 Main Street", latitude: 30.2, longitude: -97.7 }, + destination: { id: "destination", name: "Destination", address: "2 Main Street", latitude: 30.3, longitude: -97.8 }, + stops: [], service: "uberX", paymentMethod: "Visa •••• 4242", note: "", +}); +const failure = (promise: Promise, status: number, message?: string) => + expect(promise).rejects.toMatchObject({ data: { status, ...(message ? { message } : {}) } }); + +afterEach(() => vi.useRealTimers()); + +describe("database-backed accounts", () => { + test("registration and resume return matching account identity, not invitations", async () => { + const t = backend(); + const args = registration({ email: " Alex@Example.Test ", name: " Alex " }); + const session = await t.action(api.accounts.signUp, args); + expect(session.account).toEqual({ id: session.participantId, name: "Alex", email: "alex@example.test", + role: "rider", vehicle: null, plate: null }); + expect(session.roomCode).toBe(session.account.id); + expect(session.state).toEqual({ roomCode: session.account.id, revision: 0, driverOnline: false, onlineDriverIds: [], trips: [] }); + expect(session.role).toBe(session.account.role); + expect(session.name).toBe(session.account.name); + expect(session.token).toMatch(/^user\.[A-Za-z0-9_-]{43}$/); + expect(Buffer.from(session.token.slice(5), "base64url")).toHaveLength(32); + expect(session.expiresAt - Date.now()).toBeGreaterThan(sessionLifetime - 5000); + expect(session.expiresAt - Date.now()).toBeLessThanOrEqual(sessionLifetime); + expect(await t.query(api.accounts.resume, { token: session.token })).toEqual(session); + expect(await t.query(api.accounts.profile, { token: session.token })).toEqual(session.account); + expect(await t.run(ctx => ctx.db.query("rooms").collect())).toEqual([]); + expect(await t.run(ctx => ctx.db.query("participants").collect())).toEqual([]); + await failure(t.query(api.rides.state, { token: session.token }), 401); + }); + + test("driver profiles persist supplied vehicle and plate and default to Austin offline", async () => { + const t = backend(); + const args = registration({ role: "driver", vehicle: "Honda Civic", plate: "TX 1234" }); + const session = await t.action(api.accounts.signUp, args); + expect(session.account).toMatchObject({ role: "driver", vehicle: "Honda Civic", plate: "TX 1234" }); + const user = await t.run(ctx => ctx.db.query("users").first()); + expect(user).toMatchObject({ serviceArea: "austin", online: false }); + const signedIn = await t.action(api.accounts.signIn, { email: args.email, password }); + expect(signedIn.account).toEqual(session.account); + expect(signedIn.token).not.toBe(session.token); + }); + + test("passwords use independent salted scrypt hashes and sessions store only SHA-256 digests", async () => { + const t = backend(); + const first = await t.action(api.accounts.signUp, registration()); + const second = await t.action(api.accounts.signUp, registration()); + const tables = await t.run(async ctx => ({ users: await ctx.db.query("users").collect(), sessions: await ctx.db.query("sessions").collect() })); + expect(tables.users[0].passwordSalt).not.toBe(tables.users[1].passwordSalt); + expect(tables.users[0].passwordHash).not.toBe(tables.users[1].passwordHash); + for (const user of tables.users) { + expect(user.passwordSalt).toMatch(/^[0-9a-f]{32}$/); + expect(user.passwordHash).toMatch(/^[0-9a-f]{128}$/); + expect(scryptSync(password, user.passwordSalt, 64, { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }).toString("hex")).toBe(user.passwordHash); + expect(user.passwordHash).not.toBe(createHash("sha256").update(password).digest("hex")); + } + for (const session of [first, second]) { + expect(tables.sessions.find(row => row.userId === session.account.id)?.tokenHash) + .toBe(createHash("sha256").update(session.token).digest("hex")); + expect(JSON.stringify(tables)).not.toContain(session.token); + } + expect(JSON.stringify(tables)).not.toContain(password); + }); + + test("sign-in normalizes email but preserves password whitespace and generates fresh sessions", async () => { + const t = backend(); + const args = registration({ password: ` ${password} ` }); + const signedUp = await t.action(api.accounts.signUp, args); + const signedIn = await t.action(api.accounts.signIn, { email: ` ${args.email.toUpperCase()} `, password: args.password }); + expect(signedIn.account).toEqual(signedUp.account); + expect(signedIn.token).not.toBe(signedUp.token); + await failure(t.action(api.accounts.signIn, { email: args.email, password }), 401, "Invalid email or password"); + expect(await t.query(api.accounts.resume, { token: signedUp.token })).toEqual(signedUp); + }); + + test("duplicate and simultaneous normalized registration cannot replace existing users", async () => { + const t = backend(); + const args = registration(); + const results = await Promise.allSettled([ + t.action(api.accounts.signUp, args), + t.action(api.accounts.signUp, { ...args, email: args.email.toUpperCase(), name: "Imposter", role: "driver", vehicle: "Other", plate: "ABC" }), + ]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { data: { status: 409 } } }); + expect(await t.run(ctx => ctx.db.query("users").collect())).toHaveLength(1); + expect(await t.run(ctx => ctx.db.query("sessions").collect())).toHaveLength(1); + }); + + test("sign-out revokes only the presented session and is idempotent", async () => { + const t = backend(); + const args = registration(); + const original = await t.action(api.accounts.signUp, args); + const next = await t.action(api.accounts.signIn, { email: args.email, password }); + expect(await t.mutation(api.accounts.signOut, { token: original.token })).toBe(true); + expect(await t.mutation(api.accounts.signOut, { token: original.token })).toBe(true); + await failure(t.query(api.accounts.resume, { token: original.token }), 401); + await failure(t.query(api.accounts.profile, { token: original.token }), 401); + await failure(t.query(api.dispatch.state, { token: original.token }), 401); + expect((await t.query(api.accounts.resume, { token: next.token })).account).toEqual(original.account); + expect(await t.run(ctx => ctx.db.query("users").collect())).toHaveLength(1); + }); + + test("expiry applies at the exact boundary to account reads and all dispatch operations", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const t = backend(); + const args = registration(); + const session = await t.action(api.accounts.signUp, args); + vi.setSystemTime(session.expiresAt - 1); + expect((await t.query(api.accounts.resume, { token: session.token })).expiresAt).toBe(session.expiresAt); + vi.setSystemTime(session.expiresAt); + const calls = [ + t.query(api.accounts.resume, { token: session.token }), t.query(api.accounts.profile, { token: session.token }), + t.query(api.dispatch.state, { token: session.token }), + t.mutation(api.dispatch.availability, { token: session.token, online: true }), + t.mutation(api.dispatch.book, { token: session.token, request: null } as never), + t.mutation(api.dispatch.act, { token: session.token, tripID: "missing", action: { action: "cancel" } }), + ]; + for (const result of await Promise.allSettled(calls)) { + expect(result).toMatchObject({ status: "rejected", reason: { data: { status: 401 } } }); + } + const fresh = await t.action(api.accounts.signIn, { email: args.email, password }); + expect(fresh.account).toEqual(session.account); + expect(fresh.expiresAt).toBe(Date.now() + sessionLifetime); + expect(await t.mutation(api.accounts.signOut, { token: session.token })).toBe(true); + }); + + test("sign-out guards active rider and driver trips, then takes the driver offline atomically", async () => { + const t = backend(); + const serviceArea = `test-${crypto.randomUUID()}`; + const rider = await t.action(api.accounts.signUp, registration({ serviceArea })); + const driver = await t.action(api.accounts.signUp, registration({ serviceArea, role: "driver", vehicle: "Car", plate: "TX 123" })); + await t.mutation(api.dispatch.availability, { token: driver.token, online: true }); + const tripID = (await t.mutation(api.dispatch.book, { token: rider.token, request: request() })).trips[0].id; + await failure(t.mutation(api.accounts.signOut, { token: rider.token }), 409, "Cannot sign out during an active trip"); + await t.mutation(api.dispatch.act, { token: driver.token, tripID, action: { action: "accept" } }); + await failure(t.mutation(api.accounts.signOut, { token: driver.token }), 409, "Cannot sign out during an active trip"); + expect((await t.query(api.accounts.resume, { token: driver.token })).state.onlineDriverIds).toContain(driver.participantId); + await t.mutation(api.dispatch.act, { token: rider.token, tripID, action: { action: "cancel" } }); + const before = await t.query(api.dispatch.state, { token: rider.token }); + expect(await t.mutation(api.accounts.signOut, { token: driver.token })).toBe(true); + const after = await t.query(api.dispatch.state, { token: rider.token }); + expect(after.revision).toBe(before.revision + 1); + expect(after.driverOnline).toBe(false); + expect(after.onlineDriverIds).not.toContain(driver.participantId); + await failure(t.query(api.accounts.resume, { token: driver.token }), 401); + expect(await t.mutation(api.accounts.signOut, { token: driver.token })).toBe(true); + expect(await t.query(api.dispatch.state, { token: rider.token })).toEqual(after); + }); + + test("sign-out racing acceptance cannot leave a revoked online driver assigned to a trip", async () => { + const t = backend(); + const serviceArea = `test-${crypto.randomUUID()}`; + const rider = await t.action(api.accounts.signUp, registration({ serviceArea })); + const driver = await t.action(api.accounts.signUp, registration({ serviceArea, role: "driver", vehicle: "Car", plate: "TX 123" })); + await t.mutation(api.dispatch.availability, { token: driver.token, online: true }); + const tripID = (await t.mutation(api.dispatch.book, { token: rider.token, request: request() })).trips[0].id; + const results = await Promise.allSettled([ + t.mutation(api.accounts.signOut, { token: driver.token }), + t.mutation(api.dispatch.act, { token: driver.token, tripID, action: { action: "accept" } }), + ]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + const state = await t.query(api.dispatch.state, { token: rider.token }); + if (results[0].status === "fulfilled") { + expect(state.driverOnline).toBe(false); + expect(state.trips[0]).toMatchObject({ status: "requested", driver: null }); + await failure(t.query(api.accounts.resume, { token: driver.token }), 401); + } else { + expect(results[0]).toMatchObject({ reason: { data: { status: 409 } } }); + expect(state.trips[0].driver?.id).toBe(driver.participantId); + expect((await t.query(api.accounts.resume, { token: driver.token })).state.onlineDriverIds).toContain(driver.participantId); + } + }); + + test("an expired session cannot sign out the driver's live session or change availability", async () => { + const t = backend(); + const serviceArea = `test-${crypto.randomUUID()}`; + const rider = await t.action(api.accounts.signUp, registration({ serviceArea })); + const args = registration({ serviceArea, role: "driver", vehicle: "Car", plate: "TX 123" }); + const old = await t.action(api.accounts.signUp, args); + const driver = await t.action(api.accounts.signIn, { email: args.email, password }); + await t.mutation(api.dispatch.availability, { token: driver.token, online: true }); + const tripID = (await t.mutation(api.dispatch.book, { token: rider.token, request: request() })).trips[0].id; + await t.mutation(api.dispatch.act, { token: driver.token, tripID, action: { action: "accept" } }); + await t.run(async ctx => { + const oldHash = createHash("sha256").update(old.token).digest("hex"); + const session = (await ctx.db.query("sessions").withIndex("by_hash", q => q.eq("tokenHash", oldHash)).unique())!; + await ctx.db.patch(session._id, { expiresAt: Date.now() }); + }); + const before = await t.query(api.accounts.resume, { token: driver.token }); + expect(await t.mutation(api.accounts.signOut, { token: old.token })).toBe(true); + expect(await t.query(api.accounts.resume, { token: driver.token })).toEqual(before); + expect(before.state.onlineDriverIds).toContain(driver.participantId); + expect(before.state.trips[0].driver?.id).toBe(driver.participantId); + }); + + test("invalid and unknown email/password responses are identical and never echo secrets", async () => { + const t = backend(); + const args = registration(); + await t.action(api.accounts.signUp, args); + for (const input of [{ email: args.email, password: "wrong password" }, { email: "unknown@example.test", password }, + { email: "not-an-email", password }, { email: args.email, password: 123 }, { email: args.email, password: "s".repeat(129) }]) { + try { + await t.action(api.accounts.signIn, input as never); + expect.fail("Sign-in unexpectedly succeeded"); + } catch (error) { + expect(error).toMatchObject({ data: { status: 401, message: "Invalid email or password" } }); + expect(String(error)).not.toContain(args.email); + expect(String(error)).not.toContain(password); + } + } + expect(await t.run(ctx => ctx.db.query("users").collect())).toHaveLength(1); + }); + + test("login attempts are bounded per account and reset only after the window", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const t = backend(); + const args = registration(); + await t.action(api.accounts.signUp, args); + const other = registration(); + await t.action(api.accounts.signUp, other); + for (let i = 0; i < loginLimit; i++) { + await failure(t.action(api.accounts.signIn, { email: args.email, password: "incorrect password" }), 401, "Invalid email or password"); + } + await failure(t.action(api.accounts.signIn, { email: args.email.toUpperCase(), password }), 401, "Invalid email or password"); + expect((await t.action(api.accounts.signIn, { email: other.email, password })).account.email).toBe(other.email); + const user = await t.run(ctx => ctx.db.query("users").withIndex("by_email", q => q.eq("email", args.email)).unique()); + expect(user?.loginAttempts).toBe(loginLimit); + vi.setSystemTime(user!.loginWindowStart + loginWindow - 1); + await failure(t.action(api.accounts.signIn, { email: args.email, password }), 401); + vi.setSystemTime(Date.now() + 1); + expect((await t.action(api.accounts.signIn, { email: args.email, password })).account.email).toBe(args.email); + }); + + test("concurrent attempts cannot bypass the account login budget", async () => { + const t = backend(); + const args = registration(); + await t.action(api.accounts.signUp, args); + const results = await Promise.allSettled(Array.from({ length: loginLimit + 3 }, () => + t.action(api.accounts.signIn, { email: args.email, password }))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(loginLimit); + for (const result of results.filter(result => result.status === "rejected")) { + expect(result).toMatchObject({ reason: { data: { status: 401, message: "Invalid email or password" } } }); + } + expect((await t.run(ctx => ctx.db.query("users").first()))?.loginAttempts).toBe(loginLimit); + }); + + test("submitted role and identity never replace the stored account", async () => { + const t = backend(); + const args = registration(); + const rider = await t.action(api.accounts.signUp, args); + const spoof = { email: args.email, password, role: "driver", userId: "other", name: "Spoofed", online: true }; + const session = await t.action(api.accounts.signIn, spoof); + expect(session.account).toEqual(rider.account); + expect(await t.query(api.accounts.profile, { token: rider.token, ...spoof } as never)).toEqual(rider.account); + await failure(t.mutation(api.dispatch.availability, { token: rider.token, online: true, role: "driver" } as never), 403); + expect((await t.run(ctx => ctx.db.query("users").first()))?.online).toBe(false); + }); + + test.each([ + { name: " " }, { email: "invalid" }, { password: "short" }, { password: "x".repeat(129) }, + { role: "admin" }, { role: "driver" }, { role: "driver", vehicle: "Car" }, + { role: "driver", vehicle: "Car", plate: "x".repeat(21) }, { serviceArea: "x".repeat(81) }, + { serviceArea: "area/other" }, { serviceArea: "" }, { serviceArea: 123 }, + ])("invalid registration is safely rejected: %j", async overrides => { + const t = backend(); + await failure(t.action(api.accounts.signUp, registration(overrides)), 400); + expect(await t.run(ctx => ctx.db.query("users").collect())).toEqual([]); + }); + + test("malformed arguments and tokens return application errors without credential echoes", async () => { + const t = backend(); + for (const value of [{}, { name: [], email: null, password: 12 }, { name: { secret: "not-a-name" } }]) { + await failure(t.action(api.accounts.signUp, value as never), 400); + await failure(t.action(api.accounts.signIn, value as never), 401); + await failure(t.query(api.accounts.resume, value as never), 401); + await failure(t.query(api.accounts.profile, value as never), 401); + await failure(t.mutation(api.accounts.signOut, value as never), 401); + } + for (const token of ["secret-short", `user.${"X".repeat(43)}`, null, 123]) { + for (const call of [t.query(api.accounts.resume, { token } as never), t.query(api.accounts.profile, { token } as never)]) { + try { + await call; + expect.fail("Invalid token unexpectedly succeeded"); + } catch (error) { + expect(error).toMatchObject({ data: { status: 401 } }); + if (typeof token === "string") expect(String(error)).not.toContain(token); + } + } + } + }); +}); diff --git a/services/ride-convex/tests/dispatch.test.ts b/services/ride-convex/tests/dispatch.test.ts new file mode 100644 index 0000000..f0a4bd0 --- /dev/null +++ b/services/ride-convex/tests/dispatch.test.ts @@ -0,0 +1,473 @@ +// @vitest-environment node +import { convexTest } from "convex-test"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { api } from "../convex/_generated/api"; +import type { Id } from "../convex/_generated/dataModel"; +import schema from "../convex/schema"; +import { limits, ttl } from "../convex/validation"; + +const modules = import.meta.glob("../convex/**/*.ts"); +const password = "dispatch-test-password"; +const request = () => ({ + requestId: String(crypto.randomUUID()), + pickup: { id: "pickup", name: "Pickup", address: "1 Main Street", latitude: 30.2, longitude: -97.7 }, + destination: { id: "destination", name: "Destination", address: "2 Main Street", latitude: 30.3, longitude: -97.8 }, + stops: [] as { id: string; name: string; address: string; latitude: number; longitude: number }[], + service: "uberX", paymentMethod: "Visa •••• 4242", scheduledAt: null as string | null, note: "", +}); +const failure = (promise: Promise, status: number, message?: string) => + expect(promise).rejects.toMatchObject({ data: { status, ...(message ? { message } : {}) } }); + +async function setup() { + const t = convexTest({ schema, modules, transactionLimits: true }); + const serviceArea = `test-${crypto.randomUUID()}`; + const register = (name: string, role: "rider" | "driver", area = serviceArea) => t.action(api.accounts.signUp, { + name, role, email: `${crypto.randomUUID()}@example.test`, password, serviceArea: area, + ...(role === "driver" ? { vehicle: "Honda Accord", plate: "TX 4312" } : {}), + }); + const rider = await register("Alex", "rider"); + const first = await register("Jordan", "driver"); + const second = await register("Sam", "driver"); + const state = (token = rider.token) => t.query(api.dispatch.state, { token }); + const act = (token: string, tripID: string, action: string, extra = {}) => + t.mutation(api.dispatch.act, { token, tripID, action: { action, ...extra } }); + const book = (payload = request(), token = rider.token) => t.mutation(api.dispatch.book, { token, request: payload }); + const online = (token = first.token, value = true) => t.mutation(api.dispatch.availability, { token, online: value }); + const complete = async (tripID: string, token = first.token) => { + await act(token, tripID, "accept"); + await act(token, tripID, "arrive"); + await act(token, tripID, "start"); + return act(token, tripID, "complete"); + }; + const pointers = () => t.run(async ctx => (await Promise.all([rider, first, second].map(person => + ctx.db.get(person.participantId as Id<"users">)))).map(user => user!.activeTripId)); + return { t, serviceArea, register, rider, first, second, state, act, book, online, complete, pointers }; +} + +afterEach(() => vi.useRealTimers()); + +describe("account dispatch authorization and isolation", () => { + test("public signup provides independent database accounts and the existing wire state", async () => { + const { t, rider, first, state } = await setup(); + for (const person of [rider, first]) { + expect(person.roomCode).toBe(person.account.id); + expect(person.participantId).toBe(person.account.id); + expect(person.token).not.toBe(rider.participantId); + expect(person.token.length).toBeGreaterThanOrEqual(32); + expect(person.expiresAt).toBeGreaterThan(Date.now()); + expect(await t.query(api.accounts.profile, { token: person.token })).toEqual(person.account); + expect(person.state).toEqual({ roomCode: person.participantId, revision: 0, driverOnline: false, onlineDriverIds: [], trips: [] }); + } + expect(rider.token).not.toBe(first.token); + expect(rider.participantId).not.toBe(first.participantId); + expect(Object.keys(await state()).sort()).toEqual(["roomCode", "revision", "driverOnline", "onlineDriverIds", "trips"].sort()); + expect(await t.run(ctx => ctx.db.query("rooms").collect())).toEqual([]); + }); + + test("missing, forged, revoked and expired sessions reject every dispatch path safely", async () => { + const { t, rider, first, book } = await setup(); + const tripID = (await book()).trips[0].id; + const operations = (token: string) => [ + t.query(api.dispatch.state, { token }), + t.mutation(api.dispatch.availability, { token, online: true }), + t.mutation(api.dispatch.book, { token, request: request() }), + t.mutation(api.dispatch.act, { token, tripID, action: { action: "cancel" } }), + ]; + await t.mutation(api.accounts.signOut, { token: first.token }); + await t.run(async ctx => { + const session = (await ctx.db.query("sessions").collect()).find(row => row.userId === rider.participantId)!; + await ctx.db.patch(session._id, { expiresAt: Date.now() }); + }); + for (const token of ["short", `user.${crypto.randomUUID().repeat(2)}`, first.token, rider.token]) { + for (const result of await Promise.allSettled(operations(token))) { + expect(result.status).toBe("rejected"); + if (result.status === "rejected") { + expect(result.reason.data.status).toBe(401); + expect(JSON.stringify(result.reason.data)).not.toContain(token); + } + } + } + }); + + test("riders see only their trips and drivers only area offers and their own assigned history", async () => { + const { t, register, rider, first, second, state, act, book, online, complete } = await setup(); + const other = await register("Other rider", "rider"); + const remoteRider = await register("Remote rider", "rider", "isolated-remote"); + const remoteDriver = await register("Remote driver", "driver", "isolated-remote"); + await online(); + await online(remoteDriver.token); + const ownID = (await book()).trips[0].id; + const otherID = (await book(request(), other.token)).trips[0].id; + const remoteID = (await book(request(), remoteRider.token)).trips[0].id; + expect((await state()).trips.map(trip => trip.id)).toEqual([ownID]); + expect((await state(first.token)).trips.map(trip => trip.id).sort()).toEqual([ownID, otherID].sort()); + expect((await state(remoteDriver.token)).trips.map(trip => trip.id)).toEqual([remoteID]); + expect((await state()).onlineDriverIds).toEqual([first.participantId]); + expect((await state(remoteRider.token)).onlineDriverIds).toEqual([remoteDriver.participantId]); + await failure(act(other.token, ownID, "cancel"), 404); + await failure(act(remoteDriver.token, ownID, "accept"), 404); + await act(first.token, ownID, "accept"); + await act(rider.token, ownID, "message", { text: "Private door code" }); + const unassigned = await state(second.token); + expect(unassigned.trips.map(trip => trip.id)).toEqual([otherID]); + expect(JSON.stringify(unassigned)).not.toContain("Private door code"); + for (const action of ["arrive", "start", "complete", "cancel", "message", "rate"]) { + await failure(act(second.token, ownID, action, { text: "Intrusion", rating: 5, tipCents: 0 }), 403); + } + await act(first.token, ownID, "cancel"); + await complete(otherID); + expect((await state(first.token)).trips.map(trip => trip.id).sort()).toEqual([ownID, otherID].sort()); + expect((await state(second.token)).trips).toEqual([]); + expect((await state(other.token)).trips.map(trip => trip.id)).toEqual([otherID]); + expect((await t.query(api.accounts.resume, { token: rider.token })).state).toEqual(await state()); + }); + + test("client-supplied roles, names, identity, area and driver profile cannot spoof database identity", async () => { + const { t, rider, first, state, act, online } = await setup(); + const spoof = { role: "driver", name: "Forged", participantId: first.participantId, serviceArea: "elsewhere" }; + await failure(t.mutation(api.dispatch.availability, { token: rider.token, online: true, ...spoof }), 403); + const driverBooking = { token: first.token, request: request(), role: "rider" }; + await failure(t.mutation(api.dispatch.book, driverBooking), 403); + const booking = { token: rider.token, request: { ...request(), riderId: first.participantId, riderName: "Forged", fareCents: 1 }, ...spoof }; + const trip = (await t.mutation(api.dispatch.book, booking)).trips[0]; + expect(trip).toMatchObject({ riderId: rider.participantId, riderName: "Alex", fareCents: 1840 }); + await failure(act(rider.token, trip.id, "accept", spoof), 403); + await online(); + await act(first.token, trip.id, "accept", { role: "driver", name: "Forged", vehicle: "Fake", plate: "FAKE", rating: 5 }); + expect((await state()).trips[0].driver).toEqual({ id: first.participantId, name: "Jordan", vehicle: "Honda Accord", plate: "TX 4312", rating: 0 }); + await act(first.token, trip.id, "message", { text: "Hello", senderId: rider.participantId, senderName: "Forged" }); + expect((await state()).trips[0].messages[0]).toMatchObject({ senderId: first.participantId, senderName: "Jordan" }); + }); + + test("area-wide revisions order other users' discovery updates without changing on retries", async () => { + const { register, rider, first, second, state, book, online, act } = await setup(); + expect((await state()).revision).toBe(0); + await online(); + expect((await state()).revision).toBe(1); + expect((await state(first.token)).revision).toBe(1); + const payload = request(); + const booked = await book(payload); + expect(booked.revision).toBe(2); + expect((await state(second.token)).revision).toBe(2); + await online(second.token); + expect((await state()).revision).toBe(3); + expect((await book(payload)).revision).toBe(3); + const remote = await register("Remote driver", "driver", "isolated-revision-area"); + await online(remote.token); + expect((await state(remote.token)).revision).toBe(1); + expect((await state()).revision).toBe(3); + await act(first.token, booked.trips[0].id, "accept"); + expect((await state()).revision).toBe(4); + expect((await state(second.token)).revision).toBe(4); + await act(rider.token, booked.trips[0].id, "cancel"); + expect((await state(first.token)).revision).toBe(5); + }); +}); + +describe("transactional assignment and durable requests", () => { + test("concurrent drivers accept a trip exactly once and release both pointers on cancellation", async () => { + const { rider, first, second, state, act, book, online, pointers } = await setup(); + await online(); + await online(second.token); + const id = (await book()).trips[0].id; + const results = await Promise.allSettled([act(first.token, id, "accept"), act(second.token, id, "accept")]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { data: { status: 409 } } }); + const assigned = (await state()).trips[0].driver!; + const winner = assigned.id === first.participantId ? first : second; + const loser = winner === first ? second : first; + const currentPointers = await pointers(); + expect(currentPointers.filter(Boolean)).toHaveLength(2); + expect(currentPointers[0]).toBe(currentPointers[winner === first ? 1 : 2]); + await failure(online(winner.token, false), 409); + await failure(act(loser.token, id, "cancel"), 403); + await online(loser.token, false); + await act(rider.token, id, "cancel"); + expect(await pointers()).toEqual([null, null, null]); + await online(winner.token, false); + expect((await state()).driverOnline).toBe(false); + }); + + test("one driver cannot concurrently accept two different riders' trips", async () => { + const { t, register, rider, first, act, book, online, state } = await setup(); + const other = await register("Other", "rider"); + await online(); + const ids = [(await book()).trips[0].id, (await book(request(), other.token)).trips[0].id]; + const results = await Promise.allSettled(ids.map(id => act(first.token, id, "accept"))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { data: { status: 409 } } }); + const rows = await t.run(ctx => ctx.db.query("dispatchTrips").withIndex("by_driver", q => q.eq("driverId", first.participantId as Id<"users">)).collect()); + expect(rows).toHaveLength(1); + const user = await t.run(ctx => ctx.db.get(first.participantId as Id<"users">)); + expect(user!.activeTripId).toBe(rows[0]._id); + const winner = rows[0].payload.id; + const losing = ids.find(id => id !== winner)!; + await act(first.token, winner, "cancel"); + await act(first.token, losing, "accept"); + expect((await state(first.token)).trips.find(trip => trip.id === losing)!.status).toBe("accepted"); + expect((await state(rider.token)).trips).toHaveLength(1); + }); + + test("concurrent duplicate requests are idempotent and distinct requests leave one rider active", async () => { + const { t, register, rider, book } = await setup(); + const payload = request(); + const results = await Promise.all([book(payload), book(payload)]); + expect(results[0]).toEqual(results[1]); + await failure(book({ ...payload, note: "changed" }), 409); + await failure(book(), 409); + const other = await register("Other", "rider"); + const races = await Promise.allSettled([book(request(), other.token), book(request(), other.token)]); + expect(races.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(races.find(result => result.status === "rejected")).toMatchObject({ reason: { data: { status: 409 } } }); + const rows = await t.run(ctx => ctx.db.query("dispatchTrips").withIndex("by_rider", q => q.eq("riderId", rider.participantId as Id<"users">)).collect()); + expect(rows).toHaveLength(1); + }); + + test("request UUIDs remain durable beyond the history window without a lifetime cap", async () => { + const { t, register, rider, book, act, state } = await setup(); + const original = request(); + const firstID = (await book(original)).trips[0].id; + await act(rider.token, firstID, "cancel"); + for (let i = 0; i < 24; i++) { + const id = (await book()).trips[0].id; + await act(rider.token, id, "cancel"); + } + const current = await state(); + expect(current.trips).toHaveLength(20); + expect(current.trips.some(trip => trip.id === firstID)).toBe(false); + expect(await book(original)).toEqual(current); + await failure(book({ ...original, paymentMethod: "Cash" }), 409); + const active = (await book()).trips[0]; + expect(active.status).toBe("requested"); + expect(await book(original)).toEqual(await state()); + const other = await register("Other", "rider"); + expect((await book(original, other.token)).trips[0].riderId).toBe(other.participantId); + expect(await t.run(ctx => ctx.db.query("dispatchTrips").withIndex("by_rider", q => q.eq("riderId", rider.participantId as Id<"users">)).collect())).toHaveLength(26); + }); + + test("scheduled request retries succeed after their time but changed/new past bookings fail", async () => { + const { book } = await setup(); + vi.useFakeTimers(); + const original = { ...request(), scheduledAt: new Date(Date.now() + 1000).toISOString() }; + const snapshot = await book(original); + vi.setSystemTime(Date.now() + 2000); + expect(await book(original)).toEqual(snapshot); + await failure(book({ ...original, note: "changed" }), 409); + await failure(book({ ...original, requestId: crypto.randomUUID() }), 400); + }); + + test("a driver accepting an older offer retains it within twenty total history rows", async () => { + const { register, first, rider, book, online, complete, act, state } = await setup(); + const other = await register("Another rider", "rider"); + const oldID = (await book()).trips[0].id; + await online(); + for (let i = 0; i < 22; i++) await complete((await book(request(), other.token)).trips[0].id); + expect((await state(first.token)).trips).toHaveLength(21); + await act(first.token, oldID, "accept"); + const snapshot = await state(first.token); + expect(snapshot.trips).toHaveLength(20); + expect(snapshot.trips.filter(trip => trip.status === "completed")).toHaveLength(19); + expect(snapshot.trips.find(trip => trip.id === oldID)!.status).toBe("accepted"); + await act(rider.token, oldID, "cancel"); + expect((await state(first.token)).trips).toHaveLength(20); + }); +}); + +describe("dispatch lifecycle and business validation", () => { + test("independent clients persist the complete existing trip and message wire formats", async () => { + const { t, rider, first, state, act, book, online, pointers } = await setup(); + await online(); + const payload = request(); + payload.stops = [{ id: "stop", name: "Coffee", address: "3 Main Street", latitude: 31, longitude: -97 }]; + const booked = await book(payload); + const trip = booked.trips[0]; + expect(trip.fareCents).toBe(2140); + expect(trip).toMatchObject({ driver: null, scheduledAt: null, cancellationReason: null, rating: null, tipCents: 0, messages: [] }); + expect(Object.keys(trip).sort()).toEqual([ + "id", "riderId", "riderName", "pickup", "destination", "stops", "service", "paymentMethod", "scheduledAt", + "note", "status", "driver", "fareCents", "createdAt", "updatedAt", "cancellationReason", "rating", "tipCents", "messages", + ].sort()); + const driverClient = t.withIdentity({ subject: "independent-driver-device" }); + await driverClient.mutation(api.dispatch.act, { token: first.token, tripID: trip.id, action: { action: "accept" } }); + await act(rider.token, trip.id, "message", { text: " At the door " }); + await act(first.token, trip.id, "message", { text: "On my way" }); + await failure(act(first.token, trip.id, "start"), 409); + await failure(act(rider.token, trip.id, "arrive"), 403); + await act(first.token, trip.id, "arrive"); + await act(first.token, trip.id, "start"); + await failure(act(rider.token, trip.id, "cancel"), 409); + await act(first.token, trip.id, "complete"); + expect(await pointers()).toEqual([null, null, null]); + const rated = await act(rider.token, trip.id, "rate", { rating: 5, tipCents: 1000 }); + expect(rated.trips[0]).toMatchObject({ status: "completed", rating: 5, tipCents: 1000, fareCents: 2140 }); + expect(await t.withIdentity({ subject: "independent-rider-device" }).query(api.dispatch.state, { token: rider.token })).toEqual(rated); + expect((await state(first.token)).trips).toEqual(rated.trips); + expect(rated.trips[0].messages[0]).toMatchObject({ text: "At the door", senderId: rider.participantId, senderName: "Alex" }); + expect(Object.keys(rated.trips[0].messages[0]).sort()).toEqual(["id", "senderId", "senderName", "text", "createdAt"].sort()); + const next = (await book()).trips[0].id; + await act(rider.token, trip.id, "rate", { rating: 4, tipCents: 0 }); + expect((await pointers())[0]).not.toBeNull(); + await failure(book(), 409); + await act(rider.token, next, "cancel"); + }); + + test("acceptance requires online availability and lifecycle changes reject partial writes", async () => { + const { rider, first, state, act, book, online } = await setup(); + const id = (await book()).trips[0].id; + await failure(act(first.token, id, "accept"), 409); + await failure(act(first.token, id, "cancel"), 403); + await failure(act(rider.token, id, "message", { text: "Hello" }), 409); + await failure(act(rider.token, id, "cancel", { reason: "x".repeat(301) }), 400); + expect((await state()).trips[0].status).toBe("requested"); + await online(); + await act(first.token, id, "accept"); + await failure(act(first.token, id, "complete"), 409); + await failure(act(rider.token, id, "rate", { rating: 5, tipCents: 0 }), 409); + await act(first.token, id, "cancel", { reason: " Unavailable " }); + expect((await state()).trips[0].cancellationReason).toBe("Unavailable"); + await failure(act(first.token, id, "arrive"), 409); + await failure(act(rider.token, id, "message", { text: "Hi" }), 409); + await online(first.token, false); + expect((await book()).trips).toHaveLength(2); + }); + + test("chat limits, ratings and tips match rides without partial updates", async () => { + const { rider, first, act, book, online, state } = await setup(); + const id = (await book()).trips[0].id; + await online(); + await act(first.token, id, "accept"); + await failure(act(rider.token, id, "message", { text: " " }), 400); + await failure(act(first.token, id, "message", { text: "x".repeat(501) }), 400); + for (let i = 0; i < limits.messages; i++) await act(rider.token, id, "message", { text: `Message ${i}` }); + await failure(act(first.token, id, "message", { text: "One too many" }), 409); + await act(first.token, id, "arrive"); + await act(first.token, id, "start"); + await act(first.token, id, "complete"); + await failure(act(first.token, id, "rate", { rating: 5, tipCents: 0 }), 403); + for (const invalid of [ + { rating: 0, tipCents: 0 }, { rating: 6, tipCents: 0 }, { rating: 1.5, tipCents: 0 }, + { rating: null, tipCents: 0 }, { rating: "5", tipCents: 0 }, { rating: 5, tipCents: -1 }, + { rating: 5, tipCents: 10001 }, { rating: 5, tipCents: 0.5 }, { rating: 5, tipCents: null }, + ]) await failure(act(rider.token, id, "rate", invalid), 400); + expect((await state()).trips[0]).toMatchObject({ rating: null, tipCents: 0 }); + expect((await act(rider.token, id, "rate", { rating: 1, tipCents: 10000 })).trips[0].tipCents).toBe(10000); + expect((await act(rider.token, id, "rate", { rating: 5, tipCents: 0 })).trips[0].rating).toBe(5); + await failure(act(rider.token, id, "cancel"), 409); + await failure(act(first.token, id, "message", { text: "Too late" }), 409); + }); + + test.each(["uberX", "comfort", "uberXL", "green", "black", "pet"])("server computes %s fares with two stops", async service => { + const { book } = await setup(); + const fares = { uberX: 1840, comfort: 2490, uberXL: 2990, green: 1940, black: 3890, pet: 2140 }; + const payload = { ...request(), service, fareCents: 1, stops: [ + { id: "a", name: "A", address: "A", latitude: 31, longitude: -97 }, + { id: "b", name: "B", address: "B", latitude: 32, longitude: -98 }, + ] }; + expect((await book(payload)).trips[0].fareCents).toBe(fares[service as keyof typeof fares] + 600); + }); + + test.each([ + { requestId: "bad" }, { service: "toString" }, { service: "unsupported" }, { paymentMethod: "123456789012" }, + { note: "x".repeat(501) }, { paymentMethod: "" }, { pickup: { ...request().pickup, latitude: 91 } }, + { pickup: { ...request().pickup, longitude: -181 } }, { pickup: { ...request().pickup, name: "x".repeat(201) } }, + { destination: request().pickup }, { destination: { ...request().pickup, id: "other" } }, + { stops: [request().pickup] }, { stops: [request().destination, request().pickup, request().destination] }, + { scheduledAt: "bad" }, { scheduledAt: "2027-99-01T12:00:00Z" }, { scheduledAt: "2027-02-30T12:00:00Z" }, + { scheduledAt: "2027-01-01T24:00:00Z" }, { scheduledAt: "x".repeat(500) }, + ])("rejects invalid booking %#", async invalid => { + const { book } = await setup(); + await failure(book({ ...request(), ...invalid }), 400); + }); + + test("scheduled dates use the same thirty-day bounds and omitted fields stay nullable", async () => { + const { t, rider, book, act, state } = await setup(); + vi.useFakeTimers(); + for (const delta of [-1, 0, 30 * ttl + 1]) { + await failure(book({ ...request(), scheduledAt: new Date(Date.now() + delta).toISOString() }), 400); + } + const future = (await book({ ...request(), scheduledAt: new Date(Date.now() + 30 * ttl).toISOString() })).trips[0]; + await act(rider.token, future.id, "cancel"); + const { scheduledAt: _, ...payload } = request(); + const trip = (await t.mutation(api.dispatch.book, { token: rider.token, request: payload })).trips[0]; + expect(trip.scheduledAt).toBeNull(); + await act(rider.token, trip.id, "cancel"); + expect((await state()).trips[0].cancellationReason).toBeNull(); + }); + + test("malformed arguments return safe application errors across every path", async () => { + const { t, rider, first, book, act, state } = await setup(); + await failure(t.query(api.dispatch.state, {} as never), 401); + await failure(t.mutation(api.dispatch.availability, {} as never), 401); + await failure(t.mutation(api.dispatch.book, {} as never), 401); + await failure(t.mutation(api.dispatch.act, {} as never), 401); + await failure(t.mutation(api.dispatch.book, { token: rider.token, request: null } as never), 400); + for (const online of [null, "true", 1]) await failure(t.mutation(api.dispatch.availability, { token: first.token, online } as never), 400); + const id = (await book()).trips[0].id; + for (const action of [null, [], "cancel", { action: null }, { action: "x".repeat(21) }]) { + await failure(t.mutation(api.dispatch.act, { token: rider.token, tripID: id, action } as never), 400); + } + await failure(t.mutation(api.dispatch.act, { token: rider.token, tripID: null, action: { action: "cancel" } } as never), 400); + await failure(act(rider.token, id, "unsupported"), 400); + await failure(act(rider.token, "missing", "cancel"), 404); + expect((await state()).trips[0].status).toBe("requested"); + }); +}); + +describe("bounded indexed snapshots", () => { + test("area offers and online drivers remain bounded even when an area grows", async () => { + const { t, rider, first, serviceArea, register, online, book, state } = await setup(); + await book(); + await t.run(async ctx => { + const base = (await ctx.db.query("dispatchTrips").withIndex("by_rider", q => q.eq("riderId", rider.participantId as Id<"users">)).first())!; + const { _id: _, _creationTime: __, ...driver } = (await ctx.db.get(first.participantId as Id<"users">))!; + for (let i = 0; i < 40; i++) { + await ctx.db.insert("users", { ...driver, email: `driver-${i}@example.test`, online: true }); + await ctx.db.insert("dispatchTrips", { riderId: base.riderId, driverId: null, serviceArea, status: "requested", + requestId: crypto.randomUUID(), requestPayload: base.requestPayload, payload: { ...base.payload, id: crypto.randomUUID() } }); + } + }); + const snapshot = await state(first.token); + expect(snapshot.trips).toHaveLength(20); + expect(snapshot.trips.every(trip => trip.status === "requested" && trip.driver === null && trip.messages.length === 0)).toBe(true); + expect(snapshot.onlineDriverIds).toHaveLength(limits.participants); + expect(snapshot.driverOnline).toBe(true); + expect((await state()).trips).toHaveLength(20); + const late = await register("Late driver", "driver"); + await online(late.token); + const lateState = await state(late.token); + expect(lateState.onlineDriverIds).toHaveLength(limits.participants); + expect(lateState.onlineDriverIds).toContain(late.participantId); + await online(late.token, false); + expect((await state(late.token)).onlineDriverIds).not.toContain(late.participantId); + }); + + test("maximal history, old active trip and offers stay below transaction and snapshot limits", async () => { + const { t, rider, first, serviceArea, book, state } = await setup(); + const original = (await book()).trips[0]; + const worst = (length: number) => "\u0001".repeat(length); + await t.run(async ctx => { + const base = (await ctx.db.query("dispatchTrips").withIndex("by_rider", q => q.eq("riderId", rider.participantId as Id<"users">)).first())!; + const driverId = first.participantId as Id<"users">; + const maxPlace = { ...original.pickup, id: worst(100), name: worst(200), address: worst(300) }; + const payload = { ...base.payload, riderName: worst(80), pickup: maxPlace, destination: maxPlace, + stops: [maxPlace, maxPlace], paymentMethod: worst(80), note: worst(500), cancellationReason: worst(300), + driver: { id: first.participantId, name: worst(80), vehicle: worst(100), plate: worst(20), rating: 0 }, + messages: Array.from({ length: limits.messages }, () => ({ id: "m".repeat(32), senderId: rider.participantId, + senderName: worst(80), text: worst(500), createdAt: original.createdAt })) }; + await ctx.db.patch(base._id, { driverId, status: "accepted", payload: { ...payload, status: "accepted" } }); + await ctx.db.patch(driverId, { activeTripId: base._id, online: true }); + for (let i = 0; i < 20; i++) { + await ctx.db.insert("dispatchTrips", { riderId: base.riderId, driverId, serviceArea, status: "completed", + requestId: crypto.randomUUID(), requestPayload: base.requestPayload, payload: { ...payload, status: "completed", id: String(i) } }); + await ctx.db.insert("dispatchTrips", { riderId: base.riderId, driverId: null, serviceArea, status: "requested", + requestId: crypto.randomUUID(), requestPayload: base.requestPayload, + payload: { ...payload, status: "requested", id: `offer-${i}`, driver: null, messages: [], cancellationReason: null } }); + } + }); + const snapshot = await state(first.token); + expect(snapshot.trips.filter(trip => trip.driver?.id === first.participantId)).toHaveLength(20); + expect(snapshot.trips.some(trip => trip.id === original.id && trip.status === "accepted")).toBe(true); + expect(snapshot.trips.filter(trip => trip.status === "requested")).toHaveLength(20); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBeLessThan(8 * 1024 * 1024); + }); +}); diff --git a/services/ride-convex/tsconfig.json b/services/ride-convex/tsconfig.json index f06924e..256eaf2 100644 --- a/services/ride-convex/tsconfig.json +++ b/services/ride-convex/tsconfig.json @@ -7,7 +7,7 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "types": ["vite/client"] + "types": ["vite/client", "node"] }, "include": ["convex/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] } From ed6a97768495db52cc6ea379bcf14a3dddf7de6b Mon Sep 17 00:00:00 2001 From: Landseer100 <60585684+Landseer100@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:58:33 +0000 Subject: [PATCH 2/3] Keep account sessions scoped to private Revyl hooks Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --- apps/rides/.revyl/config.yaml | 2 +- scripts/test_revyl_config.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/rides/.revyl/config.yaml b/apps/rides/.revyl/config.yaml index 7dabe5a..9f7434b 100644 --- a/apps/rides/.revyl/config.yaml +++ b/apps/rides/.revyl/config.yaml @@ -6,7 +6,7 @@ session: script_path: ./scripts/prepare-revyl-session.py timeout_seconds: 30 auth_bypass: - launch_vars: [RIDES_AUTH_BYPASS_ENABLED, RIDES_AUTH_BYPASS_TOKEN, RIDES_ACCOUNT_SESSION] + launch_vars: [RIDES_AUTH_BYPASS_ENABLED, RIDES_AUTH_BYPASS_TOKEN] deep_link: "ridesdemo://revyl-auth?token=${RIDES_AUTH_BYPASS_TOKEN}&role=rider&redirect=/home" build: framework: ios diff --git a/scripts/test_revyl_config.py b/scripts/test_revyl_config.py index d06f633..b43b2bb 100644 --- a/scripts/test_revyl_config.py +++ b/scripts/test_revyl_config.py @@ -82,7 +82,10 @@ def test_session_hooks_are_isolated_and_mint_fresh_tokens(self): values.append(value) self.assertTrue(values[0] != values[1], "Session hooks must mint a fresh token") auth = session["auth_bypass"] - self.assertEqual(set(auth["launch_vars"]), set(values[0])) + self.assertEqual(set(auth["launch_vars"]), {prefix + "_AUTH_BYPASS_ENABLED", prefix + "_AUTH_BYPASS_TOKEN"}) + if prefix == "RIDES": + self.assertNotIn("RIDES_ACCOUNT_SESSION", auth["launch_vars"]) + self.assertIn("RIDES_ACCOUNT_SESSION", values[0]) link = urlparse(auth["deep_link"]) self.assertEqual(link.scheme, scheme) self.assertEqual(link.netloc, "revyl-auth") From 5f3de0b1e0eff8d76411a45cfb285b022256e1c7 Mon Sep 17 00:00:00 2001 From: Landseer100 <60585684+Landseer100@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:16:37 +0000 Subject: [PATCH 3/3] Accept native rider registration and record account verification Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --- apps/rides/scripts/start-session.py | 1 + docs/RIDES_VERIFICATION.md | 14 +++++-- scripts/test_revyl_config.py | 1 + .../ride-convex/convex/_generated/api.d.ts | 39 ++++++++++++------- services/ride-convex/convex/_generated/api.js | 3 +- .../ride-convex/convex/_generated/server.d.ts | 19 ++++++++- .../ride-convex/convex/_generated/server.js | 6 +++ services/ride-convex/convex/accountActions.ts | 4 +- services/ride-convex/convex/accountTypes.ts | 2 +- .../tests/accountRegistration.test.ts | 29 ++++++++++++++ 10 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 services/ride-convex/tests/accountRegistration.test.ts diff --git a/apps/rides/scripts/start-session.py b/apps/rides/scripts/start-session.py index f35fcca..780c0d5 100644 --- a/apps/rides/scripts/start-session.py +++ b/apps/rides/scripts/start-session.py @@ -30,6 +30,7 @@ command = [ "revyl", "device", "start", "--platform", "ios", "--app-id", app_id, + "--no-inherited-launch-vars", "--launch-env", "RIDES_AUTH_BYPASS_ENABLED=true", "--launch-env", "RIDES_AUTH_BYPASS_TOKEN=" + token, "--launch-env", "RIDES_ACCOUNT_SESSION=" + session["token"], diff --git a/docs/RIDES_VERIFICATION.md b/docs/RIDES_VERIFICATION.md index 2328ce6..8a30eed 100644 --- a/docs/RIDES_VERIFICATION.md +++ b/docs/RIDES_VERIFICATION.md @@ -2,15 +2,21 @@ No real ride, charge, call, or location tracking is performed. -## Account-backed follow-up — verification in progress +## Account-backed follow-up — October 19, 2026 The current follow-up replaces the invitation UI with database-backed registration, sign-in, persisted profiles, and sign-out. It removes customer-facing demo language, pairing controls, fixed profile ratings and wallet balances, and visible Revyl banners. New account dispatch is separate from the legacy room API described below, which remains compatible for older clients. -The integrated backend passes 114 tests, including all 45 legacy cases, and npm audit reports no vulnerabilities. The production deploy dry run also passes with Node type declarations explicitly enabled. Six configuration-isolation tests and eight account-session setup tests pass. These are local/transaction checks, not proof of deployed account behavior. +The integrated backend passes 120 tests, including all 45 legacy cases and six new nullable-registration regressions, and npm audit reports no vulnerabilities. TypeScript checking and the production deploy dry run pass. Six configuration-isolation tests and eight account-session setup tests also pass. These local/transaction checks are separate from the production checks below. -An isolated source snapshot compiled the new native screens and test bundle on the Uber account's arm64 runner. Attempting simulator execution there failed because that runner has no available iPhone simulator runtime; this does not establish executed iOS XCTest. The follow-up's exact-head simulator CI and native paired-account device verification are still required. The latest upgrade/session regression is newer than that first build snapshot. +The existing `landseer-enga/uber-clone` production service now includes the account and dispatch functions, tables, and indexes. Seven independent multi-client checks against production passed registration/password login, immutable roles and revocation, WebSocket updates, authoritative fares and idempotency, exclusive assignment, isolation/restoration, shared chat and lifecycle, rating/tip preferences, cancellation, and sign-out. -Uber API access and its existing Eats/Rides app containers are verified with the separate `REVYL_UBER_API_KEY`. GitHub repository authorization/project discovery in that account is still blocked; existing repository configs and the original API key have not been migrated or replaced. Do not treat a successful manual app build as completed GitHub automation migration. +At `ed6a977`, [Rides iOS CI](https://github.com/RevylAI/uber-eats-clone/actions/runs/39307062166/job/118246771615) executed 51 Swift core tests in Debug, 47 in Release, and 52 arm64 iOS simulator XCTest cases, including the Keychain save/restore/update/clear test. The log ends with `TEST EXECUTE SUCCEEDED`. The earlier Uber-account build runner compiled the test bundle but lacked a simulator runtime; it is not counted as an executed test run. Later changes affect backend nullable signup handling, generated bindings, the session launcher, and this verification record rather than native Swift code. + +Uber-account native app build `30497455-5e81-4acf-bc28-5b277425095b` ran on two separate iOS sessions against real production database users. The rider requested the $18.40 route, the driver received and accepted it, both phones cold-launched and restored their account/assignment, and both chat directions synchronized. Arrival, start, completion, five stars, and a $3 tip preference persisted; both clients recorded $21.40. Rebooking and cancellation removed the driver's pending offer. No room invitation or pairing UI was used, and the normal home, sign-in, driver, chat, and receipt screens have no demo or test banners. + +Ordinary native rider signup initially reproduced `Invalid vehicle`: the Swift SDK serializes absent optional fields as JSON null, but the action accepted only omitted fields. The deployed fix treats null vehicle/plate values as absent while retaining the driver's required-vehicle validation. The same native registration then succeeded, restored after a cold launch, signed out, and signed back in with its actual password. Both verification phones were signed out and released, with the original driver offline. Public-safe screenshots are attached to PR #4; credentials, launch values, and private account emails are excluded. + +Uber API access and its existing Eats/Rides app containers are verified with the separate `REVYL_UBER_API_KEY`. GitHub repository authorization/project discovery in that account is still blocked; existing repository configs and the original API key have not been migrated or replaced. Verify that any customer-account association cannot expose other repositories through the existing RevylAI installation or change Benchmarks ownership. Do not treat a successful manual app build or paired manual verification as completed GitHub automation migration or an automatic account Proof of Changes verdict. The earlier hosted-room PR at `dc03df4c` has now completed its independent single-phone Revyl proof with no defects found in the exercised request/cancellation/navigation flow. That evidence does not verify the new account follow-up or paired behavior. diff --git a/scripts/test_revyl_config.py b/scripts/test_revyl_config.py index b43b2bb..6755556 100644 --- a/scripts/test_revyl_config.py +++ b/scripts/test_revyl_config.py @@ -114,6 +114,7 @@ def test_rides_launcher_uses_nested_project_for_both_roles(self): self.assertEqual(json.loads(request.call_args.args[0].data)["args"]["role"], role) self.assertEqual(run.call_args.kwargs["cwd"], ROOT / "apps/rides") command = run.call_args.args[0] + self.assertIn("--no-inherited-launch-vars", command) link = command[command.index("--app-link") + 1] self.assertTrue(urlparse(link).scheme == "ridesdemo") self.assertTrue(parse_qs(urlparse(link).query)["role"] == [role]) diff --git a/services/ride-convex/convex/_generated/api.d.ts b/services/ride-convex/convex/_generated/api.d.ts index 20a1ab7..3becaae 100644 --- a/services/ride-convex/convex/_generated/api.d.ts +++ b/services/ride-convex/convex/_generated/api.d.ts @@ -8,11 +8,6 @@ * @module */ -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; import type * as accountActions from "../accountActions.js"; import type * as accountAuth from "../accountAuth.js"; import type * as accountStore from "../accountStore.js"; @@ -23,14 +18,12 @@ import type * as dispatchState from "../dispatchState.js"; import type * as rides from "../rides.js"; import type * as validation from "../validation.js"; -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + declare const fullApi: ApiFromModules<{ accountActions: typeof accountActions; accountAuth: typeof accountAuth; @@ -42,11 +35,31 @@ declare const fullApi: ApiFromModules<{ rides: typeof rides; validation: typeof validation; }>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ export declare const api: FilterApi< typeof fullApi, FunctionReference >; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ export declare const internal: FilterApi< typeof fullApi, FunctionReference >; + +export declare const components: {}; diff --git a/services/ride-convex/convex/_generated/api.js b/services/ride-convex/convex/_generated/api.js index 3f9c482..44bf985 100644 --- a/services/ride-convex/convex/_generated/api.js +++ b/services/ride-convex/convex/_generated/api.js @@ -8,7 +8,7 @@ * @module */ -import { anyApi } from "convex/server"; +import { anyApi, componentsGeneric } from "convex/server"; /** * A utility for referencing Convex functions in your app's API. @@ -20,3 +20,4 @@ import { anyApi } from "convex/server"; */ export const api = anyApi; export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/services/ride-convex/convex/_generated/server.d.ts b/services/ride-convex/convex/_generated/server.d.ts index e22cbd9..f235db4 100644 --- a/services/ride-convex/convex/_generated/server.d.ts +++ b/services/ride-convex/convex/_generated/server.d.ts @@ -21,6 +21,17 @@ import { } from "convex/server"; import type { DataModel } from "./dataModel.js"; +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + /** * Define a query in this Convex app's public API. * @@ -95,7 +106,13 @@ export declare const internalAction: ActionBuilder; */ export declare const httpAction: HttpActionBuilder; -export declare const env: Record; +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; /** * A set of services for use within Convex query functions. diff --git a/services/ride-convex/convex/_generated/server.js b/services/ride-convex/convex/_generated/server.js index 79c6b77..2dbe0db 100644 --- a/services/ride-convex/convex/_generated/server.js +++ b/services/ride-convex/convex/_generated/server.js @@ -92,4 +92,10 @@ export const internalAction = internalActionGeneric; */ export const httpAction = httpActionGeneric; +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ export const env = process.env; diff --git a/services/ride-convex/convex/accountActions.ts b/services/ride-convex/convex/accountActions.ts index ddcb0a7..321fbed 100644 --- a/services/ride-convex/convex/accountActions.ts +++ b/services/ride-convex/convex/accountActions.ts @@ -41,8 +41,8 @@ export const signUp = internalAction({ const password = passwordValue(input.password); const role = input.role; if (role !== "rider" && role !== "driver") fail(400, "Invalid role"); - const vehicle = input.vehicle === undefined ? null : text(input.vehicle, 100, "vehicle"); - const plate = input.plate === undefined ? null : text(input.plate, 20, "plate"); + const vehicle = input.vehicle == null ? null : text(input.vehicle, 100, "vehicle"); + const plate = input.plate == null ? null : text(input.plate, 20, "plate"); if (role === "driver" && (!vehicle || !plate)) fail(400, "Driver vehicle and plate are required"); const serviceArea = input.serviceArea === undefined ? "austin" : text(input.serviceArea, 80, "serviceArea").toLowerCase(); if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(serviceArea)) fail(400, "Invalid serviceArea"); diff --git a/services/ride-convex/convex/accountTypes.ts b/services/ride-convex/convex/accountTypes.ts index 5faf569..5dfe7b8 100644 --- a/services/ride-convex/convex/accountTypes.ts +++ b/services/ride-convex/convex/accountTypes.ts @@ -15,6 +15,6 @@ export type AccountSession = { }; export type SignUp = { name: string; email: string; password: string; role: string; - vehicle?: string; plate?: string; serviceArea?: string; + vehicle?: string | null; plate?: string | null; serviceArea?: string; }; export type SignIn = { email: string; password: string }; diff --git a/services/ride-convex/tests/accountRegistration.test.ts b/services/ride-convex/tests/accountRegistration.test.ts new file mode 100644 index 0000000..6fd34c1 --- /dev/null +++ b/services/ride-convex/tests/accountRegistration.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment node +import { convexTest } from "convex-test"; +import { expect, test } from "vitest"; +import { api } from "../convex/_generated/api"; +import schema from "../convex/schema"; + +const modules = import.meta.glob("../convex/**/*.ts"); +const nullableFields = [{ vehicle: null }, { plate: null }, { vehicle: null, plate: null }]; + +test.each(nullableFields)("rider registration accepts JSON null for absent vehicle details: %j", async fields => { + const t = convexTest({ schema, modules, transactionLimits: true }); + const email = `${crypto.randomUUID()}@example.test`; + const password = "native registration fixture"; + const session = await t.action(api.accounts.signUp, { + name: "Taylor", email, password, role: "rider", ...fields, + }); + expect(session.account).toMatchObject({ name: "Taylor", email, role: "rider", vehicle: null, plate: null }); + expect((await t.action(api.accounts.signIn, { email, password })).account).toEqual(session.account); + expect((await t.query(api.accounts.resume, { token: session.token })).account).toEqual(session.account); +}); + +test.each(nullableFields)("driver registration still requires non-null vehicle details: %j", async fields => { + const t = convexTest({ schema, modules, transactionLimits: true }); + await expect(t.action(api.accounts.signUp, { + name: "Taylor", email: `${crypto.randomUUID()}@example.test`, password: "native registration fixture", + role: "driver", vehicle: "Silver sedan", plate: "TX 1234", ...fields, + })).rejects.toMatchObject({ data: { status: 400 } }); + expect(await t.run(ctx => ctx.db.query("users").collect())).toEqual([]); +});