From 5fd60ba07d245d1c4e5a1e7f8623d0fcea1d7004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 20:10:00 +0200 Subject: [PATCH 001/495] WIP: Komoot import integration Add Komoot API client, import logic, crypto helpers, integration routes, DB schema changes, and i18n strings for the Komoot import feature. Import processing runs as a durable pg-boss background job with retries instead of blocking the HTTP request. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/jobs/komoot-import.ts | 55 +++++ apps/journal/app/lib/boss.server.ts | 14 ++ apps/journal/app/lib/crypto.server.test.ts | 40 ++++ apps/journal/app/lib/crypto.server.ts | 31 +++ apps/journal/app/lib/komoot-import.server.ts | 143 +++++++++++++ apps/journal/app/lib/komoot.server.test.ts | 54 +++++ apps/journal/app/lib/komoot.server.ts | 141 +++++++++++++ apps/journal/app/root.tsx | 3 + apps/journal/app/routes.ts | 5 + .../routes/api.integrations.komoot.connect.ts | 44 ++++ .../api.integrations.komoot.disconnect.ts | 23 +++ .../api.integrations.komoot.import-status.ts | 35 ++++ .../routes/api.integrations.komoot.import.ts | 45 ++++ apps/journal/app/routes/auth.register.tsx | 57 +++-- apps/journal/app/routes/integrations.tsx | 195 ++++++++++++++++++ apps/journal/app/routes/privacy.tsx | 1 + apps/journal/server.ts | 7 +- infrastructure/docker-compose.yml | 1 + infrastructure/secrets.app.env | 8 +- openspec/changes/komoot-import/tasks.md | 56 ++--- packages/db/src/schema/journal.ts | 19 ++ packages/i18n/src/locales/de.ts | 27 +++ packages/i18n/src/locales/en.ts | 27 +++ packages/jobs/src/worker.ts | 2 +- 24 files changed, 982 insertions(+), 51 deletions(-) create mode 100644 apps/journal/app/jobs/komoot-import.ts create mode 100644 apps/journal/app/lib/boss.server.ts create mode 100644 apps/journal/app/lib/crypto.server.test.ts create mode 100644 apps/journal/app/lib/crypto.server.ts create mode 100644 apps/journal/app/lib/komoot-import.server.ts create mode 100644 apps/journal/app/lib/komoot.server.test.ts create mode 100644 apps/journal/app/lib/komoot.server.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.connect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.disconnect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import-status.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import.ts create mode 100644 apps/journal/app/routes/integrations.tsx diff --git a/apps/journal/app/jobs/komoot-import.ts b/apps/journal/app/jobs/komoot-import.ts new file mode 100644 index 0000000..cd262e8 --- /dev/null +++ b/apps/journal/app/jobs/komoot-import.ts @@ -0,0 +1,55 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { eq, and } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { decrypt } from "../lib/crypto.server.ts"; +import { importKomootTours } from "../lib/komoot-import.server.ts"; +import type { KomootCredentials } from "../lib/komoot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface KomootImportData { + userId: string; + connectionId: string; + batchId: string; +} + +export const komootImportJob: JobDefinition = { + name: "komoot-import", + retryLimit: 1, + expireInSeconds: 300, + async handler(jobs) { + for (const job of jobs) { + const { userId, connectionId, batchId } = job.data; + logger.info({ userId, connectionId, batchId }, "starting Komoot import job"); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.id, connectionId), + eq(syncConnections.userId, userId), + ), + ); + + if (!connection?.encryptedCredentials) { + throw new Error(`Komoot connection ${connectionId} not found or missing credentials`); + } + + const { email, password } = JSON.parse(decrypt(connection.encryptedCredentials)) as { + email: string; + password: string; + }; + const creds: KomootCredentials = { + email, + password, + username: connection.providerUserId!, + token: connection.accessToken, + }; + + await importKomootTours(userId, connectionId, creds, batchId); + logger.info({ batchId }, "Komoot import job completed"); + } + }, +}; diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts new file mode 100644 index 0000000..d588ecc --- /dev/null +++ b/apps/journal/app/lib/boss.server.ts @@ -0,0 +1,14 @@ +import type { createBoss } from "@trails-cool/jobs"; + +type Boss = ReturnType; + +let boss: Boss | null = null; + +export function setBoss(instance: Boss): void { + boss = instance; +} + +export function getBoss(): Boss { + if (!boss) throw new Error("pg-boss not initialized — server still starting?"); + return boss; +} diff --git a/apps/journal/app/lib/crypto.server.test.ts b/apps/journal/app/lib/crypto.server.test.ts new file mode 100644 index 0000000..4e04039 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { encrypt, decrypt } from "./crypto.server"; + +beforeAll(() => { + process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests"; +}); + +describe("crypto", () => { + it("encrypts and decrypts a string", () => { + const plaintext = "my-secret-password-123"; + const encrypted = encrypt(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(decrypt(encrypted)).toBe(plaintext); + }); + + it("produces different ciphertext for the same input", () => { + const plaintext = "same-input"; + const a = encrypt(plaintext); + const b = encrypt(plaintext); + expect(a).not.toBe(b); + expect(decrypt(a)).toBe(plaintext); + expect(decrypt(b)).toBe(plaintext); + }); + + it("handles empty string", () => { + const encrypted = encrypt(""); + expect(decrypt(encrypted)).toBe(""); + }); + + it("handles unicode", () => { + const plaintext = "Passwort mit Ümlauten: äöü 🏔️"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("throws on tampered ciphertext", () => { + const encrypted = encrypt("secret"); + const tampered = encrypted.slice(0, -2) + "XX"; + expect(() => decrypt(tampered)).toThrow(); + }); +}); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts new file mode 100644 index 0000000..5223ec0 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.ts @@ -0,0 +1,31 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + const secret = process.env.INTEGRATION_SECRET; + if (!secret) throw new Error("INTEGRATION_SECRET environment variable is required"); + return scryptSync(secret, "trails-cool-integrations", 32); +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString("base64"); +} + +export function decrypt(encoded: string): string { + const key = getKey(); + const buf = Buffer.from(encoded, "base64"); + const iv = buf.subarray(0, IV_LENGTH); + const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + decipher.setAuthTag(tag); + return decipher.update(encrypted) + decipher.final("utf8"); +} diff --git a/apps/journal/app/lib/komoot-import.server.ts b/apps/journal/app/lib/komoot-import.server.ts new file mode 100644 index 0000000..5ef1573 --- /dev/null +++ b/apps/journal/app/lib/komoot-import.server.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { importBatches } from "@trails-cool/db/schema/journal"; +import { createActivity } from "./activities.server.ts"; +import { createRoute, type RouteInput } from "./routes.server.ts"; +import { isAlreadyImported, recordImport } from "./sync/imports.server.ts"; +import { fetchTours, fetchTourGpx, type KomootCredentials, type KomootTour } from "./komoot.server.ts"; + +export async function importKomootTours( + userId: string, + connectionId: string, + creds: KomootCredentials, + existingBatchId?: string, +): Promise { + const db = getDb(); + const batchId = existingBatchId ?? randomUUID(); + + if (!existingBatchId) { + await db.insert(importBatches).values({ + id: batchId, + userId, + connectionId, + status: "running", + }); + } else { + await db + .update(importBatches) + .set({ status: "running" }) + .where(eq(importBatches.id, batchId)); + } + + try { + let page = 0; + let totalFound = 0; + let importedCount = 0; + let duplicateCount = 0; + let hasMore = true; + + while (hasMore) { + const result = await fetchTours(creds, page); + totalFound += result.tours.length; + + await db + .update(importBatches) + .set({ totalFound }) + .where(eq(importBatches.id, batchId)); + + for (const tour of result.tours) { + const alreadyImported = await isAlreadyImported(userId, "komoot", tour.id); + if (alreadyImported) { + duplicateCount++; + continue; + } + + try { + await importSingleTour(userId, creds, tour); + importedCount++; + } catch (err) { + console.error(`Failed to import Komoot tour ${tour.id}:`, err); + } + + await db + .update(importBatches) + .set({ importedCount, duplicateCount }) + .where(eq(importBatches.id, batchId)); + } + + hasMore = page + 1 < result.totalPages; + page++; + } + + await db + .update(importBatches) + .set({ + status: "completed", + totalFound, + importedCount, + duplicateCount, + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + + return batchId; + } catch (err) { + await db + .update(importBatches) + .set({ + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err), + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + throw err; + } +} + +async function importSingleTour( + userId: string, + creds: KomootCredentials, + tour: KomootTour, +): Promise { + let gpx: string | undefined; + try { + gpx = await fetchTourGpx(creds, tour.id); + } catch { + // Tour without downloadable GPX — import metadata only + } + + const routeInput: RouteInput = { + name: tour.name, + gpx, + }; + + const routeId = await createRoute(userId, routeInput); + + // Mark the route source as komoot + const db = getDb(); + const { routes } = await import("@trails-cool/db/schema/journal"); + await db.update(routes).set({ source: "komoot" }).where(eq(routes.id, routeId)); + + const activityId = await createActivity(userId, { + name: tour.name, + gpx, + routeId, + distance: tour.distance, + duration: tour.duration, + startedAt: new Date(tour.date), + }); + + await recordImport(userId, "komoot", tour.id, activityId); +} + +export async function getLatestBatch(userId: string, connectionId: string) { + const db = getDb(); + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connectionId)) + .orderBy(importBatches.startedAt) + .limit(1); + return batch ?? null; +} diff --git a/apps/journal/app/lib/komoot.server.test.ts b/apps/journal/app/lib/komoot.server.test.ts new file mode 100644 index 0000000..4f78851 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { parseTourResponse } from "./komoot.server"; + +describe("komoot", () => { + describe("parseTourResponse", () => { + it("parses a page of tours", () => { + const data = { + _embedded: { + tours: [ + { + id: 12345, + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevation_up: 850, + elevation_down: 830, + }, + { + id: 67890, + name: "Cycling to work", + sport: "touringbicycle", + date: "2026-03-14T07:00:00.000Z", + distance: 8500, + duration: 1800, + elevation_up: 45, + elevation_down: 50, + }, + ], + }, + }; + + const tours = parseTourResponse(data); + expect(tours).toHaveLength(2); + expect(tours[0]).toEqual({ + id: "12345", + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevationUp: 850, + elevationDown: 830, + }); + }); + + it("handles empty response", () => { + expect(parseTourResponse({})).toEqual([]); + expect(parseTourResponse({ _embedded: {} })).toEqual([]); + expect(parseTourResponse({ _embedded: { tours: [] } })).toEqual([]); + }); + }); +}); diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts new file mode 100644 index 0000000..1b08369 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.ts @@ -0,0 +1,141 @@ +const API_BASE = "https://api.komoot.de/v007"; +const AUTH_BASE = "https://api.komoot.de/v006"; + +export interface KomootCredentials { + email: string; + password: string; + username: string; + token: string; +} + +export interface KomootTour { + id: string; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevationUp: number; + elevationDown: number; +} + +export interface KomootTourPage { + tours: KomootTour[]; + totalPages: number; + page: number; +} + +export async function login( + email: string, + password: string, +): Promise<{ username: string; token: string }> { + const res = await fetch(`${AUTH_BASE}/account/email/${email}/`, { + headers: { + Authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`, + }, + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error("Invalid Komoot credentials"); + } + throw new Error(`Komoot login failed: ${res.status}`); + } + + const data = (await res.json()) as { username: string }; + return { username: data.username, token: Buffer.from(`${email}:${password}`).toString("base64") }; +} + +export async function fetchTours( + creds: KomootCredentials, + page = 0, +): Promise { + const res = await fetch( + `${API_BASE}/users/${creds.username}/tours/?page=${page}&limit=50&type=tour_recorded&sort_field=date&sort_direction=desc`, + { + headers: { Authorization: `Basic ${creds.token}` }, + }, + ); + + if (!res.ok) { + if (res.status === 401) throw new Error("Komoot auth expired"); + throw new Error(`Komoot tours fetch failed: ${res.status}`); + } + + const data = (await res.json()) as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + page?: { totalPages: number; number: number }; + }; + + console.log("[komoot] page response keys:", Object.keys(data), "page field:", JSON.stringify(data.page)); + + const tours = (data._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); + + return { + tours, + totalPages: data.page?.totalPages ?? 1, + page: data.page?.number ?? 0, + }; +} + +export async function fetchTourGpx( + creds: KomootCredentials, + tourId: string, +): Promise { + const res = await fetch(`${API_BASE}/tours/${tourId}.gpx`, { + headers: { Authorization: `Basic ${creds.token}` }, + }); + + if (!res.ok) { + throw new Error(`Komoot GPX fetch failed for tour ${tourId}: ${res.status}`); + } + + return res.text(); +} + +export function parseTourResponse(data: unknown): KomootTour[] { + const obj = data as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + }; + return (obj._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index be4a5f4..b4094b0 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -68,6 +68,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { {t("nav.activities")} + + {t("nav.integrations")} + )} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 537369b..19dd6c9 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -32,6 +32,11 @@ export default [ route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), + route("integrations", "routes/integrations.tsx"), + route("api/integrations/komoot/connect", "routes/api.integrations.komoot.connect.ts"), + route("api/integrations/komoot/disconnect", "routes/api.integrations.komoot.disconnect.ts"), + route("api/integrations/komoot/import", "routes/api.integrations.komoot.import.ts"), + route("api/integrations/komoot/import-status", "routes/api.integrations.komoot.import-status.ts"), route("privacy", "routes/privacy.tsx"), // REST API v1 route("api/v1/routes", "routes/api.v1.routes._index.ts"), diff --git a/apps/journal/app/routes/api.integrations.komoot.connect.ts b/apps/journal/app/routes/api.integrations.komoot.connect.ts new file mode 100644 index 0000000..b77ff29 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.connect.ts @@ -0,0 +1,44 @@ +import { data } from "react-router"; +import { randomUUID } from "node:crypto"; +import type { Route } from "./+types/api.integrations.komoot.connect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { login } from "~/lib/komoot.server"; +import { encrypt } from "~/lib/crypto.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const form = await request.formData(); + const email = String(form.get("email") ?? "").trim(); + const password = String(form.get("password") ?? ""); + + if (!email || !password) { + return data({ error: "Email and password are required" }, { status: 400 }); + } + + let credentials: { username: string; token: string }; + try { + credentials = await login(email, password); + } catch { + return data({ error: "Invalid Komoot credentials" }, { status: 400 }); + } + + const encrypted = encrypt(JSON.stringify({ email, password })); + + const db = getDb(); + await db.insert(syncConnections).values({ + id: randomUUID(), + userId: user.id, + provider: "komoot", + accessToken: credentials.token, + refreshToken: "", + expiresAt: new Date("2099-12-31"), + providerUserId: credentials.username, + encryptedCredentials: encrypted, + }); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.disconnect.ts b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts new file mode 100644 index 0000000..971fee2 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts @@ -0,0 +1,23 @@ +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.disconnect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + await db + .delete(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import-status.ts b/apps/journal/app/routes/api.integrations.komoot.import-status.ts new file mode 100644 index 0000000..aad5d30 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import-status.ts @@ -0,0 +1,35 @@ +import { data } from "react-router"; +import { eq, and, desc } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import-status"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection) { + return data({ batch: null }); + } + + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connection.id)) + .orderBy(desc(importBatches.startedAt)) + .limit(1); + + return data({ batch: batch ?? null }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import.ts b/apps/journal/app/routes/api.integrations.komoot.import.ts new file mode 100644 index 0000000..3417f4d --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; +import { getBoss } from "~/lib/boss.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection?.encryptedCredentials) { + return data({ error: "Komoot not connected" }, { status: 400 }); + } + + const batchId = randomUUID(); + await db.insert(importBatches).values({ + id: batchId, + userId: user.id, + connectionId: connection.id, + status: "queued", + }); + + const boss = getBoss(); + await boss.send("komoot-import", { + userId: user.id, + connectionId: connection.id, + batchId, + }); + + return data({ batchId }); +} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 90ea6b1..dc8f9f0 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -8,11 +8,14 @@ export default function RegisterPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [supportsPasskey, setSupportsPasskey] = useState(null); + const [usePasskey, setUsePasskey] = useState(true); const [magicLinkSent, setMagicLinkSent] = useState(false); useEffect(() => { import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { - setSupportsPasskey(browserSupportsWebAuthn()); + const supported = browserSupportsWebAuthn(); + setSupportsPasskey(supported); + setUsePasskey(supported); }); }, []); @@ -114,7 +117,7 @@ export default function RegisterPage() { {t("auth.registerDescription")}

-
+
); From bc311ab7bcdb0c2d523a116e5c76c2168a1dc3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:27:56 +0200 Subject: [PATCH 036/495] Apply minimal legal-review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Privacy: - Sentry: add Art. 44 ff. DSGVO third-country transfer note (Sentry is US-based; we rely on SCCs). Soften "no IPs" → "IP-Adressen werden nicht aktiv gespeichert" per reviewer's preferred wording. Drop the unverified "Frankfurt" hosting claim; keep Auftragsverarbeiter framing. - SMTP → "externer SMTP-Dienst" per reviewer's phrasing. - Storage-durations section: add explicit alpha-reset caveat so it aligns with Terms §4 (database resets). Terms: - § 1 service wording: "kostenloser Dienst" → "derzeit kostenloser Dienst" (leaves pricing model open without promising free-forever). - § 7 acceptable use: scraping clause softened to "kein massenhaftes automatisiertes Auslesen von Daten" (drops the specific "to build competing services" qualifier). Imprint: - Normalise address formatting: multi-line across both DE blocks and the EN § 5 TMG block (§ 18 MStV and EN block previously used a comma-separated single line). One style only, everywhere. - Add whitespace-nowrap to the mailto link so the email address never breaks mid-word on narrow viewports. No changes to legal bases, server-logs section, storage durations themselves, or any third-party other than Sentry + SMTP wording. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/legal.imprint.tsx | 23 +++++++++---- apps/journal/app/routes/legal.privacy.tsx | 41 ++++++++++++++++------- apps/journal/app/routes/legal.terms.tsx | 14 ++++---- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/apps/journal/app/routes/legal.imprint.tsx b/apps/journal/app/routes/legal.imprint.tsx index adb0f3e..e77fb78 100644 --- a/apps/journal/app/routes/legal.imprint.tsx +++ b/apps/journal/app/routes/legal.imprint.tsx @@ -39,7 +39,10 @@ export default function ImprintPage() {

Kontakt

E-Mail:{" "} - + {operator.email}

@@ -52,8 +55,9 @@ export default function ImprintPage() {
{operator.responsiblePerson}
- {operator.address.street}, {operator.address.postalCode}{" "} - {operator.address.city} + {operator.address.street} +
+ {operator.address.postalCode} {operator.address.city}
@@ -100,8 +104,12 @@ export default function ImprintPage() { Service provider (§ 5 TMG)
- {operator.name}, {operator.address.street},{" "} - {operator.address.postalCode} {operator.address.city},{" "} + {operator.name} +
+ {operator.address.street} +
+ {operator.address.postalCode} {operator.address.city} +
{operator.address.country}
@@ -110,7 +118,10 @@ export default function ImprintPage() {

Contact

Email:{" "} - + {operator.email}

diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index bddeeaa..1d6a368 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -98,7 +98,8 @@ export default function PrivacyPage() { Fehlermeldungen, Browser-/OS-Information aus dem User-Agent, Performance-Metriken. Bei eingeloggten Journal-Nutzer:innen zusätzlich die Nutzer-ID (keine E-Mail, kein Benutzername). - Keine IP-Adressen, keine Cookies, keine Formulareingaben. + IP-Adressen werden nicht aktiv gespeichert, ebenso keine Cookies + und keine Formulareingaben.

@@ -182,10 +183,19 @@ export default function PrivacyPage() {

  • Server-Logfiles: maximal 14 Tage
  • Fehlerdaten (Sentry): 90 Tage
  • +

    + Hinweis Alpha: Während der Alpha-Phase behält sich der Betreiber + ausdrücklich vor, die Datenbank zurückzusetzen oder einzelne + Datensätze zu löschen. Dies kann zu Datenverlust führen, bevor Sie + eine Löschung veranlassen. Details dazu in den Nutzungsbedingungen. +

    English. Account and content kept until you delete them. Ephemeral data (sessions, magic-link tokens, logs, Sentry events) - deleted automatically on the schedules above. + deleted automatically on the schedules above. Alpha caveat: the + operator may reset the database or delete individual records + during alpha, which can cause data loss before you request + deletion. See the Terms of Service.

    @@ -205,10 +215,16 @@ export default function PrivacyPage() { Performance-Monitoring. Was übermittelt wird: Stacktraces, Fehlertext, Browser-/OS-Informationen aus dem User-Agent, Performance-Daten; bei eingeloggten Journal-Nutzer:innen - zusätzlich die Nutzer-ID. Nicht übermittelt: - IP-Adresse, Cookies, vollständige HTTP-Header - (sendDefaultPii ist deaktiviert). Keine - Session-Replays. Hosting innerhalb der EU (Frankfurt). + zusätzlich die Nutzer-ID. IP-Adressen werden nicht aktiv + gespeichert (sendDefaultPii ist deaktiviert), ebenso + keine Cookies und keine vollständigen HTTP-Header. Keine + Session-Replays. Sentry agiert als externer Dienstleister + (Auftragsverarbeiter) für die Fehlerbehandlung. Da Sentry ein + Anbieter mit Sitz in den USA ist, kann im Einzelfall eine + Übermittlung personenbezogener Daten in ein Drittland im Sinne + der Art. 44 ff. DSGVO stattfinden; wir stützen uns hierbei auf + die von Sentry bereitgestellten + Standardvertragsklauseln.
  • OpenStreetMap – Kartenkacheln werden beim Anzeigen @@ -237,10 +253,11 @@ export default function PrivacyPage() { selbst gehosteten Instanz. Keine Weitergabe an Dritte.
  • - SMTP-Versand – Transaktions-E-Mails (Magic Link, - Willkommensnachricht) werden über einen SMTP-Dienst versendet. - Dabei wird die E-Mail-Adresse der Empfänger:in an den SMTP-Dienst - übergeben. + E-Mail-Versand – Transaktions-E-Mails (Magic + Link, Willkommensnachricht) werden über einen konfigurierten + externen SMTP-Dienst versendet. Dabei wird die E-Mail-Adresse + der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene + Instanzen konfigurieren ihren eigenen Mailserver.
  • Hosting – Die Dienste werden in Rechenzentren @@ -250,7 +267,7 @@ export default function PrivacyPage() {

    English. Third parties and what they receive: Sentry (error - details, no IPs/cookies, EU-hosted); OpenStreetMap tile servers + details, no IPs/cookies); OpenStreetMap tile servers (your IP and user-agent, directly from your browser, to load map tiles); Overpass (via our server-side proxy, so upstream only sees our server); BRouter (self-hosted, no third party involved); SMTP @@ -366,7 +383,7 @@ export default function PrivacyPage() {

  • Journal server: always on
  • Journal browser: only after login, torn down on logout
  • Planner: server only; the browser never loads Sentry
  • -
  • No IPs, no cookies, no headers (sendDefaultPii: false)
  • +
  • IPs not actively stored, no cookies, no full headers (sendDefaultPii: false)
  • No replays (replay integration not installed, sample rates 0)
  • User ID only (no email/username) on Journal-logged-in events
  • diff --git a/apps/journal/app/routes/legal.terms.tsx b/apps/journal/app/routes/legal.terms.tsx index b8538f8..ab18684 100644 --- a/apps/journal/app/routes/legal.terms.tsx +++ b/apps/journal/app/routes/legal.terms.tsx @@ -23,7 +23,7 @@ export default function TermsPage() { 1. Gegenstand und Alpha-Status

    - trails.cool ist ein experimenteller, kostenloser Dienst zur Planung + trails.cool ist ein experimenteller, derzeit kostenloser Dienst zur Planung und Dokumentation von Outdoor-Aktivitäten. Der Dienst befindet sich in aktiver Entwicklung (Alpha). Funktionen, Schnittstellen und gespeicherte Daten können jederzeit ohne Vorankündigung geändert, @@ -31,7 +31,8 @@ export default function TermsPage() { Verfügbarkeitsgarantie besteht nicht.

    - English. trails.cool is an experimental, free service in + English. trails.cool is an experimental service, + currently free of charge, in active alpha development. Features, data, and availability can change at any time without notice. No availability is guaranteed.

    @@ -136,15 +137,12 @@ export default function TermsPage() { Abfragen, Denial-of-Service)
  • Kein Umgehen technischer Schutzmaßnahmen
  • -
  • - Keine Verwendung des Dienstes zum Aufbau konkurrierender - Angebote durch massenhaftes Abziehen von Daten -
  • +
  • Kein massenhaftes automatisiertes Auslesen von Daten
  • English. No illegal content, no abuse (spam, flooding, - DoS), no circumvention of technical protections, no bulk scraping - to build competing services. + DoS), no circumvention of technical protections, no bulk + automated data extraction.

    From 2c512ce75e038d16689c308114778267dd09e042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:33:27 +0200 Subject: [PATCH 037/495] Realign legal-disclaimers spec with post-review implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes one contradiction and closes five gaps where the code (after the legal-review rounds in #235, #246, #247) now exceeds what the spec required. Contradiction fixed: - Alpha banner was specified as dismissible via sessionStorage, with "reappears in a new session". In reality the dismiss control and sessionStorage were removed during privacy hardening — the banner is now always visible. Updated the requirement to match. New scenarios documenting what's already shipped: - Impressum: EU ODR link + non-participation notice; English translation noted as informational. - Terms of Service: service availability, minimum age 16 for Journal accounts, content usage-rights licence, acceptable-use rules (including no mass automated data extraction), German-law-tiered liability, bilingual structure. - Datenschutzerklärung: explicit data-categories-and-purposes list; Terms acceptance classified under Art. 6(1)(b) and explicitly NOT (1)(a); dedicated server-logs section (≤14 days, Art. 6(1)(f)); storage-durations list; alpha-reset caveat; per-third-party disclosures with transfer details (Sentry, OSM tile servers, Overpass proxy, BRouter, SMTP); Art. 44 ff. DSGVO third-country transfer note for Sentry with SCC basis; Berlin supervisory authority; Privacy Manifest appendix. - Footer: now includes a link to the source repository on both apps. No functional changes — only spec text catching up with what is live in production. Co-Authored-By: Claude Opus 4.7 (1M context) --- openspec/specs/legal-disclaimers/spec.md | 102 ++++++++++++++++++----- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/openspec/specs/legal-disclaimers/spec.md b/openspec/specs/legal-disclaimers/spec.md index 1363e8a..d383972 100644 --- a/openspec/specs/legal-disclaimers/spec.md +++ b/openspec/specs/legal-disclaimers/spec.md @@ -5,7 +5,7 @@ Legal pages, signup acknowledgement, and alpha-state disclosures required for op ## Requirements ### Requirement: Impressum page -The Journal SHALL serve an Impressum page at `/legal/imprint` containing the operator's legal information per §5 TMG and §18 MStV. +The Journal SHALL serve an Impressum page at `/legal/imprint` containing the operator's legal information per §5 TMG and §18 MStV, an EU online-dispute-resolution notice, and an English translation. #### Scenario: Impressum is accessible - **WHEN** a user navigates to `/legal/imprint` @@ -15,63 +15,123 @@ The Journal SHALL serve an Impressum page at `/legal/imprint` containing the ope - **WHEN** a user views any page on the Journal or Planner - **THEN** the footer contains a link to the Impressum +#### Scenario: EU ODR notice +- **WHEN** a user reads the Impressum +- **THEN** they see a link to the EU online-dispute-resolution platform (ec.europa.eu/consumers/odr) and a statement that the operator is not willing or obliged to participate in consumer arbitration proceedings + +#### Scenario: English translation +- **WHEN** a user reads the Impressum +- **THEN** an English version of each section is rendered below its German counterpart and the page notes that the German version is authoritative + ### Requirement: Terms of Service page -The Journal SHALL serve a Terms of Service page at `/legal/terms` with clear disclaimers about the service's alpha state. +The Journal SHALL serve a Terms of Service page at `/legal/terms` with disclosures about alpha state, service availability, minimum age, content usage rights, and acceptable use. #### Scenario: Alpha status disclosure - **WHEN** a user reads the Terms page -- **THEN** the Terms clearly state that the service is in early development, untested, and may change without notice +- **THEN** the Terms clearly state that the service is in early development, currently free of charge, and may change without notice + +#### Scenario: Service availability +- **WHEN** a user reads the Terms page +- **THEN** the Terms state that the operator may modify, limit, interrupt, or discontinue the service or individual accounts at any time #### Scenario: Database reset disclosure - **WHEN** a user reads the Terms page - **THEN** the Terms explicitly state that the operator may delete all user data at any time without prior notice -#### Scenario: Warranty disclaimer +#### Scenario: Warranty and liability - **WHEN** a user reads the Terms page -- **THEN** the Terms disclaim all warranties to the extent permitted by German consumer law +- **THEN** the Terms disclaim warranties and tier liability consistent with German law (unlimited for intent / gross negligence / life-body-health; limited-to-foreseeable for cardinal-duty breach under slight negligence; otherwise excluded) #### Scenario: Data export responsibility - **WHEN** a user reads the Terms page -- **THEN** the Terms state that the user is responsible for exporting their own data and link to the data export functionality +- **THEN** the Terms state that the user is responsible for exporting their own data and reference the GPX / JSON export functionality + +#### Scenario: Minimum age for accounts +- **WHEN** a user reads the Terms page +- **THEN** the Terms state that Journal accounts require a minimum age of 16, while anonymous Planner use has no age requirement + +#### Scenario: Content usage rights +- **WHEN** a user reads the Terms page +- **THEN** the Terms state that users retain ownership of their content and grant the operator only a limited, non-transferable licence to store, process, and display that content for the purpose of operating the service + +#### Scenario: Acceptable use +- **WHEN** a user reads the Terms page +- **THEN** the Terms prohibit illegal content, abusive use (spam, flooding, DoS), circumvention of technical protections, and mass automated data extraction + +#### Scenario: English translation +- **WHEN** a user reads the Terms page +- **THEN** each German section has a short English summary, and the page notes that the German version is authoritative ### Requirement: Datenschutzerklärung -The Journal SHALL serve a GDPR-compliant privacy policy at `/legal/privacy`, replacing the existing `/privacy` route. +The Journal SHALL serve a GDPR-compliant privacy policy at `/legal/privacy`, replacing the existing `/privacy` route. It SHALL follow classical GDPR structure with a developer-friendly Privacy Manifest appended at the end. #### Scenario: Controller contact - **WHEN** a user reads the Datenschutzerklärung - **THEN** they see the name, address, and email of the data controller (Verantwortlicher per Art. 4 Nr. 7 DSGVO) +#### Scenario: Data categories and purposes +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** they see an explicit enumeration of the data categories processed (account data, user content, auth artefacts, Planner session state, server logs, Sentry error data) together with the purpose of each + #### Scenario: Legal basis for processing - **WHEN** a user reads the Datenschutzerklärung -- **THEN** each category of data processing lists its legal basis (e.g., Art. 6 Abs. 1 lit. b for contract, lit. f for legitimate interest) +- **THEN** each category of data processing lists its legal basis (Art. 6 Abs. 1 lit. b for accounts / login / stored content / transactional email; lit. f for server logs, rate-limiting, error monitoring) + +#### Scenario: Terms acceptance is not consent +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** acceptance of the Terms of Service is classified under Art. 6 Abs. 1 lit. b (contract) and NOT under lit. a (consent), and the policy states this explicitly + +#### Scenario: Server logs disclosure +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** a dedicated section describes that HTTP requests are logged (IP address, timestamp, method, path, status code, user-agent), the purpose (security / operations / debugging), the legal basis (Art. 6 Abs. 1 lit. f), and a retention period of at most 14 days + +#### Scenario: Storage durations +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** a list of storage durations is shown covering at minimum: account and user content (until deletion by the user), Planner sessions (≤7 days), magic-link tokens (≤15 minutes), server logs (≤14 days), Sentry events (≤90 days) + +#### Scenario: Alpha reset caveat +- **WHEN** a user reads the storage-durations section +- **THEN** the section notes that during alpha the operator may reset the database or delete individual records, which can cause data loss before a user requests deletion, and points to the Terms of Service + +#### Scenario: Third-party disclosures +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** each third party receives its own entry describing what is transmitted and why, covering at minimum: Sentry (error tracking; sendDefaultPii disabled; no session replays), OpenStreetMap tile servers (browser transmits IP and user-agent directly to OSM), Overpass (queries proxied through the Planner server so upstream only sees our server IP), BRouter (self-hosted, no third party involved), and the SMTP service used for transactional email + +#### Scenario: Third-country transfer note for Sentry +- **WHEN** a user reads the Sentry disclosure +- **THEN** the policy notes that Sentry is a US-based provider and that transfers into a third country within the meaning of Art. 44 ff. DSGVO may occur, with the legal mechanism (Standard Contractual Clauses) named #### Scenario: Data subject rights - **WHEN** a user reads the Datenschutzerklärung -- **THEN** they see their rights under GDPR: access (Art. 15), rectification (Art. 16), erasure (Art. 17), data portability (Art. 20), objection (Art. 21), and right to lodge a complaint with a supervisory authority +- **THEN** they see their rights under GDPR: access (Art. 15), rectification (Art. 16), erasure (Art. 17), restriction (Art. 18), data portability (Art. 20), objection (Art. 21), and the right to lodge a complaint with a supervisory authority + +#### Scenario: Complaint authority +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** they see the address and website of the Berlin data-protection commissioner as the competent supervisory authority + +#### Scenario: Privacy Manifest appendix +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** a developer-friendly Privacy Manifest follows the formal sections, summarising Planner (no cookies / no localStorage / no sessionStorage / no browser-side Sentry), Journal (what is stored, exportable), Sentry scope by login state, and security practices #### Scenario: Redirect from old privacy URL - **WHEN** a user navigates to `/privacy` - **THEN** they are redirected to `/legal/privacy` with a 301 status ### Requirement: Alpha banner -The Journal SHALL display a persistent banner notifying users that the service is in alpha, dismissible for the browser session. +The Journal SHALL display a persistent, non-dismissable banner on every page notifying users that the service is in alpha and that their data may be reset. -#### Scenario: Banner visible on first visit -- **WHEN** a user visits any Journal page for the first time in a session -- **THEN** a banner is shown with text stating trails.cool is in early development and data may be reset, with a link to the Terms - -#### Scenario: Banner dismissible -- **WHEN** a user clicks the dismiss button on the alpha banner -- **THEN** the banner is hidden for the remainder of the browser session (via sessionStorage) -- **AND** the banner reappears in a new session +#### Scenario: Banner always visible +- **WHEN** a user views any Journal page at any time during the alpha phase +- **THEN** the alpha banner is rendered with text stating trails.cool is in early development and data may be reset +- **AND** there is no dismiss control; the banner does not use cookies, localStorage, or sessionStorage ### Requirement: Footer legal links -Both the Journal and Planner SHALL render a footer with links to Impressum, Datenschutzerklärung, and Terms. +Both the Journal and Planner SHALL render a footer with links to Impressum, Datenschutzerklärung, Terms of Service, and the source repository. #### Scenario: Footer on Journal - **WHEN** a user views any Journal page -- **THEN** the footer contains links labeled "Impressum", "Privacy", and "Terms" pointing to the respective legal pages +- **THEN** the footer contains links labeled "Impressum", "Privacy", and "Terms" pointing to the respective `/legal/*` pages, plus a link to the source repository #### Scenario: Footer on Planner - **WHEN** a user views any Planner page -- **THEN** the footer contains the same three legal links, pointing to the Journal instance (since the Planner is stateless and legal pages live on the Journal) +- **THEN** the footer contains the same legal links, pointing at absolute URLs on the Journal instance (since the Planner is stateless and legal pages live on the Journal), plus a link to the source repository From c4874dc04c0a62796cf628c7c7a133cb0783e42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:40:44 +0200 Subject: [PATCH 038/495] Park self-host-overpass spec under docs/ideas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interim proxy (#239/#240/#242/#243) covers the day-one needs for Overpass — User-Agent compliance via server-side proxy, same-origin + rate limit + cache with coalescing, bbox quantization, observability. Further work on self-hosting the upstream is no longer urgent. Move the full OpenSpec artifact set out of openspec/changes/ so it doesn't clutter the active change list, and park it under docs/ideas/self-host-overpass/ as a reference for when we revive it. Adds a short README at the new location capturing: - current interim solution - triggers that would justify reviving (rate limits, >1 req/s, etc.) - key decisions already made (Hetzner-to-Hetzner firewall model, DOCKER-USER chain, capacity ceiling at DACH on current box) - how to switch when the time comes (flip OVERPASS_URL, no client changes) The files were never committed in the first place (WIP in the working tree through the proposal session), so no git history to preserve. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ideas/self-host-overpass/.openspec.yaml | 2 + docs/ideas/self-host-overpass/README.md | 52 +++++++ docs/ideas/self-host-overpass/design.md | 131 ++++++++++++++++++ docs/ideas/self-host-overpass/proposal.md | 34 +++++ .../specs/infrastructure/spec.md | 34 +++++ .../specs/osm-poi-overlays/spec.md | 16 +++ .../specs/overpass-hosting/spec.md | 76 ++++++++++ .../specs/rate-limiting/spec.md | 12 ++ docs/ideas/self-host-overpass/tasks.md | 62 +++++++++ 9 files changed, 419 insertions(+) create mode 100644 docs/ideas/self-host-overpass/.openspec.yaml create mode 100644 docs/ideas/self-host-overpass/README.md create mode 100644 docs/ideas/self-host-overpass/design.md create mode 100644 docs/ideas/self-host-overpass/proposal.md create mode 100644 docs/ideas/self-host-overpass/specs/infrastructure/spec.md create mode 100644 docs/ideas/self-host-overpass/specs/osm-poi-overlays/spec.md create mode 100644 docs/ideas/self-host-overpass/specs/overpass-hosting/spec.md create mode 100644 docs/ideas/self-host-overpass/specs/rate-limiting/spec.md create mode 100644 docs/ideas/self-host-overpass/tasks.md diff --git a/docs/ideas/self-host-overpass/.openspec.yaml b/docs/ideas/self-host-overpass/.openspec.yaml new file mode 100644 index 0000000..863bff1 --- /dev/null +++ b/docs/ideas/self-host-overpass/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-17 diff --git a/docs/ideas/self-host-overpass/README.md b/docs/ideas/self-host-overpass/README.md new file mode 100644 index 0000000..4977919 --- /dev/null +++ b/docs/ideas/self-host-overpass/README.md @@ -0,0 +1,52 @@ +# self-host-overpass (parked) + +Full OpenSpec artifact set (`proposal.md`, `design.md`, `specs/`, `tasks.md`) +for hosting our own Overpass API. Moved here from `openspec/changes/` so it +does not clutter the active change list; revive by moving the directory back +under `openspec/changes/` when ready to implement. + +## Status + +**Parked.** The interim proxy work (`/api/overpass` → `overpass.private.coffee`, +see `apps/planner/app/routes/api.overpass.ts`) covers the day-one needs: +User-Agent compliance, same-origin check, rate limiting, server-side cache +with coalescing, bbox quantization, Grafana observability. That buys us time +before we need to own the upstream. + +## When to revive + +Revisit once **any** of these is true: + +- private.coffee rate-limits our traffic or changes policy +- Our query volume makes continued use of a free public instance feel like + abuse (rule of thumb: >1 req/s sustained) +- We want POI coverage outside regions private.coffee happens to import +- Privacy posture requires full control of the upstream + +## Key decisions already made + +- **Topology**: Overpass runs on the maintainer's second Hetzner box + (FSN1, dedicated, i7-2600, 32 GB, 2×3 TB HDD, 1.8 TB free). Both hosts + share Hetzner's internal backbone with ~1 ms RTT. +- **Access control**: host-level firewall via nftables `DOCKER-USER` chain + (Docker bypasses `INPUT` when publishing ports, which is the classic + gotcha). Only the Planner host's egress IP is allowed on the Overpass + port. Planner host IP lives in `/etc/overpass/planner-ip.env` on the + Overpass box, **not** in the repo. Tailscale / WireGuard / vSwitch kept + as future hardening options. +- **Capacity ceiling on current hardware**: DACH extract fits comfortably + in the 23 GB of RAM available on the Overpass box. Europe+ would need + different hardware (AX52 ≈ €54/mo for a dedicated 64 GB NVMe box that + handles planet at low user counts; see design.md). +- **Switch path**: no client changes needed to cut over — the Planner + proxy already reads `OVERPASS_URL` from env and defaults to + private.coffee. Flipping the env var points at our own instance. + +## What's in the folder + +- `proposal.md` — why / what / impact +- `design.md` — topology, firewall pattern, capacity analysis, risks +- `specs/` — delta specs (would land against `overpass-hosting`, + `osm-poi-overlays`, `infrastructure`, `rate-limiting`) +- `tasks.md` — 10 groups of implementation tasks +- `.openspec.yaml` — OpenSpec scaffolding; keep for when it comes back diff --git a/docs/ideas/self-host-overpass/design.md b/docs/ideas/self-host-overpass/design.md new file mode 100644 index 0000000..9a9a253 --- /dev/null +++ b/docs/ideas/self-host-overpass/design.md @@ -0,0 +1,131 @@ +## Context + +The Planner's POI overlays issue Overpass QL queries from the browser to `overpass.kumi.systems` (primary) and `overpass-api.de` (fallback) via `apps/planner/app/lib/overpass.ts`. These are community-run services with shared rate limits; during the alpha we will quickly exceed what is socially acceptable for a free public resource, and every browser tab that opens the Planner leaks its viewport coordinates to those third-party hosts. + +BRouter is already self-hosted and already demonstrates the access-restriction pattern we want to reuse: the browser never calls BRouter directly — it POSTs to the Planner server's `/api/route`, which rate-limits by session and then calls `http://brouter:17777` on the compose-internal Docker network. BRouter has no `ports:` mapping and no Caddy route, so it simply isn't reachable from the public internet. No token, no CORS — topology is the boundary. + +Two constraints shape the Overpass design differently from BRouter: +- The existing Hetzner compose host has 40 GB of SSD; the BRouter segment files fit comfortably but a Germany Overpass DB is already ~40–60 GB and Europe is ~200–350 GB. Running Overpass next to BRouter is not viable. +- The maintainer has a second Hetzner server in the same datacenter (FSN1) with 1.8 TB free. Running Overpass there is cheap and generous, but it puts the backend on a *different host* — so the compose-internal network that protects BRouter doesn't exist naturally, and we need an equivalent private network between the two Hetzner boxes. + +## Goals / Non-Goals + +**Goals:** +- Run our own Overpass endpoint on the dedicated server +- Make the Planner use that endpoint exclusively for POI queries +- Keep the endpoint off the public internet — same security posture as BRouter today +- Keep POI latency comparable to or better than current public endpoints +- Keep the instance up-to-date via daily diff replication + +**Non-Goals:** +- Serve Overpass publicly to other apps outside trails.cool +- Host Overpass on the Hetzner compose host (won't fit) +- Replace the Overpass stack with a custom engine — we want query-level compatibility +- Add per-user quotas (Planner is anonymous; per-session is enough) + +## Decisions + +### Topology: host-level firewall allowlist between two Hetzner boxes + +**Decision:** Keep both hosts on their existing Hetzner public IPs (no overlay, no vSwitch). On the Overpass host, configure nftables so the Overpass port only accepts connections from the Planner host's egress IP; drop everything else. The Planner server proxy calls the Overpass host directly over Hetzner's internal backbone, which routes the traffic at ~1 ms RTT and never leaves Hetzner's network. + +**Alternatives considered:** +- **Tailscale overlay** — stronger (encrypted transport, resilient to IP changes, MagicDNS hostnames) but adds a daemon on both hosts and a third-party control plane. Useful as a future hardening step, not needed for v1. +- **Self-hosted WireGuard** — same as Tailscale minus the third party, more key-management work. +- **Hetzner vSwitch** — native Hetzner VLAN-tagged private network between dedicated and Cloud. Free, no daemons, but requires Robot + Cloud configuration and a VLAN sub-interface on the dedicated server. +- **Public Caddy + mTLS** — exposes an Overpass URL publicly behind client certs; more moving parts than we need. +- **Bearer token + public endpoint** — tokens in env vars are operationally awkward and don't add anything over a firewall allowlist for the server-to-server case. + +**Why firewall allowlist:** It is the smallest change that reproduces BRouter's "topology is the boundary" property. Traffic already routes inside Hetzner's network (sub-ms RTT confirmed); the only additional thing we need is for the Overpass host's kernel to reject packets that don't originate from the Planner host. No daemon, no control plane, no cert rotation. + +**What we give up:** transport is not encrypted (Hetzner backbone, but still), and if the Planner host's egress IP ever changes we have to update the allowlist. Both are acceptable trade-offs for v1; Tailscale is the obvious upgrade path if either bites. + +### Making the firewall actually work with Docker + +**Decision:** Use the `DOCKER-USER` nftables/iptables chain for the allowlist. Docker runs this chain *before* its own FORWARD rules and will never touch it, so user-defined rules are not clobbered on container restart or daemon reload. + +**Why this matters:** Docker publishes ports by inserting rules into `PREROUTING` (DNAT) and `FORWARD` that run *before* the host's `INPUT` chain. A naïve `-A INPUT -p tcp --dport 8080 -j DROP` silently does nothing — the packet is already NATed into the container's network namespace before `INPUT` is consulted. This is a classic gotcha and has bitten us before. + +**Rule shape (parameterised by the Planner host IP, which is *not* checked into the repo):** +1. `iptables -I DOCKER-USER -i -s -p tcp --dport -j ACCEPT` +2. `iptables -I DOCKER-USER -i -p tcp --dport -j DROP` +3. Persist via `iptables-save` + systemd unit or `nftables.conf`. + +The Planner host IP lives in an env file on the Overpass host (e.g. `/etc/overpass/planner-ip.env`), loaded by the rule-apply script. Rotating it is a single variable edit and a script rerun. + +**Alternatives:** +- **`network_mode: host` on the Overpass container** — skips Docker's NAT entirely; the container listens directly on the host network, so regular `INPUT` rules work. Simpler rule logic, but the container has to bind a specific host port and port conflicts become the operator's problem. Keep as a fallback if `DOCKER-USER` ever surprises us. +- **`iptables=false` in Docker daemon config** — disables Docker's automatic rule management entirely, but then we own every NAT rule manually. Too invasive. +- **Bind the published port to `127.0.0.1` only** — doesn't work here because we need trails.cool to reach it over the public interface. + +### Which Overpass image to run + +**Decision:** Use the community `wiktorn/overpass-api` Docker image, pinned, wrapped in `infrastructure/overpass-host/`. + +**Why:** Most widely deployed, actively maintained, handles PBF import + diff replication out of the box, matches the query surface the browser already uses. Wrapping it in our own directory gives us a single place for configuration. + +### Extract size + +**Decision:** Start with a Germany (or DACH) extract. The `OVERPASS_PBF_URL` is an env var so switching extracts is a data-load step, not a code change. + +**Why:** Disk on the dedicated host is plentiful (~2 TB free) but RAM is the binding constraint. With ~23 GB available on the Overpass host after existing services, Germany (8–16 GB working set) or DACH (10–18 GB) fit comfortably and leave headroom for the page cache. Europe (32+ GB) would evict the host's other services and hit swap — not acceptable on a shared box. Planet (~128 GB) is out of scope for this hardware. Treat DACH as the practical ceiling for this deployment; scaling to Europe+ is a deliberate hardware decision (more RAM, or a different host), not a silent incident. + +### Access restriction model + +**Decision:** Same two-boundary model as BRouter, adapted for the two-host topology: + +1. **Browser → Planner server** (`/api/overpass`): + - Requires Planner session cookie (same cookie the rest of the Planner uses) + - Same-origin / Origin-header check as defense-in-depth + - Per-session rate limit via the existing `packages/rate-limiting` capability +2. **Planner server → Overpass** (Tailscale): + - Overpass binds only to the Tailscale interface + - Planner resolves `overpass.tailnet` over MagicDNS and connects on the private network + - No token required because the network itself is the auth boundary — same logic as BRouter + +**What CORS does *not* do here:** CORS only restricts browsers from *reading* cross-origin responses; it doesn't stop `curl` or any non-browser client from sending requests. An `Origin` check at the proxy is worth having, but only as defense-in-depth — the real protection is that Overpass isn't on the public internet. + +### Rate limiting + +**Decision:** Extend `packages/rate-limiting` with a per-session bucket for `/api/overpass`: ~20 queries/min with a burst of 5. Exceeded requests return 429; the existing POI UI already handles 429. + +**Why:** Matches the client's debounced pan/zoom traffic; a scripted misuse hits the ceiling immediately. Mirrors how `/api/route` protects BRouter. + +### Data refresh + +**Decision:** Use `wiktorn/overpass-api`'s built-in replication pointing at Geofabrik's daily diffs. Nothing to schedule externally. + +**Why:** Zero operational overhead; daily staleness is fine for POIs. + +### Where the initial load runs + +**Decision:** The one-shot PBF import runs on the Overpass host via `docker compose run --rm overpass-loader`, operator-invoked, documented in `infrastructure/overpass-host/README.md`. Replication takes over afterwards. + +**Why:** Initial import takes hours and can't block a normal deploy loop; it's a one-time operation. + +## Risks / Trade-offs + +- **Tailscale dependency** → Tailscale's control plane is a third party. Data plane is peer-to-peer and keeps working during control-plane outages; new connections may fail briefly. Mitigation: monitor via the existing observability stack; consider self-hosted Headscale or WireGuard if we outgrow the Tailscale free tier or want to remove the dependency. +- **Two-host operational burden** → The dedicated server adds a host to keep patched and monitored. Mitigation: minimal compose (Overpass only), same backup/monitoring approach as Hetzner. +- **Shared-host coupling** → The Overpass host is a general-purpose server already running unrelated services. trails.cool now depends on that host's uptime, and any RAM pressure from other services can degrade Overpass query latency (and vice versa). Mitigation: cap the Overpass working set at DACH-scale so it never competes for the last few GB of RAM; monitor available memory on that host alongside Overpass health. +- **Initial import eats hours of maintainer time** → Scripted in `infrastructure/overpass-host/scripts/initial-load.sh`, documented, run once per extract change. +- **Replication drift** → Expose replication lag as a Prometheus metric via the existing observability stack (Prometheus scrapes over Tailscale). Alert at >48 h. +- **Removing public fallback removes resilience** → If our Overpass is down, POI overlays are broken until it's back. Trade-off vs. privacy and reliability; accept it and treat Overpass like BRouter. +- **Blast radius on proxy bugs** → A bug in `/api/overpass` skipping the rate limiter could let a malicious tab exhaust the backend. Mitigation: limiter enforced by middleware, not per-route code; integration tests cover the 429 path. + +## Migration Plan + +1. Tailscale onboarding: create a tailnet, install Tailscale on the Hetzner host, install on the dedicated server, verify ping over the mesh. +2. On the dedicated server: check out `infrastructure/overpass-host/`, run the one-shot PBF import, start the service, confirm it responds on the Tailscale interface only. +3. Merge the Planner code changes behind an env var (`OVERPASS_URL`) that defaults to the public endpoint; set the env var to the Tailscale URL on Hetzner. +4. Flip the client code to use `/api/overpass`. Monitor error rate and latency. +5. Once stable, remove the public-endpoint fallback and the env-var override, locking in the self-hosted path. + +**Rollback:** Change `OVERPASS_URL` back to a public endpoint URL. The dedicated server stays up but unused. No data migration. + +## Open Questions + +- First-cut extract: Germany only, or DACH? Planet is overkill for alpha even with 1.8 TB of disk. +- Do we add a Grafana panel for Overpass query volume + replication lag in the same PR, or ship the service first and add observability as a follow-up? +- Should the proxy cache Overpass responses (Redis / in-memory) on top of the existing client-side cache, given many Planner sessions pan over the same cities? +- Prometheus scraping over Tailscale: scrape from the Hetzner Prometheus across the tailnet, or run a local exporter on the Overpass host and push? diff --git a/docs/ideas/self-host-overpass/proposal.md b/docs/ideas/self-host-overpass/proposal.md new file mode 100644 index 0000000..968fef7 --- /dev/null +++ b/docs/ideas/self-host-overpass/proposal.md @@ -0,0 +1,34 @@ +## Why + +The Planner's POI overlays depend on public Overpass API instances (`overpass.kumi.systems`, `overpass-api.de`) called directly from the browser. These instances are community-run, rate-limit aggressively, have no SLA, and every Planner user contends for the same pool — on launch we would be abusing someone else's free service and relying on it to stay up. Hosting our own Overpass keeps the Planner working under load, lets us tune rate limits for our traffic, and lets us stop leaking our users' viewport coordinates to a third party. The endpoint must be locked to Planner traffic only so we are not inadvertently running a free public Overpass mirror for the rest of the internet. + +## What Changes + +- Overpass runs on a **separate Hetzner server** the maintainer already owns in FSN1 (1.8 TB free disk), not on the existing Hetzner compose host, since the latter is storage-constrained (40 GB SSD) while Germany alone is ~40–60 GB and Europe is ~200–350 GB +- The Overpass host runs a **host-level firewall (nftables) that only accepts traffic from the Planner host's egress IP** on the Overpass port. Both hosts sit in Hetzner FSN1 and measured RTT is ~1 ms over Hetzner's internal backbone, so no overlay network is needed for latency. The firewall allowlist gives us the same "only trails.cool can reach it" property as a VPN, with zero extra daemons. No public Caddy route, no public DNS record — the port exists but all packets from any other source are dropped at the kernel. +- Tailscale/WireGuard remains on the table as a future hardening step (transport encryption, IP-rotation resilience) and is explicitly documented as a migration path. +- On the Overpass host: a small Docker Compose file running a community Overpass image with a configurable OSM extract URL and built-in diff replication for daily updates +- Planner server gains an `/api/overpass` proxy route that forwards queries to `http://:8080` on the private network — mirrors how `/api/route` already proxies to the internal BRouter +- Planner browser code switches from public Overpass endpoints to the Planner's own `/api/overpass` — the two public endpoints are removed from the fallback list +- Access restriction is **exactly the BRouter model**, just extended across two hosts: browser → Planner (same-origin + session + rate limit) → private network → backend. CORS/origin checks on the proxy are defense-in-depth, not the primary boundary. +- Rate limiting: per-session budget on the proxy route (e.g. 20 queries/min) layered on top of the existing `packages/rate-limiting` capability +- **BREAKING**: `apps/planner/app/lib/overpass.ts` stops talking to public Overpass hosts + +## Capabilities + +### New Capabilities +- `overpass-hosting`: Operate a self-hosted Overpass API instance fed from a regional OSM extract with nightly diff updates, fronted only by an authenticated planner proxy route. Covers the container, the data import and refresh lifecycle, the proxy route contract, and the access-restriction model. + +### Modified Capabilities +- `osm-poi-overlays`: POI queries now route through the Planner's `/api/overpass` proxy instead of public Overpass endpoints. The public-endpoint fallback requirement is removed. +- `infrastructure`: Adds a new long-running service (Overpass) to the compose stack, with a volume for the OSM database, a separate CI/CD workflow for the initial data load, and monitoring hooks for replication lag. +- `rate-limiting`: Adds a per-session bucket for `/api/overpass` to protect the underlying service. + +## Impact + +- **Code**: `apps/planner/app/lib/overpass.ts` (endpoint change), new route `apps/planner/app/routes/api.overpass.ts`, `packages/rate-limiting` integration for the new route +- **Infrastructure (existing Hetzner host)**: Add `OVERPASS_URL` env var to the Planner container pointing at the Overpass host. No new compose service on this host. +- **Infrastructure (Overpass Hetzner host)**: New `infrastructure/overpass-host/` directory with its own compose file, Dockerfile wrapper, nftables ruleset (parameterised by the Planner host IP — not checked in), and one-shot data-load script. Deployed independently of the existing CD pipelines. +- **Data**: With 1.8 TB of headroom we can run an extract as large as Europe (~200–350 GB) or even planet without redesign. Start with the extract that matches current user base and scale later — the choice is a data-load decision, not a schema decision. +- **Privacy**: Removes third-party leakage of viewport coordinates to public Overpass hosts; aligns with the Planner's privacy-first principle. +- **Rollback**: If the self-hosted instance is unhealthy, we can point `/api/overpass` back at a public endpoint via env var — but the long-term contract is that public endpoints are no longer used. diff --git a/docs/ideas/self-host-overpass/specs/infrastructure/spec.md b/docs/ideas/self-host-overpass/specs/infrastructure/spec.md new file mode 100644 index 0000000..c7e1e0b --- /dev/null +++ b/docs/ideas/self-host-overpass/specs/infrastructure/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Separate Overpass host +The trails.cool infrastructure SHALL include a second Hetzner host, distinct from the existing compose host, dedicated to running the Overpass service and its OSM data volume. + +#### Scenario: Host isolation +- **WHEN** the operator provisions the Overpass host +- **THEN** it runs only the Overpass stack (plus its firewall/tooling) and shares no filesystem, database, or container with the existing trails.cool compose host + +#### Scenario: Host-specific configuration lives in its own directory +- **WHEN** a change is made to the Overpass host configuration +- **THEN** the change is contained to a dedicated `infrastructure/overpass-host/` directory (compose file, Dockerfile wrapper, firewall rule template, load scripts) and does not touch the existing compose stack + +### Requirement: Planner-side configuration for the Overpass proxy +The Planner container SHALL read the Overpass endpoint from an environment variable so the target host can be swapped without code changes. + +#### Scenario: Endpoint configurable +- **WHEN** the operator sets `OVERPASS_URL` on the Planner container to any reachable Overpass endpoint +- **THEN** the Planner proxy route forwards queries to that endpoint without a rebuild or code change + +#### Scenario: Missing configuration handled gracefully +- **WHEN** `OVERPASS_URL` is unset or empty +- **THEN** the Planner proxy responds with a service-unavailable status and a log message, rather than contacting an unintended default + +### Requirement: Overpass observability +The infrastructure SHALL surface basic Overpass health signals via the existing Prometheus / Grafana stack. + +#### Scenario: Service up metric +- **WHEN** Prometheus scrapes the overpass service (directly or via a sidecar exporter / blackbox probe) +- **THEN** an `overpass_up` gauge reflects whether the service is responding to health checks + +#### Scenario: Replication lag metric +- **WHEN** Prometheus scrapes the overpass replication state +- **THEN** a metric reports the age of the most recently applied OSM diff, so alerts can fire when lag exceeds 48 hours diff --git a/docs/ideas/self-host-overpass/specs/osm-poi-overlays/spec.md b/docs/ideas/self-host-overpass/specs/osm-poi-overlays/spec.md new file mode 100644 index 0000000..48df16d --- /dev/null +++ b/docs/ideas/self-host-overpass/specs/osm-poi-overlays/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Overpass rate limit handling +The Planner SHALL handle Overpass API rate limits gracefully. POI queries SHALL be sent to the Planner's own `/api/overpass` proxy route, not to any public Overpass endpoint. + +#### Scenario: Rate limited response +- **WHEN** the `/api/overpass` proxy returns a 429 status (either from the proxy's own session rate limiter or propagated from the upstream Overpass service) +- **THEN** the Planner shows a temporary "POI data unavailable — try again shortly" message and retries with exponential backoff + +#### Scenario: Overpass unavailable +- **WHEN** the `/api/overpass` proxy is unreachable or returns a 5xx status +- **THEN** the Planner shows a message and tile overlays continue to function normally + +#### Scenario: No fallback to public endpoints +- **WHEN** the `/api/overpass` proxy returns any error +- **THEN** the Planner does NOT fall back to a public Overpass endpoint; the error surfaces to the user via the existing POI error UI diff --git a/docs/ideas/self-host-overpass/specs/overpass-hosting/spec.md b/docs/ideas/self-host-overpass/specs/overpass-hosting/spec.md new file mode 100644 index 0000000..b39f59d --- /dev/null +++ b/docs/ideas/self-host-overpass/specs/overpass-hosting/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: Self-hosted Overpass service +The trails.cool infrastructure SHALL run an Overpass API instance as a Docker service on a dedicated host, populated from a configurable regional OpenStreetMap extract, and reachable only from the Planner host. + +#### Scenario: Service reachable from the Planner host +- **WHEN** the Planner server sends an Overpass query from its configured egress address to the Overpass host on the configured port +- **THEN** the request is accepted and served + +#### Scenario: Service not reachable from any other source +- **WHEN** any other host on the public internet attempts to connect to the Overpass host's Overpass port +- **THEN** the connection is dropped by the host firewall and the Overpass service never sees the packets + +#### Scenario: Regional extract is configurable +- **WHEN** the operator sets a different `OVERPASS_PBF_URL` and runs the initial-load procedure +- **THEN** the service comes up populated from that extract without code changes + +### Requirement: Firewall compatible with Docker +The Overpass host firewall SHALL enforce the allowlist in a way that survives Docker daemon restarts, container restarts, and port-publication rule changes — i.e. user rules MUST be placed on the chain Docker reserves for user-managed filtering rather than relying on chains that Docker bypasses when publishing container ports. + +#### Scenario: Rule survives container restart +- **WHEN** the Overpass container is stopped and started +- **THEN** the firewall allowlist is still in effect and unauthorised sources are still dropped without operator intervention + +#### Scenario: Rule survives Docker daemon restart +- **WHEN** the Docker daemon on the Overpass host is restarted +- **THEN** the firewall allowlist is still in effect and unauthorised sources are still dropped without operator intervention + +#### Scenario: Planner host address change +- **WHEN** the Planner host's egress address changes and the operator updates the configured allowlist address +- **THEN** applying the updated ruleset restores Planner connectivity without requiring Docker or Overpass to restart + +### Requirement: Overpass data refresh +The Overpass service SHALL keep its OSM database current by applying upstream diffs on a recurring schedule without manual intervention after the initial import. + +#### Scenario: Daily replication +- **WHEN** 24 hours have passed since the last diff application +- **THEN** the container has fetched and applied the next diff from the upstream provider, and the replication timestamp advances + +#### Scenario: Recoverable replication failure +- **WHEN** diff replication fails once (network blip, upstream 5xx) +- **THEN** the container retries on its next scheduled interval without requiring an operator to restart it + +#### Scenario: Replication lag observable +- **WHEN** replication has been failing for more than 48 hours +- **THEN** a monitoring signal indicates the service is stale so the operator can investigate + +### Requirement: Planner Overpass proxy route +The Planner server SHALL expose an authenticated, rate-limited proxy route that forwards Overpass QL queries to the Overpass host. This SHALL be the only path through which Overpass is reachable from outside the Overpass host. + +#### Scenario: Forward valid query +- **WHEN** an authenticated Planner browser session POSTs a valid Overpass QL query to `/api/overpass` +- **THEN** the proxy forwards the query to the Overpass service and returns the upstream response body and status + +#### Scenario: Reject unauthenticated request +- **WHEN** a request arrives at `/api/overpass` without a valid Planner session cookie +- **THEN** the proxy responds with HTTP 401 and does not contact the Overpass service + +#### Scenario: Reject cross-origin request +- **WHEN** a request arrives at `/api/overpass` with an Origin header not matching the Planner's own origin +- **THEN** the proxy responds with HTTP 403 and does not contact the Overpass service + +#### Scenario: Rate limit exceeded +- **WHEN** a session sends more Overpass queries than the configured per-session limit allows within the rate-limit window +- **THEN** the proxy responds with HTTP 429 and does not contact the Overpass service + +### Requirement: Initial data load is out-of-band +The initial import of the regional OSM extract into the Overpass database SHALL NOT run as part of a normal deploy and SHALL NOT block routine container restarts once the data volume is populated. + +#### Scenario: First-time setup +- **WHEN** the operator provisions a new Overpass host with an empty data volume +- **THEN** a documented one-shot procedure (e.g. a compose-run command) performs the initial PBF download and import, and exits cleanly + +#### Scenario: Routine restart +- **WHEN** the `overpass` service is restarted with an already-populated data volume +- **THEN** the service comes up and is query-ready without re-importing data diff --git a/docs/ideas/self-host-overpass/specs/rate-limiting/spec.md b/docs/ideas/self-host-overpass/specs/rate-limiting/spec.md new file mode 100644 index 0000000..584b467 --- /dev/null +++ b/docs/ideas/self-host-overpass/specs/rate-limiting/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Overpass proxy rate limit +The Planner SHALL limit Overpass queries on the `/api/overpass` proxy route to 20 per session per minute, with a burst allowance of 5. + +#### Scenario: Overpass rate limit exceeded +- **WHEN** a session sends more than 20 Overpass queries within 60 seconds (beyond the burst allowance) +- **THEN** the server responds with 429 and the request is NOT forwarded to the upstream Overpass service + +#### Scenario: Normal browsing within limit +- **WHEN** a session pans and zooms the map at a realistic pace (well under 20 queries/minute) +- **THEN** all queries are forwarded to the upstream Overpass service without rate-limit rejections diff --git a/docs/ideas/self-host-overpass/tasks.md b/docs/ideas/self-host-overpass/tasks.md new file mode 100644 index 0000000..730df40 --- /dev/null +++ b/docs/ideas/self-host-overpass/tasks.md @@ -0,0 +1,62 @@ +## 1. Overpass host Docker image + +- [ ] 1.1 Create `infrastructure/overpass-host/` with a `Dockerfile` wrapping a pinned `wiktorn/overpass-api` release +- [ ] 1.2 Add `infrastructure/overpass-host/scripts/initial-load.sh` that downloads the PBF from `OVERPASS_PBF_URL` and runs the one-shot import +- [ ] 1.3 Document the first-time setup (initial import command, expected duration, disk footprint) in `infrastructure/overpass-host/README.md` + +## 2. Overpass host compose + +- [ ] 2.1 Add `infrastructure/overpass-host/docker-compose.yml` with the `overpass` service: image, named volume for the OSM DB, env vars (`OVERPASS_PBF_URL`, `OVERPASS_DIFF_URL`, `OVERPASS_META` as needed), healthcheck, restart policy, explicit published port binding on the public interface +- [ ] 2.2 Add the named volume for the OSM database and document the expected disk footprint for the chosen extract + +## 3. Firewall (Docker-aware) + +- [ ] 3.1 Write an nftables / iptables rule template using the `DOCKER-USER` chain: ACCEPT from `` to the overpass port on the public interface, DROP everything else on that port +- [ ] 3.2 Load the Planner host IP from a local env file on the Overpass host (e.g. `/etc/overpass/planner-ip.env`) — do NOT check the IP into the repo +- [ ] 3.3 Add a `scripts/apply-firewall.sh` that renders the rule template, applies it, and persists across reboots (systemd unit or `nftables.conf`) +- [ ] 3.4 Verify rules survive `systemctl restart docker` and `docker compose restart` without clobbering +- [ ] 3.5 Verify an outside host (e.g. laptop home IP) cannot connect to the overpass port; verify the Planner host can + +## 4. Planner proxy route + +- [ ] 4.1 Create `apps/planner/app/routes/api.overpass.ts` as a React Router action that accepts POST with an Overpass QL body +- [ ] 4.2 Read the upstream URL from `OVERPASS_URL` env var; return 503 with a clear log message when unset or empty +- [ ] 4.3 Enforce session + same-origin: reuse the Planner's existing session cookie check; reject cross-origin with 403 +- [ ] 4.4 Stream the upstream response body and status back to the caller; pass through 429s as-is +- [ ] 4.5 Add unit tests covering 401 (no session), 403 (cross-origin), 503 (unset `OVERPASS_URL`), and happy-path forwarding (mock upstream) + +## 5. Planner compose wiring + +- [ ] 5.1 Add `OVERPASS_URL` to the planner service env in `infrastructure/docker-compose.yml`, pointing at the Overpass host's URL (value held in the SOPS-encrypted env file, not hard-coded) +- [ ] 5.2 Update `cd-infra.yml` SCP sources list if any new files under `infrastructure/` are added + +## 6. Rate limiting + +- [ ] 6.1 Add an Overpass proxy limiter to `packages/rate-limiting` (or the Planner equivalent) at 20 queries/session/min with a burst of 5 +- [ ] 6.2 Return 429 when exceeded; never contact upstream Overpass on rejected requests +- [ ] 6.3 Add a test that rapid-fire requests from one session hit 429 and no upstream call is made + +## 7. Planner client switch + +- [ ] 7.1 Change `apps/planner/app/lib/overpass.ts` to POST to `/api/overpass` instead of iterating over `OVERPASS_ENDPOINTS` +- [ ] 7.2 Remove the `OVERPASS_ENDPOINTS` constant and the public-endpoint fallback loop +- [ ] 7.3 Update `apps/planner/app/lib/overpass.test.ts` to reflect the new single-endpoint path +- [ ] 7.4 Verify the existing POI error UI (rate-limit banner, unavailable message) still fires on 429 / 5xx from the proxy + +## 8. Observability + +- [ ] 8.1 Expose an `overpass_up` probe (HTTP healthcheck wrapped as a Prometheus metric — either a blackbox probe or a small sidecar) +- [ ] 8.2 Expose a replication-lag metric derived from the Overpass `replicate_id` / timestamp +- [ ] 8.3 Add a Grafana panel (or extend an existing dashboard) showing Overpass up/down and replication lag + +## 9. Documentation + +- [ ] 9.1 Update `docs/architecture.md` to reflect that POI queries go via Planner → Overpass host over a firewall-restricted public route +- [ ] 9.2 Update the Planner README (or equivalent) to note the new `OVERPASS_URL` dependency and the one-time initial-load step +- [ ] 9.3 Update the Journal privacy manifest to remove references to third-party Overpass hosts + +## 10. Cutover + +- [ ] 10.1 Initial import on the Overpass host via the documented one-shot procedure; confirm query works end-to-end from the Planner UI +- [ ] 10.2 Monitor Sentry, Grafana Overpass panel, and error rate in the Planner POI UI for 24 h +- [ ] 10.3 Remove any transitional feature flag; public Overpass endpoints are no longer referenced anywhere in the repo From 9c4c3d644471dd511b5fdf083106335ab16de2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:46:34 +0200 Subject: [PATCH 039/495] Store Terms version alongside acceptance timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer's follow-up: it's not enough to record when a user accepted the Terms; we also need to record which version of the text they saw. Changes: - journal.users gains a nullable `terms_version` text column (nullable so the three pre-existing users without a version are kept as-is). - New apps/journal/app/lib/legal.ts exports TERMS_VERSION as a single source of truth, reused by the legal pages' "Last updated" header and by the registration flow as the value to send/store. - Registration form posts `termsVersion` alongside `termsAccepted` on all three relevant steps (start, finish, register-magic-link). - API route validates that `termsVersion` is a non-empty string on any step that requires terms, and forwards it to the auth server. - auth.server finishRegistration and registerWithMagicLink now take `termsVersion` and persist it on the users row. - journal-auth spec gets a new scenario for version storage and a rejection scenario for missing version. PRIVACY_LAST_UPDATED is also exported from the same module and used by the Privacy page header, keeping both pages on a single legal.ts source of truth for "last updated" labels. Privacy is not per-user stored — it's informational, not contract. Existing users have NULL terms_version; if we ever prompt them to re-accept updated Terms, we can backfill with the version they re-accept at that point. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/lib/auth.server.ts | 17 +++++++++++++++-- apps/journal/app/lib/legal.ts | 19 +++++++++++++++++++ apps/journal/app/routes/api.auth.register.ts | 12 ++++++++---- apps/journal/app/routes/auth.register.tsx | 18 ++++++++++++++++-- apps/journal/app/routes/legal.privacy.tsx | 3 ++- apps/journal/app/routes/legal.terms.tsx | 4 +++- openspec/specs/journal-auth/spec.md | 5 +++++ packages/db/src/schema/journal.ts | 1 + 8 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 apps/journal/app/lib/legal.ts diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 204aa64..d91059a 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -53,6 +53,7 @@ export async function finishRegistration( username: string, response: RegistrationResponseJSON, challenge: string, + termsVersion: string, ) { const db = getDb(); @@ -77,6 +78,7 @@ export async function finishRegistration( username, domain, termsAcceptedAt: new Date(), + termsVersion, }); await db.insert(credentials).values({ @@ -147,7 +149,11 @@ export async function addPasskeyFinish( // --- Registration via Magic Link (no passkey) --- -export async function registerWithMagicLink(email: string, username: string): Promise { +export async function registerWithMagicLink( + email: string, + username: string, + termsVersion: string, +): Promise { const db = getDb(); const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); @@ -159,7 +165,14 @@ export async function registerWithMagicLink(email: string, username: string): Pr const userId = randomUUID(); const domain = process.env.DOMAIN ?? "localhost"; - await db.insert(users).values({ id: userId, email, username, domain, termsAcceptedAt: new Date() }); + await db.insert(users).values({ + id: userId, + email, + username, + domain, + termsAcceptedAt: new Date(), + termsVersion, + }); // Create magic token for verification const token = randomBytes(32).toString("base64url"); diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts new file mode 100644 index 0000000..a312bc7 --- /dev/null +++ b/apps/journal/app/lib/legal.ts @@ -0,0 +1,19 @@ +/** + * Version identifier for the currently-published Terms of Service. + * + * Stored on `users.terms_version` when a user accepts the Terms at + * registration. Bump this string whenever the Terms text changes in a way + * that warrants a re-acceptance — typically on each legal-review update. + * + * Kept as a plain date string (the "Last updated" date shown on the Terms + * page itself) so spec, storage, and UI stay in lockstep without a separate + * versioning scheme. + */ +export const TERMS_VERSION = "2026-04-19"; + +/** + * "Last updated" date shown on the Privacy Policy. Privacy changes don't + * require re-acceptance (the policy is informational, not contract), so this + * is display-only — not persisted. + */ +export const PRIVACY_LAST_UPDATED = "2026-04-19"; diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index eac3e68..2410a0b 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -6,14 +6,18 @@ import { logger } from "~/lib/logger.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); - const { step, email, username, response, challenge, userId, termsAccepted } = body; + const { step, email, username, response, challenge, userId, termsAccepted, termsVersion } = body; const origin = process.env.ORIGIN ?? `http://localhost:3000`; - // Registration steps require terms acceptance + // Registration steps require terms acceptance + the version the client + // agreed to (stored for audit so we can tell which text the user saw). const requiresTerms = step === "start" || step === "finish" || step === "register-magic-link"; if (requiresTerms && !termsAccepted) { return data({ error: "Terms of Service must be accepted" }, { status: 400 }); } + if (requiresTerms && (typeof termsVersion !== "string" || termsVersion.length === 0)) { + return data({ error: "Terms of Service version missing" }, { status: 400 }); + } try { if (step === "start") { @@ -22,7 +26,7 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "finish") { - const newUserId = await finishRegistration(userId, email, username, response, challenge); + const newUserId = await finishRegistration(userId, email, username, response, challenge, termsVersion); const cookie = await createSession(newUserId, request); sendWelcome(email, username).catch((err) => logger.error({ err }, "Failed to send welcome email"), @@ -31,7 +35,7 @@ export async function action({ request }: Route.ActionArgs) { } if (step === "register-magic-link") { - const token = await registerWithMagicLink(email, username); + const token = await registerWithMagicLink(email, username, termsVersion); const link = `${origin}/auth/verify?token=${token}`; if (process.env.NODE_ENV !== "production") { return data({ step: "magic-link-sent", devLink: link }); diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index dec2692..a209eaa 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { TERMS_VERSION } from "~/lib/legal"; export default function RegisterPage() { const { t } = useTranslation("journal"); @@ -32,7 +33,13 @@ export default function RegisterPage() { const startResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "start", email, username, termsAccepted }), + body: JSON.stringify({ + step: "start", + email, + username, + termsAccepted, + termsVersion: TERMS_VERSION, + }), }); const startData = await startResp.json(); @@ -53,6 +60,7 @@ export default function RegisterPage() { email, username, termsAccepted, + termsVersion: TERMS_VERSION, response: webAuthnResp, challenge: startData.options.challenge, userId: startData.userId, @@ -85,7 +93,13 @@ export default function RegisterPage() { const resp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ step: "register-magic-link", email, username, termsAccepted }), + body: JSON.stringify({ + step: "register-magic-link", + email, + username, + termsAccepted, + termsVersion: TERMS_VERSION, + }), }); const result = await resp.json(); diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 1d6a368..90a601c 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -1,4 +1,5 @@ import { operator } from "~/lib/operator"; +import { PRIVACY_LAST_UPDATED } from "~/lib/legal"; export function meta() { return [ @@ -14,7 +15,7 @@ export default function PrivacyPage() { Datenschutzerklärung / Privacy Policy

    - Stand / Last updated: 2026-04-18. Die deutsche Fassung ist maßgeblich. + Stand / Last updated: {PRIVACY_LAST_UPDATED}. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.

    diff --git a/apps/journal/app/routes/legal.terms.tsx b/apps/journal/app/routes/legal.terms.tsx index ab18684..22015a2 100644 --- a/apps/journal/app/routes/legal.terms.tsx +++ b/apps/journal/app/routes/legal.terms.tsx @@ -1,3 +1,5 @@ +import { TERMS_VERSION } from "~/lib/legal"; + export function meta() { return [ { title: "Nutzungsbedingungen — trails.cool" }, @@ -12,7 +14,7 @@ export default function TermsPage() { Nutzungsbedingungen / Terms of Service

    - Stand / Last updated: 2026-04-18 • Alpha — subject to change. Die + Stand / Last updated: {TERMS_VERSION} • Alpha — subject to change. Die deutsche Fassung ist maßgeblich. The German version is authoritative; English summaries follow each section.

    diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index 2b2fb24..2949f10 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -27,3 +27,8 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser #### Scenario: Acknowledgement recorded - **WHEN** a user successfully registers - **THEN** the current timestamp is stored in `users.terms_accepted_at` +- **AND** the version identifier of the Terms the user saw is stored in `users.terms_version` + +#### Scenario: Missing version rejected +- **WHEN** a registration request arrives without a non-empty `termsVersion` field +- **THEN** the server responds with HTTP 400 and does not create a user diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 23365d3..97f717c 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -30,6 +30,7 @@ export const users = journalSchema.table("users", { bio: text("bio"), domain: text("domain").notNull(), termsAcceptedAt: timestamp("terms_accepted_at", { withTimezone: true }), + termsVersion: text("terms_version"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); From d52828543afcc2d384b4dd12dc42004a7f2146bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:51:41 +0200 Subject: [PATCH 040/495] Seed docs/legal-archive with Terms / Privacy / Imprint snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a simple legal-archive folder that preserves the full text of the Terms, Privacy Policy, and Impressum at each version, plus a small renderer script so future snapshots are mechanical: python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md Why: - Terms are per-user contract. users.terms_version maps each user to a file in this folder so we can always tell what text they agreed to. - Privacy is informational but GDPR Art. 13/14 still expects us to be able to say what the policy disclosed on a given date. - Impressum archived for symmetry; cheap. How it works: - scripts/render-legal.py reads the TSX source, resolves operator.* placeholders from operator.ts and TERMS_VERSION / PRIVACY_LAST_UPDATED from legal.ts, strips JSX tags, and prints clean markdown to stdout. - A README in the folder explains when to bump and when to add a snapshot (material change only, not typo fixes). Seeded entries: - terms-2026-04-19.md - privacy-2026-04-19.md - imprint-2026-04-19.md No automation / CI check yet — trust-based process documented in the folder's README. Revisit if discipline slips. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/legal-archive/README.md | 53 ++++ docs/legal-archive/imprint-2026-04-19.md | 83 ++++++ docs/legal-archive/privacy-2026-04-19.md | 358 +++++++++++++++++++++++ docs/legal-archive/terms-2026-04-19.md | 157 ++++++++++ scripts/render-legal.py | 122 ++++++++ 5 files changed, 773 insertions(+) create mode 100644 docs/legal-archive/README.md create mode 100644 docs/legal-archive/imprint-2026-04-19.md create mode 100644 docs/legal-archive/privacy-2026-04-19.md create mode 100644 docs/legal-archive/terms-2026-04-19.md create mode 100755 scripts/render-legal.py diff --git a/docs/legal-archive/README.md b/docs/legal-archive/README.md new file mode 100644 index 0000000..6531452 --- /dev/null +++ b/docs/legal-archive/README.md @@ -0,0 +1,53 @@ +# Legal archive + +Frozen snapshots of the Terms of Service, Privacy Policy, and Impressum at +each version. Kept so we can always answer "what did the Terms / Privacy +text say on a given date" without digging through git blame. + +## Why + +- **Terms**: users accept a specific version at registration + (`users.terms_accepted_at` + `users.terms_version`). The version string + is the date in this folder — so there is always a file here matching + every value that exists in that column. +- **Privacy**: users don't "accept" a privacy policy, but GDPR Art. 13/14 + requires us to tell users how their data is processed *at the time* it's + processed. If a regulator or user asks what the policy said on + YYYY-MM-DD, this folder answers. +- **Impressum**: less legally critical, but free to snapshot for symmetry. + +## When to add a file + +Whenever any of these three texts change materially, run the snapshot step +as part of the change: + +1. Bump `TERMS_VERSION` / `PRIVACY_LAST_UPDATED` in + [`apps/journal/app/lib/legal.ts`](../../apps/journal/app/lib/legal.ts) + to today's date. +2. Re-render the affected page(s) into this folder as + `-YYYY-MM-DD.md`. +3. Commit the bump **and** the new snapshot in the same PR. + +A trivial wording tweak or typo fix does not need a new snapshot; only +changes that affect meaning / behaviour / purposes / third parties / legal +basis / retention / etc. + +## File naming + +`-YYYY-MM-DD.md` where `` is one of `terms`, `privacy`, +`imprint`, and the date matches the `Last updated` line in the +corresponding legal page on the day the snapshot was taken. + +## How to render + +The snapshots are plain markdown extracted from the TSX source by +stripping JSX tags / attributes and resolving the `operator.*` +placeholders. Same approach as the pbcopy export used during legal +reviews. One-liner (from repo root): + +```bash +python3 scripts/render-legal.py > docs/legal-archive/-YYYY-MM-DD.md +``` + +(If `scripts/render-legal.py` doesn't exist yet, the extraction logic is +in the commit that seeded this directory — see PR history.) diff --git a/docs/legal-archive/imprint-2026-04-19.md b/docs/legal-archive/imprint-2026-04-19.md new file mode 100644 index 0000000..670e0f7 --- /dev/null +++ b/docs/legal-archive/imprint-2026-04-19.md @@ -0,0 +1,83 @@ +# Impressum — trails.cool + +Impressum / Legal Notice + +Die deutsche Fassung ist maßgeblich. / The German version is authoritative. + +Anbieter nach § 5 TMG + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +Kontakt + +E-Mail: + +legal@trails.cool + +Inhaltlich verantwortlich nach § 18 Abs. 2 MStV + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Streitbeilegung + +Die Europäische Kommission stellt eine Plattform zur +Online-Streitbeilegung (OS) bereit: + +https://ec.europa.eu/consumers/odr + +. + +Wir sind nicht bereit oder verpflichtet, an +Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle +teilzunehmen. + +Alpha-Status + +trails.cool befindet sich in aktiver Entwicklung (Alpha). Inhalte, +Funktionen und Daten können sich jederzeit ändern oder entfallen. + +English version + +Service provider (§ 5 TMG) + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +Contact + +Email: + +legal@trails.cool + +Content responsibility (§ 18 (2) MStV) + +Ullrich Schäfer, address as above. + +Dispute resolution + +The European Commission provides an online dispute resolution platform at + +https://ec.europa.eu/consumers/odr + +. We are not willing or obliged to participate in dispute resolution +proceedings before a consumer arbitration board. + +Alpha status + +trails.cool is under active development (alpha). Features and data +may change or be removed at any time. diff --git a/docs/legal-archive/privacy-2026-04-19.md b/docs/legal-archive/privacy-2026-04-19.md new file mode 100644 index 0000000..c8e9faa --- /dev/null +++ b/docs/legal-archive/privacy-2026-04-19.md @@ -0,0 +1,358 @@ +# Privacy Policy — trails.cool + +Datenschutzerklärung / Privacy Policy + +Stand / Last updated: 2026-04-19. Die deutsche Fassung ist maßgeblich. +The German version is authoritative; English summaries follow each +section. + +1. Verantwortlicher + +Verantwortlich für die Datenverarbeitung im Sinne der DSGVO ist: + +Ullrich Schäfer + +Mehringdamm 87 + +10965 Berlin + +Germany + +E-Mail: + +legal@trails.cool + +English. +Data controller under GDPR is the party named +above. Contact for any data-protection matter: legal@trails.cool. + +2. Erhobene Daten und Zwecke + +Wir verarbeiten nur die Daten, die für den Betrieb der jeweiligen +Funktion erforderlich sind. trails.cool besteht aus zwei Teilen: +dem +Journal +(trails.cool, mit Konto) und dem + +Planner +(planner.trails.cool, anonym). + +Kontodaten (Journal): +E-Mail-Adresse, +Benutzername, Anzeigename, Passkey-Public-Key. Zweck: +Kontoverwaltung und Anmeldung. + +Nutzerinhalte (Journal): +Routen (GPX-Daten, +Geometrie, Titel, Beschreibung) und Aktivitäten (Titel, +Beschreibung, Datum, Verknüpfung zu Routen). Zweck: Speicherung +und Anzeige innerhalb des Dienstes. + +Anmeldedaten (Journal): +kurzlebige Magic-Link-Token +(zur E-Mail-basierten Anmeldung). Zweck: Authentifizierung. + +Sitzungscookie (Journal): +eine zufällige +Sitzungs-ID nach dem Einloggen. Zweck: Authentifizierung während +der Sitzung. + +Planner-Sitzungsdaten: +anonyme Sitzungs-ID, +kollaborativer Zustand (Wegpunkte, Notizen). Keine Zuordnung zu +einer Person. Zweck: gemeinsames Planen von Routen. + +Server-Logfiles: +IP-Adresse, Zeitstempel, +HTTP-Methode, Pfad, Statuscode, User-Agent. Zweck: Sicherheit, +Betrieb, Fehlersuche. Details siehe Abschnitt 4. + +Fehlerdaten (Sentry): +Stacktraces, +Fehlermeldungen, Browser-/OS-Information aus dem User-Agent, +Performance-Metriken. Bei eingeloggten Journal-Nutzer:innen +zusätzlich die Nutzer-ID (keine E-Mail, kein Benutzername). +IP-Adressen werden nicht aktiv gespeichert, ebenso keine Cookies +und keine Formulareingaben. + +English. +The Journal stores only what you provide (account +details and your own routes/activities) plus short-lived auth +artefacts. The Planner is anonymous and holds only ephemeral +session state. Server logs and Sentry error data are covered +separately below. + +3. Rechtsgrundlagen + +Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung): + +Kontoführung, Anmeldung per Passkey oder Magic Link, Speicherung +und Anzeige der von Ihnen erstellten Routen und Aktivitäten, +Versand notwendiger Transaktions-E-Mails, Bereitstellung anonymer +Planner-Sitzungen. Ein Konto sowie die Bestätigung der +Nutzungsbedingungen sind Bestandteil dieses Nutzungsvertrags – +sie sind +keine +Einwilligung im Sinne von Art. 6 Abs. 1 +lit. a DSGVO. + +Art. 6 Abs. 1 lit. f DSGVO (berechtigte Interessen): + +kurzzeitige Server-Logfiles zur Sicherung des Betriebs und zur +Missbrauchsabwehr, Rate-Limiting, Fehlermonitoring über Sentry +zur Sicherstellung der Funktionsfähigkeit des Dienstes. + +English. +Contract (Art. 6(1)(b)) covers everything +account- and content-related; legitimate interests (Art. 6(1)(f)) +cover short-lived server logs, rate-limiting, and error monitoring. +We do +not +rely on consent for any of this. + +4. Server-Logfiles + +Beim Aufruf der Dienste werden automatisch technische Informationen +protokolliert: + +IP-Adresse + +Zeitstempel + +HTTP-Methode, Pfad, Statuscode + +User-Agent (Browser, Betriebssystem) + +Zweck: +Sicherheit, Betrieb und Fehlersuche. + +Rechtsgrundlage: +Art. 6 Abs. 1 lit. f DSGVO. + +Speicherdauer: +maximal 14 Tage, danach automatische +Löschung. Eine Zusammenführung dieser Daten mit anderen Datenquellen +findet nicht statt. + +English. +HTTP requests to our servers are logged (IP, +timestamp, method, path, status, user-agent) for up to 14 days for +operational and security purposes under Art. 6(1)(f), then deleted. + +5. Speicherdauer + +Konto und zugehörige Inhalte: bis zur Löschung durch Sie + +Planner-Sitzungen: automatische Löschung nach 7 Tagen Inaktivität + +Magic-Link-Token: 15 Minuten + +Server-Logfiles: maximal 14 Tage + +Fehlerdaten (Sentry): 90 Tage + +Hinweis Alpha: Während der Alpha-Phase behält sich der Betreiber +ausdrücklich vor, die Datenbank zurückzusetzen oder einzelne +Datensätze zu löschen. Dies kann zu Datenverlust führen, bevor Sie +eine Löschung veranlassen. Details dazu in den Nutzungsbedingungen. + +English. +Account and content kept until you delete them. +Ephemeral data (sessions, magic-link tokens, logs, Sentry events) +deleted automatically on the schedules above. Alpha caveat: the +operator may reset the database or delete individual records +during alpha, which can cause data loss before you request +deletion. See the Terms of Service. + +6. Empfänger und Drittanbieter + +Wir geben personenbezogene Daten nur an die unten genannten +Auftragsverarbeiter und Dritten weiter, und auch dort nur im +jeweils notwendigen Umfang. + +Sentry +(Functional Software Inc.) – Fehler- und +Performance-Monitoring. Was übermittelt wird: Stacktraces, +Fehlertext, Browser-/OS-Informationen aus dem User-Agent, +Performance-Daten; bei eingeloggten Journal-Nutzer:innen +zusätzlich die Nutzer-ID. IP-Adressen werden nicht aktiv +gespeichert ( +sendDefaultPii +ist deaktiviert), ebenso +keine Cookies und keine vollständigen HTTP-Header. Keine +Session-Replays. Sentry agiert als externer Dienstleister +(Auftragsverarbeiter) für die Fehlerbehandlung. Da Sentry ein +Anbieter mit Sitz in den USA ist, kann im Einzelfall eine +Übermittlung personenbezogener Daten in ein Drittland im Sinne +der Art. 44 ff. DSGVO stattfinden; wir stützen uns hierbei auf +die von Sentry bereitgestellten +Standardvertragsklauseln. + +OpenStreetMap +– Kartenkacheln werden beim Anzeigen +der Karten direkt vom Browser von OSM-Tile-Servern geladen. Dabei +werden +IP-Adresse und User-Agent +an OSM übertragen. +Dies ist notwendig, um überhaupt eine Karte darzustellen; es gilt +die + +OSM Foundation Privacy Policy + +. + +Overpass API +(POI-Daten) – POI-Abfragen laufen +serverseitig über unsere eigene Route +/api/overpass +. +Der Upstream-Dienst sieht nur unsere Server-IP, nicht die Ihrer +Nutzer:innen. Aktueller Upstream: +overpass.private.coffee + +, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist +geplant. + +BRouter +– Routenberechnung läuft auf einer von uns +selbst gehosteten Instanz. Keine Weitergabe an Dritte. + +E-Mail-Versand +– Transaktions-E-Mails (Magic +Link, Willkommensnachricht) werden über einen konfigurierten +externen SMTP-Dienst versendet. Dabei wird die E-Mail-Adresse +der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene +Instanzen konfigurieren ihren eigenen Mailserver. + +Hosting +– Die Dienste werden in Rechenzentren +innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit +dem Hoster besteht. + +English. +Third parties and what they receive: Sentry (error +details, no IPs/cookies); OpenStreetMap tile servers +(your IP and user-agent, directly from your browser, to load map +tiles); Overpass (via our server-side proxy, so upstream only sees +our server); BRouter (self-hosted, no third party involved); SMTP +provider (your email address for magic link / welcome mail); +hosting provider in the EU under a DPA. + +7. Ihre Rechte + +Als betroffene Person stehen Ihnen die folgenden Rechte zu: + +Auskunft (Art. 15 DSGVO) + +Berichtigung (Art. 16 DSGVO) + +Löschung (Art. 17 DSGVO) + +Einschränkung der Verarbeitung (Art. 18 DSGVO) + +Datenübertragbarkeit (Art. 20 DSGVO) + +Widerspruch gegen Verarbeitung auf Basis berechtigter Interessen (Art. 21 DSGVO) + +Zur Ausübung dieser Rechte genügt eine formlose E-Mail an + +legal@trails.cool + +. Einen vollständigen Export Ihrer Routen und Aktivitäten können Sie +jederzeit direkt in den Kontoeinstellungen herunterladen (GPX bzw. +JSON). Ihr Konto samt Inhalten können Sie dort ebenfalls selbst +löschen. + +English. +You have the standard GDPR rights (access, +rectification, erasure, restriction, portability, objection). Email +us to exercise them. Data exports and account deletion are also +available directly in your account settings. + +8. Beschwerderecht + +Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu +beschweren. Für uns zuständig ist: + +Berliner Beauftragte für Datenschutz und Informationsfreiheit + +Friedrichstr. 219 + +10969 Berlin + +www.datenschutz-berlin.de + +English. +You have the right to lodge a complaint with a +supervisory authority; ours is the Berlin data-protection +commissioner (address above). + +Privacy Manifest + +A plain-language summary of what we do, for readers who prefer +behaviour over paragraphs. Binding text is above. + +Planner (planner.trails.cool) + +Anonymous by design. No account, no identifier, nothing persisted +past the session. + +No cookies, no localStorage, no sessionStorage + +No browser-side error tracking (Sentry is not loaded) + +Session data is deleted automatically after 7 days of inactivity + +Journal (trails.cool) + +Holds only what you put in. Exportable any time, deletable any time. + +Account: email, username, display name, passkey public key + +Routes: GPX, geometry, title, description + +Activities: title, description, date, linked route + +GPX / JSON export available per object and overall + +Sentry + +Journal server: always on + +Journal browser: only after login, torn down on logout + +Planner: server only; the browser never loads Sentry + +IPs not actively stored, no cookies, no full headers ( +sendDefaultPii: false +) + +No replays (replay integration not installed, sample rates 0) + +User ID only (no email/username) on Journal-logged-in events + +What we don't do + +Sell data + +Show ads + +Build user profiles + +Run tracking pixels, analytics, or A/B tests + +Security + +Auth via passkey (WebAuthn) or magic link — no passwords stored + +HTTPS + HSTS preload on all origins; session cookies httpOnly + Secure + +CSP, X-Frame-Options, X-Content-Type-Options on every response + +Containers run non-root; host firewall restricts to HTTP/HTTPS/SSH + +Gitleaks + dependency audit on every PR + +Vulnerability reports: + +SECURITY.md diff --git a/docs/legal-archive/terms-2026-04-19.md b/docs/legal-archive/terms-2026-04-19.md new file mode 100644 index 0000000..6fce5f2 --- /dev/null +++ b/docs/legal-archive/terms-2026-04-19.md @@ -0,0 +1,157 @@ +# Terms of Service — trails.cool + +Nutzungsbedingungen / Terms of Service + +Stand / Last updated: 2026-04-19 • Alpha — subject to change. Die +deutsche Fassung ist maßgeblich. The German version is authoritative; +English summaries follow each section. + +1. Gegenstand und Alpha-Status + +trails.cool ist ein experimenteller, derzeit kostenloser Dienst zur Planung +und Dokumentation von Outdoor-Aktivitäten. Der Dienst befindet sich +in aktiver Entwicklung (Alpha). Funktionen, Schnittstellen und +gespeicherte Daten können jederzeit ohne Vorankündigung geändert, +unterbrochen, gelöscht oder eingestellt werden. Eine +Verfügbarkeitsgarantie besteht nicht. + +English. +trails.cool is an experimental service, +currently free of charge, in +active alpha development. Features, data, and availability can +change at any time without notice. No availability is guaranteed. + +2. Mindestalter + +Die Nutzung eines Journal-Kontos ist erst ab einem Alter von 16 +Jahren zulässig. Anonyme Nutzung des Planners unterliegt keiner +Altersbeschränkung. + +English. +A Journal account is available only to users +aged 16 or older. Anonymous use of the Planner has no age +requirement. + +3. Dienstverfügbarkeit + +Der Betreiber kann den Dienst jederzeit ändern, einschränken, +unterbrechen oder einstellen, auch einzelne Funktionen oder +einzelne Konten. Wartungs- und Ausfallzeiten sind möglich. + +English. +The operator may modify, limit, interrupt, or +discontinue the service (or parts of it, or individual accounts) +at any time. Downtime and maintenance windows may occur. + +4. Zurücksetzen der Datenbank + +Während der Alpha-Phase behält sich der Betreiber ausdrücklich das +Recht vor, die gesamte Datenbank zurückzusetzen oder einzelne +Datensätze ohne vorherige Benachrichtigung zu löschen. +Nutzer:innen sollten wichtige Routen und Aktivitäten selbstständig +über die Export-Funktion (GPX bzw. JSON) sichern. + +English. +During alpha, the operator may reset the entire +database or delete individual records without notice. Back up +anything important via the GPX / JSON export. + +5. Eigenverantwortung für Daten + +Nutzer:innen sind für die Sicherung ihrer Inhalte selbst +verantwortlich. GPX- und JSON-Exporte sind im Journal jederzeit +für jede Route und Aktivität verfügbar. Der Betreiber schuldet +keine Datenwiederherstellung. + +English. +You are responsible for backing up your own +content. Exports are always available in the Journal; the +operator owes no data recovery. + +6. Inhalte und Nutzungsrechte + +Die von Ihnen eingestellten Inhalte (Routen, Aktivitäten, +Beschreibungen, GPX-Dateien) bleiben Ihr Eigentum. Sie räumen dem +Betreiber lediglich das einfache, nicht übertragbare Recht ein, +diese Inhalte zum Zweck des Betriebs des Dienstes zu speichern, +zu verarbeiten und Ihnen wieder anzuzeigen. Eine darüber +hinausgehende Nutzung, Veröffentlichung oder Weitergabe findet +nicht statt. + +English. +You retain ownership of everything you upload. +You grant the operator a limited, non-transferable licence to +store, process, and display your content solely for operating the +service; no other use, publication, or sharing. + +7. Nutzungsregeln + +Keine Verbreitung rechtswidriger Inhalte + +Keine missbräuchliche Nutzung (Spam, hochfrequente automatisierte +Abfragen, Denial-of-Service) + +Kein Umgehen technischer Schutzmaßnahmen + +Kein massenhaftes automatisiertes Auslesen von Daten + +English. +No illegal content, no abuse (spam, flooding, +DoS), no circumvention of technical protections, no bulk +automated data extraction. + +8. Kontobeendigung + +Sie können Ihr Konto jederzeit über die Einstellungen löschen; +damit werden auch die zugehörigen Inhalte gelöscht. Der Betreiber +kann Konten bei Verstößen gegen diese Bedingungen nach +angemessener Interessenabwägung sperren oder löschen. + +English. +Delete your account (and its content) any time +via settings. The operator may suspend or delete accounts that +violate these terms, balancing interests reasonably. + +9. Haftung + +Der Dienst wird ohne Gewährleistung bereitgestellt. Der Betreiber +haftet uneingeschränkt bei Vorsatz und grober Fahrlässigkeit sowie +bei Verletzung von Leben, Körper oder Gesundheit. Im Übrigen ist +die Haftung für leicht fahrlässige Pflichtverletzungen auf den +vertragstypischen, vorhersehbaren Schaden bei Verletzung +wesentlicher Vertragspflichten (Kardinalpflichten) begrenzt. Eine +darüber hinausgehende Haftung – insbesondere für Datenverlust – +ist ausgeschlossen, soweit gesetzlich zulässig. + +English. +No warranty. Unlimited liability for intent, +gross negligence, and injury to life, body, or health. For +slight negligence, liability is limited to foreseeable damages +arising from breach of material contractual duties. Liability +beyond that — in particular for data loss — is excluded to the +extent permitted by law. + +10. Änderungen der Bedingungen + +Diese Nutzungsbedingungen können sich während der Alpha-Phase +ändern. Bei wesentlichen Änderungen werden registrierte +Nutzer:innen per E-Mail informiert und müssen die neuen +Bedingungen erneut bestätigen. + +English. +These terms may change during alpha. Registered +users will be emailed about material changes and prompted to +re-accept. + +11. Anwendbares Recht + +Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Für +Verbraucher:innen mit gewöhnlichem Aufenthalt in einem Mitgliedstaat +der Europäischen Union bleibt der zwingende Schutz des Rechts ihres +Aufenthaltslandes unberührt. + +English. +German law applies, excluding the UN Convention +on Contracts for the International Sale of Goods. Consumers +habitually residing in an EU member state retain the mandatory +protection of their national law. diff --git a/scripts/render-legal.py b/scripts/render-legal.py new file mode 100755 index 0000000..23beda7 --- /dev/null +++ b/scripts/render-legal.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Render a trails.cool legal page (Terms / Privacy / Impressum) from its TSX +source into plain markdown suitable for `docs/legal-archive/`. + +Usage (from repo root): + python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md + python3 scripts/render-legal.py privacy > docs/legal-archive/privacy-YYYY-MM-DD.md + python3 scripts/render-legal.py imprint > docs/legal-archive/imprint-YYYY-MM-DD.md + +Strips JSX tags + attributes and resolves the `operator.*` placeholders +from `apps/journal/app/lib/operator.ts` so the output is a clean +human-readable snapshot. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +OPERATOR_FILE = REPO_ROOT / "apps/journal/app/lib/operator.ts" +LEGAL_FILE = REPO_ROOT / "apps/journal/app/lib/legal.ts" + +PAGES = { + "terms": REPO_ROOT / "apps/journal/app/routes/legal.terms.tsx", + "privacy": REPO_ROOT / "apps/journal/app/routes/legal.privacy.tsx", + "imprint": REPO_ROOT / "apps/journal/app/routes/legal.imprint.tsx", +} + +TITLES = { + "terms": "Terms of Service — trails.cool", + "privacy": "Privacy Policy — trails.cool", + "imprint": "Impressum — trails.cool", +} + + +def load_operator() -> dict[str, str]: + src = OPERATOR_FILE.read_text() + fields = ("name", "street", "postalCode", "city", "country", "email", "responsiblePerson") + return { + f: m.group(1) if (m := re.search(rf'{f}:\s*"([^"]+)"', src)) else "" + for f in fields + } + + +def load_legal_constants() -> dict[str, str]: + """Exported top-level `export const NAME = "value";` from legal.ts.""" + src = LEGAL_FILE.read_text() + return dict(re.findall(r'export const (\w+)\s*=\s*"([^"]+)"', src)) + + +def render(src: str, operator: dict[str, str], legal: dict[str, str]) -> str: + # Resolve operator placeholders + src = re.sub(r'\{operator\.address\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) + src = re.sub(r'\{operator\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) + + # Resolve legal.ts constants (TERMS_VERSION, PRIVACY_LAST_UPDATED, …) + for name, value in legal.items(): + src = re.sub(r'\{' + re.escape(name) + r'\}', value, src) + + # Unwrap template literals and explicit single-space expressions + src = re.sub(r'\{`(.*?)`\}', r'\1', src) + src = re.sub(r'\{"\s*"\}', ' ', src) + + # Strip common JSX attributes we don't want in prose + src = re.sub(r'\s(?:className|href|target|rel|id|name|content|htmlFor)="[^"]*"', '', src) + + # Drop any remaining {expr} blocks (unresolved expressions) + src = re.sub(r'\{[^{}]*\}', '', src) + + # Replace tags with newlines to preserve paragraph structure + src = re.sub(r'<[^>]+>', '\n', src) + + # HTML entity decode (minimal) + for entity, replacement in ( + ("'", "'"), + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", '"'), + (" ", " "), + ): + src = src.replace(entity, replacement) + + # Collapse blank lines + lines = [ln.rstrip() for ln in src.splitlines()] + out: list[str] = [] + prev_blank = False + for ln in lines: + if ln.strip() == "": + if not prev_blank and out: + out.append("") + prev_blank = True + else: + out.append(ln.strip()) + prev_blank = False + return "\n".join(out).strip() + + +def main() -> int: + if len(sys.argv) != 2 or sys.argv[1] not in PAGES: + sys.stderr.write(f"Usage: {sys.argv[0]} {{{'|'.join(PAGES)}}}\n") + return 2 + + page = sys.argv[1] + operator = load_operator() + legal = load_legal_constants() + src = PAGES[page].read_text() + + # Slice out the JSX tree: everything between "return (" and the closing ");" + src = re.split(r'return \(\s*\n', src, maxsplit=1)[-1] + src = re.sub(r'\);\s*\}\s*$', '', src.rstrip()) + + body = render(src, operator, legal) + print(f"# {TITLES[page]}\n\n{body}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2abc207c2ff844d5b4be81029dcf9a6250f038a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:52:53 +0200 Subject: [PATCH 041/495] Convert render-legal to TypeScript The repo is a Node/TS stack, so a Python renderer is out of place. Port the script to TypeScript, runnable via `node --experimental-strip-types scripts/render-legal.ts `, and update the README to match. Verified byte-identical output against the Python version before removing it. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/legal-archive/README.md | 14 ++-- scripts/render-legal.py | 122 --------------------------------- scripts/render-legal.ts | 126 +++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 129 deletions(-) delete mode 100755 scripts/render-legal.py create mode 100755 scripts/render-legal.ts diff --git a/docs/legal-archive/README.md b/docs/legal-archive/README.md index 6531452..ffb7b98 100644 --- a/docs/legal-archive/README.md +++ b/docs/legal-archive/README.md @@ -40,14 +40,14 @@ corresponding legal page on the day the snapshot was taken. ## How to render -The snapshots are plain markdown extracted from the TSX source by -stripping JSX tags / attributes and resolving the `operator.*` -placeholders. Same approach as the pbcopy export used during legal -reviews. One-liner (from repo root): +Snapshots are extracted from the TSX source by stripping JSX tags / +attributes and resolving the `operator.*` placeholders plus the +constants from `apps/journal/app/lib/legal.ts`. One-liner (from repo +root): ```bash -python3 scripts/render-legal.py > docs/legal-archive/-YYYY-MM-DD.md +node --experimental-strip-types scripts/render-legal.ts \ + > docs/legal-archive/-YYYY-MM-DD.md ``` -(If `scripts/render-legal.py` doesn't exist yet, the extraction logic is -in the commit that seeded this directory — see PR history.) +where `` is one of `terms`, `privacy`, `imprint`. diff --git a/scripts/render-legal.py b/scripts/render-legal.py deleted file mode 100755 index 23beda7..0000000 --- a/scripts/render-legal.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -""" -Render a trails.cool legal page (Terms / Privacy / Impressum) from its TSX -source into plain markdown suitable for `docs/legal-archive/`. - -Usage (from repo root): - python3 scripts/render-legal.py terms > docs/legal-archive/terms-YYYY-MM-DD.md - python3 scripts/render-legal.py privacy > docs/legal-archive/privacy-YYYY-MM-DD.md - python3 scripts/render-legal.py imprint > docs/legal-archive/imprint-YYYY-MM-DD.md - -Strips JSX tags + attributes and resolves the `operator.*` placeholders -from `apps/journal/app/lib/operator.ts` so the output is a clean -human-readable snapshot. -""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -OPERATOR_FILE = REPO_ROOT / "apps/journal/app/lib/operator.ts" -LEGAL_FILE = REPO_ROOT / "apps/journal/app/lib/legal.ts" - -PAGES = { - "terms": REPO_ROOT / "apps/journal/app/routes/legal.terms.tsx", - "privacy": REPO_ROOT / "apps/journal/app/routes/legal.privacy.tsx", - "imprint": REPO_ROOT / "apps/journal/app/routes/legal.imprint.tsx", -} - -TITLES = { - "terms": "Terms of Service — trails.cool", - "privacy": "Privacy Policy — trails.cool", - "imprint": "Impressum — trails.cool", -} - - -def load_operator() -> dict[str, str]: - src = OPERATOR_FILE.read_text() - fields = ("name", "street", "postalCode", "city", "country", "email", "responsiblePerson") - return { - f: m.group(1) if (m := re.search(rf'{f}:\s*"([^"]+)"', src)) else "" - for f in fields - } - - -def load_legal_constants() -> dict[str, str]: - """Exported top-level `export const NAME = "value";` from legal.ts.""" - src = LEGAL_FILE.read_text() - return dict(re.findall(r'export const (\w+)\s*=\s*"([^"]+)"', src)) - - -def render(src: str, operator: dict[str, str], legal: dict[str, str]) -> str: - # Resolve operator placeholders - src = re.sub(r'\{operator\.address\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) - src = re.sub(r'\{operator\.(\w+)\}', lambda m: operator.get(m.group(1), ""), src) - - # Resolve legal.ts constants (TERMS_VERSION, PRIVACY_LAST_UPDATED, …) - for name, value in legal.items(): - src = re.sub(r'\{' + re.escape(name) + r'\}', value, src) - - # Unwrap template literals and explicit single-space expressions - src = re.sub(r'\{`(.*?)`\}', r'\1', src) - src = re.sub(r'\{"\s*"\}', ' ', src) - - # Strip common JSX attributes we don't want in prose - src = re.sub(r'\s(?:className|href|target|rel|id|name|content|htmlFor)="[^"]*"', '', src) - - # Drop any remaining {expr} blocks (unresolved expressions) - src = re.sub(r'\{[^{}]*\}', '', src) - - # Replace tags with newlines to preserve paragraph structure - src = re.sub(r'<[^>]+>', '\n', src) - - # HTML entity decode (minimal) - for entity, replacement in ( - ("'", "'"), - ("&", "&"), - ("<", "<"), - (">", ">"), - (""", '"'), - (" ", " "), - ): - src = src.replace(entity, replacement) - - # Collapse blank lines - lines = [ln.rstrip() for ln in src.splitlines()] - out: list[str] = [] - prev_blank = False - for ln in lines: - if ln.strip() == "": - if not prev_blank and out: - out.append("") - prev_blank = True - else: - out.append(ln.strip()) - prev_blank = False - return "\n".join(out).strip() - - -def main() -> int: - if len(sys.argv) != 2 or sys.argv[1] not in PAGES: - sys.stderr.write(f"Usage: {sys.argv[0]} {{{'|'.join(PAGES)}}}\n") - return 2 - - page = sys.argv[1] - operator = load_operator() - legal = load_legal_constants() - src = PAGES[page].read_text() - - # Slice out the JSX tree: everything between "return (" and the closing ");" - src = re.split(r'return \(\s*\n', src, maxsplit=1)[-1] - src = re.sub(r'\);\s*\}\s*$', '', src.rstrip()) - - body = render(src, operator, legal) - print(f"# {TITLES[page]}\n\n{body}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/render-legal.ts b/scripts/render-legal.ts new file mode 100755 index 0000000..5558502 --- /dev/null +++ b/scripts/render-legal.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * Render a trails.cool legal page (Terms / Privacy / Impressum) from its TSX + * source into plain markdown suitable for `docs/legal-archive/`. + * + * Usage (from repo root): + * node --experimental-strip-types scripts/render-legal.ts terms > docs/legal-archive/terms-YYYY-MM-DD.md + * node --experimental-strip-types scripts/render-legal.ts privacy > docs/legal-archive/privacy-YYYY-MM-DD.md + * node --experimental-strip-types scripts/render-legal.ts imprint > docs/legal-archive/imprint-YYYY-MM-DD.md + * + * Strips JSX tags + attributes and resolves `operator.*` placeholders from + * operator.ts plus `TERMS_VERSION` / `PRIVACY_LAST_UPDATED` from legal.ts so + * the output is a clean human-readable snapshot. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const OPERATOR_FILE = join(REPO_ROOT, "apps/journal/app/lib/operator.ts"); +const LEGAL_FILE = join(REPO_ROOT, "apps/journal/app/lib/legal.ts"); + +const PAGES = { + terms: { file: "apps/journal/app/routes/legal.terms.tsx", title: "Terms of Service — trails.cool" }, + privacy: { file: "apps/journal/app/routes/legal.privacy.tsx", title: "Privacy Policy — trails.cool" }, + imprint: { file: "apps/journal/app/routes/legal.imprint.tsx", title: "Impressum — trails.cool" }, +} as const; + +type Page = keyof typeof PAGES; + +function loadOperator(): Record { + const src = readFileSync(OPERATOR_FILE, "utf8"); + const fields = ["name", "street", "postalCode", "city", "country", "email", "responsiblePerson"]; + const out: Record = {}; + for (const f of fields) { + const m = src.match(new RegExp(`${f}:\\s*"([^"]+)"`)); + out[f] = m ? m[1]! : ""; + } + return out; +} + +function loadLegalConstants(): Record { + const src = readFileSync(LEGAL_FILE, "utf8"); + const out: Record = {}; + for (const m of src.matchAll(/export const (\w+)\s*=\s*"([^"]+)"/g)) { + out[m[1]!] = m[2]!; + } + return out; +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function render(src: string, operator: Record, legal: Record): string { + // Resolve operator placeholders + src = src.replace(/\{operator\.address\.(\w+)\}/g, (_, k: string) => operator[k] ?? ""); + src = src.replace(/\{operator\.(\w+)\}/g, (_, k: string) => operator[k] ?? ""); + + // Resolve legal.ts constants + for (const [name, value] of Object.entries(legal)) { + src = src.replace(new RegExp(`\\{${escapeRegex(name)}\\}`, "g"), value); + } + + // Unwrap template literals and explicit single-space expressions + src = src.replace(/\{`(.*?)`\}/gs, "$1"); + src = src.replace(/\{"\s*"\}/g, " "); + + // Strip common JSX attributes + src = src.replace(/\s(?:className|href|target|rel|id|name|content|htmlFor)="[^"]*"/g, ""); + + // Drop any remaining {expr} blocks (unresolved expressions) + src = src.replace(/\{[^{}]*\}/g, ""); + + // Replace tags with newlines to preserve paragraph structure + src = src.replace(/<[^>]+>/g, "\n"); + + // HTML entity decode (minimal) + const entities: Array<[string, string]> = [ + ["'", "'"], + ["&", "&"], + ["<", "<"], + [">", ">"], + [""", '"'], + [" ", " "], + ]; + for (const [e, r] of entities) src = src.split(e).join(r); + + // Collapse blank lines + const lines = src.split("\n").map((l) => l.replace(/\s+$/, "")); + const out: string[] = []; + let prevBlank = false; + for (const l of lines) { + if (l.trim() === "") { + if (!prevBlank && out.length) out.push(""); + prevBlank = true; + } else { + out.push(l.trim()); + prevBlank = false; + } + } + return out.join("\n").trim(); +} + +function main(): number { + const arg = process.argv[2]; + if (!arg || !(arg in PAGES)) { + process.stderr.write(`Usage: render-legal.ts {${Object.keys(PAGES).join("|")}}\n`); + return 2; + } + const page = arg as Page; + const operator = loadOperator(); + const legal = loadLegalConstants(); + let src = readFileSync(join(REPO_ROOT, PAGES[page].file), "utf8"); + + // Slice out the JSX tree: everything between `return (` and the closing `);` + src = src.split(/return \(\s*\n/).slice(-1)[0]!; + src = src.replace(/\);\s*\}\s*$/, "").trimEnd(); + + const body = render(src, operator, legal); + process.stdout.write(`# ${PAGES[page].title}\n\n${body}\n`); + return 0; +} + +process.exit(main()); From 374102e63326e8d50b3376b9410f56d5199e7178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 07:56:01 +0200 Subject: [PATCH 042/495] Make scripts/ a pnpm workspace + add README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render-legal.ts needs @types/node available to the TS language server so editors (VS Code, cursor, etc.) don't complain about missing Node globals. Without a tsconfig in this folder VS Code falls back to a default config with no types and shows red squiggles on every process/fs/path reference. Solution: scripts/ becomes its own pnpm workspace (@trails-cool/scripts), carrying @types/node as a devDep from the catalog, and a tiny tsconfig.json with types: [node] and include *.ts. This also wires the folder into `pnpm typecheck` as task #14, so CI type-checks scripts alongside every other package. No runtime impact — the workspace is never bundled or deployed. README documents the layout, current contents (render-legal.ts + check-dockerfiles.sh), and how to add new scripts. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 6 ++++++ pnpm-workspace.yaml | 1 + scripts/README.md | 31 +++++++++++++++++++++++++++++++ scripts/package.json | 13 +++++++++++++ scripts/tsconfig.json | 8 ++++++++ 5 files changed, 59 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/package.json create mode 100644 scripts/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b10a7d0..0d2149f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -654,6 +654,12 @@ importers: packages/ui: {} + scripts: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.19.17 + packages: '@adobe/css-tools@4.4.4': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a860bb..68d71bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/*" - "packages/*" + - "scripts" catalog: react: ^19.2.5 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..525a3ce --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,31 @@ +# scripts + +One-off CLIs for maintenance tasks that don't belong inside any of the +apps or packages. Small enough to live in a single file each. + +This folder is a pnpm workspace (`@trails-cool/scripts`) purely so TypeScript +has a home to resolve `@types/node` from — it isn't published, bundled, or +deployed. Each script is a self-contained `.ts` file run with +`node --experimental-strip-types`. + +## Contents + +| Script | Purpose | +|---|---| +| `render-legal.ts` | Render a legal page (Terms / Privacy / Imprint) from its TSX source to plain markdown for `docs/legal-archive/`. See [`docs/legal-archive/README.md`](../docs/legal-archive/README.md). | +| `check-dockerfiles.sh` | Bash script run in CI to verify every workspace package is COPY'd into each app's Dockerfile. | + +## Adding a script + +1. Drop the file in this folder (`.ts` preferred; shell is fine for + filesystem/docker glue). +2. For TypeScript, `tsconfig.json` already picks up every `*.ts` in this + directory. +3. Run it with `node --experimental-strip-types scripts/.ts `. +4. Document purpose + invocation in the table above. + +## Why not per-package scripts? + +Some things (Dockerfile audits, legal-archive rendering, DB one-offs) don't +belong inside any single workspace. Keeping them here avoids cross-package +dependencies and keeps the app/package roots focused on product code. diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..586b341 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,13 @@ +{ + "name": "@trails-cool/scripts", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint ." + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..fed7cdc --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "noEmit": true + }, + "include": ["*.ts"] +} From f16e80a2eba51be2fe160bc33ad6c4eeaa3f41a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 08:06:16 +0200 Subject: [PATCH 043/495] Prompt users with stale terms_version to re-accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have NULL terms_version, and any future Terms update would leave every existing user in the same state. Close the loop now that we have version storage by redirecting any logged-in user whose users.terms_version doesn't match the currently-published TERMS_VERSION to a dedicated acceptance page. Changes: - auth.server: new recordTermsAcceptance(userId, version) helper that writes both terms_accepted_at and terms_version. - root loader: if the session user has a stale or NULL terms_version, throw redirect("/auth/accept-terms?returnTo=") unless the request is already on an allow-listed path (/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable and logout works. - New route /auth/accept-terms (GET renders the prompt, POST records acceptance and bounces to a sanitised returnTo). Same-origin check on returnTo to avoid open-redirect abuse. Logout button is provided as an escape hatch. - i18n: new auth.reaccept.* keys for EN and DE. - Spec: new Requirement + five scenarios (redirect, allow-list, successful re-accept, missing consent, returnTo sanitisation). No action on the three legacy users is required beyond what they'll experience on their next visit — the gate takes care of it. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/lib/auth.server.ts | 13 ++ apps/journal/app/root.tsx | 25 +++- apps/journal/app/routes.ts | 1 + apps/journal/app/routes/auth.accept-terms.tsx | 117 ++++++++++++++++++ openspec/specs/journal-auth/spec.md | 23 ++++ packages/i18n/src/locales/de.ts | 7 ++ packages/i18n/src/locales/en.ts | 7 ++ 7 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 apps/journal/app/routes/auth.accept-terms.tsx diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index d91059a..0aa8861 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -410,6 +410,19 @@ export const sessionStorage = createCookieSessionStorage({ }, }); +/** + * Record the user's acceptance of the current Terms version. Updates both + * `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user + * re-accepts after the Terms have been updated. + */ +export async function recordTermsAcceptance(userId: string, termsVersion: string) { + const db = getDb(); + await db + .update(users) + .set({ termsAcceptedAt: new Date(), termsVersion }) + .where(eq(users.id, userId)); +} + export async function createSession(userId: string, request: Request) { const session = await sessionStorage.getSession(request.headers.get("Cookie")); session.set("userId", userId); diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index 09b32ec..6d6d2b4 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link } from "react-router"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link, redirect } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; @@ -10,8 +10,17 @@ import { LocaleProvider } from "~/components/LocaleContext"; import { AlphaBanner } from "~/components/AlphaBanner"; import { Footer } from "~/components/Footer"; import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; +import { TERMS_VERSION } from "~/lib/legal"; import stylesheet from "@trails-cool/ui/styles.css?url"; +// Paths that must stay reachable even when the user has a stale +// terms_version, so they can read the Terms, accept them, or log out. +const TERMS_GATE_ALLOWLIST = [ + "/auth/accept-terms", + "/auth/logout", + "/legal/", +]; + export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }]; export function Layout({ children }: { children: React.ReactNode }) { @@ -39,6 +48,20 @@ export function Layout({ children }: { children: React.ReactNode }) { export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); const locale = detectLocale(request); + + // Gate logged-in users with stale / missing terms_version: send them to the + // re-accept page on any request that isn't already on an allow-listed path. + if (user && user.termsVersion !== TERMS_VERSION) { + const pathname = new URL(request.url).pathname; + const onAllowlistedPath = TERMS_GATE_ALLOWLIST.some((p) => + p.endsWith("/") ? pathname.startsWith(p) : pathname === p, + ); + if (!onAllowlistedPath) { + const returnTo = encodeURIComponent(pathname + new URL(request.url).search); + throw redirect(`/auth/accept-terms?returnTo=${returnTo}`); + } + } + return { user: user ? { id: user.id, username: user.username } : null, locale }; } diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 8e4558b..20e7dfe 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -9,6 +9,7 @@ export default [ route("auth/login", "routes/auth.login.tsx"), route("auth/verify", "routes/auth.verify.tsx"), route("auth/logout", "routes/auth.logout.tsx"), + route("auth/accept-terms", "routes/auth.accept-terms.tsx"), route("api/auth/register", "routes/api.auth.register.ts"), route("api/auth/login", "routes/api.auth.login.ts"), route("routes", "routes/routes._index.tsx"), diff --git a/apps/journal/app/routes/auth.accept-terms.tsx b/apps/journal/app/routes/auth.accept-terms.tsx new file mode 100644 index 0000000..111b186 --- /dev/null +++ b/apps/journal/app/routes/auth.accept-terms.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Form, data, redirect, useLoaderData, useSearchParams } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/auth.accept-terms"; +import { getSessionUser, recordTermsAcceptance } from "~/lib/auth.server"; +import { TERMS_VERSION } from "~/lib/legal"; + +export function meta() { + return [ + { title: "Updated Terms of Service — trails.cool" }, + { name: "robots", content: "noindex" }, + ]; +} + +/** + * Paths we'll bounce back to after a successful acceptance. We only allow + * same-origin absolute paths to avoid being used as an open redirect. + */ +function safeReturnTo(raw: string | null): string { + if (!raw) return "/"; + if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + // If the user is already current, bounce them back (e.g. double-submit). + if (user.termsVersion === TERMS_VERSION) { + const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo")); + throw redirect(returnTo); + } + return { previousVersion: user.termsVersion }; +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + + const form = await request.formData(); + const accepted = form.get("termsAccepted") === "on" || form.get("termsAccepted") === "true"; + if (!accepted) { + return data({ error: "Terms of Service must be accepted to continue" }, { status: 400 }); + } + + await recordTermsAcceptance(user.id, TERMS_VERSION); + + const returnTo = safeReturnTo(form.get("returnTo")?.toString() ?? null); + throw redirect(returnTo); +} + +export default function AcceptTermsPage() { + const { t } = useTranslation("journal"); + const { previousVersion } = useLoaderData(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo") ?? "/"; + const [accepted, setAccepted] = useState(false); + + return ( +
    +

    + {t("auth.reaccept.heading")} +

    +

    + {previousVersion + ? t("auth.reaccept.bodyUpdated", { from: previousVersion, to: TERMS_VERSION }) + : t("auth.reaccept.bodyNew", { version: TERMS_VERSION })} +

    + +
    + + + + +
    + +
    + +
    +
    + ); +} diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index 2949f10..2806cdc 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -32,3 +32,26 @@ The registration form SHALL require explicit acknowledgement of the Terms of Ser #### Scenario: Missing version rejected - **WHEN** a registration request arrives without a non-empty `termsVersion` field - **THEN** the server responds with HTTP 400 and does not create a user + +### Requirement: Re-accept updated Terms on next visit +Logged-in users whose stored `terms_version` does not match the currently-published version SHALL be prompted to accept the current Terms before accessing any non-allow-listed page. + +#### Scenario: Stale version redirects to accept-terms page +- **WHEN** a logged-in user whose `users.terms_version` is NULL or differs from the current `TERMS_VERSION` requests any page outside the allow-list (`/auth/accept-terms`, `/auth/logout`, `/legal/*`) +- **THEN** the server redirects them to `/auth/accept-terms?returnTo=` + +#### Scenario: Allow-list keeps Terms and logout reachable +- **WHEN** the same user requests `/legal/terms`, `/legal/privacy`, `/legal/imprint`, `/auth/accept-terms`, or `/auth/logout` +- **THEN** the request is served normally without being redirected + +#### Scenario: Successful re-acceptance updates both fields +- **WHEN** a user submits the acceptance form with the required checkbox ticked +- **THEN** the server updates `users.terms_version` to the current version and `users.terms_accepted_at` to the current timestamp, then redirects to the `returnTo` path (or `/`) + +#### Scenario: Re-acceptance rejects missing consent +- **WHEN** the form is submitted without the checkbox ticked +- **THEN** the server responds with HTTP 400 and does not update the user row + +#### Scenario: returnTo is restricted to same-origin paths +- **WHEN** a `returnTo` value is not a same-origin absolute path (missing leading `/`, or starting with `//`) +- **THEN** the server redirects to `/` instead, preventing open-redirect abuse diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f17e32b..b4cb12c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -288,6 +288,13 @@ export default { alreadyHaveAccount: "Bereits ein Konto?", handleWillBe: "Dein Handle wird {{handle}} sein", passkeyNotFound: "Für diese Seite wurde kein Passkey gefunden. Registriere ein neues Konto oder nutze stattdessen einen Magic Link.", + reaccept: { + heading: "Aktualisierte Nutzungsbedingungen", + bodyUpdated: "Die Nutzungsbedingungen wurden aktualisiert (von Version {{from}} auf {{to}}). Bitte lies und akzeptiere sie, um trails.cool weiter zu nutzen.", + bodyNew: "Ab sofort erfassen wir, welche Version der Nutzungsbedingungen du akzeptiert hast. Bitte lies die aktuelle Version ({{version}}) und akzeptiere sie, um trails.cool weiter zu nutzen.", + submit: "Akzeptieren und fortfahren", + logoutInstead: "Stattdessen abmelden", + }, registerDescription: "Registriere dich mit einem Passkey — kein Passwort nötig.", registerWithPasskey: "Mit Passkey registrieren", creatingPasskey: "Erstelle Passkey...", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a4c9a15..f41764d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -288,6 +288,13 @@ export default { alreadyHaveAccount: "Already have an account?", handleWillBe: "Your handle will be {{handle}}", passkeyNotFound: "No passkey found for this site. Register a new account or use a magic link instead.", + reaccept: { + heading: "Updated Terms of Service", + bodyUpdated: "The Terms of Service have been updated (from version {{from}} to {{to}}). Please review and accept to continue using trails.cool.", + bodyNew: "We now record which version of the Terms of Service you've accepted. Please review the current version ({{version}}) and accept to continue using trails.cool.", + submit: "Accept and continue", + logoutInstead: "Log out instead", + }, registerDescription: "Register with a passkey — no password needed.", registerWithPasskey: "Register with Passkey", creatingPasskey: "Creating passkey...", From 5b40bd9b0059790945fa1e17187a2a8f3346d0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 08:13:24 +0200 Subject: [PATCH 044/495] Show empty-state CTA on Route detail page when no waypoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed: both non-maintainer users (pistazie, nelli) created a route, never planned in it, and stopped. The Route detail page rendered as a dead end — name, a row of buttons, then blank. Changes: - Detect "empty" as no geometry AND no distance (the state every new route has before any planning happens). - When empty, hide the top-row button cluster (Edit in Planner / Edit / Export GPX) and render a centered dashed-border card in its place. - Card shows: large headline, short body (owner-specific vs viewer-only copy), primary "Open in Planner" button (same action as the existing top-row button, just visually prominent), and a secondary "or upload a GPX file" link to the edit page. - Bilingual copy added under routes.empty.*. No non-empty route loses any UI — those paths stay identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/routes.$id.tsx | 36 +++++++++++++++++++++++++- packages/i18n/src/locales/de.ts | 7 +++++ packages/i18n/src/locales/en.ts | 7 +++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 5623e31..821bc1b 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -99,6 +99,11 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { const [editLoading, setEditLoading] = useState(false); const [highlightedDay, setHighlightedDay] = useState(null); + // A route is "empty" when it has no geometry and no computed distance — + // i.e. nobody has planned any waypoints yet. Surfaced as a dedicated + // empty-state below so the page isn't a dead end. + const isEmpty = !route.geojson && route.distance == null; + const handleEditInPlanner = useCallback(async () => { setEditLoading(true); try { @@ -121,7 +126,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {

    {route.description}

    )} - {isOwner && ( + {isOwner && !isEmpty && (
    + + {t("routes.empty.uploadGpx")} + +
    + )} + + )} + {versions.length > 0 && (

    Version History

    diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b4cb12c..87af7f4 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -179,6 +179,13 @@ export default { noRoutesYet: "Noch keine Routen. Erstelle deine erste Route!", noMapPreview: "Keine Kartenvorschau", saveChanges: "Änderungen speichern", + empty: { + heading: "Diese Route ist leer", + bodyOwner: "Du hast noch keine Wegpunkte geplant. Öffne sie im Planer, um eine Strecke zu skizzieren, oder lade eine GPX-Datei hoch.", + bodyViewer: "Diese Route enthält noch keine Wegpunkte.", + openInPlanner: "Im Planer öffnen", + uploadGpx: "oder eine GPX-Datei hochladen", + }, }, activities: { title: "Aktivitäten", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f41764d..1560842 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -179,6 +179,13 @@ export default { noRoutesYet: "No routes yet. Create your first route!", noMapPreview: "No map preview", saveChanges: "Save Changes", + empty: { + heading: "This route is empty", + bodyOwner: "You haven't planned any waypoints yet. Open it in the Planner to sketch a path, or upload a GPX file.", + bodyViewer: "This route doesn't have any waypoints yet.", + openInPlanner: "Open in Planner", + uploadGpx: "or upload a GPX file", + }, }, activities: { title: "Activities", From 621d91e14f0f5b41943c71c516de384d3d668265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 08:23:44 +0200 Subject: [PATCH 045/495] Park mobile-activity-recording + mobile-nearby-sync under docs/ideas Both are blocked on the mobile-app foundation and on a user base that actually records activities or needs offline peer sync. With 3 users and 0 activities on prod today, these are engineering for a hypothetical population. Move the artifact sets out of openspec/changes/ (so openspec list stays clean) and under docs/ideas/ where self-host-overpass already lives. Each gets a short README documenting: - current parked status - revive triggers (when should we look at this again?) - key constraints not to rediscover (background-GPS privacy review; BLE dev-build requirement; QR-only subset as a cheaper path) Reviving is `git mv docs/ideas/ openspec/changes/` and running /opsx:apply, same pattern as self-host-overpass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mobile-activity-recording/.openspec.yaml | 0 .../ideas/mobile-activity-recording/README.md | 39 +++++++++++++++++ .../mobile-activity-recording/proposal.md | 0 .../specs/mobile-activity-recording/spec.md | 0 .../ideas}/mobile-nearby-sync/.openspec.yaml | 0 docs/ideas/mobile-nearby-sync/README.md | 43 +++++++++++++++++++ .../ideas}/mobile-nearby-sync/proposal.md | 0 .../specs/mobile-nearby-sync/spec.md | 0 8 files changed, 82 insertions(+) rename {openspec/changes => docs/ideas}/mobile-activity-recording/.openspec.yaml (100%) create mode 100644 docs/ideas/mobile-activity-recording/README.md rename {openspec/changes => docs/ideas}/mobile-activity-recording/proposal.md (100%) rename {openspec/changes => docs/ideas}/mobile-activity-recording/specs/mobile-activity-recording/spec.md (100%) rename {openspec/changes => docs/ideas}/mobile-nearby-sync/.openspec.yaml (100%) create mode 100644 docs/ideas/mobile-nearby-sync/README.md rename {openspec/changes => docs/ideas}/mobile-nearby-sync/proposal.md (100%) rename {openspec/changes => docs/ideas}/mobile-nearby-sync/specs/mobile-nearby-sync/spec.md (100%) diff --git a/openspec/changes/mobile-activity-recording/.openspec.yaml b/docs/ideas/mobile-activity-recording/.openspec.yaml similarity index 100% rename from openspec/changes/mobile-activity-recording/.openspec.yaml rename to docs/ideas/mobile-activity-recording/.openspec.yaml diff --git a/docs/ideas/mobile-activity-recording/README.md b/docs/ideas/mobile-activity-recording/README.md new file mode 100644 index 0000000..ccc2075 --- /dev/null +++ b/docs/ideas/mobile-activity-recording/README.md @@ -0,0 +1,39 @@ +# mobile-activity-recording (parked) + +OpenSpec change for recording GPS activities on-device in the mobile app +(ride/hike tracking, live stats, save as Journal activity, HealthKit / +Health Connect export). Moved here from `openspec/changes/` so it does not +clutter the active change list; revive by moving the directory back under +`openspec/changes/` when ready to implement. + +## Status + +**Parked.** Blocked on `mobile-app` (the foundation change) landing first, +and on there being enough Journal users actually logging activities to +justify the native-recording complexity. At the current user count (3) and +activity count (0), shipping this would be writing code for nobody. + +## When to revive + +Revisit once **any** of these is true: + +- The mobile app is live and users are asking to record from the phone + instead of importing from Wahoo/Garmin/Komoot later +- The web Journal has a meaningful stream of imported activities, so the + feature lands into an existing behaviour rather than inventing one +- A specific user request justifies the iOS/Android background-location + privacy review (App Store's bar for background GPS is high) + +## Key constraints to remember + +- **Background GPS requires explicit user consent + justification** on iOS + and Android. App Store privacy review will scrutinise this. +- **Dependencies**: `expo-location` for the track, plus `expo-health` (or + equivalent) for HealthKit / Health Connect integration. +- **Battery**: continuous GPS at high accuracy is a power-hungry path; + recording UI needs clear on/off state and resumption. + +## What's in the folder + +- `proposal.md` — why / what / impact +- `specs/` — delta specs (would land against a new `mobile-activity-recording` capability) diff --git a/openspec/changes/mobile-activity-recording/proposal.md b/docs/ideas/mobile-activity-recording/proposal.md similarity index 100% rename from openspec/changes/mobile-activity-recording/proposal.md rename to docs/ideas/mobile-activity-recording/proposal.md diff --git a/openspec/changes/mobile-activity-recording/specs/mobile-activity-recording/spec.md b/docs/ideas/mobile-activity-recording/specs/mobile-activity-recording/spec.md similarity index 100% rename from openspec/changes/mobile-activity-recording/specs/mobile-activity-recording/spec.md rename to docs/ideas/mobile-activity-recording/specs/mobile-activity-recording/spec.md diff --git a/openspec/changes/mobile-nearby-sync/.openspec.yaml b/docs/ideas/mobile-nearby-sync/.openspec.yaml similarity index 100% rename from openspec/changes/mobile-nearby-sync/.openspec.yaml rename to docs/ideas/mobile-nearby-sync/.openspec.yaml diff --git a/docs/ideas/mobile-nearby-sync/README.md b/docs/ideas/mobile-nearby-sync/README.md new file mode 100644 index 0000000..ee23976 --- /dev/null +++ b/docs/ideas/mobile-nearby-sync/README.md @@ -0,0 +1,43 @@ +# mobile-nearby-sync (parked) + +OpenSpec change for sharing routes between nearby devices without internet +(BLE-based peer sync + QR-code waypoint fallback) in the mobile app. +Moved here from `openspec/changes/` so it does not clutter the active +change list; revive by moving the directory back under +`openspec/changes/` when ready to implement. + +## Status + +**Parked.** Blocked on `mobile-app` landing first, and on there being +enough users actually hiking/bikepacking together to justify the BLE +complexity. At the current user count (3) this is engineering for a +hypothetical group trip. + +## When to revive + +Revisit once **any** of these is true: + +- The mobile app is live and users start reporting "we were in a dead zone + and couldn't update the route" +- A group / tour organiser asks specifically for peer-to-peer route sync +- QR-code waypoint sharing alone (the simpler half of this spec) would + cover the need — worth shipping that first as a narrower change + +## Key constraints to remember + +- **Requires an Expo dev build** — `react-native-ble-plx` isn't available + in Expo Go. +- **iOS + Android BLE APIs differ significantly**; expect platform-specific + bugs. Background BLE advertising has battery and OS-lifecycle caveats + (iOS suspends advertising when backgrounded after a grace period). +- **Permissions sprawl**: iOS `NSBluetoothAlwaysUsageDescription`, Android + `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` + `BLUETOOTH_ADVERTISE`, plus + location on older Android. +- **QR waypoint sharing is an order of magnitude simpler** than BLE and + covers a big chunk of the use case — consider extracting that as a + separate, earlier change if the need becomes real. + +## What's in the folder + +- `proposal.md` — why / what / non-goals / impact / future ideas (TXQR) +- `specs/` — delta specs (would land against a new `mobile-nearby-sync` capability) diff --git a/openspec/changes/mobile-nearby-sync/proposal.md b/docs/ideas/mobile-nearby-sync/proposal.md similarity index 100% rename from openspec/changes/mobile-nearby-sync/proposal.md rename to docs/ideas/mobile-nearby-sync/proposal.md diff --git a/openspec/changes/mobile-nearby-sync/specs/mobile-nearby-sync/spec.md b/docs/ideas/mobile-nearby-sync/specs/mobile-nearby-sync/spec.md similarity index 100% rename from openspec/changes/mobile-nearby-sync/specs/mobile-nearby-sync/spec.md rename to docs/ideas/mobile-nearby-sync/specs/mobile-nearby-sync/spec.md From dcbd703f4767230e840672be09acb30ee352a9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 08:49:59 +0200 Subject: [PATCH 046/495] Propose social MVP: public-content-visibility + demo-activity-bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stacked OpenSpec change proposals for a demoable social layer, with scope deliberately minimal ("enough to send a URL and have it open to real-looking content without signup"): 1. public-content-visibility - Adds visibility enum {private, unlisted, public} on routes + activities, defaulting to private for every existing row. - Detail pages (/routes/:id, /activities/:id) become accessible to logged-out visitors when content is public or unlisted; private → 404 (not 403) to avoid existence leaks. - Broadens /users/:username into a public profile listing only public routes + activities; 404s when there's no public content to prevent account enumeration. - Open Graph / Twitter Card meta on public detail + profile pages. - Visibility selector in the owner's edit flow. - Out of scope: follow/follower, cross-user feed, reactions, federation. 2. demo-activity-bot (depends on #1) - A single bot user, Bruno the trail dog, seeded on worker startup when DEMO_BOT_ENABLED=true. Reserved username, sentinel email, no credentials. - pg-boss recurring job fires every 90 min, decides-to-walk with p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at organic times. - Each walk: random start + end within inner-Berlin bbox, trekking only (dogs don't ride bikes), 2-12 km crow. BRouter plans the route; the route GPX is also attached as the activity's trace. - Everything inserted with visibility=public, synthetic=true. - Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old (default 14). Hard cap of 40 items/14d protects against runaway growth. - Small "🐕 demo account" badge on /users/bruno for honesty. Both changes are artifacts only — no code lands with this PR. Apply in order after merge: public-content-visibility first, then demo-activity-bot. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../changes/demo-activity-bot/.openspec.yaml | 2 + openspec/changes/demo-activity-bot/design.md | 154 ++++++++++++++++++ .../changes/demo-activity-bot/proposal.md | 35 ++++ .../specs/activity-feed/spec.md | 16 ++ .../specs/demo-activity-bot/spec.md | 77 +++++++++ .../specs/route-management/spec.md | 16 ++ openspec/changes/demo-activity-bot/tasks.md | 72 ++++++++ .../public-content-visibility/.openspec.yaml | 2 + .../public-content-visibility/design.md | 111 +++++++++++++ .../public-content-visibility/proposal.md | 37 +++++ .../specs/activity-feed/spec.md | 48 ++++++ .../specs/public-profiles/spec.md | 22 +++ .../specs/route-management/spec.md | 52 ++++++ .../public-content-visibility/tasks.md | 62 +++++++ 14 files changed, 706 insertions(+) create mode 100644 openspec/changes/demo-activity-bot/.openspec.yaml create mode 100644 openspec/changes/demo-activity-bot/design.md create mode 100644 openspec/changes/demo-activity-bot/proposal.md create mode 100644 openspec/changes/demo-activity-bot/specs/activity-feed/spec.md create mode 100644 openspec/changes/demo-activity-bot/specs/demo-activity-bot/spec.md create mode 100644 openspec/changes/demo-activity-bot/specs/route-management/spec.md create mode 100644 openspec/changes/demo-activity-bot/tasks.md create mode 100644 openspec/changes/public-content-visibility/.openspec.yaml create mode 100644 openspec/changes/public-content-visibility/design.md create mode 100644 openspec/changes/public-content-visibility/proposal.md create mode 100644 openspec/changes/public-content-visibility/specs/activity-feed/spec.md create mode 100644 openspec/changes/public-content-visibility/specs/public-profiles/spec.md create mode 100644 openspec/changes/public-content-visibility/specs/route-management/spec.md create mode 100644 openspec/changes/public-content-visibility/tasks.md diff --git a/openspec/changes/demo-activity-bot/.openspec.yaml b/openspec/changes/demo-activity-bot/.openspec.yaml new file mode 100644 index 0000000..c8af3f5 --- /dev/null +++ b/openspec/changes/demo-activity-bot/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/demo-activity-bot/design.md b/openspec/changes/demo-activity-bot/design.md new file mode 100644 index 0000000..2925617 --- /dev/null +++ b/openspec/changes/demo-activity-bot/design.md @@ -0,0 +1,154 @@ +## Context + +The Journal runs on a single Hetzner Cloud container with Postgres + PostGIS, BRouter as a sibling compose service, and pg-boss already configured as the background-job queue (`apps/journal/server.ts` boots a worker on startup). `public-content-visibility` introduces a `visibility` column on routes + activities; once that lands, a piece of content marked `public` is viewable by logged-out visitors at its permanent URL and on the owner's `/users/:username` profile. + +This change layers a synthetic-content generator on top. The motivation is narrow — a non-empty feed for prospective users we're demoing to. The design matches that: **cheap, single-region, single-user, single-purpose**. Nothing here should generalise to "simulate a real community"; the bot is allowed to look slightly repetitive because it exists to fill an empty demo, not to fake scale. + +Adjacent context: +- BRouter runs internally and already rate-limits per-session at the Planner, but here we're inside the Journal server, so the bot controls its own cadence rather than sharing a limiter with user-driven routing. Load is low — at most a handful of routes per day. +- pg-boss supports cron-style recurring schedules and singleton jobs (no concurrent duplicates), which are exactly what we need. + +## Goals / Non-Goals + +**Goals:** +- A logged-out visitor landing on `trails.cool/users/demo` sees a recent-looking public profile with several routes and activities. +- The content is generated entirely on-host, with no third-party data calls. +- Synthetic content is trivially separable from real content (single boolean flag). +- The bot can be disabled without a deploy (env flag) and its output wiped with a single DELETE. +- Cadence is boring. One content item every few hours is enough — we are not trying to pretend a Strava-scale community. + +**Non-Goals:** +- Realistic social signals (likes, comments, follower counts). +- Multiple bot personas or multi-region coverage. +- Route topology tricks beyond "point-to-point via BRouter" (no multi-day, no loops, no no-go areas). +- Photos or media on activities. +- Running the bot in local dev / CI / staging — disabled by default everywhere except prod. + +## Decisions + +### Single bot user: Bruno the trail dog + +**Decision:** One account with: + +- Username: `bruno` (reserved; a later real registration with that username is blocked) +- Display name: `Bruno` +- Bio: short, whimsical, something like *"Professional park inspector. Currently accepting tennis balls."* +- Sentinel email: `bruno@` — unroutable, so magic-link login cannot succeed +- No passkey credentials +- `terms_accepted_at` / `terms_version` populated to the current values at bootstrap + +The persona is a park-walking dog whose "human" logs the walks. This choice: + +- Makes the bot legibly fictional at a glance — a dog account with emoji-laced names reads as *charming*, not as a real-user-you're-being-tricked-into-believing-in. +- Narrows the generator's parameter space (trekking profile only, short distances, urban parks) so output is consistent and failure-prone edge cases get pruned. +- Keeps the copy bank small and writable in one sitting. + +**Alternatives considered:** +- **"Finn" — realistic commuter-cyclist.** Fills the feed convincingly, but deception risk if a visitor later realises. Rejected. +- **"trails.cool test pilot" — explicitly diagnostic.** Honest but dead boring; kills the "lively feed" goal the bot exists for. Rejected. +- **Multi-persona community simulation.** Out of scope for v1. + +### Bruno's generation parameters + +**Decision:** The persona constrains the generator more tightly than the generic v1 draft: + +- **Profile pool**: `["trekking"]` only (dogs don't ride bikes). +- **Distance band**: 2–12 km crow distance between start and end — a realistic walk, not a hike. +- **Region**: Berlin inner (bbox roughly `13.25,52.45,13.55,52.60`), biased toward parks/green areas: Grunewald, Tiergarten, Tempelhofer Feld, Volkspark Friedrichshain, Treptower Park, Müggelsee shoreline. The bbox itself is permissive; the copy just reads as park-flavoured. +- **Started-at window**: 07:00–20:00 local, with a slight bias toward morning/evening ("walkies" times). +- **Copy templates**: a mix of serious-sounding audit language ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"). Bilingual EN + DE. + +**Why narrower:** the persona is a feature, not a limitation. Not every dog-walk needs to be in a park, but pretending Bruno walked 40 km across Brandenburg would break the illusion the whole persona exists to prop up. + +### Route generation: BRouter point-to-point in a seed region + +**Decision:** A configured seed region is a bounding box. For the Bruno persona: + +- Region: Berlin inner (`13.25,52.45,13.55,52.60`) — narrower than "Berlin + Brandenburg"; a dog-walk spanning Brandenburg breaks the illusion. +- Profile: `trekking` only (see the Bruno persona decision above). +- Start point: random within the box. +- End point: 2–12 km crow distance from start. +- Query BRouter with those two waypoints and `trekking`; reject and retry if BRouter returns no route or the computed distance is outside a sanity band (say, 1.5–18 km real distance). +- Persist the returned GPX as the route's source of truth and let existing enrichment compute the rest (PostGIS geom, distance, elevation). + +The bbox stays env-configurable; changing `DEMO_BOT_REGION` relocates Bruno without a code change. + +**Alternatives considered:** +- **Pre-recorded GPX files in a corpus.** Chosen by user to be (b) — synthesised via BRouter — in the conversation that led here. Keeps variety cheap and relocatable (change the bbox, new city). +- **Komoot import** — reject, external dependency. +- **Multi-waypoint (3+ stops)** — slight realism gain, much higher generation failure rate (more chances for BRouter to return no-route). Skip. + +### Activity generation follows the route + +**Decision:** When a route is generated, immediately derive an activity from it: + +- `route_id` links to the new route +- `name` and `description` drawn from a small templated set (e.g., "Weekend ride through the Havelland", "Evening loop around Müggelsee") +- `started_at` = today between 06:00 and 20:00 local (random), `duration` = distance ÷ an average speed per profile ± jitter +- `distance`, `elevation_gain`, `elevation_loss` = the route's computed values +- `gpx` = the route's GPX verbatim (the activity "happened" along the planned route) +- `visibility = 'public'`, `synthetic = true` + +**Why bundled:** keeping route + activity generation in one transaction means the feed item is coherent the moment it appears. Separating them just to pretend the user planned then rode feels like theatre — we're not hiding the bot, we're just filling the surface. + +### Cadence and guards + +**Decision:** A dog walks twice a day, sometimes three times, never six. The schedule should look like Bruno's day, not like a cron job. + +- A recurring pg-boss job `demo-bot:generate` fires **every 90 minutes** as a singleton. +- Most ticks decide to *not* walk: the handler rolls a per-tick probability and skips if it doesn't hit. The probability is **0.12 per tick during 07:00–21:00 local, 0 otherwise** — which yields roughly 2–3 walks per day across the day, biased to waking hours. +- This both looks human (walks aren't on a fixed cron) and makes the generation load tiny: one BRouter call per walk, so ~2–3 per day. +- The job skips itself entirely when `process.env.DEMO_BOT_ENABLED !== "true"`. Dev / CI / tests: no-op. +- Hard cap: if there are already ≥ 40 synthetic items in the last 14 days (≈ 3/day × 14 rounded up), skip for that tick. Stops runaway growth if the retention job fails. +- BRouter call per generation: 1. Bot traffic is rounding-error compared to real user traffic. + +**Alternatives considered:** +- **Fixed cron every 4 hours** — boring, too regular, a demo visitor with sharp eyes notices. +- **Poisson process with mean 3/day** — more faithful but more code. The 90-minute-tick-with-probability scheme is close enough. +- **Walk at specific realistic times** (morning, lunch, evening) — adds temporal pattern. Probably worth revisiting once we see the feed in practice, but not worth coding up front. + +### Retention + +**Decision:** A second recurring job `demo-bot:prune` runs daily and deletes synthetic routes + activities whose `created_at` is older than `DEMO_BOT_RETENTION_DAYS` (default 14). Route-version rows cascade-delete via existing FK. + +**Why daily, not weekly:** smaller blast radius if the prune gets something wrong, and it keeps the feed visibly "recent" rather than stable. + +### Flagging synthetic content at the DB level + +**Decision:** Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` and `journal.activities`. Set `true` only when the bot inserts. + +**Why:** makes the "delete all bot content" operation a one-liner and lets future listing code exclude synthetic if we decide to, without introspecting content shape or owner. `owner_id = demo_user_id` would almost work as a proxy, but the dedicated flag decouples identity from status — if we later delete and re-seed the demo user we don't lose the signal. + +### Env surface + +**Decision:** +- `DEMO_BOT_ENABLED`: `"true"` turns the generator + prune on; anything else is off. Absent means off. +- `DEMO_BOT_RETENTION_DAYS`: integer, defaults to 14. +- `DEMO_BOT_REGION`: JSON `{ "bbox": [w,s,e,n] }`, defaults to inner Berlin (`13.25,52.45,13.55,52.60`) to suit Bruno's park-walker persona. +- No secrets. The whole feature is public-facing by design. + +## Risks / Trade-offs + +- **BRouter outages halt the feed** → acceptable; the job logs and skips. The feed will stop refreshing but existing items remain visible. +- **Uncanny repetition** (same start neighbourhoods, template names) → mitigated by varying the start point randomly and the templated copy, but still acceptable for a demo. A reviewer will obviously figure out it's synthetic if they look — we're not hiding it. +- **Mistaking bot content for real content during analytics** → mitigated by the `synthetic` flag; analytics queries filter `WHERE synthetic = false` when they want real usage. +- **Runaway insert if retention breaks** → mitigated by the hard cap (50 items in last 14 days) inside the generator. +- **GDPR / privacy concerns** → none new: the bot has no real PII, its content is first-party, and `demo@trails.cool` is a reserved sentinel address. +- **Someone logs in as `demo`** → the user has no credentials (no passkey, no magic-token). Email sentinel means a magic-link request can't succeed either (no inbox). Safe. +- **Accidentally enabled in dev** → mitigated by the explicit env opt-in; default-off in every environment but prod. + +## Migration Plan + +1. Merge schema + code; `drizzle-kit push --force` adds `synthetic` columns. +2. Set `DEMO_BOT_ENABLED=true` on the prod Journal container; leave every other environment off. +3. On next worker restart, the bootstrap step inserts the `demo` user. +4. First generation run produces one route + activity. Verify at `/users/demo`. +5. After a few ticks, verify the feed looks plausible and the prune doesn't fire unexpectedly (it won't, nothing is 14 days old). + +**Rollback:** `DEMO_BOT_ENABLED=false` and redeploy. To wipe all generated content: `DELETE FROM journal.activities WHERE synthetic = true; DELETE FROM journal.routes WHERE synthetic = true;`. The `demo` user row can stay — it's cheap and keeps the URL stable if we re-enable later. + +## Open Questions + +- **Do we want the profile to also get a "synthetic content" badge** so honest readers (and us, on demos) can tell at a glance? A tiny "demo account" pill on `/users/demo` is cheap. Decide during implementation. +- **Should the bot post a few backfilled items on first enablement** so the feed isn't just one item for the first four hours? Probably yes — a small one-time bootstrap that generates 3–5 items immediately if the synthetic-item count is 0. Proposing it as a task. +- **Should BRouter calls be recorded as `brouter_request_duration_seconds` the same as user requests**? Probably — same histogram is fine; synthetic traffic is a small addition and it keeps ops alerts meaningful. diff --git a/openspec/changes/demo-activity-bot/proposal.md b/openspec/changes/demo-activity-bot/proposal.md new file mode 100644 index 0000000..bf6814d --- /dev/null +++ b/openspec/changes/demo-activity-bot/proposal.md @@ -0,0 +1,35 @@ +## Why + +To demo trails.cool to prospective users and contributors we need live-looking content on the public surface introduced by `public-content-visibility`. Today a demo link opens to an empty profile. Even after visibility ships, the Journal has 3 users and 0 activities — the feed shows "nothing happened today," which is the worst possible first impression. + +A synthetic "demo" user that autonomously produces plausible activities closes that gap. It also keeps pressure on the content-creation code paths: if the bot is wedged, the import / routing / geometry pipelines are probably broken for real users too. + +This change is the direct follow-up to `public-content-visibility` and is only useful once that lands. + +## What Changes + +- A dedicated bot user account (username e.g. `demo`) is bootstrapped on first run with a stable ID, a public display name, and `visibility` defaults that mark its content public. +- A recurring pg-boss job (the existing background-jobs infrastructure on the Journal host) runs every few hours, chooses a random start + end point within a configured seed region, asks BRouter for a route, persists the route + a derived activity attributed to the demo user with `visibility = 'public'`. +- Synthetic rows are explicitly flagged at the database level (`synthetic boolean NOT NULL DEFAULT false`) so a single DELETE can clean them up and listings can optionally exclude them later. +- A retention job trims synthetic activities older than a configurable window (default 14 days) so the feed looks fresh, not a swamp of old rides. +- Everything is gated behind a `DEMO_BOT_ENABLED` env flag. Local dev, CI, and tests default to disabled. The flag is set on prod only. +- Route profiles vary: trekking and fastbike, with plausible distance ranges for each. Start times of day look reasonable. Names and descriptions draw from a small templated copy set (in both EN and DE). + +## Capabilities + +### New Capabilities +- `demo-activity-bot`: A bot user, a scheduler that seeds plausible public routes + activities using BRouter and a seed region, and a retention job to keep the feed bounded. + +### Modified Capabilities +- `route-management`: adds a `synthetic` flag alongside the `visibility` work from `public-content-visibility`, so listings and exports can distinguish bot content if they ever need to. +- `activity-feed`: same `synthetic` flag for symmetry. + +## Impact + +- **Code**: new `apps/journal/app/jobs/demo-bot.ts` for the job handlers, small helpers for picking seed coordinates and templating names, a one-time bootstrap that inserts the demo user if missing. +- **Data**: a schema change (the `synthetic` column on two tables) plus ongoing inserts into those same tables. Bounded by the retention job. +- **Infrastructure**: no new services — pg-boss is already in the stack. `DEMO_BOT_ENABLED` is a new env var on the prod Journal container. +- **External calls**: BRouter calls from the bot; throttled at the scheduler level so the bot never competes with real user traffic. No external API beyond BRouter. +- **Privacy**: the bot user has no real email, no PII, and its content is clearly author-attributed to `demo`. The legal / privacy pages do not need to change — this is first-party content the operator controls. +- **Non-goals (explicit)**: realistic social signals (no likes/comments on demo content), multi-user bot personas (one user for v1), cross-region variety (one seed region), multi-day tours, photos or media attachments. +- **Rollback**: flip `DEMO_BOT_ENABLED=false` — the job stops. A single `DELETE FROM ... WHERE synthetic = true` removes all bot content without touching real data. diff --git a/openspec/changes/demo-activity-bot/specs/activity-feed/spec.md b/openspec/changes/demo-activity-bot/specs/activity-feed/spec.md new file mode 100644 index 0000000..a2ec420 --- /dev/null +++ b/openspec/changes/demo-activity-bot/specs/activity-feed/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Synthetic activity flag +The Journal SHALL persist a `synthetic` boolean on every activity so automated / demo content can be distinguished from user-created content. + +#### Scenario: User-created activities default to non-synthetic +- **WHEN** an activity is created through any user-facing flow (GPX upload, sync import, Planner handoff) +- **THEN** the activity row is persisted with `synthetic = false` + +#### Scenario: Bot inserts flag their rows as synthetic +- **WHEN** the demo-activity-bot inserts an activity +- **THEN** the row is persisted with `synthetic = true` + +#### Scenario: Synthetic flag is not user-editable +- **WHEN** an activity owner edits the activity via any user-facing action +- **THEN** the stored `synthetic` value is not changed by the edit diff --git a/openspec/changes/demo-activity-bot/specs/demo-activity-bot/spec.md b/openspec/changes/demo-activity-bot/specs/demo-activity-bot/spec.md new file mode 100644 index 0000000..169c7ca --- /dev/null +++ b/openspec/changes/demo-activity-bot/specs/demo-activity-bot/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Demo user bootstrap +The Journal SHALL ensure a dedicated bot user exists when the demo bot starts, creating it on first run if missing. + +#### Scenario: Bot user created on first run +- **WHEN** the Journal worker starts with `DEMO_BOT_ENABLED=true` and no `users` row matches the reserved demo username +- **THEN** a new `users` row is inserted with the reserved username, a public-facing display name, a sentinel email (`demo@`), no passkey credentials, and `terms_accepted_at` + `terms_version` populated at the current version +- **AND** subsequent worker startups are idempotent — no second row is inserted + +#### Scenario: Demo user has no usable credentials +- **WHEN** any request attempts to authenticate as the demo user via passkey or magic-link +- **THEN** authentication fails because no passkey is registered and no mailbox receives magic-link mails + +### Requirement: Synthetic content generation job +The Journal SHALL run a recurring background job that generates one public route and one linked public activity for the demo user per run, subject to an env flag and a rate cap. + +#### Scenario: Disabled in non-production environments +- **WHEN** the `DEMO_BOT_ENABLED` env var is absent or any value other than `"true"` +- **THEN** the job body is a no-op: no BRouter calls, no inserts, no errors + +#### Scenario: Enabled generation flow (decide-to-walk fires) +- **WHEN** `DEMO_BOT_ENABLED=true`, the local hour is within 07:00–21:00, the per-tick Bernoulli roll fires, and the hard cap has not been reached +- **THEN** the job picks a random start and end point within the configured seed region, calls BRouter with the `trekking` profile, persists the returned GPX as a new route with `visibility='public'` and `synthetic=true`, and inserts a linked activity with the same GPX, `visibility='public'`, `synthetic=true`, a plausible `started_at` and `duration`, and a templated Bruno-voiced name/description +- **AND** the route and activity are attributed to the demo user + +#### Scenario: Decide-to-walk does not fire +- **WHEN** the local hour is outside 07:00–21:00, or the Bernoulli roll does not fire +- **THEN** the job returns without inserting anything — Bruno just isn't walking right now + +#### Scenario: BRouter failure is tolerated +- **WHEN** the BRouter call returns no route, a rate-limit, or an error +- **THEN** the job logs the failure, inserts nothing, and exits without throwing — the next scheduled tick retries + +#### Scenario: Hard cap prevents runaway growth +- **WHEN** there are already 40 or more synthetic items created in the last 14 days +- **THEN** the job skips generation for that tick + +#### Scenario: Singleton scheduling prevents overlap +- **WHEN** a tick fires while the previous run is still executing +- **THEN** the new tick is skipped (pg-boss singleton semantics) so the job cannot overlap itself + +### Requirement: Synthetic content retention +The Journal SHALL run a recurring job that deletes synthetic routes and activities older than a configurable window. + +#### Scenario: Prune removes old synthetic content +- **WHEN** the prune job runs with `DEMO_BOT_RETENTION_DAYS=14` +- **THEN** every row in `journal.routes` and `journal.activities` with `synthetic=true` and `created_at < now() - 14 days` is deleted +- **AND** route-version rows cascade-delete via existing foreign keys +- **AND** rows with `synthetic=false` are never touched + +#### Scenario: Prune is a no-op when nothing is old +- **WHEN** the prune job runs and no synthetic rows exceed the retention window +- **THEN** no DELETE statements execute and the job returns normally + +#### Scenario: Disabled in non-production environments +- **WHEN** `DEMO_BOT_ENABLED` is not `"true"` +- **THEN** the prune job body is a no-op + +### Requirement: Initial backfill +On first enablement (when no synthetic content exists yet) the Journal SHALL populate the demo profile with a small batch of items so the first visitor does not see a single-item feed. + +#### Scenario: First enablement backfills several items +- **WHEN** the bot is enabled for the first time and the count of synthetic routes is 0 +- **THEN** the generation job produces 3–5 items in sequence during its first run +- **AND** subsequent runs produce one item at a time as normal + +### Requirement: Seed region is configurable +The Journal SHALL read the seed region (bounding box) from an env var so the deployment can change it without a code change. + +#### Scenario: Env-configured region +- **WHEN** `DEMO_BOT_REGION` is set to a JSON object containing a `bbox` array of four numbers `[west, south, east, north]` +- **THEN** all generated start and end points fall within that box + +#### Scenario: Sensible default +- **WHEN** `DEMO_BOT_REGION` is unset +- **THEN** the job uses a documented default region (inner Berlin) so that out-of-the-box runs still produce plausible Bruno-style walks diff --git a/openspec/changes/demo-activity-bot/specs/route-management/spec.md b/openspec/changes/demo-activity-bot/specs/route-management/spec.md new file mode 100644 index 0000000..a61f717 --- /dev/null +++ b/openspec/changes/demo-activity-bot/specs/route-management/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Synthetic route flag +The Journal SHALL persist a `synthetic` boolean on every route so automated / demo content can be distinguished from user-created content. + +#### Scenario: User-created routes default to non-synthetic +- **WHEN** a route is created through any user-facing flow (New Route, GPX import, Planner handoff) +- **THEN** the route row is persisted with `synthetic = false` + +#### Scenario: Bot inserts flag their rows as synthetic +- **WHEN** the demo-activity-bot inserts a route +- **THEN** the row is persisted with `synthetic = true` + +#### Scenario: Synthetic flag is not user-editable +- **WHEN** a route owner edits a route via any user-facing action +- **THEN** the stored `synthetic` value is not changed by the edit diff --git a/openspec/changes/demo-activity-bot/tasks.md b/openspec/changes/demo-activity-bot/tasks.md new file mode 100644 index 0000000..f8170a8 --- /dev/null +++ b/openspec/changes/demo-activity-bot/tasks.md @@ -0,0 +1,72 @@ +## 1. Schema + +- [ ] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts` +- [ ] 1.2 Add the same column to `journal.activities` +- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers + +## 2. Bruno bootstrap + +- [ ] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls."). +- [ ] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated. +- [ ] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`. +- [ ] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@`). + +## 3. Region + generation helpers + +- [ ] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]` +- [ ] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale) +- [ ] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1 +- [ ] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats + +## 4. BRouter client for the bot + +- [ ] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats +- [ ] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker + +## 5. Generation job + +- [ ] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode +- [ ] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"` +- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day +- [ ] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40 +- [ ] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return +- [ ] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX + +## 6. Initial backfill + +- [ ] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going) +- [ ] 6.2 After the backfill path, fall through to the normal single-item generation + +## 7. Retention job + +- [ ] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton +- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"` +- [ ] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14 +- [ ] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes +- [ ] 7.5 Log the count removed for observability + +## 8. Ops + env surface + +- [ ] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/secrets.app.env`'s commented template (do not enable in any environment file except prod) +- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod +- [ ] 8.3 Expose counts via a Prometheus gauge (e.g. `demo_bot_synthetic_routes_total`, `demo_bot_synthetic_activities_total`) and reuse the existing `brouter_request_duration_seconds` histogram for the bot's BRouter calls — no new metric plumbing needed + +## 9. UX tweaks + +- [ ] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'` +- [ ] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto") + +## 10. Tests + +- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate +- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile +- [ ] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales +- [ ] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled +- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched + +## 11. Rollout + +- [ ] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set +- [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy +- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 3–5 items, and `/users/demo` renders them publicly +- [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet diff --git a/openspec/changes/public-content-visibility/.openspec.yaml b/openspec/changes/public-content-visibility/.openspec.yaml new file mode 100644 index 0000000..c8af3f5 --- /dev/null +++ b/openspec/changes/public-content-visibility/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/public-content-visibility/design.md b/openspec/changes/public-content-visibility/design.md new file mode 100644 index 0000000..390df32 --- /dev/null +++ b/openspec/changes/public-content-visibility/design.md @@ -0,0 +1,111 @@ +## Context + +The Journal uses a straightforward "logged-in or you're not seeing anything" access model today. Routes and activities are owned by a single user via `owner_id` foreign keys, and every detail-page loader calls `getSessionUser(request)` then either serves the page or (implicitly) returns the "route not found" error. There is no notion of cross-user visibility. + +The Planner is separately anonymous, so that's not a concern here — this change is Journal-only. + +Two near-term pressures shape the design: + +- **The demo plan** (send a URL to a prospective user or contributor and have them see real content without signing up). Anything that requires a friction step on arrival defeats the demo. +- **Upcoming `demo-activity-bot`** that generates synthetic activities. That content has to be publicly viewable or the bot is pointless. + +What this design deliberately *does not* touch: +- Followers / subscriptions / per-user feeds +- Reactions / comments / any write surface for non-owners +- ActivityPub / Fedify / federation +- Per-segment privacy on a single route + +Those are all future chapters. Keeping the first step narrow means less to get wrong and less to re-design later. + +## Goals / Non-Goals + +**Goals:** +- A route or activity owner can mark a piece of content as public. +- Logged-out visitors can open that content at its permanent URL. +- A logged-out visitor can find the rest of that user's public content via `/users/:username`. +- Sharing a public URL on Slack / Twitter / Bluesky / email produces a decent preview. +- Default stays private — nothing already in the database becomes visible just because this change ships. + +**Non-Goals:** +- Discovery (there is no "browse public routes" page on the instance yet). +- Indexing control beyond the default (no per-route `robots` override). +- Changing the Planner's anonymous model. +- Any write access for non-owners on public content. + +## Decisions + +### Visibility model: three values, not two + +**Decision:** `visibility` enum `'private' | 'unlisted' | 'public'`. Stored as a text column for simplicity; can be migrated to a native enum later if worth it. + +- `private` (default) — only the owner can fetch the detail page; listings never show it. +- `unlisted` — anyone with the URL can view; excluded from the owner's public profile and any future discovery pages. +- `public` — anyone can view; appears on the owner's public profile. + +**Alternatives:** +- **Just `private | public`** — simpler, but kills the useful "I want to share with someone without putting it on my profile" case. Every other sharing-oriented service has an unlisted tier; it's cheap to add now and irritating to retrofit later. +- **Sharing via time-limited tokens** (Google-Docs-style) — more powerful but out of scope for the demo goal. Parked. + +### Default is `private` for all existing rows + +**Decision:** The new column ships with `DEFAULT 'private'` and every existing row — two real routes and zero activities — remains private. Users opt in to publication by explicitly changing the value. + +**Why:** This change cannot silently un-privatise data anyone uploaded under the old rules. That principle outweighs the demo inconvenience (we'll mark the demo user's seeded content public at insert time). + +### Access check lives in route loaders + +**Decision:** Each detail-page loader (`routes.$id.tsx`, `activities.$id.tsx`) resolves the content row, compares `visibility` + `owner_id` against the session user: + +- `visibility === 'public'` → serve. +- `visibility === 'unlisted'` AND the request is a direct detail page URL → serve. +- otherwise → serve only if the requester is the owner, else 404 (not 403 — leaking existence is a privacy bug). + +A small helper `canView(content, user)` in `~/lib/auth.server` centralises the rule so list endpoints use the same logic. + +**Alternatives:** +- **Route-level middleware** (before loaders) — cleaner in theory but React Router 7 loaders are the right place for content-specific auth, and centralising via a helper avoids coupling the router tree to visibility semantics. + +### Public profile page reuses the existing `/users/:username` route + +**Decision:** That route exists today but is auth-gated (and renders the current user's own routes/activities). Broaden it: + +- Publicly accessible. +- Shows public routes + public activities of the requested username, most recent first. +- 404 if the user has no public content at all. This hides the existence of private-only accounts; an attacker can't enumerate accounts by username. +- A "This is your profile" control strip visible only to the logged-in owner, with a quick link to settings. + +### Open Graph / Twitter Card meta tags + +**Decision:** Each public route / activity detail page emits OG tags via React Router's `meta` export: + +- `og:title` — route or activity name + "· trails.cool" +- `og:description` — user description (truncated) or a sensible default +- `og:type` — `"article"` +- `og:site_name` — `"trails.cool"` +- `twitter:card` — `"summary"` (summary, not summary_large_image; we don't have social cards yet) + +A future `social-preview-images` follow-up can add rendered static map PNGs. Explicitly out of scope here. + +### `robots` control + +**Decision:** Public route / activity pages emit no `robots` meta (defaults to indexable). The legal pages keep their existing `noindex`. No sitemap is generated; discovery via search is passive. + +## Risks / Trade-offs + +- **Username enumeration via profile 404** → Mitigation: 404 both for "no such user" and "user exists but has no public content", so the two are indistinguishable. Timing-side-channel on the DB lookup is not defended against in v1; acceptable at this scale. +- **Future listing pages will want more data** → The `canView` helper already supports it. Adding an `index` boolean or "discoverable" flag is additive. +- **`unlisted` is only private-ish** → If the URL leaks it's effectively public. That's the Internet; same as every other unlisted-sharing feature. Documented in the settings UI copy. +- **Content previously private becomes linkable** → Once a user makes a route public and shares it, they can't fully retract — archives exist. Same as any web publish. No new warning for v1; the visibility selector itself is unambiguous. +- **Spec drift risk** → `route-management` and `activity-feed` both gain modified requirements; keep the delta specs tight so the archive survives review. + +## Migration Plan + +1. Merge schema change; `drizzle-kit push --force` on deploy adds the column with default `'private'`. +2. Logged-out access to public pages is a new code path — nothing existing regresses since all rows remain `'private'` until a user changes them. +3. `demo-activity-bot` is the first caller that will create rows with `visibility: 'public'` at insert time. +4. Rollback: `UPDATE routes SET visibility = 'private'; UPDATE activities SET visibility = 'private';` restores the prior behaviour without any code change; the public code path becomes dead but harmless. + +## Open Questions + +- Should the settings page add a default-visibility preference per user (e.g. "make new routes public by default")? Probably nice but not in this spec; revisit after observing real usage. +- Do we want to emit `` on unlisted pages to discourage indexing of the URL if it leaks? Low-cost, worth considering during implementation. diff --git a/openspec/changes/public-content-visibility/proposal.md b/openspec/changes/public-content-visibility/proposal.md new file mode 100644 index 0000000..b2996e9 --- /dev/null +++ b/openspec/changes/public-content-visibility/proposal.md @@ -0,0 +1,37 @@ +## Why + +trails.cool currently has no way to show its content to anyone who isn't logged in. Route and activity detail pages require auth, there is no public-facing profile page, and shared URLs have no Open Graph metadata — so a link pasted in Slack or on a social platform looks like a blank trails.cool card. + +Two immediate reasons to fix this: + +1. **Demos + acquisition.** The next phase of the project is onboarding more users and contributors. The pitch lands better when you can send a URL that opens directly to "look at this route / this weekend's ride" without making the visitor register first. +2. **Prerequisite for any `demo-activity-bot` work.** A synthetic-activity generator is pointless if the generated content isn't publicly viewable. This change unblocks that follow-up. + +This is also the smallest possible step into the "social" direction the Journal was always described as heading for. It does not introduce follow/follower, cross-user feeds, reactions, or ActivityPub — those remain future work. + +## What Changes + +- New `visibility` column on routes and activities with values `private` (default for existing rows), `unlisted` (reachable only by direct URL, excluded from listings), `public` (visible everywhere). +- Logged-out visitors can view **public** route and activity detail pages. Private / nonexistent routes return 404, just like logged-in requests to other users' private content. Unlisted renders for anyone with the URL but does not appear in listings. +- New public profile page at `/users/:username` that lists that user's **public** routes and activities. Returns 404 if the user has no public content (keeps the existence of private-only accounts itself private). +- Route detail page gains a visibility selector for owners (the existing edit page is the natural home). +- Open Graph / Twitter Card meta tags on public route and activity detail pages so shared URLs preview nicely (title, description, a small route preview image for future, the instance name as site). +- **BREAKING for spec-readers only**: several existing requirements in `route-management` and `activity-feed` are modified to reflect the new "auth-or-public" access rule. The default in migration is `private`, so **behaviour for existing users does not change** — their routes stay inaccessible to logged-out visitors until they explicitly make one public. + +## Capabilities + +### New Capabilities +- `public-profiles`: Public-facing user pages at `/users/:username` listing a user's public routes and activities, with nothing shown for users who have no public content. + +### Modified Capabilities +- `route-management`: adds visibility field + modified access rules so public routes are viewable without auth; owners can change visibility. +- `activity-feed`: adds visibility field; public activities render to logged-out visitors. + +## Impact + +- **Code**: schema (`visibility` column on `routes` + `activities`), loader auth changes on `routes.$id.tsx` / `activities.$id.tsx`, new route `users.$username.tsx` (already exists but is auth-gated today), a visibility selector somewhere in the route edit flow, a `meta` export on public detail pages for OG tags. +- **Data**: default `visibility = private` for all existing rows — three users, zero activities, two empty routes — so migration is effectively a no-op for behaviour. No backfill needed. +- **Privacy**: this change explicitly reduces the default privacy posture *only if a user opts in by marking content public*. A short sentence in `/legal/privacy` notes public content is world-visible. No new third-party surface. +- **Search engines**: public pages are not actively promoted (no sitemap). `robots: noindex` stays on legal pages and settings; `routes` and `activities` public pages get a permissive default (search-indexable) so demo URLs work well. +- **Non-goals (explicit)**: follow/follower, a cross-user feed, reactions/comments, federation, route privacy per-section. Keeping scope tight. +- **Rollback**: flipping the default back to `private` everywhere is a single UPDATE. Pages gating on visibility degrade to "auth-only" if we remove the public path. diff --git a/openspec/changes/public-content-visibility/specs/activity-feed/spec.md b/openspec/changes/public-content-visibility/specs/activity-feed/spec.md new file mode 100644 index 0000000..b0cffab --- /dev/null +++ b/openspec/changes/public-content-visibility/specs/activity-feed/spec.md @@ -0,0 +1,48 @@ +## MODIFIED Requirements + +### Requirement: Activity detail page +The Journal SHALL display an activity detail page with map, stats, and description. Access depends on the activity's `visibility`: `public` activities are viewable by anyone including unauthenticated visitors, `unlisted` activities are viewable by anyone who has the URL, and `private` activities are viewable only by the owner. + +#### Scenario: Owner views own activity +- **WHEN** a logged-in user navigates to an activity they own at any visibility +- **THEN** they see the activity name, description, a map with the GPS trace, distance, duration, and elevation stats + +#### Scenario: Anyone views a public activity +- **WHEN** any visitor (including unauthenticated) navigates to a `public` activity's URL +- **THEN** they see the full activity detail page as above + +#### Scenario: Anyone with the URL views an unlisted activity +- **WHEN** any visitor navigates directly to an `unlisted` activity's URL +- **THEN** they see the full activity detail page as above + +#### Scenario: Non-owner is blocked from a private activity +- **WHEN** a visitor who is not the owner requests a `private` activity URL +- **THEN** the server responds with HTTP 404 (not 403), so the existence of the private activity is not leaked + +#### Scenario: Public and unlisted activity pages emit social-share metadata +- **WHEN** a visitor loads a `public` or `unlisted` activity detail page +- **THEN** the response emits Open Graph and Twitter Card meta tags (`og:title`, `og:description`, `og:type="article"`, `og:site_name`, `twitter:card="summary"`) + +## ADDED Requirements + +### Requirement: Activity visibility +The Journal SHALL persist a `visibility` value on every activity and SHALL allow the owner to change it. + +#### Scenario: New activities default to private +- **WHEN** an activity is created without an explicit visibility +- **THEN** the activity row is persisted with `visibility = 'private'` + +#### Scenario: Owner changes an activity's visibility +- **WHEN** an activity owner selects a different visibility (`private`, `unlisted`, `public`) and saves +- **THEN** the stored visibility is updated and subsequent access checks use the new value immediately + +### Requirement: Activity listings respect visibility +The Journal's own-activities feed SHALL show the owner everything regardless of visibility, while any cross-user listing SHALL only include activities with `visibility = 'public'`. + +#### Scenario: Own activity feed is unchanged +- **WHEN** a logged-in user views their own activity feed +- **THEN** the feed includes all of their own activities regardless of visibility + +#### Scenario: Public profile lists only public activities +- **WHEN** a visitor loads `/users/:username` +- **THEN** the rendered list of activities includes only the user's `public` activities; `unlisted` and `private` activities are omitted diff --git a/openspec/changes/public-content-visibility/specs/public-profiles/spec.md b/openspec/changes/public-content-visibility/specs/public-profiles/spec.md new file mode 100644 index 0000000..e2f4cd0 --- /dev/null +++ b/openspec/changes/public-content-visibility/specs/public-profiles/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Public profile page +The Journal SHALL serve a public profile page at `/users/:username` that lists the user's public routes and activities in reverse chronological order, viewable without authentication. + +#### Scenario: Logged-out visitor views a profile with public content +- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user with at least one `public` route or activity +- **THEN** the page renders that user's display name (falling back to username), the `@username@domain` handle, and a reverse-chronological list of their `public` routes and `public` activities +- **AND** items marked `unlisted` or `private` do NOT appear in the list + +#### Scenario: Profile 404 when there is no public content +- **WHEN** a visitor navigates to `/users/:username` for a user whose content is all `private` or `unlisted`, or for a username that does not exist +- **THEN** the server responds with HTTP 404 +- **AND** the response does NOT distinguish the two cases, so existence of a private-only account is not leaked + +#### Scenario: Owner sees their own profile +- **WHEN** a user navigates to their own `/users/:username` while logged in +- **THEN** the page renders exactly the same as for a logged-out visitor, plus a small owner-only control strip linking to settings + +#### Scenario: Profile page emits social-share metadata +- **WHEN** any visitor loads a populated `/users/:username` +- **THEN** the page emits Open Graph tags (`og:title`, `og:site_name`, `og:type="profile"`) so links shared on social platforms render a meaningful preview diff --git a/openspec/changes/public-content-visibility/specs/route-management/spec.md b/openspec/changes/public-content-visibility/specs/route-management/spec.md new file mode 100644 index 0000000..fe804d0 --- /dev/null +++ b/openspec/changes/public-content-visibility/specs/route-management/spec.md @@ -0,0 +1,52 @@ +## MODIFIED Requirements + +### Requirement: View route +The Journal SHALL display route details including map, metadata, and elevation stats. Access depends on the route's `visibility`: `public` routes are viewable by anyone including unauthenticated visitors, `unlisted` routes are viewable by anyone who has the URL, and `private` routes are viewable only by the owner. + +#### Scenario: Owner views own route +- **WHEN** a logged-in user navigates to a route they own at any visibility +- **THEN** they see the route name, description, a map with the route polyline, distance, and elevation gain/loss + +#### Scenario: Anyone views a public route +- **WHEN** any visitor (including unauthenticated) navigates to a `public` route's URL +- **THEN** they see the full route detail page as above + +#### Scenario: Anyone with the URL views an unlisted route +- **WHEN** any visitor navigates directly to an `unlisted` route's URL +- **THEN** they see the full route detail page as above + +#### Scenario: Non-owner is blocked from a private route +- **WHEN** a visitor who is not the owner requests a `private` route URL +- **THEN** the server responds with HTTP 404 (not 403), so the existence of the private route is not leaked + +#### Scenario: Public and unlisted route pages emit social-share metadata +- **WHEN** a visitor loads a `public` or `unlisted` route detail page +- **THEN** the response emits Open Graph and Twitter Card meta tags (`og:title`, `og:description`, `og:type="article"`, `og:site_name`, `twitter:card="summary"`) + +## ADDED Requirements + +### Requirement: Route visibility +The Journal SHALL persist a `visibility` value on every route and SHALL allow the owner to change it. + +#### Scenario: New routes default to private +- **WHEN** a route is created without an explicit visibility +- **THEN** the route row is persisted with `visibility = 'private'` + +#### Scenario: Owner changes a route's visibility +- **WHEN** a route owner selects a different visibility (`private`, `unlisted`, `public`) in the edit flow and saves +- **THEN** the stored visibility is updated and subsequent access checks use the new value immediately + +#### Scenario: Non-owner cannot change visibility +- **WHEN** a request to update visibility arrives from a user who is not the route owner +- **THEN** the server rejects it with HTTP 403 or 404 (matching the current update-route behaviour), and the stored value is unchanged + +### Requirement: Route listings respect visibility +Any listing that exposes routes beyond the owner's own dashboard SHALL only include routes with `visibility = 'public'`. + +#### Scenario: Public profile lists only public routes +- **WHEN** a visitor loads `/users/:username` +- **THEN** the rendered list of routes includes only the user's `public` routes; `unlisted` and `private` routes are omitted + +#### Scenario: Owner's own routes list is unchanged +- **WHEN** a logged-in user views their own routes list at `/routes` +- **THEN** the list includes all of their own routes regardless of visibility diff --git a/openspec/changes/public-content-visibility/tasks.md b/openspec/changes/public-content-visibility/tasks.md new file mode 100644 index 0000000..4196ff5 --- /dev/null +++ b/openspec/changes/public-content-visibility/tasks.md @@ -0,0 +1,62 @@ +## 1. Schema + +- [ ] 1.1 Add `visibility text NOT NULL DEFAULT 'private'` to `journal.routes` in `packages/db/src/schema/journal.ts` +- [ ] 1.2 Add the same column to `journal.activities` in the same file +- [ ] 1.3 Export a shared type `type Visibility = 'private' | 'unlisted' | 'public'` from `packages/db/src/schema/journal.ts` (or a small adjacent module) and reuse at the app layer + +## 2. Server-side access helper + +- [ ] 2.1 Add `canView(content, user)` in `apps/journal/app/lib/auth.server.ts` that returns `true` for `public`, `true` for `unlisted` (the direct-URL case — callers pass `true` when routed to a detail page, `false` when generating listings), and ownership-checked otherwise +- [ ] 2.2 Unit test the three matrix cells + +## 3. Route detail access + +- [ ] 3.1 Update `apps/journal/app/routes/routes.$id.tsx` loader: fetch route, then apply `canView`; return 404 when not allowed +- [ ] 3.2 Expose `visibility` on the loader's returned shape so the component can render the current-visibility badge to the owner +- [ ] 3.3 Emit Open Graph / Twitter Card `meta` for `public` and `unlisted` routes (title, description, `og:type="article"`, `og:site_name="trails.cool"`, `twitter:card="summary"`) + +## 4. Activity detail access + +- [ ] 4.1 Mirror 3.1 in `apps/journal/app/routes/activities.$id.tsx` +- [ ] 4.2 Mirror 3.3 for activities + +## 5. Visibility selector in the edit flow + +- [ ] 5.1 Add a visibility ` on routes/:id/edit with owner-only access. - Activity detail page gets a small visibility form + set-visibility action intent (no separate activity-edit page needed). - EN + DE i18n under routes.visibility.* and activities.visibility.*. Listings: - Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner for cross-user queries. Existing owner-scoped listRoutes/listActivities stay — owners see their own content regardless of visibility. Public profile: - /users/:username is now truly public. Renders the user's public routes + activities, 404s when no public content exists AND viewer isn't the owner (prevents account enumeration). - Owner sees a short "this is your profile" note linking to settings. - Open Graph meta (og:type=profile) for shareable preview. Privacy manifest: - Added a bullet noting public content is world-visible on profile and indexable by search engines. - Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive snapshot. Tests: - 13 unit tests for canView covering the full matrix. - 6 E2E tests in e2e/public-content.test.ts covering: - Private route → 404 for logged-out visitor - Public route → reachable + OG tags present (og:title, og:type, og:site_name) - Owner still sees own private content - Profile 404 when no public content - Profile renders when at least one public route exists - Unlisted route reachable via direct URL but hidden from profile - Test file runs serially (describe.configure mode=serial) to avoid WebAuthn virtual-authenticator races under Playwright's default parallel workers. - New public-content Playwright project added to config. Rollout safety: every existing row in prod keeps visibility='private' by default — nothing becomes visible to outsiders until an owner explicitly opts in. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/lib/activities.server.ts | 34 ++ apps/journal/app/lib/auth.server.ts | 40 ++ apps/journal/app/lib/canView.test.ts | 64 +++ apps/journal/app/lib/legal.ts | 2 +- apps/journal/app/lib/routes.server.ts | 20 + apps/journal/app/routes/activities.$id.tsx | 76 +++- apps/journal/app/routes/legal.privacy.tsx | 8 + apps/journal/app/routes/routes.$id.edit.tsx | 39 +- apps/journal/app/routes/routes.$id.tsx | 34 +- apps/journal/app/routes/users.$username.tsx | 140 ++++++- docs/legal-archive/privacy-2026-04-20.md | 377 ++++++++++++++++++ e2e/public-content.test.ts | 179 +++++++++ .../public-content-visibility/tasks.md | 66 +-- packages/db/src/schema/journal.ts | 11 + packages/i18n/src/locales/de.ts | 21 + packages/i18n/src/locales/en.ts | 21 + playwright.config.ts | 8 + 17 files changed, 1079 insertions(+), 61 deletions(-) create mode 100644 apps/journal/app/lib/canView.test.ts create mode 100644 docs/legal-archive/privacy-2026-04-20.md create mode 100644 e2e/public-content.test.ts diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index a4ec344..f2ea007 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes, syncImports } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; import { setGeomFromGpx } from "./routes.server.ts"; @@ -13,6 +14,21 @@ export interface ActivityInput { distance?: number | null; duration?: number | null; startedAt?: Date | null; + visibility?: Visibility; +} + +export async function updateActivityVisibility( + id: string, + ownerId: string, + visibility: Visibility, +): Promise { + const db = getDb(); + const result = await db + .update(activities) + .set({ visibility }) + .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))) + .returning({ id: activities.id }); + return result.length > 0; } export async function createActivity(ownerId: string, input: ActivityInput) { @@ -100,6 +116,24 @@ export async function listActivities(ownerId: string) { return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } +/** + * List the *public* activities of a given owner. Used for cross-user + * listings (the public profile page); never includes `unlisted` or + * `private` content. + */ +export async function listPublicActivitiesForOwner(ownerId: string) { + const db = getDb(); + const rows = await db + .select() + .from(activities) + .where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public"))) + .orderBy(desc(activities.createdAt)); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { const db = getDb(); await db diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 0aa8861..cc006bc 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -13,6 +13,7 @@ import type { } from "@simplewebauthn/types"; import { getDb } from "./db.ts"; import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; const RP_NAME = "trails.cool"; const RP_ID = process.env.DOMAIN ?? "localhost"; @@ -410,6 +411,45 @@ export const sessionStorage = createCookieSessionStorage({ }, }); +/** + * A row that carries the minimum a visibility check needs. + */ +export interface Viewable { + ownerId: string; + visibility: Visibility; +} + +/** + * The caller's identity. `null` represents a logged-out visitor. + */ +export interface Viewer { + id: string; +} + +/** + * Decide whether a viewer may see a piece of content. + * + * - `public` content is viewable by anyone. + * - `unlisted` content is viewable only on direct-link access — listings + * should omit it. Callers rendering a detail page pass `asDirectLink: + * true`; listings default to `false`. + * - `private` content is viewable only by the owner. + * + * Centralised here so detail loaders and listing queries use the same + * rule. Intentionally does not throw; callers handle the `false` case + * (usually by returning HTTP 404 rather than 403 to avoid leaking + * existence). + */ +export function canView( + content: Viewable, + viewer: Viewer | null, + { asDirectLink = false }: { asDirectLink?: boolean } = {}, +): boolean { + if (content.visibility === "public") return true; + if (content.visibility === "unlisted" && asDirectLink) return true; + return viewer?.id === content.ownerId; +} + /** * Record the user's acceptance of the current Terms version. Updates both * `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user diff --git a/apps/journal/app/lib/canView.test.ts b/apps/journal/app/lib/canView.test.ts new file mode 100644 index 0000000..ec7e259 --- /dev/null +++ b/apps/journal/app/lib/canView.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { canView, type Viewable, type Viewer } from "./auth.server.ts"; + +const owner: Viewer = { id: "owner-id" }; +const other: Viewer = { id: "other-id" }; + +function row(visibility: "private" | "unlisted" | "public"): Viewable { + return { ownerId: owner.id, visibility }; +} + +describe("canView", () => { + describe("public content", () => { + it("is viewable by the owner", () => { + expect(canView(row("public"), owner)).toBe(true); + }); + it("is viewable by another logged-in user", () => { + expect(canView(row("public"), other)).toBe(true); + }); + it("is viewable by a logged-out visitor", () => { + expect(canView(row("public"), null)).toBe(true); + }); + it("ignores asDirectLink (always viewable)", () => { + expect(canView(row("public"), null, { asDirectLink: false })).toBe(true); + expect(canView(row("public"), null, { asDirectLink: true })).toBe(true); + }); + }); + + describe("unlisted content", () => { + it("is viewable by the owner", () => { + expect(canView(row("unlisted"), owner)).toBe(true); + }); + it("is viewable by another user on a direct link", () => { + expect(canView(row("unlisted"), other, { asDirectLink: true })).toBe(true); + }); + it("is viewable by a logged-out visitor on a direct link", () => { + expect(canView(row("unlisted"), null, { asDirectLink: true })).toBe(true); + }); + it("is NOT visible in a listing to another user", () => { + expect(canView(row("unlisted"), other, { asDirectLink: false })).toBe(false); + }); + it("is NOT visible in a listing to a logged-out visitor", () => { + expect(canView(row("unlisted"), null, { asDirectLink: false })).toBe(false); + }); + it("defaults to the listing rule when asDirectLink is omitted", () => { + expect(canView(row("unlisted"), other)).toBe(false); + expect(canView(row("unlisted"), null)).toBe(false); + }); + }); + + describe("private content", () => { + it("is viewable by the owner", () => { + expect(canView(row("private"), owner)).toBe(true); + expect(canView(row("private"), owner, { asDirectLink: true })).toBe(true); + }); + it("is NOT viewable by another user", () => { + expect(canView(row("private"), other)).toBe(false); + expect(canView(row("private"), other, { asDirectLink: true })).toBe(false); + }); + it("is NOT viewable by a logged-out visitor", () => { + expect(canView(row("private"), null)).toBe(false); + expect(canView(row("private"), null, { asDirectLink: true })).toBe(false); + }); + }); +}); diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts index a312bc7..9105096 100644 --- a/apps/journal/app/lib/legal.ts +++ b/apps/journal/app/lib/legal.ts @@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19"; * require re-acceptance (the policy is informational, not contract), so this * is display-only — not persisted. */ -export const PRIVACY_LAST_UPDATED = "2026-04-19"; +export const PRIVACY_LAST_UPDATED = "2026-04-20"; diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 2a77a14..1d6f13c 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; import { sql } from "drizzle-orm"; @@ -10,6 +11,7 @@ export interface RouteInput { description?: string; gpx?: string; routingProfile?: string; + visibility?: Visibility; } export async function createRoute(ownerId: string, input: RouteInput) { @@ -96,6 +98,23 @@ export async function listRoutes(ownerId: string) { return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } +/** + * List the *public* routes of a given owner. Used for cross-user listings + * (the public profile page); never includes `unlisted` or `private` content. + */ +export async function listPublicRoutesForOwner(ownerId: string) { + const db = getDb(); + const rows = await db + .select() + .from(routes) + .where(and(eq(routes.ownerId, ownerId), eq(routes.visibility, "public"))) + .orderBy(desc(routes.updatedAt)); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + export async function updateRoute( id: string, ownerId: string, @@ -106,6 +125,7 @@ export async function updateRoute( const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) updateData.name = input.name; if (input.description !== undefined) updateData.description = input.description; + if (input.visibility !== undefined) updateData.visibility = input.visibility; if (input.gpx) { updateData.gpx = input.gpx; diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 1b80106..3c840ae 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -1,12 +1,15 @@ import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/activities.$id"; -import { getSessionUser } from "~/lib/auth.server"; -import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server"; +import { canView, getSessionUser } from "~/lib/auth.server"; +import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity, updateActivityVisibility } from "~/lib/activities.server"; import { deleteImportByActivity } from "~/lib/sync/imports.server"; import { listRoutes } from "~/lib/routes.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; +import type { Visibility } from "@trails-cool/db/schema/journal"; + +const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loader({ params, request }: Route.LoaderArgs) { const activity = await getActivity(params.id); @@ -15,6 +18,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { const user = await getSessionUser(request); const isOwner = user?.id === activity.ownerId; + // Visibility gate — public always, unlisted on direct link, private owner-only. + // 404 (not 403) to avoid leaking existence. + if (!canView(activity, user, { asDirectLink: true })) { + throw data({ error: "Activity not found" }, { status: 404 }); + } + const userRoutes = isOwner && user ? await listRoutes(user.id) : []; return data({ @@ -30,6 +39,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { hasGpx: !!activity.gpx, geojson: activity.geojson ?? null, startedAt: activity.startedAt?.toISOString() ?? null, + visibility: activity.visibility, createdAt: activity.createdAt.toISOString(), importSource: activity.importSource, }, @@ -66,12 +76,41 @@ export async function action({ params, request }: Route.ActionArgs) { return data({ error: "Activity not found" }, { status: 404 }); } + if (intent === "set-visibility") { + const raw = formData.get("visibility") as string | null; + if (!raw || !VISIBILITY_VALUES.has(raw as Visibility)) { + return data({ error: "Invalid visibility" }, { status: 400 }); + } + const ok = await updateActivityVisibility(params.id, user.id, raw as Visibility); + if (!ok) return data({ error: "Activity not found" }, { status: 404 }); + return redirect(`/activities/${params.id}`); + } + return data({ error: "Unknown action" }, { status: 400 }); } export function meta({ data: loaderData }: Route.MetaArgs) { - const name = (loaderData as { activity: { name: string } })?.activity?.name ?? "Activity"; - return [{ title: `${name} — trails.cool` }]; + const activity = (loaderData as { activity?: { name: string; description: string | null; visibility: string } })?.activity; + const name = activity?.name ?? "Activity"; + const title = `${name} — trails.cool`; + const tags: Array> = [{ title }]; + + if (activity && (activity.visibility === "public" || activity.visibility === "unlisted")) { + const description = (activity.description && activity.description.length > 0) + ? activity.description.slice(0, 280) + : `An activity on trails.cool`; + tags.push( + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:type", content: "article" }, + { property: "og:site_name", content: "trails.cool" }, + { name: "twitter:card", content: "summary" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + ); + } + + return tags; } export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) { @@ -177,6 +216,35 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
    )} + {isOwner && ( +
    +
    + +
    + + +
    + +
    +
    + )} + {isOwner && (
    { if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}> diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 90a601c..bdc805d 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -375,6 +375,14 @@ export default function PrivacyPage() {
  • Routes: GPX, geometry, title, description
  • Activities: title, description, date, linked route
  • GPX / JSON export available per object and overall
  • +
  • + Routes and activities each carry a visibility setting + (private / unlisted / public) + that defaults to private. Content you explicitly mark + public is visible to anyone on the internet, + including on your public profile at /users/<you> + and on search engines that index those pages. +
  • diff --git a/apps/journal/app/routes/routes.$id.edit.tsx b/apps/journal/app/routes/routes.$id.edit.tsx index 9dce0ca..e1fe2e9 100644 --- a/apps/journal/app/routes/routes.$id.edit.tsx +++ b/apps/journal/app/routes/routes.$id.edit.tsx @@ -1,7 +1,11 @@ import { data, redirect } from "react-router"; +import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id.edit"; import { getSessionUser } from "~/lib/auth.server"; import { getRoute, updateRoute } from "~/lib/routes.server"; +import type { Visibility } from "@trails-cool/db/schema/journal"; + +const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loader({ params, request }: Route.LoaderArgs) { const user = await getSessionUser(request); @@ -12,7 +16,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { if (route.ownerId !== user.id) throw data({ error: "Not authorized" }, { status: 403 }); return data({ - route: { id: route.id, name: route.name, description: route.description }, + route: { + id: route.id, + name: route.name, + description: route.description, + visibility: route.visibility, + }, }); } @@ -24,13 +33,17 @@ export async function action({ params, request }: Route.ActionArgs) { const name = formData.get("name") as string; const description = formData.get("description") as string; const gpxFile = formData.get("gpx") as File | null; + const visibilityRaw = formData.get("visibility") as string | null; - const input: { name?: string; description?: string; gpx?: string } = {}; + const input: { name?: string; description?: string; gpx?: string; visibility?: Visibility } = {}; if (name) input.name = name; if (description !== null) input.description = description; if (gpxFile && gpxFile.size > 0) { input.gpx = await gpxFile.text(); } + if (visibilityRaw && VISIBILITY_VALUES.has(visibilityRaw as Visibility)) { + input.visibility = visibilityRaw as Visibility; + } await updateRoute(params.id, user.id, input); return redirect(`/routes/${params.id}`); @@ -42,6 +55,7 @@ export function meta(_args: Route.MetaArgs) { export default function EditRoutePage({ loaderData }: Route.ComponentProps) { const { route } = loaderData; + const { t } = useTranslation("journal"); return (
    @@ -75,6 +89,27 @@ export default function EditRoutePage({ loaderData }: Route.ComponentProps) { />
    +
    + + +

    + {route.visibility === "private" && t("routes.visibility.privateHelp")} + {route.visibility === "unlisted" && t("routes.visibility.unlistedHelp")} + {route.visibility === "public" && t("routes.visibility.publicHelp")} +

    +
    +