From dc03df4c4938c714f76e60a6fa15341303be6606 Mon Sep 17 00:00:00 2001 From: Landseer100 <60585684+Landseer100@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:22:40 +0000 Subject: [PATCH] Connect native Rides to the existing Convex deployment Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --- .github/workflows/rides.yml | 13 +- README.md | 2 +- apps/rides/.revyl/config.yaml | 8 +- apps/rides/App/RidesDemoApp.swift | 4 +- apps/rides/Core/ConvexRideAPI.swift | 92 + apps/rides/Core/RideAPI.swift | 38 + apps/rides/Core/RideStore.swift | 66 +- apps/rides/Features/ConnectionView.swift | 17 +- apps/rides/Package.swift | 5 +- apps/rides/Tests/ConvexRideAPITests.swift | 187 ++ apps/rides/project.yml | 8 + apps/rides/scripts/build-ios.sh | 6 +- apps/rides/scripts/verify-ios.sh | 2 +- docs/RIDES.md | 24 +- docs/RIDES_VERIFICATION.md | 18 +- services/ride-convex/.gitignore | 3 + services/ride-convex/README.md | 69 + .../ride-convex/convex/_generated/api.d.ts | 51 + services/ride-convex/convex/_generated/api.js | 23 + .../convex/_generated/dataModel.d.ts | 60 + .../ride-convex/convex/_generated/server.d.ts | 162 ++ .../ride-convex/convex/_generated/server.js | 101 + services/ride-convex/convex/rides.ts | 213 ++ services/ride-convex/convex/schema.ts | 44 + services/ride-convex/convex/tsconfig.json | 4 + services/ride-convex/convex/validation.ts | 95 + services/ride-convex/package-lock.json | 1804 +++++++++++++++++ services/ride-convex/package.json | 20 + services/ride-convex/tests/rides.test.ts | 367 ++++ services/ride-convex/tsconfig.json | 13 + services/ride-convex/vitest.config.ts | 5 + 31 files changed, 3477 insertions(+), 47 deletions(-) create mode 100644 apps/rides/Core/ConvexRideAPI.swift create mode 100644 apps/rides/Tests/ConvexRideAPITests.swift create mode 100644 services/ride-convex/.gitignore create mode 100644 services/ride-convex/README.md create mode 100644 services/ride-convex/convex/_generated/api.d.ts create mode 100644 services/ride-convex/convex/_generated/api.js create mode 100644 services/ride-convex/convex/_generated/dataModel.d.ts create mode 100644 services/ride-convex/convex/_generated/server.d.ts create mode 100644 services/ride-convex/convex/_generated/server.js create mode 100644 services/ride-convex/convex/rides.ts create mode 100644 services/ride-convex/convex/schema.ts create mode 100644 services/ride-convex/convex/tsconfig.json create mode 100644 services/ride-convex/convex/validation.ts create mode 100644 services/ride-convex/package-lock.json create mode 100644 services/ride-convex/package.json create mode 100644 services/ride-convex/tests/rides.test.ts create mode 100644 services/ride-convex/tsconfig.json create mode 100644 services/ride-convex/vitest.config.ts diff --git a/.github/workflows/rides.yml b/.github/workflows/rides.yml index e74eb35..fc424d9 100644 --- a/.github/workflows/rides.yml +++ b/.github/workflows/rides.yml @@ -2,10 +2,19 @@ name: Rides demo on: push: branches: [main] - paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml'] + paths: ['apps/rides/**', 'services/ride-demo/**', 'services/ride-convex/**', '.github/workflows/rides.yml'] pull_request: - paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml'] + paths: ['apps/rides/**', 'services/ride-demo/**', 'services/ride-convex/**', '.github/workflows/rides.yml'] jobs: + convex: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm ci && npm run typecheck && npm test + working-directory: services/ride-convex server: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 4656e59..d812502 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EatsClone -This repository also contains a separate native **Rides** app in [`apps/rides`](apps/rides) and its shared demo service in [`services/ride-demo`](services/ride-demo). Eats remains unchanged. See the [rider/driver two-phone demo guide](docs/RIDES.md) for builds, pairing, references, and limitations. +This repository also contains a separate native **Rides** app in [`apps/rides`](apps/rides), its hosted [Convex backend](services/ride-convex), and an optional legacy [HTTP service](services/ride-demo). Eats remains unchanged. See the [rider/driver two-phone demo guide](docs/RIDES.md) for builds, pairing, references, and limitations. An independent, self-contained food delivery demo built with native SwiftUI for iOS 17 and later. Its interaction design is inspired by the Uber Eats references on Mobbin. This project is not affiliated with, endorsed by, or connected to Uber, Uber Eats, Mobbin, Unsplash, or any restaurant shown in its sample catalog. diff --git a/apps/rides/.revyl/config.yaml b/apps/rides/.revyl/config.yaml index 331f650..7240daf 100644 --- a/apps/rides/.revyl/config.yaml +++ b/apps/rides/.revyl/config.yaml @@ -24,6 +24,7 @@ pr_review: paths: - apps/rides/** - services/ride-demo/** + - services/ride-convex/** - docs/RIDES.md - docs/RIDES_VERIFICATION.md - .github/workflows/rides.yml @@ -40,8 +41,9 @@ pr_review: 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. - - Open Where to, select an offline destination, choose a vehicle and payment label, and reach Confirm your pickup. Without a configured demo server, Connect to request a ride opens Demo connection rather than inventing a driver, trip, or charge. Do not enter real payment information. - - Dismiss the connection and booking sheets, then verify Home, Services, Activity, and Account remain reachable. Open saved places and wallet from Account using only demo data. - - Two-phone dispatch, chat, completion, and cancellation require a shared reachable HTTPS service and a second phone. Do not report those behaviors as passed from this single-phone proof. + - 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. strict_ci_check: build: true diff --git a/apps/rides/App/RidesDemoApp.swift b/apps/rides/App/RidesDemoApp.swift index ed32b19..e7ee756 100644 --- a/apps/rides/App/RidesDemoApp.swift +++ b/apps/rides/App/RidesDemoApp.swift @@ -47,8 +47,8 @@ struct RideRootView: View { } .sheet(isPresented: $store.showConnection) { DemoConnectionView().environmentObject(store) } .fullScreenCover(isPresented: $store.showBooking) { BookingFlowView().environmentObject(store) } - .task(id: scenePhase) { - if scenePhase == .active { await store.runPolling() } + .task(id: scenePhase == .active ? store.credentials?.token : nil) { + if scenePhase == .active { await store.runUpdates() } } } } diff --git a/apps/rides/Core/ConvexRideAPI.swift b/apps/rides/Core/ConvexRideAPI.swift new file mode 100644 index 0000000..2cc6bb0 --- /dev/null +++ b/apps/rides/Core/ConvexRideAPI.swift @@ -0,0 +1,92 @@ +import Combine +import ConvexMobile +import Foundation +import Security + +extension TripRequest: ConvexEncodable {} +extension RideAction: ConvexEncodable {} + +final class ConvexRideAPIClient: RideAPI { + let baseURL: URL + private let client: ConvexClient + private let connectionState = CurrentValueSubject(false) + private var socketObservation: AnyCancellable? + + init(baseURL: URL) { + self.baseURL = baseURL + client = ConvexClient(deploymentUrl: baseURL.absoluteString) + socketObservation = client.watchWebSocketState().sink { [connectionState] in connectionState.send($0 == .connected) } + } + + static func freshToken() throws -> String { + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw RideAPIError(status: 0, message: "Couldn't create a secure session. Try again.") + } + return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + + func create(name: String) async throws -> RideSession { + try await mutate("createRoom", args: ["name": name, "token": Self.freshToken()]) + } + + func join(code: String, role: DemoRole, name: String) async throws -> RideSession { + try await mutate("joinRoom", args: ["code": code, "role": role.rawValue, "name": name, "token": Self.freshToken()]) + } + + 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 { + return state + } + throw RideAPIError(status: 0, message: "The shared ride connection closed. Try again.") + } catch { throw Self.apiError(error) } + } + + func availability(token: String, online: Bool) async throws -> RideRoomState { + try await mutate("availability", args: ["token": token, "online": online]) + } + + func book(token: String, request: TripRequest) async throws -> RideRoomState { + try await mutate("book", 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]) + } + + func updates(token: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let state = client.subscribe(to: "rides:state", with: ["token": token], yielding: RideRoomState.self) + .map(RideSyncEvent.state) + let connection = connectionState + .map(RideSyncEvent.connection) + .setFailureType(to: ClientError.self) + let subscription = state.merge(with: connection).sink { completion in + switch completion { + case .finished: continuation.finish() + case .failure(let error): continuation.finish(throwing: Self.apiError(error)) + } + } receiveValue: { event in + continuation.yield(event) + } + continuation.onTermination = { _ in subscription.cancel() } + } + } + + private func mutate(_ name: String, args: [String: ConvexEncodable?]) async throws -> T { + do { return try await client.mutation("rides:" + name, with: args) } + catch { throw Self.apiError(error) } + } + + static func apiError(_ error: Error) -> RideAPIError { + if let error = error as? RideAPIError { return error } + struct Failure: Decodable { let status: Int; let message: String } + if let error = error as? ClientError, case .ConvexError(let data) = error, + let failure = try? JSONDecoder().decode(Failure.self, from: Data(data.utf8)), + (400..<500).contains(failure.status) { + return RideAPIError(status: failure.status, message: failure.message) + } + return RideAPIError(status: 0, message: "Couldn't reach the shared ride backend. Check your connection and try again.") + } +} diff --git a/apps/rides/Core/RideAPI.swift b/apps/rides/Core/RideAPI.swift index 806daf1..ef74276 100644 --- a/apps/rides/Core/RideAPI.swift +++ b/apps/rides/Core/RideAPI.swift @@ -9,6 +9,11 @@ struct RideAPIError: LocalizedError, Equatable { var errorDescription: String? { message } } +enum RideSyncEvent { + case state(RideRoomState) + case connection(Bool) +} + protocol RideAPI { var baseURL: URL { get } func create(name: String) async throws -> RideSession @@ -17,6 +22,39 @@ protocol RideAPI { func availability(token: String, online: Bool) async throws -> RideRoomState func book(token: String, request: TripRequest) async throws -> RideRoomState func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState + func updates(token: String) -> AsyncThrowingStream +} + +extension RideAPI { + func updates(token: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + while !Task.isCancelled { + let incoming = try await state(token: token) + continuation.yield(.connection(true)) + continuation.yield(.state(incoming)) + try await Task.sleep(for: .seconds(1)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } +} + +enum RideBackend { + static var defaultAddress: String { + Bundle.main.object(forInfoDictionaryKey: "ConvexDeploymentURL") as? String ?? "" + } + + static func client(for url: URL) -> any RideAPI { + if url.host?.hasSuffix(".convex.cloud") == true { return ConvexRideAPIClient(baseURL: url) } + return RideAPIClient(baseURL: url) + } } struct RideAPIClient: RideAPI { diff --git a/apps/rides/Core/RideStore.swift b/apps/rides/Core/RideStore.swift index 0f63159..abff906 100644 --- a/apps/rides/Core/RideStore.swift +++ b/apps/rides/Core/RideStore.swift @@ -40,6 +40,7 @@ final class RideStore: ObservableObject { @Published private(set) var credentials: StoredRideSession? @Published private(set) var state: RideRoomState? @Published private(set) var lastSync: Date? + @Published private(set) var syncConnected = false @Published private(set) var busy = false @Published var errorMessage: String? @Published var notice: String? @@ -67,7 +68,7 @@ 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 = { RideAPIClient(baseURL: $0) }) { + init(defaults: UserDefaults = .standard, vault: any RideSessionVault = KeychainRideSessionVault(), apiFactory: @escaping (URL) -> any RideAPI = { RideBackend.client(for: $0) }) { self.defaults = defaults self.vault = vault self.apiFactory = apiFactory @@ -75,7 +76,7 @@ final class RideStore: ObservableObject { 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") ?? "" + serverAddress = restored?.baseURL.absoluteString ?? 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 @@ -91,7 +92,7 @@ final class RideStore: ObservableObject { var earningsCents: Int { (state?.trips ?? []).filter { $0.status == .completed && $0.driver?.id == credentials?.participantId }.reduce(0) { $0 + $1.totalCents } } - var syncIsFresh: Bool { lastSync.map { Date().timeIntervalSince($0) < 5 } ?? false } + var syncIsFresh: Bool { syncConnected } func chooseRole(_ value: DemoRole) { guard !connected else { return } @@ -133,14 +134,30 @@ final class RideStore: ObservableObject { api = nil state = nil lastSync = nil + syncConnected = false focusedTripID = nil roomInput = "" vault.clear() } - func runPolling() async { - while !Task.isCancelled { - if connected { await sync() } + func runUpdates() async { + guard let credentials, let api else { return } + defer { if self.credentials?.token == credentials.token { syncConnected = false } } + while !Task.isCancelled, self.credentials?.token == credentials.token { + var connectionLive = false + do { + for try await event in api.updates(token: credentials.token) { + guard !Task.isCancelled, self.credentials?.token == credentials.token else { return } + switch event { + case .state(let state): apply(state, for: credentials, live: connectionLive) + case .connection(let live): + connectionLive = live + syncConnected = live && state != nil + if live && errorMessage?.hasPrefix("Connection lost.") == true { errorMessage = nil } + } + } + } catch { handleSyncError(error, for: credentials) } + guard self.credentials?.token == credentials.token else { return } do { try await Task.sleep(for: .seconds(1)) } catch { return } } } @@ -152,26 +169,33 @@ final class RideStore: ObservableObject { guard !Task.isCancelled else { return } apply(state, for: credentials) } catch { - guard !Task.isCancelled, self.credentials?.token == credentials.token else { return } - if let error = error as? RideAPIError, [401, 404].contains(error.status) { - self.credentials = nil - self.api = nil - state = nil - lastSync = nil - vault.clear() - showConnection = true - errorMessage = "This demo session has expired. Create or join a room again." - } else { - errorMessage = "Connection lost. Check the demo server and tap Retry." - } + handleSyncError(error, for: credentials) + } + } + + private func handleSyncError(_ error: Error, for credentials: StoredRideSession) { + 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() + showConnection = true + errorMessage = "This demo session has expired. Create or join a room again." + } else { + errorMessage = "Connection lost. Check your internet connection and tap Retry." } } - private func apply(_ incoming: RideRoomState, for credentials: StoredRideSession) { + private func apply(_ incoming: RideRoomState, for credentials: StoredRideSession, live: Bool = true) { guard self.credentials?.token == credentials.token, incoming.roomCode == credentials.roomCode else { return } guard state == nil || incoming.revision >= state!.revision else { return } state = incoming + if credentials.role == .rider, let active = incoming.activeTrip { focusedTripID = active.id } lastSync = Date() + syncConnected = live if errorMessage?.hasPrefix("Connection lost.") == true { errorMessage = nil } } @@ -186,6 +210,10 @@ final class RideStore: ObservableObject { errorMessage = nil return true } catch { + if (error as? RideAPIError)?.status == 401 { + handleSyncError(error, for: credentials) + return false + } if (error as? RideAPIError)?.status == 409 { await sync() } errorMessage = error.localizedDescription return false diff --git a/apps/rides/Features/ConnectionView.swift b/apps/rides/Features/ConnectionView.swift index 19f2e5c..43731bd 100644 --- a/apps/rides/Features/ConnectionView.swift +++ b/apps/rides/Features/ConnectionView.swift @@ -5,6 +5,7 @@ struct DemoConnectionView: View { @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 { @@ -21,7 +22,10 @@ struct DemoConnectionView: View { 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) - Text(session.roomCode).font(.system(size: 30, weight: .bold, design: .monospaced)).tracking(3).textSelection(.enabled) + 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." @@ -43,9 +47,12 @@ struct DemoConnectionView: View { } } VStack(alignment: .leading, spacing: 7) { - Text("Demo server").font(.subheadline.bold()) - TextField("https://your-demo-server", text: $store.serverAddress).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled().padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)).accessibilityIdentifier("demo.server") - Text("The server must be reachable by both phones. No public server is bundled.").font(.caption).foregroundStyle(.secondary) + 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()) @@ -73,7 +80,7 @@ struct DemoConnectionView: View { .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("The room and its trip history stay on the server until the room expires. Keep the invitation to rejoin as the rider.") } + } 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/Package.swift b/apps/rides/Package.swift index 7671b42..89c79a2 100644 --- a/apps/rides/Package.swift +++ b/apps/rides/Package.swift @@ -1,12 +1,13 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 import PackageDescription let package = Package( name: "RidesDemoCore", platforms: [.macOS(.v14), .iOS(.v17)], products: [.library(name: "RidesDemo", targets: ["RidesDemo"])], + dependencies: [.package(url: "https://github.com/get-convex/convex-swift", exact: "0.8.1")], targets: [ - .target(name: "RidesDemo", path: "Core"), + .target(name: "RidesDemo", dependencies: [.product(name: "ConvexMobile", package: "convex-swift")], path: "Core"), .testTarget(name: "RidesDemoTests", dependencies: ["RidesDemo"], path: "Tests") ] ) diff --git a/apps/rides/Tests/ConvexRideAPITests.swift b/apps/rides/Tests/ConvexRideAPITests.swift new file mode 100644 index 0000000..4e23082 --- /dev/null +++ b/apps/rides/Tests/ConvexRideAPITests.swift @@ -0,0 +1,187 @@ +import Combine +import ConvexMobile +import XCTest +@testable import RidesDemo + +final class ConvexRideAPITests: XCTestCase { + func testSecureTokensAreFreshURLSafeAndNeverAdminCredentials() throws { + let first = try ConvexRideAPIClient.freshToken() + let second = try ConvexRideAPIClient.freshToken() + XCTAssertTrue(first != second) + XCTAssertTrue(first.count >= 43) + XCTAssertTrue(first.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") }) + } + + func testConvexAndLegacyBackendsSelectTheirOwnTransport() throws { + let convex = try RideAPIClient.validatedURL("https://fixture.convex.cloud") + let legacy = try RideAPIClient.validatedURL("https://legacy.example.com") + XCTAssertTrue(RideBackend.client(for: convex) is ConvexRideAPIClient) + XCTAssertTrue(RideBackend.client(for: legacy) is RideAPIClient) + } + + func testConvexArgumentsKeepNumbersAndDoNotSendClientFares() throws { + var draft = BookingDraft() + draft.destination = RideCatalog.places[1] + let request = try TripRequest(draft: draft) + let encoded = try XCTUnwrap(request.convexEncode().data(using: .utf8)) + let value = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + XCTAssertEqual(value["service"] as? String, "uberX") + XCTAssertNil(value["fareCents"]) + let action = RideAction(action: "rate", rating: 5, tipCents: 300) + let data = try XCTUnwrap(action.convexEncode().data(using: .utf8)) + let rating = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(rating["rating"] as? Int, 5) + XCTAssertEqual(rating["tipCents"] as? Int, 300) + } + + func testStructuredErrorsPreserveStatusWithoutExposingInternalErrors() { + let expected = ClientError.ConvexError(data: "{\"status\":409,\"message\":\"Trip is no longer available\"}") + XCTAssertEqual(ConvexRideAPIClient.apiError(expected), RideAPIError(status: 409, message: "Trip is no longer available")) + let internalFailure = ClientError.ServerError(msg: "private fixture data") + let safe = ConvexRideAPIClient.apiError(internalFailure) + XCTAssertEqual(safe.status, 0) + XCTAssertFalse(safe.message.contains("private fixture")) + } +} + +private final class StreamRideVault: RideSessionVault { + var value: StoredRideSession? + func load() -> StoredRideSession? { value } + func save(_ session: StoredRideSession) throws { value = session } + func clear() { value = nil } +} + +private final class StreamRideAPI: RideAPI { + let baseURL = URL(string: "https://fixture.convex.cloud")! + var continuation: AsyncThrowingStream.Continuation? + var subscribed: (() -> Void)? + var mutationFailure: RideAPIError? + var current = RideRoomState(roomCode: "FIXTUREABCD", revision: 1, driverOnline: false, onlineDriverIds: [], trips: []) + func create(name: String) async throws -> RideSession { RideSession(roomCode: current.roomCode, participantId: "rider-fixture", token: "session-fixture", role: .rider, name: name, state: current) } + func join(code: String, role: DemoRole, name: String) async throws -> RideSession { try await create(name: name) } + func state(token: String) async throws -> RideRoomState { current } + func availability(token: String, online: Bool) async throws -> RideRoomState { current } + func book(token: String, request: TripRequest) async throws -> RideRoomState { + if let mutationFailure { throw mutationFailure } + return current + } + func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState { current } + func updates(token: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + self.continuation = continuation + subscribed?() + } + } +} + +@MainActor +final class RideRealtimeTests: XCTestCase { + func testRealtimeUpdatesApplyWithoutPollingAndCancellationKeepsSession() async throws { + let suite = "RidesRealtime.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let api = StreamRideAPI() + let vault = StreamRideVault() + let store = RideStore(defaults: defaults, vault: vault, apiFactory: { _ in api }) + store.serverAddress = api.baseURL.absoluteString + await store.connect(createNew: true) + let subscribed = expectation(description: "Subscribed to room changes") + api.subscribed = { subscribed.fulfill() } + let task = Task { await store.runUpdates() } + await fulfillment(of: [subscribed], timeout: 2) + let updated = expectation(description: "Applied server-pushed revision") + let observer = store.$state.compactMap { $0 }.filter { $0.revision == 2 }.first().sink { _ in updated.fulfill() } + api.continuation?.yield(.connection(true)) + api.continuation?.yield(.state(RideRoomState(roomCode: api.current.roomCode, revision: 2, driverOnline: true, onlineDriverIds: ["driver-fixture"], trips: []))) + await fulfillment(of: [updated], timeout: 2) + XCTAssertTrue(store.state?.driverOnline == true) + XCTAssertTrue(store.syncIsFresh) + task.cancel() + await task.value + observer.cancel() + XCTAssertTrue(store.connected) + XCTAssertNotNil(vault.value) + XCTAssertFalse(store.syncIsFresh) + } + + func testCachedStateDoesNotMarkADisconnectedStreamLive() async throws { + let suite = "RidesOffline.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let api = StreamRideAPI() + let store = RideStore(defaults: defaults, vault: StreamRideVault(), apiFactory: { _ in api }) + store.serverAddress = api.baseURL.absoluteString + await store.connect(createNew: true) + let subscribed = expectation(description: "Subscribed") + api.subscribed = { subscribed.fulfill() } + let task = Task { await store.runUpdates() } + await fulfillment(of: [subscribed], timeout: 2) + let updated = expectation(description: "Applied cached state") + let observer = store.$state.compactMap { $0 }.filter { $0.revision == 2 }.first().sink { _ in updated.fulfill() } + api.continuation?.yield(.connection(false)) + api.continuation?.yield(.state(RideRoomState(roomCode: api.current.roomCode, revision: 2, driverOnline: true, onlineDriverIds: ["driver-fixture"], trips: []))) + await fulfillment(of: [updated], timeout: 2) + XCTAssertFalse(store.syncIsFresh) + task.cancel() + await task.value + observer.cancel() + } + + func testExpiredRealtimeCredentialClearsVaultAndRequestsReconnection() async throws { + let suite = "RidesRealtime.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let api = StreamRideAPI() + let vault = StreamRideVault() + let store = RideStore(defaults: defaults, vault: vault, apiFactory: { _ in api }) + store.serverAddress = api.baseURL.absoluteString + await store.connect(createNew: true) + let subscribed = expectation(description: "Subscribed") + api.subscribed = { subscribed.fulfill() } + let task = Task { await store.runUpdates() } + await fulfillment(of: [subscribed], timeout: 2) + api.continuation?.finish(throwing: RideAPIError(status: 401, message: "Expired")) + await task.value + XCTAssertFalse(store.connected) + XCTAssertNil(vault.value) + XCTAssertTrue(store.showConnection) + } + + func testRestoredActiveRideKeepsItsReceiptFocusedWhenDriverCompletes() async throws { + let suite = "RidesReceipt.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let api = StreamRideAPI() + let vault = StreamRideVault() + try vault.save(StoredRideSession(try await api.create(name: "Fixture rider"), baseURL: api.baseURL)) + let store = RideStore(defaults: defaults, vault: vault, apiFactory: { _ in api }) + func trip(_ status: TripStatus) -> DemoTrip { + DemoTrip(id: "trip-fixture", riderId: "rider-fixture", riderName: "Fixture rider", pickup: RideCatalog.places[0], destination: RideCatalog.places[1], stops: [], service: "uberX", paymentMethod: "Demo Cash", scheduledAt: nil, note: "", status: status, driver: nil, fareCents: 1840, createdAt: "2026-10-19T12:00:00.000Z", updatedAt: "2026-10-19T12:00:00.000Z", cancellationReason: nil, rating: nil, tipCents: 0, messages: []) + } + api.current = RideRoomState(roomCode: api.current.roomCode, revision: 2, driverOnline: true, onlineDriverIds: [], trips: [trip(.inProgress)]) + await store.sync() + XCTAssertEqual(store.focusedTripID, "trip-fixture") + api.current = RideRoomState(roomCode: api.current.roomCode, revision: 3, driverOnline: true, onlineDriverIds: [], trips: [trip(.completed)]) + await store.sync() + XCTAssertNil(store.activeTrip) + XCTAssertEqual(store.focusedTrip?.status, .completed) + } + + func testExpiredMutationClearsTheSessionEvenWithoutAnExpiryOnlyQueryPush() async throws { + let suite = "RidesExpiredMutation.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let api = StreamRideAPI() + let vault = StreamRideVault() + let store = RideStore(defaults: defaults, vault: vault, apiFactory: { _ in api }) + store.serverAddress = api.baseURL.absoluteString + await store.connect(createNew: true) + api.mutationFailure = RideAPIError(status: 401, message: "Invalid or expired bearer token") + store.draft.destination = RideCatalog.places[1] + await store.book() + XCTAssertFalse(store.connected) + XCTAssertNil(vault.value) + XCTAssertTrue(store.showConnection) + XCTAssertTrue(store.errorMessage?.contains("expired") == true) + } +} diff --git a/apps/rides/project.yml b/apps/rides/project.yml index 5b574be..1505d72 100644 --- a/apps/rides/project.yml +++ b/apps/rides/project.yml @@ -1,4 +1,8 @@ name: RidesDemo +packages: + ConvexMobile: + url: https://github.com/get-convex/convex-swift + exactVersion: 0.8.1 options: deploymentTarget: iOS: '17.0' @@ -10,6 +14,7 @@ settings: CODE_SIGN_IDENTITY: '-' MARKETING_VERSION: '0.1.0' CURRENT_PROJECT_VERSION: '1' + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]': x86_64 targets: RidesDemo: type: application @@ -18,6 +23,7 @@ targets: path: .generated/RidesDemo-Info.plist properties: CFBundleDisplayName: Rides + ConvexDeploymentURL: https://harmless-mammoth-742.convex.cloud UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -38,6 +44,8 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.revyl.ridesdemo ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon 'CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]': Simulator.entitlements + dependencies: + - package: ConvexMobile RidesDemoTests: type: bundle.unit-test platform: iOS diff --git a/apps/rides/scripts/build-ios.sh b/apps/rides/scripts/build-ios.sh index 2321c2a..014673c 100755 --- a/apps/rides/scripts/build-ios.sh +++ b/apps/rides/scripts/build-ios.sh @@ -1,13 +1,17 @@ #!/bin/bash set -euo pipefail cd "$(dirname "$0")/.." +if [ "$(uname -m)" != arm64 ]; then + echo 'ConvexMobile 0.8.1 requires an Apple Silicon Mac and arm64 simulator.' >&2 + exit 1 +fi if ! command -v xcodegen >/dev/null; then brew install xcodegen fi swift test swift test -c release xcodegen generate -xcodebuild build-for-testing -project RidesDemo.xcodeproj -scheme RidesDemo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug -derivedDataPath build CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +xcodebuild build-for-testing -project RidesDemo.xcodeproj -scheme RidesDemo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug -derivedDataPath build ARCHS=arm64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- python3 - <<'PY' import plistlib import subprocess diff --git a/apps/rides/scripts/verify-ios.sh b/apps/rides/scripts/verify-ios.sh index 690f5d4..f4877a6 100755 --- a/apps/rides/scripts/verify-ios.sh +++ b/apps/rides/scripts/verify-ios.sh @@ -3,4 +3,4 @@ set -euo pipefail cd "$(dirname "$0")/.." bash scripts/build-ios.sh DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; device=next((d["udid"] for group in json.load(sys.stdin)["devices"].values() for d in group if "iPhone" in d["name"]), None); device or sys.exit("No available iPhone simulator. Install an iOS Simulator runtime in Xcode before running simulator tests."); print(device)') -xcodebuild test-without-building -project RidesDemo.xcodeproj -scheme RidesDemo -destination "platform=iOS Simulator,id=$DEVICE_ID" -configuration Debug -derivedDataPath build CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +xcodebuild test-without-building -project RidesDemo.xcodeproj -scheme RidesDemo -destination "platform=iOS Simulator,id=$DEVICE_ID" -configuration Debug -derivedDataPath build ARCHS=arm64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- diff --git a/docs/RIDES.md b/docs/RIDES.md index 9f35ed4..aff4d63 100644 --- a/docs/RIDES.md +++ b/docs/RIDES.md @@ -22,13 +22,17 @@ The root project remains Eats-only; run Rides CLI commands with `-C apps/rides` 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. -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, offline booking through pickup confirmation, the connection-required boundary, tabs, saved places, and wallet. It deliberately does not claim to verify a two-phone trip without a reachable shared service and a second phone. Paired dispatch, chat, completion, and cancellation were verified separately on native phones. This setup does not deploy a permanent backend or establish full Uber parity. +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. -On a 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. 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 server tests and the simulator verification separately, on pull requests and pushes to `main`. +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. ## Shared service -Two phones cannot synchronize through per-device stubs. `services/ride-demo` is a dependency-free Node 24 HTTP service with SQLite/WAL persistence. It stores isolated invitation rooms, per-participant bearer credentials (hashed server-side), demo requests, acceptance, transitions, messages, and ratings. It never dispatches a real car or processes a payment. See its [API and hosting documentation](../services/ride-demo/README.md). +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 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. + +The original dependency-free Node 24 HTTP/SQLite service remains available for optional local or self-hosted development: ```sh cd services/ride-demo @@ -36,26 +40,26 @@ npm test npm start ``` -For persistent demos, run its Docker image behind HTTPS and mount `/data` on durable storage. Both phones need the same reachable HTTPS origin. A temporary workspace preview is useful for verification but is not permanent hosting: the workspace can sleep, the process can stop, and phones then show a connection error. No transient backend address or invitation is hardcoded in the app. +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**, enter the service's HTTPS address and a sample name, and tap **Create rider room**. -2. Keep the invitation private. On the second phone, choose **Driver**, enter the same service address and the rider's room code, and tap **Join as Driver**. Tap **GO** to become available. +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 after the next poll. The rider can submit a rating and demo tip; receipts and the driver's demo earnings update from shared state. +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. The client polls once per second while foregrounded and discards 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. +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. ## Isolated Revyl sign-in 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: ```sh -python3 apps/rides/scripts/start-session.py --role rider --server https://your-demo-server.example -python3 apps/rides/scripts/start-session.py --role driver --server https://your-demo-server.example +python3 apps/rides/scripts/start-session.py --role rider +python3 apps/rides/scripts/start-session.py --role driver ``` 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. diff --git a/docs/RIDES_VERIFICATION.md b/docs/RIDES_VERIFICATION.md index 7572b8c..e54f037 100644 --- a/docs/RIDES_VERIFICATION.md +++ b/docs/RIDES_VERIFICATION.md @@ -1,6 +1,22 @@ # Rides verification -Verified on October 18, 2026. No real ride, charge, call, or location tracking is performed. +No real ride, charge, call, or location tracking is performed. + +## 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. + +The backend passes 45 Convex tests, TypeScript checking, and an npm audit with no vulnerabilities. Independent clients against the actual production deployment additionally verified live subscriptions, server-authoritative fares, booking idempotency/conflicts, exactly one winner from competing driver acceptance, assigned-driver permissions, two-way chat, arrival/start/completion, five stars and a $3 demo tip, cancellation, room isolation, invalid-token rejection, and history retrieved through a newly created client. Those live checks are separate from the mock transaction tests. + +Native Swift build `86cf5c9d-c5a3-4d9b-ad71-6438bc59cd02` ran core tests in Debug and Release and built the arm64 simulator app/test bundle. Two independent native phones against Convex created/joined a private room, went online, requested and accepted a ride, exchanged both chat messages, and advanced arrival, start, and completion. A cold-launched rider also restored an accepted active trip from Keychain without creating a new room or credential. The initial generic simulator build exposed the SDK's missing Intel slice; the Rides build now explicitly targets arm64. Eats builds are unchanged. + +The current PR's checks and native evidence remain authoritative for subsequent client changes. The new regression coverage includes cached/offline connection state, subscription cancellation, expired query and mutation credentials, and retaining receipt focus after restoring an active trip. Current Rides automation uses the hosted backend for a single-phone request/cancellation, not an invented driver acceptance; paired behavior still requires two phones. + +Rooms have a 24-hour authorization lifetime and bounded history; expiry is not automatic document deletion. Authentication uses private demo-room capabilities, not verified real identities. Real maps, GPS, matching, payments, and background/push delivery remain out of scope. + +## Original HTTP baseline — October 18, 2026 + +The results below describe the earlier SQLite/HTTP implementation and its temporary verification server, not the hosted Convex deployment above. ## Automated checks diff --git a/services/ride-convex/.gitignore b/services/ride-convex/.gitignore new file mode 100644 index 0000000..dedc722 --- /dev/null +++ b/services/ride-convex/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env* +coverage/ diff --git a/services/ride-convex/README.md b/services/ride-convex/README.md new file mode 100644 index 0000000..f4cee47 --- /dev/null +++ b/services/ride-convex/README.md @@ -0,0 +1,69 @@ +# Transactional rides backend + +Convex 1.47.0 implementation of `services/ride-demo/server.js`, using the JSON shapes in `apps/rides/Core/RideModels.swift`. This package contains no deployment credentials, project provisioning, Swift changes, or admin endpoints. + +## Local verification + +```sh +cd services/ride-convex +npm ci +npm run codegen +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. + +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. + +## Deploy to the existing project + +The Rides app's public production URL is `https://harmless-mammoth-742.convex.cloud`, in team `landseer-enga`, project `uber-clone`. Do not create another project or commit an admin credential. With a securely configured Convex personal access token (`CONVEX_OVERRIDE_ACCESS_TOKEN`) or an authenticated CLI session: + +```sh +npm ci +npx convex deployment select landseer-enga:uber-clone:dev +npx convex deploy --dry-run --typecheck enable --yes +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 + +All 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 | +| --- | --- | --- | --- | +| `createRoom` | mutation | `{ name, token }` | `RideSession` | +| `joinRoom` | mutation | `{ code, role, name, token }` | `RideSession` | +| `state` | query | `{ token }` | `RideRoomState` | +| `availability` | mutation | `{ token, online }` | `RideRoomState` | +| `book` | mutation | `{ token, request: { requestId, pickup, destination, stops, service, paymentMethod, scheduledAt?, note } }` | `RideRoomState` | +| `act` | mutation | `{ token, tripID, action: { action, reason?, rating?, tipCents?, text? } }` | `RideRoomState` | + +`RideSession` is `{ roomCode, participantId, token, role, name, state }`. The returned token is the exact client-supplied credential, not a server-generated replacement. `RideRoomState` is `{ roomCode, revision, driverOnline, onlineDriverIds, trips }`. Trips and messages contain only the Swift wire fields, never Convex `_id`, `_creationTime`, request fingerprints, or credential hashes. Nullable trip fields (`scheduledAt`, `driver`, `cancellationReason`, `rating`) are always present as JSON null until populated. Dates are UTC ISO-8601 strings with milliseconds. Optional request/action fields can be omitted; `scheduledAt` and cancellation `reason` can also be null. + +Expected failures throw `ConvexError({ status, message })`: 400 for invalid input, 401 for missing/invalid/expired credentials, 403 for forbidden roles, 404 for an unavailable room or room-scoped trip, 409 for a conflicting operation or a full room, and 503 if invitation allocation fails. Error messages never include submitted credential values. Arguments are validated inside handlers to avoid framework argument-validation errors echoing a credential-containing argument envelope. + +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 + +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. + +Rooms expire 24 hours after creation. Every credential expires no later than its room, and joining never extends the lifetime. Expired documents are inaccessible through the public functions but are not automatically deleted; expiry is an authorization boundary, not a retention/deletion policy. + +Invitations are private, case-sensitive, 16-character capabilities using `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (80 bits). They are derived from the client's cryptographically random token using domain-separated SHA-256, mapped without modulo bias into the 32-character alphabet, and checked for collisions inside the transaction. The invitation digest is independent of the stored credential digest; the token cannot be recovered from the invitation. This relies on the required secure client token generation, not on ordinary JavaScript randomness. Non-secret participant/trip/message identifiers use Convex's strong seeded PRNG. Sharing an invitation permits someone to join as the existing rider or as a new driver and read that room's full trip history. A rider join preserves the original rider's identity and name; drivers receive separate identities. Reusing a token is allowed only for an idempotent join to the same room and role. Tokens cannot be reassigned across rooms or roles. + +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 + +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. + +All state changes are single Convex transactions. The room's active-trip pointer prevents concurrent bookings; acceptance reads and writes the same trip atomically, so only one online driver can win. Only the assigned driver can arrive, start, or complete. Only the rider or assigned driver can cancel before a trip starts or chat during an accepted, arriving, or in-progress trip. An assigned driver cannot go offline while their trip is active. Only the rider can rate a completed trip, with an integer rating of 1–5 and integer tip of 0–10000 cents; ratings may be updated as in the existing service. + +Fares are server-authoritative: UberX 1840, Comfort 2490, UberXL 2990, Green 1940, Black 3890, and Pet 2140 cents, plus 300 cents per stop. A booking requires a UUID request ID, at most two stops, unique place IDs and coordinates across the entire route, finite latitude/longitude within geographic bounds, and bounded strings matching the existing service. Payment labels cannot contain 12 consecutive digits. Reservation timestamps must be valid ISO-8601 dates after now and at most 30 days ahead. Calendar dates that normalize into another day are rejected. + +The canonical normalized booking payload is stored with the room-scoped request ID. An identical retry returns current room state without creating another trip, including after the scheduled time has passed or the trip has ended. A changed payload with that request ID returns 409. New bookings still undergo current-time reservation validation. Notes are limited to the backend's existing 500 characters; the Swift draft may apply its stricter 300-character UI limit. Messages are 1–500 characters after trimming, names at most 80, and cancellation reasons at most 300. diff --git a/services/ride-convex/convex/_generated/api.d.ts b/services/ride-convex/convex/_generated/api.d.ts new file mode 100644 index 0000000..95e413a --- /dev/null +++ b/services/ride-convex/convex/_generated/api.d.ts @@ -0,0 +1,51 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @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; +}>; + +/** + * 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 new file mode 100644 index 0000000..44bf985 --- /dev/null +++ b/services/ride-convex/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/services/ride-convex/convex/_generated/dataModel.d.ts b/services/ride-convex/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..f97fd19 --- /dev/null +++ b/services/ride-convex/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/services/ride-convex/convex/_generated/server.d.ts b/services/ride-convex/convex/_generated/server.d.ts new file mode 100644 index 0000000..f235db4 --- /dev/null +++ b/services/ride-convex/convex/_generated/server.d.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} 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. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +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; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/services/ride-convex/convex/_generated/server.js b/services/ride-convex/convex/_generated/server.js new file mode 100644 index 0000000..2dbe0db --- /dev/null +++ b/services/ride-convex/convex/_generated/server.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +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/rides.ts b/services/ride-convex/convex/rides.ts new file mode 100644 index 0000000..4bfff00 --- /dev/null +++ b/services/ride-convex/convex/rides.ts @@ -0,0 +1,213 @@ +import { v } from "convex/values"; +import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; +import { alphabet, booking, fail, fares, invitation, limits, object, randomId, text, tokenHash, ttl, validSchedule } from "./validation"; + +type Reader = QueryCtx | MutationCtx; + +async function roomState(ctx: Reader, room: Doc<"rooms">) { + const people = await ctx.db.query("participants").withIndex("by_room", q => q.eq("roomId", room._id)).take(limits.participants); + const trips = await ctx.db.query("trips").withIndex("by_room", q => q.eq("roomId", room._id)).order("desc").take(limits.trips); + const onlineDriverIds = people.filter(person => person.role === "driver" && person.online).map(person => person.id); + return { roomCode: room.code, revision: room.revision, driverOnline: onlineDriverIds.length > 0, + onlineDriverIds, trips: trips.map(trip => trip.payload) }; +} + +function checkExpiry(room: Doc<"rooms"> | null): asserts room is Doc<"rooms"> { + if (!room || Date.now() >= room.expiresAt) fail(404, "Room expired"); +} + +async function authenticate(ctx: Reader, token: unknown) { + const hash = await tokenHash(token); + const credential = await ctx.db.query("credentials").withIndex("by_hash", q => q.eq("tokenHash", hash)).unique(); + if (!credential || Date.now() >= credential.expiresAt) fail(401, "Invalid or expired bearer token"); + const participant = await ctx.db.get(credential.participantId); + if (!participant) fail(401, "Invalid bearer token"); + const room = await ctx.db.get(participant.roomId); + checkExpiry(room); + return { participant, room }; +} + +async function changed(ctx: MutationCtx, room: Doc<"rooms">) { + await ctx.db.patch(room._id, { revision: room.revision + 1 }); + return roomState(ctx, { ...room, revision: room.revision + 1 }); +} + +async function session(ctx: Reader, room: Doc<"rooms">, participant: Doc<"participants">, token: string) { + return { roomCode: room.code, participantId: participant.id, token, role: participant.role, + name: participant.name, state: await roomState(ctx, room) }; +} + +export const createRoom = mutation({ + args: v.any(), + handler: async (ctx, args: { name: string; token: string }) => { + const input = object(args, "arguments"); + const hash = await tokenHash(input.token); + const name = text(input.name, 80, "name"); + const existing = await ctx.db.query("credentials").withIndex("by_hash", q => q.eq("tokenHash", hash)).unique(); + if (existing) fail(409, "Credential already in use"); + let code = ""; + for (let attempt = 0; attempt < 20; attempt++) { + const candidate = await invitation(args.token, attempt); + if (!await ctx.db.query("rooms").withIndex("by_code", q => q.eq("code", candidate)).unique()) { + code = candidate; + break; + } + } + if (!code) fail(503, "Could not allocate room"); + const riderId = randomId(); + const expiresAt = Date.now() + ttl; + const roomId = await ctx.db.insert("rooms", { + code, riderId, expiresAt, revision: 0, activeTripId: null, tripCount: 0, participantCount: 1, credentialCount: 1, + }); + const participantId = await ctx.db.insert("participants", { roomId, id: riderId, role: "rider", name, online: false }); + await ctx.db.insert("credentials", { tokenHash: hash, participantId, expiresAt }); + return session(ctx, (await ctx.db.get(roomId))!, (await ctx.db.get(participantId))!, args.token); + }, +}); + +export const joinRoom = mutation({ + args: v.any(), + handler: async (ctx, args: { code: string; role: string; name: string; token: string }) => { + const input = object(args, "arguments"); + const hash = await tokenHash(input.token); + const code = text(input.code, 16, "code"); + if (code.length !== 16 || ![...code].every(char => alphabet.includes(char))) fail(404, "Room not found"); + const room = await ctx.db.query("rooms").withIndex("by_code", q => q.eq("code", code)).unique(); + checkExpiry(room); + const name = text(input.name, 80, "name"); + const role = input.role; + if (role !== "rider" && role !== "driver") fail(400, "Invalid role"); + const existing = await ctx.db.query("credentials").withIndex("by_hash", q => q.eq("tokenHash", hash)).unique(); + if (existing) { + const auth = await authenticate(ctx, input.token); + if (auth.room._id !== room._id || auth.participant.role !== role) fail(409, "Credential already in use"); + return session(ctx, room, auth.participant, args.token); + } + if (room.credentialCount >= limits.credentials) fail(409, "Room credential limit reached"); + if (role === "driver" && room.participantCount >= limits.participants) fail(409, "Room participant limit reached"); + let participantId; + if (role === "rider") { + const rider = await ctx.db.query("participants").withIndex("by_room_id", q => q.eq("roomId", room._id).eq("id", room.riderId)).unique(); + if (!rider || rider.role !== "rider") fail(404, "Rider not found"); + participantId = rider._id; + } else { + participantId = await ctx.db.insert("participants", { roomId: room._id, id: randomId(), role, name, online: false }); + } + await ctx.db.insert("credentials", { tokenHash: hash, participantId, expiresAt: room.expiresAt }); + await ctx.db.patch(room._id, { credentialCount: room.credentialCount + 1, + participantCount: room.participantCount + (role === "driver" ? 1 : 0), revision: room.revision + 1 }); + return session(ctx, { ...room, revision: room.revision + 1 }, (await ctx.db.get(participantId))!, args.token); + }, +}); + +export const state = query({ + args: v.any(), + handler: async (ctx, args: { token: string }) => { + const { room } = await authenticate(ctx, object(args, "arguments").token); + return roomState(ctx, room); + }, +}); + +export const availability = mutation({ + args: v.any(), + handler: async (ctx, args: { token: string; online: boolean }) => { + const input = object(args, "arguments"); + const { participant, room } = await authenticate(ctx, input.token); + if (participant.role !== "driver") fail(403, "Driver role required"); + if (typeof input.online !== "boolean") fail(400, "online must be boolean"); + if (!input.online && room.activeTripId) { + const active = await ctx.db.get(room.activeTripId); + if (active?.payload.driver?.id === participant.id) fail(409, "Cannot go offline during an active trip"); + } + await ctx.db.patch(participant._id, { online: input.online }); + return changed(ctx, room); + }, +}); + +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 { participant, room } = await authenticate(ctx, input.token); + if (participant.role !== "rider" || participant.id !== room.riderId) fail(403, "Room owner rider required"); + const { requestId, payload } = booking(input.request); + const requestPayload = JSON.stringify(payload); + const previous = await ctx.db.query("trips").withIndex("by_room_request", q => q.eq("roomId", room._id).eq("requestId", requestId)).unique(); + if (previous) { + if (previous.requestPayload !== requestPayload) fail(409, "requestId already used with different payload"); + return roomState(ctx, room); + } + validSchedule(payload.scheduledAt); + if (room.activeTripId) fail(409, "Room already has an active trip"); + if (room.tripCount >= limits.trips) fail(409, "Room trip limit reached"); + const now = new Date().toISOString(); + const tripId = await ctx.db.insert("trips", { + roomId: room._id, requestId, requestPayload, + payload: { id: randomId(), riderId: participant.id, riderName: participant.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(room._id, { activeTripId: tripId, tripCount: room.tripCount + 1 }); + return changed(ctx, room); + }, +}); + +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 { participant, room } = 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("trips").withIndex("by_room_id", q => q.eq("roomId", room._id).eq("payload.id", tripID)).unique(); + if (!row) fail(404, "Trip not found"); + const trip = row.payload; + const owner = participant.role === "rider" && participant.id === trip.riderId; + const assigned = participant.role === "driver" && participant.id === trip.driver?.id; + if (action === "accept") { + if (participant.role !== "driver") fail(403, "Driver role required"); + if (!participant.online) fail(409, "Driver must be online"); + if (trip.status !== "requested" || room.activeTripId !== row._id) fail(409, "Trip is no longer available"); + trip.status = "accepted"; + trip.driver = { id: participant.id, name: participant.name, vehicle: "Toyota Prius", plate: "DEMO 24", rating: 4.98 }; + } 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: participant.id, senderName: participant.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 }); + if (trip.status === "completed" || trip.status === "cancelled") { + if (room.activeTripId === row._id) await ctx.db.patch(room._id, { activeTripId: null }); + } + return changed(ctx, room); + }, +}); diff --git a/services/ride-convex/convex/schema.ts b/services/ride-convex/convex/schema.ts new file mode 100644 index 0000000..289228f --- /dev/null +++ b/services/ride-convex/convex/schema.ts @@ -0,0 +1,44 @@ +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export const role = v.union(v.literal("rider"), v.literal("driver")); +export const place = v.object({ + id: v.string(), name: v.string(), address: v.string(), + latitude: v.number(), longitude: v.number(), +}); +export const message = v.object({ + id: v.string(), senderId: v.string(), senderName: v.string(), + text: v.string(), createdAt: v.string(), +}); +export const trip = v.object({ + id: v.string(), riderId: v.string(), riderName: v.string(), + pickup: place, destination: place, stops: v.array(place), + service: v.string(), paymentMethod: v.string(), scheduledAt: v.union(v.string(), v.null()), + note: v.string(), + status: v.union(v.literal("requested"), v.literal("accepted"), v.literal("arriving"), + v.literal("in_progress"), v.literal("completed"), v.literal("cancelled")), + driver: v.union(v.object({ + id: v.string(), name: v.string(), vehicle: v.string(), plate: v.string(), rating: v.number(), + }), v.null()), + fareCents: v.number(), createdAt: v.string(), updatedAt: v.string(), + cancellationReason: v.union(v.string(), v.null()), rating: v.union(v.number(), v.null()), + tipCents: v.number(), messages: v.array(message), +}); + +export default defineSchema({ + rooms: defineTable({ + code: v.string(), riderId: v.string(), revision: v.number(), expiresAt: v.number(), + activeTripId: v.union(v.id("trips"), v.null()), + tripCount: v.number(), participantCount: v.number(), credentialCount: v.number(), + }).index("by_code", ["code"]), + participants: defineTable({ + roomId: v.id("rooms"), id: v.string(), role, name: v.string(), online: v.boolean(), + }).index("by_room", ["roomId"]).index("by_room_id", ["roomId", "id"]), + credentials: defineTable({ + tokenHash: v.string(), participantId: v.id("participants"), expiresAt: v.number(), + }).index("by_hash", ["tokenHash"]), + trips: defineTable({ + roomId: v.id("rooms"), requestId: v.string(), requestPayload: v.string(), payload: trip, + }).index("by_room", ["roomId"]).index("by_room_request", ["roomId", "requestId"]) + .index("by_room_id", ["roomId", "payload.id"]), +}); diff --git a/services/ride-convex/convex/tsconfig.json b/services/ride-convex/convex/tsconfig.json new file mode 100644 index 0000000..5197ce2 --- /dev/null +++ b/services/ride-convex/convex/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/services/ride-convex/convex/validation.ts b/services/ride-convex/convex/validation.ts new file mode 100644 index 0000000..759b081 --- /dev/null +++ b/services/ride-convex/convex/validation.ts @@ -0,0 +1,95 @@ +import { ConvexError, type Infer } from "convex/values"; +import { place } from "./schema"; + +export const fares: Record = { + uberX: 1840, comfort: 2490, uberXL: 2990, green: 1940, black: 3890, pet: 2140, +}; +export const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; +export const ttl = 24 * 60 * 60_000; +export const limits = { participants: 32, credentials: 128, trips: 20, messages: 100 }; + +export function fail(status: number, message: string): never { + throw new ConvexError({ status, message }); +} + +export function object(value: unknown, field: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(400, `Invalid ${field}`); + return value as Record; +} + +export function text(value: unknown, max: number, field: string, required = true): string { + if (typeof value !== "string" || (required && !value.trim()) || value.length > max) fail(400, `Invalid ${field}`); + return value.trim(); +} + +export async function tokenHash(value: unknown): Promise { + if (typeof value !== "string" || value.length < 32 || value.length > 256) { + fail(401, "Invalid bearer token"); + } + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(bytes), byte => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function invitation(token: string, attempt: number): Promise { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`ride-room-invitation-v1:${attempt}:${token}`)); + return Array.from(new Uint8Array(bytes).slice(0, 16), byte => alphabet[byte & 31]).join(""); +} + +export function randomString(length: number, characters: string): string { + return Array.from({ length }, () => characters[Math.floor(Math.random() * characters.length)]).join(""); +} + +export const randomId = () => randomString(32, "0123456789abcdef"); + +function coordinate(value: unknown, min: number, max: number, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) fail(400, `Invalid ${field}`); + return value; +} + +function validPlace(value: unknown, field: string): Infer { + const input = object(value, field); + return { + id: text(input.id, 100, `${field}.id`), name: text(input.name, 200, `${field}.name`), + address: text(input.address, 300, `${field}.address`), + latitude: coordinate(input.latitude, -90, 90, `${field}.latitude`), + longitude: coordinate(input.longitude, -180, 180, `${field}.longitude`), + }; +} + +export function booking(value: unknown) { + const input = object(value, "request"); + const requestId = text(input.requestId, 36, "requestId"); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) { + fail(400, "requestId must be a UUID"); + } + const pickup = validPlace(input.pickup, "pickup"); + const destination = validPlace(input.destination, "destination"); + if (!Array.isArray(input.stops) || input.stops.length > 2) fail(400, "stops must contain at most 2 places"); + const stops = input.stops.map((value, index) => validPlace(value, `stops[${index}]`)); + const points = [pickup, ...stops, destination]; + if (new Set(points.map(point => point.id)).size !== points.length) fail(400, "Route place IDs must be unique"); + if (new Set(points.map(point => `${point.latitude},${point.longitude}`)).size !== points.length) fail(400, "Route coordinates must be unique"); + if (typeof input.service !== "string" || !Object.hasOwn(fares, input.service)) fail(400, "Unsupported service"); + const paymentMethod = text(input.paymentMethod, 80, "paymentMethod"); + if (/[0-9]{12,}/.test(paymentMethod)) fail(400, "Payment details are not accepted"); + let scheduledAt: string | null = null; + if (input.scheduledAt !== undefined && input.scheduledAt !== null) { + if (typeof input.scheduledAt !== "string" || input.scheduledAt.length > 40 || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?(?:Z|[+-]\d\d:\d\d)$/.test(input.scheduledAt) || + !Number.isFinite(Date.parse(input.scheduledAt))) fail(400, "Invalid scheduledAt"); + const calendar = input.scheduledAt.slice(0, 10); + const calendarDate = Date.parse(`${calendar}T00:00:00Z`); + if (!Number.isFinite(calendarDate) || new Date(calendarDate).toISOString().slice(0, 10) !== calendar || + Number(input.scheduledAt.slice(11, 13)) > 23 || Number(input.scheduledAt.slice(14, 16)) > 59 || + Number(input.scheduledAt.slice(17, 19)) > 59) fail(400, "Invalid scheduledAt"); + scheduledAt = new Date(input.scheduledAt).toISOString(); + } + const note = input.note === undefined ? "" : text(input.note, 500, "note", false); + return { requestId, payload: { pickup, destination, stops, service: input.service, paymentMethod, scheduledAt, note } }; +} + +export function validSchedule(scheduledAt: string | null) { + if (scheduledAt === null) return; + const delta = Date.parse(scheduledAt) - Date.now(); + if (delta <= 0 || delta > 30 * ttl) fail(400, "scheduledAt must be within the next 30 days"); +} diff --git a/services/ride-convex/package-lock.json b/services/ride-convex/package-lock.json new file mode 100644 index 0000000..2414c8e --- /dev/null +++ b/services/ride-convex/package-lock.json @@ -0,0 +1,1804 @@ +{ + "name": "ride-convex", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ride-convex", + "version": "1.0.0", + "dependencies": { + "convex": "1.47.0" + }, + "devDependencies": { + "@edge-runtime/vm": "5.0.0", + "convex-test": "0.0.54", + "typescript": "5.9.3", + "vitest": "4.1.12" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", + "integrity": "sha512-FqoxaBT+prPBHBwE1WXS1ocnu/VLTQyZ6NMUBAdbP7N2hsFTTxMC/jMu2D/8GAlMQfxeuppcPuCUk/HO3fpIvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-5.0.0.tgz", + "integrity": "sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@edge-runtime/primitives": "6.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.12", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convex": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.47.0.tgz", + "integrity": "sha512-B1z/lOmex7i09NASq8+2WG9rZLzGXXOSkpwGOpaNIvOs4awyW4RMgpVBjt8r2wvvHwhEBEsOr2pGYpulnmAxYQ==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.21.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "@clerk/react": "^6.4.3", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "@clerk/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex-test": { + "version": "0.0.54", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.54.tgz", + "integrity": "sha512-C0v2SQcuxrELAJRNzE6fQ686XDPor7UFoC22jcoJXeCRqhLo8ZIMZ4jyUHoo1UdAL2enCb0AbiprCVpLDN8u9Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.32.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/services/ride-convex/package.json b/services/ride-convex/package.json new file mode 100644 index 0000000..e92d17d --- /dev/null +++ b/services/ride-convex/package.json @@ -0,0 +1,20 @@ +{ + "name": "ride-convex", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "codegen": "env -u CONVEX_OVERRIDE_ACCESS_TOKEN -u CONVEX_DEPLOY_KEY -u CONVEX_DEPLOYMENT -u CONVEX_URL convex codegen --system-udfs --typecheck disable", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "convex": "1.47.0" + }, + "devDependencies": { + "@edge-runtime/vm": "5.0.0", + "convex-test": "0.0.54", + "typescript": "5.9.3", + "vitest": "4.1.12" + } +} diff --git a/services/ride-convex/tests/rides.test.ts b/services/ride-convex/tests/rides.test.ts new file mode 100644 index 0000000..9b94125 --- /dev/null +++ b/services/ride-convex/tests/rides.test.ts @@ -0,0 +1,367 @@ +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 { limits, ttl } from "../convex/validation"; + +const modules = import.meta.glob("../convex/**/*.ts"); +const token = () => crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); +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: "Demo 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 rider = await t.mutation(api.rides.createRoom, { name: "Alex", token: token() }); + const first = await t.mutation(api.rides.joinRoom, { code: rider.roomCode, name: "Jordan", role: "driver", token: token() }); + const second = await t.mutation(api.rides.joinRoom, { code: rider.roomCode, name: "Sam", role: "driver", token: token() }); + const act = (credential: string, tripID: string, action: string, extra = {}) => + t.mutation(api.rides.act, { token: credential, tripID, action: { action, ...extra } }); + const book = (booking = request()) => t.mutation(api.rides.book, { token: rider.token, request: booking }); + const online = (credential = first.token) => t.mutation(api.rides.availability, { token: credential, online: true }); + return { t, rider, first, second, act, book, online }; +} + +afterEach(() => vi.useRealTimers()); + +describe("sessions and authorization", () => { + test("private invitations preserve the rider and create distinct drivers", async () => { + const { t, rider, first, second } = await setup(); + expect(rider.roomCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/); + const rejoined = await t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "rider", name: "Replacement", token: token() }); + expect(rejoined.participantId).toBe(rider.participantId); + expect(rejoined.name).toBe("Alex"); + expect(first.participantId).not.toBe(second.participantId); + expect(rejoined.token).not.toBe(rider.token); + expect(Object.keys(rider).sort()).toEqual(["roomCode", "participantId", "token", "role", "name", "state"].sort()); + expect((await t.query(api.rides.state, { token: rider.token })).revision).toBe(3); + const retried = await t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "driver", name: "Changed", token: first.token }); + expect(retried.participantId).toBe(first.participantId); + expect(retried.name).toBe("Jordan"); + expect(retried.state.revision).toBe(3); + }); + + test("only SHA-256 credential hashes are stored", async () => { + const { t, rider, first, second } = await setup(); + const tables = await t.run(async ctx => ({ + rooms: await ctx.db.query("rooms").collect(), participants: await ctx.db.query("participants").collect(), + credentials: await ctx.db.query("credentials").collect(), trips: await ctx.db.query("trips").collect(), + })); + for (const person of [rider, first, second]) expect(JSON.stringify(tables)).not.toContain(person.token); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rider.token)); + const hex = Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join(""); + expect(tables.credentials.some(credential => credential.tokenHash === hex)).toBe(true); + }); + + test("invalid credentials fail every public path without leaking their value", async () => { + const { t, rider } = await setup(); + for (const badToken of ["short", token()]) { + const operations = [ + t.query(api.rides.state, { token: badToken }), + t.mutation(api.rides.availability, { token: badToken, online: true }), + t.mutation(api.rides.book, { token: badToken, request: request() }), + t.mutation(api.rides.act, { token: badToken, tripID: "missing", action: { action: "cancel" } }), + ]; + for (const result of await Promise.allSettled(operations)) { + expect(result.status).toBe("rejected"); + if (result.status === "rejected") { + expect(result.reason.data.status).toBe(401); + expect(JSON.stringify(result.reason.data)).not.toContain(badToken); + } + } + } + await failure(t.mutation(api.rides.createRoom, { token: "short", name: "Alex" }), 401); + await failure(t.mutation(api.rides.joinRoom, { token: "short", code: rider.roomCode, role: "rider", name: "Alex" }), 401); + }); + + test("tokens cannot change room or role and trip lookup is room scoped", async () => { + const { t, rider, first, act, book } = await setup(); + const other = await t.mutation(api.rides.createRoom, { name: "Other", token: token() }); + const state = await book(); + const id = state.trips[0].id; + await failure(t.mutation(api.rides.joinRoom, { code: other.roomCode, role: "rider", name: "Other", token: rider.token }), 409); + await failure(t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "driver", name: "Alex", token: rider.token }), 409); + await failure(t.mutation(api.rides.createRoom, { name: "Alex", token: rider.token }), 409); + await failure(act(other.token, id, "cancel"), 404); + await failure(t.mutation(api.rides.book, { token: first.token, request: request() }), 403); + await failure(t.mutation(api.rides.availability, { token: rider.token, online: true }), 403); + expect((await t.query(api.rides.state, { token: other.token })).trips).toEqual([]); + }); + + test("every read and write rejects credentials at the 24-hour boundary", async () => { + vi.useFakeTimers(); + const { t, rider, first, book } = await setup(); + const tripID = (await book()).trips[0].id; + vi.setSystemTime(Date.now() + ttl); + await failure(t.query(api.rides.state, { token: rider.token }), 401); + await failure(t.mutation(api.rides.availability, { token: first.token, online: true }), 401); + await failure(t.mutation(api.rides.book, { token: rider.token, request: request() }), 401); + await failure(t.mutation(api.rides.act, { token: rider.token, tripID, action: { action: "cancel" } }), 401); + await failure(t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "rider", name: "Alex", token: token() }), 404); + }); + + test("a newly joined credential does not extend the room lifetime", async () => { + vi.useFakeTimers(); + const { t, rider } = await setup(); + vi.setSystemTime(Date.now() + ttl - 1000); + const joined = await t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "rider", name: "Alex", token: token() }); + vi.setSystemTime(Date.now() + 1000); + await failure(t.query(api.rides.state, { token: joined.token }), 401); + }); + + test("concurrent credential reuse cannot create two rooms", async () => { + const t = convexTest({ schema, modules, transactionLimits: true }); + const credential = token(); + const results = await Promise.allSettled([ + t.mutation(api.rides.createRoom, { name: "Alex", token: credential }), + t.mutation(api.rides.createRoom, { name: "Other", token: credential }), + ]); + 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("rooms").collect())).toHaveLength(1); + }); +}); + +describe("transactions and lifecycle", () => { + test("a full trip is persisted across independent clients with exact wire fields", async () => { + const { t, rider, first, second, act, book, online } = await setup(); + const riderClient = t.withIdentity({ subject: "rider-device" }); + const driverClient = t.withIdentity({ subject: "driver-device" }); + await online(); + const booking = request(); + booking.stops = [{ id: "stop", name: "Coffee", address: "3 Main Street", latitude: 31, longitude: -97 }]; + const booked = await book(booking); + 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 accepted = await driverClient.mutation(api.rides.act, { token: first.token, tripID: trip.id, action: { action: "accept" } }); + expect(accepted.trips[0].driver).toEqual({ id: first.participantId, name: "Jordan", vehicle: "Toyota Prius", plate: "DEMO 24", rating: 4.98 }); + 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(second.token, trip.id, "arrive"), 403); + await failure(act(first.token, trip.id, "start"), 409); + 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"); + 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 }); + const persisted = await riderClient.query(api.rides.state, { token: rider.token }); + expect(persisted).toEqual(rated); + expect(await driverClient.query(api.rides.state, { token: first.token })).toEqual(rated); + expect(persisted.trips[0].messages[0]).toMatchObject({ text: "At the door", senderId: rider.participantId, senderName: "Alex" }); + expect(Object.keys(persisted.trips[0].messages[0]).sort()).toEqual(["id", "senderId", "senderName", "text", "createdAt"].sort()); + expect((await book()).trips).toHaveLength(2); + }); + + test("concurrent acceptance assigns exactly one driver atomically", async () => { + const { t, rider, first, second, act, book, online } = await setup(); + await online(first.token); + await online(second.token); + const tripID = (await book()).trips[0].id; + const results = await Promise.allSettled([act(first.token, tripID, "accept"), act(second.token, tripID, "accept")]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.find(result => result.status === "rejected"); + expect(rejected).toMatchObject({ reason: { data: { status: 409 } } }); + const current = await t.query(api.rides.state, { token: rider.token }); + const winner = current.trips[0].driver!.id === first.participantId ? first : second; + const loser = winner === first ? second : first; + await failure(act(loser.token, tripID, "message", { text: "Intrusion" }), 403); + await failure(act(loser.token, tripID, "cancel"), 403); + await failure(t.mutation(api.rides.availability, { token: winner.token, online: false }), 409); + await t.mutation(api.rides.availability, { token: loser.token, online: false }); + await act(winner.token, tripID, "cancel", { reason: "Unavailable" }); + expect((await t.mutation(api.rides.availability, { token: winner.token, online: false })).driverOnline).toBe(false); + }); + + test("concurrent booking and idempotency retain a single room trip", async () => { + const { t, rider, book } = await setup(); + const payload = request(); + const same = await Promise.all([book(payload), book(payload)]); + expect(same[0]).toEqual(same[1]); + await failure(book({ ...payload, note: "changed" }), 409, "requestId already used with different payload"); + await failure(book(), 409, "Room already has an active trip"); + expect((await t.query(api.rides.state, { token: rider.token })).trips).toHaveLength(1); + const another = await t.mutation(api.rides.createRoom, { name: "Other", token: token() }); + await t.mutation(api.rides.book, { token: another.token, request: payload }); + }); + + test("concurrent different request IDs cannot create two active trips", async () => { + const { book } = await setup(); + const results = await Promise.allSettled([book(), book()]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { data: { status: 409 } } }); + }); + + test("idempotent scheduled bookings still succeed after their scheduled time", async () => { + vi.useFakeTimers(); + const { book } = await setup(); + const payload = { ...request(), scheduledAt: new Date(Date.now() + 1000).toISOString() }; + const original = await book(payload); + vi.setSystemTime(Date.now() + 2000); + expect(await book(payload)).toEqual(original); + }); + + test("acceptance requires an online driver; rider and assigned-driver permissions are enforced", async () => { + const { rider, first, act, book, online } = await setup(); + const tripID = (await book()).trips[0].id; + await failure(act(rider.token, tripID, "accept"), 403); + await failure(act(first.token, tripID, "accept"), 409); + await failure(act(first.token, tripID, "cancel"), 403); + await online(); + await act(first.token, tripID, "accept"); + await failure(act(rider.token, tripID, "arrive"), 403); + await failure(act(rider.token, tripID, "rate", { rating: 5, tipCents: 0 }), 409); + await act(first.token, tripID, "cancel"); + await failure(act(first.token, tripID, "arrive"), 409); + await failure(act(rider.token, tripID, "message", { text: "Hi" }), 409); + }); +}); + +describe("business validation and bounds", () => { + test.each([ + { requestId: "not-a-uuid" }, { 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 must be in the next 30 days", async () => { + vi.useFakeTimers(); + const { book } = await setup(); + for (const delta of [-1, 0, 30 * ttl + 1]) { + await failure(book({ ...request(), scheduledAt: new Date(Date.now() + delta).toISOString() }), 400); + } + expect((await book({ ...request(), scheduledAt: new Date(Date.now() + 30 * ttl).toISOString() })).trips[0].scheduledAt).not.toBeNull(); + }); + + test.each(["uberX", "comfort", "uberXL", "green", "black", "pet"])("server computes %s fare", 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 }; + expect((await book(payload)).trips[0].fareCents).toBe(fares[service as keyof typeof fares]); + }); + + test("chat, cancellation, ratings and tips enforce limits without partial writes", async () => { + const { t, rider, first, act, book, online } = await setup(); + const tripID = (await book()).trips[0].id; + await failure(act(rider.token, tripID, "message", { text: "Hi" }), 409); + await failure(act(rider.token, tripID, "cancel", { reason: "x".repeat(301) }), 400); + expect((await t.query(api.rides.state, { token: rider.token })).trips[0].status).toBe("requested"); + await online(); + await act(first.token, tripID, "accept"); + await failure(act(rider.token, tripID, "message", { text: " " }), 400); + await failure(act(first.token, tripID, "message", { text: "x".repeat(501) }), 400); + for (let i = 0; i < limits.messages; i++) await act(rider.token, tripID, "message", { text: `Message ${i}` }); + await failure(act(first.token, tripID, "message", { text: "One too many" }), 409); + await act(first.token, tripID, "arrive"); + await act(first.token, tripID, "start"); + await act(first.token, tripID, "complete"); + await failure(act(first.token, tripID, "rate", { rating: 5, tipCents: 0 }), 403); + for (const invalid of [{ rating: 0, tipCents: 0 }, { rating: 6, tipCents: 0 }, { rating: 1.5, tipCents: 0 }, + { rating: 5, tipCents: -1 }, { rating: 5, tipCents: 10001 }, { rating: 5, tipCents: 0.5 }]) { + await failure(act(rider.token, tripID, "rate", invalid), 400); + } + expect((await act(rider.token, tripID, "rate", { rating: 1, tipCents: 10000 })).trips[0].tipCents).toBe(10000); + expect((await act(rider.token, tripID, "rate", { rating: 5, tipCents: 0 })).trips[0].rating).toBe(5); + await failure(act(rider.token, tripID, "cancel"), 409); + await failure(act(rider.token, tripID, "message", { text: "Too late" }), 409); + }); + + test("cancelled trips have explicit nullable fields and allow a new booking", async () => { + const { rider, act, book } = await setup(); + const payload = request(); + const tripID = (await book(payload)).trips[0].id; + const cancelled = await act(rider.token, tripID, "cancel", { reason: null }); + expect(cancelled.trips[0]).toMatchObject({ status: "cancelled", cancellationReason: null }); + expect(await book(payload)).toEqual(cancelled); + expect((await book()).trips).toHaveLength(2); + }); + + test("Swift can omit optional booking and action fields", async () => { + const { t, rider, act } = await setup(); + const { scheduledAt: _, ...payload } = request(); + const booked = await t.mutation(api.rides.book, { token: rider.token, request: payload }); + expect(booked.trips[0].scheduledAt).toBeNull(); + const cancelled = await act(rider.token, booked.trips[0].id, "cancel"); + expect(cancelled.trips[0].cancellationReason).toBeNull(); + }); + + test("bounded history preserves idempotency instead of silently deleting records", async () => { + const { t, rider, act, book } = await setup(); + const original = request(); + const firstID = (await book(original)).trips[0].id; + await act(rider.token, firstID, "cancel"); + for (let i = 1; i < limits.trips; i++) { + const booked = await book(); + await act(rider.token, booked.trips[0].id, "cancel"); + } + await failure(book(), 409, "Room trip limit reached"); + expect((await book(original)).trips).toHaveLength(limits.trips); + expect((await t.query(api.rides.state, { token: rider.token })).trips).toHaveLength(limits.trips); + }); + + test("participants and credentials are capped without replacing existing identities", async () => { + const { t, rider } = await setup(); + for (let i = 3; i < limits.participants; i++) { + await t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "driver", name: "Driver", token: token() }); + } + await failure(t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "driver", name: "Excess", token: token() }), 409); + for (let i = limits.participants; i < limits.credentials; i++) { + await t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "rider", name: "Ignored", token: token() }); + } + await failure(t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "rider", name: "Excess", token: token() }), 409); + expect((await t.query(api.rides.state, { token: rider.token })).trips).toEqual([]); + }); + + test("a maximally populated snapshot stays below transaction and return limits", async () => { + const { t, rider, book, act } = await setup(); + const original = (await book()).trips[0]; + await act(rider.token, original.id, "cancel"); + const worst = (length: number) => "\u0001".repeat(length); + await t.run(async ctx => { + const room = (await ctx.db.query("rooms").withIndex("by_code", q => q.eq("code", rider.roomCode)).unique())!; + const base = (await ctx.db.query("trips").withIndex("by_room", q => q.eq("roomId", room._id)).first())!; + 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: "d".repeat(32), name: worst(80), vehicle: "Toyota Prius", plate: "DEMO 24", rating: 4.98 }, + 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, { payload }); + for (let i = 1; i < limits.trips; i++) { + await ctx.db.insert("trips", { roomId: room._id, requestId: String(crypto.randomUUID()), requestPayload: base.requestPayload, + payload: { ...payload, id: String(i) } }); + } + await ctx.db.patch(room._id, { tripCount: limits.trips }); + }); + const snapshot = await t.query(api.rides.state, { token: rider.token }); + expect(snapshot.trips).toHaveLength(limits.trips); + expect(new TextEncoder().encode(JSON.stringify(snapshot)).byteLength).toBeLessThan(8 * 1024 * 1024); + }); + + test("malformed arguments produce safe application errors", async () => { + const { t, rider } = await setup(); + const unsafe = { token: rider.token, request: null } as unknown as { token: string; request: ReturnType }; + await failure(t.mutation(api.rides.book, unsafe), 400, "Invalid request"); + await failure(t.mutation(api.rides.act, { token: rider.token, tripID: "missing", action: { action: "unsupported" } }), 404); + await failure(t.mutation(api.rides.createRoom, { name: " ", token: token() }), 400); + await failure(t.mutation(api.rides.joinRoom, { code: rider.roomCode, role: "admin", name: "Other", token: token() }), 400); + }); +}); diff --git a/services/ride-convex/tsconfig.json b/services/ride-convex/tsconfig.json new file mode 100644 index 0000000..f06924e --- /dev/null +++ b/services/ride-convex/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["convex/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +} diff --git a/services/ride-convex/vitest.config.ts b/services/ride-convex/vitest.config.ts new file mode 100644 index 0000000..aceb4ec --- /dev/null +++ b/services/ride-convex/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { environment: "edge-runtime", include: ["tests/**/*.test.ts"] }, +});