From 2d02ae3f9e206185c7380a06b86ea577590036a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 22 Mar 2026 23:43:47 +0100 Subject: [PATCH] Add BRouter proxy, rate limiting, and spec updates (tasks 5.1-5.2) (#11) --- apps/planner/app/lib/brouter.ts | 37 +++++++++++++++ apps/planner/app/lib/rate-limit.test.ts | 34 ++++++++++++++ apps/planner/app/lib/rate-limit.ts | 43 ++++++++++++++++++ apps/planner/app/routes.ts | 1 + apps/planner/app/routes/api.route.ts | 45 +++++++++++++++++++ .../phase-1-mvp/specs/planner-session/spec.md | 10 ++++- openspec/changes/phase-1-mvp/tasks.md | 8 ++-- 7 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 apps/planner/app/lib/brouter.ts create mode 100644 apps/planner/app/lib/rate-limit.test.ts create mode 100644 apps/planner/app/lib/rate-limit.ts create mode 100644 apps/planner/app/routes/api.route.ts diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts new file mode 100644 index 0000000..d9c02d1 --- /dev/null +++ b/apps/planner/app/lib/brouter.ts @@ -0,0 +1,37 @@ +const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777"; + +export interface RouteRequest { + waypoints: Array<{ lat: number; lon: number }>; + profile?: string; + alternativeIdx?: number; + format?: string; +} + +export async function computeRoute(request: RouteRequest): Promise { + if (request.waypoints.length < 2) { + throw new Error("At least 2 waypoints are required"); + } + + const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|"); + + const params = new URLSearchParams({ + lonlats, + profile: request.profile ?? "trekking", + alternativeidx: String(request.alternativeIdx ?? 0), + format: request.format ?? "geojson", + }); + + const url = `${BROUTER_URL}/brouter?${params}`; + const response = await fetch(url); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`BRouter error (${response.status}): ${body}`); + } + + return response.json(); +} + +export function getBRouterUrl(): string { + return BROUTER_URL; +} diff --git a/apps/planner/app/lib/rate-limit.test.ts b/apps/planner/app/lib/rate-limit.test.ts new file mode 100644 index 0000000..a4ec910 --- /dev/null +++ b/apps/planner/app/lib/rate-limit.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { checkRateLimit } from "./rate-limit"; + +describe("checkRateLimit", () => { + it("allows requests within limit", () => { + const result = checkRateLimit("test-allow", { maxRequests: 5, windowMs: 60000 }); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(4); + }); + + it("blocks requests exceeding limit", () => { + const key = "test-block"; + const opts = { maxRequests: 3, windowMs: 60000 }; + + checkRateLimit(key, opts); // 1 + checkRateLimit(key, opts); // 2 + checkRateLimit(key, opts); // 3 + + const result = checkRateLimit(key, opts); // 4 — over limit + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.retryAfterSeconds).toBeGreaterThan(0); + }); + + it("uses separate counters per key", () => { + const opts = { maxRequests: 1, windowMs: 60000 }; + + const a = checkRateLimit("key-a", opts); + const b = checkRateLimit("key-b", opts); + + expect(a.allowed).toBe(true); + expect(b.allowed).toBe(true); + }); +}); diff --git a/apps/planner/app/lib/rate-limit.ts b/apps/planner/app/lib/rate-limit.ts new file mode 100644 index 0000000..94a836c --- /dev/null +++ b/apps/planner/app/lib/rate-limit.ts @@ -0,0 +1,43 @@ +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const DEFAULT_MAX_REQUESTS = 60; + +// Clean up expired entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (now > entry.resetAt) { + store.delete(key); + } + } +}, 60 * 1000); + +export function checkRateLimit( + key: string, + options?: { windowMs?: number; maxRequests?: number }, +): { allowed: boolean; remaining: number; retryAfterSeconds?: number } { + const windowMs = options?.windowMs ?? DEFAULT_WINDOW_MS; + const maxRequests = options?.maxRequests ?? DEFAULT_MAX_REQUESTS; + const now = Date.now(); + + let entry = store.get(key); + if (!entry || now > entry.resetAt) { + entry = { count: 0, resetAt: now + windowMs }; + store.set(key, entry); + } + + entry.count++; + + if (entry.count > maxRequests) { + const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000); + return { allowed: false, remaining: 0, retryAfterSeconds }; + } + + return { allowed: true, remaining: maxRequests - entry.count }; +} diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts index ffd73f8..584332b 100644 --- a/apps/planner/app/routes.ts +++ b/apps/planner/app/routes.ts @@ -3,5 +3,6 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("api/sessions", "routes/api.sessions.ts"), + route("api/route", "routes/api.route.ts"), route("session/:id", "routes/session.$id.tsx"), ] satisfies RouteConfig; diff --git a/apps/planner/app/routes/api.route.ts b/apps/planner/app/routes/api.route.ts new file mode 100644 index 0000000..cad5f37 --- /dev/null +++ b/apps/planner/app/routes/api.route.ts @@ -0,0 +1,45 @@ +import { data } from "react-router"; +import type { Route } from "./+types/api.route"; +import { computeRoute } from "~/lib/brouter"; +import { checkRateLimit } from "~/lib/rate-limit"; + +export async function action({ request }: Route.ActionArgs) { + if (request.method !== "POST") { + return data({ error: "Method not allowed" }, { status: 405 }); + } + + const body = await request.json(); + const { waypoints, profile, sessionId } = body as { + waypoints: Array<{ lat: number; lon: number }>; + profile?: string; + sessionId?: string; + }; + + if (!waypoints || waypoints.length < 2) { + return data({ error: "At least 2 waypoints are required" }, { status: 400 }); + } + + // Rate limit by session ID or IP + const rateLimitKey = sessionId ?? request.headers.get("x-forwarded-for") ?? "unknown"; + const limit = checkRateLimit(`route:${rateLimitKey}`); + + if (!limit.allowed) { + return data( + { error: "Rate limit exceeded" }, + { + status: 429, + headers: { "Retry-After": String(limit.retryAfterSeconds) }, + }, + ); + } + + try { + const route = await computeRoute({ waypoints, profile }); + return data(route, { + headers: { "X-RateLimit-Remaining": String(limit.remaining) }, + }); + } catch (e) { + const message = e instanceof Error ? e.message : "Route computation failed"; + return data({ error: message }, { status: 502 }); + } +} diff --git a/openspec/changes/phase-1-mvp/specs/planner-session/spec.md b/openspec/changes/phase-1-mvp/specs/planner-session/spec.md index aaa559f..9e20ba9 100644 --- a/openspec/changes/phase-1-mvp/specs/planner-session/spec.md +++ b/openspec/changes/phase-1-mvp/specs/planner-session/spec.md @@ -59,12 +59,20 @@ The session owner (initiator) SHALL be able to manually close a session. - **THEN** all connected participants are notified, the session triggers auto-save if a callback exists, and the session becomes inaccessible ### Requirement: User presence -The Planner SHALL display presence indicators showing which users are currently connected to a session. +The Planner SHALL display presence indicators showing which users are currently connected to a session, including live cursors on the map. #### Scenario: Show connected users - **WHEN** multiple users are connected to a session - **THEN** each user sees a list of other connected users with assigned colors +#### Scenario: Live map cursors +- **WHEN** a user moves their mouse over the map +- **THEN** other participants see a labeled cursor at that position on their map, colored to match the user's assigned color + +#### Scenario: Cursor disappears on leave +- **WHEN** a user disconnects from the session +- **THEN** their cursor disappears from all other participants' maps within 5 seconds + ### Requirement: No user data collection The Planner SHALL NOT collect, store, or track any personal user data. Sessions are anonymous by default. diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index a2d1278..ff8d962 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -37,12 +37,11 @@ - [x] 4.5 Implement session join page (GET /session/:id → connect to Yjs document) - [x] 4.6 Implement session expiry (garbage collection cron, configurable TTL) - [x] 4.7 Implement manual session close (owner action, notify participants) -- [ ] 4.8 Implement user presence display (Yjs awareness, colors, names) ## 5. Planner — BRouter Integration -- [ ] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter) -- [ ] 5.2 Implement rate limiting middleware (60 requests/session/hour) +- [x] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter) +- [x] 5.2 Implement rate limiting middleware (60 requests/session/hour) - [ ] 5.3 Implement routing host election via Yjs awareness (host/participant roles) - [ ] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp) - [ ] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter) @@ -51,7 +50,8 @@ ## 6. Planner — Map UI -- [ ] 6.1 Integrate MapView component in Planner with full-screen layout +- [ ] 6.1 Integrate MapView component in Planner with full-screen layout and client-side Yjs connection +- [ ] 6.1a Implement user presence display (Yjs awareness, colors, names, live map cursors) - [ ] 6.2 Implement waypoint add (click map → add to Y.Array) - [ ] 6.3 Implement waypoint drag (move marker → update Y.Array) - [ ] 6.4 Implement waypoint delete (right-click → remove from Y.Array)