From 2a981a838302546bada460e277e451f3b48af667 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:11:54 +0200
Subject: [PATCH 001/468] Add Prometheus metrics + Grafana panels for Overpass
proxy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds four metrics surfaced via the existing /metrics endpoint:
- overpass_cache_events_total{result=hit|miss|coalesced}
- overpass_cache_size (gauge)
- overpass_upstream_duration_seconds (histogram)
- overpass_upstream_requests_total{status} — covers 2xx/4xx/5xx and
an explicit "error" label for network-level failures
Grafana dashboards/planner.json gets a new row:
- Upstream health (5m 2xx success ratio, red <90%, green >99%)
- Cache hit ratio
- Cache size
- Upstream p95 latency
Plus timeseries panels for cache event breakdown, upstream status
over time, and p50/p95/p99 upstream latency.
The success-rate formula uses clamp_min(..., 1) on the denominator so
it doesn't NaN when traffic is zero.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/planner/app/lib/metrics.server.ts | 31 ++++
apps/planner/app/routes/api.overpass.ts | 15 ++
.../grafana/dashboards/planner.json | 162 ++++++++++++++++++
3 files changed, 208 insertions(+)
diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts
index 46d2060..b89f8c2 100644
--- a/apps/planner/app/lib/metrics.server.ts
+++ b/apps/planner/app/lib/metrics.server.ts
@@ -41,4 +41,35 @@ export const brouterRequestDuration = getOrCreate("brouter_request_duration_seco
}),
);
+export const overpassCacheEvents = getOrCreate("overpass_cache_events_total", () =>
+ new client.Counter({
+ name: "overpass_cache_events_total",
+ help: "Overpass proxy cache events",
+ labelNames: ["result"] as const, // hit | miss | coalesced
+ }),
+);
+
+export const overpassCacheSize = getOrCreate("overpass_cache_size", () =>
+ new client.Gauge({
+ name: "overpass_cache_size",
+ help: "Current number of entries in the Overpass proxy cache",
+ }),
+);
+
+export const overpassUpstreamDuration = getOrCreate("overpass_upstream_duration_seconds", () =>
+ new client.Histogram({
+ name: "overpass_upstream_duration_seconds",
+ help: "Duration of upstream Overpass API requests in seconds",
+ buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
+ }),
+);
+
+export const overpassUpstreamRequests = getOrCreate("overpass_upstream_requests_total", () =>
+ new client.Counter({
+ name: "overpass_upstream_requests_total",
+ help: "Upstream Overpass API requests by status",
+ labelNames: ["status"] as const,
+ }),
+);
+
export const registry = client.register;
diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts
index e1b90ea..e97b7c3 100644
--- a/apps/planner/app/routes/api.overpass.ts
+++ b/apps/planner/app/routes/api.overpass.ts
@@ -1,5 +1,11 @@
import type { Route } from "./+types/api.overpass";
import { checkRateLimit } from "~/lib/rate-limit";
+import {
+ overpassCacheEvents,
+ overpassCacheSize,
+ overpassUpstreamDuration,
+ overpassUpstreamRequests,
+} from "~/lib/metrics.server";
const UPSTREAM_URL = process.env.OVERPASS_URL ?? "https://overpass.private.coffee/api/interpreter";
const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)";
@@ -36,6 +42,7 @@ function putInCache(key: string, body: string, contentType: string) {
cache.delete(oldest);
}
cache.set(key, { body, contentType, expiresAt: Date.now() + CACHE_TTL_MS });
+ overpassCacheSize.set(cache.size);
}
export async function action({ request }: Route.ActionArgs) {
@@ -56,6 +63,7 @@ export async function action({ request }: Route.ActionArgs) {
// Cache hit: skip rate limit and upstream entirely
const cached = getFromCache(cacheKey);
if (cached) {
+ overpassCacheEvents.inc({ result: "hit" });
return new Response(cached.body, {
status: 200,
headers: { "Content-Type": cached.contentType, "X-Cache": "hit" },
@@ -75,7 +83,9 @@ export async function action({ request }: Route.ActionArgs) {
// same session don't each hit upstream for the identical bbox.
let pending = inFlight.get(cacheKey);
if (!pending) {
+ overpassCacheEvents.inc({ result: "miss" });
pending = (async () => {
+ const start = Date.now();
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
@@ -85,6 +95,8 @@ export async function action({ request }: Route.ActionArgs) {
},
body,
});
+ overpassUpstreamDuration.observe((Date.now() - start) / 1000);
+ overpassUpstreamRequests.inc({ status: String(upstream.status) });
const responseBody = await upstream.text();
const contentType = upstream.headers.get("content-type") ?? "application/json";
const cacheable = upstream.status === 200 && !responseBody.includes("rate_limited");
@@ -94,12 +106,15 @@ export async function action({ request }: Route.ActionArgs) {
}
})();
inFlight.set(cacheKey, pending);
+ } else {
+ overpassCacheEvents.inc({ result: "coalesced" });
}
let result;
try {
result = await pending;
} catch {
+ overpassUpstreamRequests.inc({ status: "error" });
return new Response("Upstream Overpass unavailable", { status: 502 });
}
diff --git a/infrastructure/grafana/dashboards/planner.json b/infrastructure/grafana/dashboards/planner.json
index 72a9e02..084fa8e 100644
--- a/infrastructure/grafana/dashboards/planner.json
+++ b/infrastructure/grafana/dashboards/planner.json
@@ -97,6 +97,168 @@
"legendFormat": "{{route}}"
}
]
+ },
+ {
+ "title": "Overpass Upstream Health (5m success rate)",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 0,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(overpass_upstream_requests_total{status=~\"2..\"}[5m])) / clamp_min(sum(rate(overpass_upstream_requests_total[5m])), 1)",
+ "legendFormat": "success"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "orange", "value": 0.9 },
+ { "color": "green", "value": 0.99 }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Hit Ratio (5m)",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 6,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(overpass_cache_events_total{result=\"hit\"}[5m])) / clamp_min(sum(rate(overpass_cache_events_total[5m])), 1)",
+ "legendFormat": "hit ratio"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Size",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 12,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "overpass_cache_size",
+ "legendFormat": "entries"
+ }
+ ]
+ },
+ {
+ "title": "Overpass Upstream p95",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 18,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p95"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Events",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 0,
+ "y": 19
+ },
+ "targets": [
+ {
+ "expr": "sum by (result) (rate(overpass_cache_events_total[5m]))",
+ "legendFormat": "{{result}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops"
+ }
+ }
+ },
+ {
+ "title": "Overpass Upstream Status",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 12,
+ "y": 19
+ },
+ "targets": [
+ {
+ "expr": "sum by (status) (rate(overpass_upstream_requests_total[5m]))",
+ "legendFormat": "{{status}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops"
+ }
+ }
+ },
+ {
+ "title": "Overpass Upstream Latency",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 24,
+ "x": 0,
+ "y": 26
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.50, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p50"
+ },
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p95"
+ },
+ {
+ "expr": "histogram_quantile(0.99, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p99"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
}
]
}
From 62e208d850f85903e1ab6d60d80b3ab1df4d3d8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:11:54 +0200
Subject: [PATCH 002/468] Add Prometheus metrics + Grafana panels for Overpass
proxy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds four metrics surfaced via the existing /metrics endpoint:
- overpass_cache_events_total{result=hit|miss|coalesced}
- overpass_cache_size (gauge)
- overpass_upstream_duration_seconds (histogram)
- overpass_upstream_requests_total{status} — covers 2xx/4xx/5xx and
an explicit "error" label for network-level failures
Grafana dashboards/planner.json gets a new row:
- Upstream health (5m 2xx success ratio, red <90%, green >99%)
- Cache hit ratio
- Cache size
- Upstream p95 latency
Plus timeseries panels for cache event breakdown, upstream status
over time, and p50/p95/p99 upstream latency.
The success-rate formula uses clamp_min(..., 1) on the denominator so
it doesn't NaN when traffic is zero.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/planner/app/lib/metrics.server.ts | 31 ++++
apps/planner/app/routes/api.overpass.ts | 15 ++
.../grafana/dashboards/planner.json | 162 ++++++++++++++++++
3 files changed, 208 insertions(+)
diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts
index 46d2060..b89f8c2 100644
--- a/apps/planner/app/lib/metrics.server.ts
+++ b/apps/planner/app/lib/metrics.server.ts
@@ -41,4 +41,35 @@ export const brouterRequestDuration = getOrCreate("brouter_request_duration_seco
}),
);
+export const overpassCacheEvents = getOrCreate("overpass_cache_events_total", () =>
+ new client.Counter({
+ name: "overpass_cache_events_total",
+ help: "Overpass proxy cache events",
+ labelNames: ["result"] as const, // hit | miss | coalesced
+ }),
+);
+
+export const overpassCacheSize = getOrCreate("overpass_cache_size", () =>
+ new client.Gauge({
+ name: "overpass_cache_size",
+ help: "Current number of entries in the Overpass proxy cache",
+ }),
+);
+
+export const overpassUpstreamDuration = getOrCreate("overpass_upstream_duration_seconds", () =>
+ new client.Histogram({
+ name: "overpass_upstream_duration_seconds",
+ help: "Duration of upstream Overpass API requests in seconds",
+ buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
+ }),
+);
+
+export const overpassUpstreamRequests = getOrCreate("overpass_upstream_requests_total", () =>
+ new client.Counter({
+ name: "overpass_upstream_requests_total",
+ help: "Upstream Overpass API requests by status",
+ labelNames: ["status"] as const,
+ }),
+);
+
export const registry = client.register;
diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts
index e1b90ea..e97b7c3 100644
--- a/apps/planner/app/routes/api.overpass.ts
+++ b/apps/planner/app/routes/api.overpass.ts
@@ -1,5 +1,11 @@
import type { Route } from "./+types/api.overpass";
import { checkRateLimit } from "~/lib/rate-limit";
+import {
+ overpassCacheEvents,
+ overpassCacheSize,
+ overpassUpstreamDuration,
+ overpassUpstreamRequests,
+} from "~/lib/metrics.server";
const UPSTREAM_URL = process.env.OVERPASS_URL ?? "https://overpass.private.coffee/api/interpreter";
const USER_AGENT = "trails.cool Planner (https://trails.cool; legal@trails.cool)";
@@ -36,6 +42,7 @@ function putInCache(key: string, body: string, contentType: string) {
cache.delete(oldest);
}
cache.set(key, { body, contentType, expiresAt: Date.now() + CACHE_TTL_MS });
+ overpassCacheSize.set(cache.size);
}
export async function action({ request }: Route.ActionArgs) {
@@ -56,6 +63,7 @@ export async function action({ request }: Route.ActionArgs) {
// Cache hit: skip rate limit and upstream entirely
const cached = getFromCache(cacheKey);
if (cached) {
+ overpassCacheEvents.inc({ result: "hit" });
return new Response(cached.body, {
status: 200,
headers: { "Content-Type": cached.contentType, "X-Cache": "hit" },
@@ -75,7 +83,9 @@ export async function action({ request }: Route.ActionArgs) {
// same session don't each hit upstream for the identical bbox.
let pending = inFlight.get(cacheKey);
if (!pending) {
+ overpassCacheEvents.inc({ result: "miss" });
pending = (async () => {
+ const start = Date.now();
try {
const upstream = await fetch(UPSTREAM_URL, {
method: "POST",
@@ -85,6 +95,8 @@ export async function action({ request }: Route.ActionArgs) {
},
body,
});
+ overpassUpstreamDuration.observe((Date.now() - start) / 1000);
+ overpassUpstreamRequests.inc({ status: String(upstream.status) });
const responseBody = await upstream.text();
const contentType = upstream.headers.get("content-type") ?? "application/json";
const cacheable = upstream.status === 200 && !responseBody.includes("rate_limited");
@@ -94,12 +106,15 @@ export async function action({ request }: Route.ActionArgs) {
}
})();
inFlight.set(cacheKey, pending);
+ } else {
+ overpassCacheEvents.inc({ result: "coalesced" });
}
let result;
try {
result = await pending;
} catch {
+ overpassUpstreamRequests.inc({ status: "error" });
return new Response("Upstream Overpass unavailable", { status: 502 });
}
diff --git a/infrastructure/grafana/dashboards/planner.json b/infrastructure/grafana/dashboards/planner.json
index 72a9e02..084fa8e 100644
--- a/infrastructure/grafana/dashboards/planner.json
+++ b/infrastructure/grafana/dashboards/planner.json
@@ -97,6 +97,168 @@
"legendFormat": "{{route}}"
}
]
+ },
+ {
+ "title": "Overpass Upstream Health (5m success rate)",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 0,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(overpass_upstream_requests_total{status=~\"2..\"}[5m])) / clamp_min(sum(rate(overpass_upstream_requests_total[5m])), 1)",
+ "legendFormat": "success"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "orange", "value": 0.9 },
+ { "color": "green", "value": 0.99 }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Hit Ratio (5m)",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 6,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "sum(rate(overpass_cache_events_total{result=\"hit\"}[5m])) / clamp_min(sum(rate(overpass_cache_events_total[5m])), 1)",
+ "legendFormat": "hit ratio"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Size",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 12,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "overpass_cache_size",
+ "legendFormat": "entries"
+ }
+ ]
+ },
+ {
+ "title": "Overpass Upstream p95",
+ "type": "stat",
+ "gridPos": {
+ "h": 5,
+ "w": 6,
+ "x": 18,
+ "y": 14
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p95"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
+ },
+ {
+ "title": "Overpass Cache Events",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 0,
+ "y": 19
+ },
+ "targets": [
+ {
+ "expr": "sum by (result) (rate(overpass_cache_events_total[5m]))",
+ "legendFormat": "{{result}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops"
+ }
+ }
+ },
+ {
+ "title": "Overpass Upstream Status",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 12,
+ "x": 12,
+ "y": 19
+ },
+ "targets": [
+ {
+ "expr": "sum by (status) (rate(overpass_upstream_requests_total[5m]))",
+ "legendFormat": "{{status}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ops"
+ }
+ }
+ },
+ {
+ "title": "Overpass Upstream Latency",
+ "type": "timeseries",
+ "gridPos": {
+ "h": 7,
+ "w": 24,
+ "x": 0,
+ "y": 26
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.50, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p50"
+ },
+ {
+ "expr": "histogram_quantile(0.95, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p95"
+ },
+ {
+ "expr": "histogram_quantile(0.99, sum(rate(overpass_upstream_duration_seconds_bucket[5m])) by (le))",
+ "legendFormat": "p99"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s"
+ }
+ }
}
]
}
From 21a00afa85d0c46e559f8a9de16bf3a41b35f2d6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:17:48 +0200
Subject: [PATCH 003/468] Archive legal-disclaimers change and sync specs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move completed legal-disclaimers change to archive, apply deltas to
main specs: append "Terms acknowledgement at signup" requirement to
journal-auth and create the legal-disclaimers capability spec
covering Impressum, Terms, Datenschutzerklärung, alpha banner, and
footer links.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../.openspec.yaml | 0
.../2026-04-18-legal-disclaimers}/design.md | 0
.../2026-04-18-legal-disclaimers}/proposal.md | 0
.../specs/journal-auth/spec.md | 0
.../specs/legal-disclaimers/spec.md | 0
.../2026-04-18-legal-disclaimers}/tasks.md | 8 +-
openspec/specs/journal-auth/spec.md | 16 ++++
openspec/specs/legal-disclaimers/spec.md | 77 +++++++++++++++++++
8 files changed, 97 insertions(+), 4 deletions(-)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/.openspec.yaml (100%)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/design.md (100%)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/proposal.md (100%)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/specs/journal-auth/spec.md (100%)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/specs/legal-disclaimers/spec.md (100%)
rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/tasks.md (91%)
create mode 100644 openspec/specs/legal-disclaimers/spec.md
diff --git a/openspec/changes/legal-disclaimers/.openspec.yaml b/openspec/changes/archive/2026-04-18-legal-disclaimers/.openspec.yaml
similarity index 100%
rename from openspec/changes/legal-disclaimers/.openspec.yaml
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/.openspec.yaml
diff --git a/openspec/changes/legal-disclaimers/design.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/design.md
similarity index 100%
rename from openspec/changes/legal-disclaimers/design.md
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/design.md
diff --git a/openspec/changes/legal-disclaimers/proposal.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/proposal.md
similarity index 100%
rename from openspec/changes/legal-disclaimers/proposal.md
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/proposal.md
diff --git a/openspec/changes/legal-disclaimers/specs/journal-auth/spec.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/specs/journal-auth/spec.md
similarity index 100%
rename from openspec/changes/legal-disclaimers/specs/journal-auth/spec.md
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/specs/journal-auth/spec.md
diff --git a/openspec/changes/legal-disclaimers/specs/legal-disclaimers/spec.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/specs/legal-disclaimers/spec.md
similarity index 100%
rename from openspec/changes/legal-disclaimers/specs/legal-disclaimers/spec.md
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/specs/legal-disclaimers/spec.md
diff --git a/openspec/changes/legal-disclaimers/tasks.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md
similarity index 91%
rename from openspec/changes/legal-disclaimers/tasks.md
rename to openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md
index f82543f..c8bb718 100644
--- a/openspec/changes/legal-disclaimers/tasks.md
+++ b/openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md
@@ -40,7 +40,7 @@
## 7. Review & Publish
-- [ ] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final
-- [ ] 7.2 Deploy and verify all three legal pages render correctly at their URLs
-- [ ] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success
-- [ ] 7.4 Verify the alpha banner appears and dismisses correctly
+- [x] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final
+- [x] 7.2 Deploy and verify all three legal pages render correctly at their URLs
+- [x] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success
+- [x] 7.4 Verify the alpha banner appears and dismisses correctly
diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md
index ffd4d34..2b2fb24 100644
--- a/openspec/specs/journal-auth/spec.md
+++ b/openspec/specs/journal-auth/spec.md
@@ -11,3 +11,19 @@ The journal auth system SHALL store OAuth tokens for external services alongside
- **WHEN** a user connects their Wahoo account
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
- **AND** tokens are associated with the journal user ID
+
+### Requirement: Terms acknowledgement at signup
+The registration form SHALL require explicit acknowledgement of the Terms of Service before an account can be created.
+
+#### Scenario: Checkbox required
+- **WHEN** a user views the registration form
+- **THEN** they see a required checkbox labeled "I have read and agree to the Terms of Service, including that trails.cool is in alpha and my data may be reset"
+- **AND** the checkbox label links to the Terms page
+
+#### Scenario: Cannot submit without acknowledgement
+- **WHEN** a user attempts to register without checking the acknowledgement box
+- **THEN** the form blocks submission and shows a validation message
+
+#### Scenario: Acknowledgement recorded
+- **WHEN** a user successfully registers
+- **THEN** the current timestamp is stored in `users.terms_accepted_at`
diff --git a/openspec/specs/legal-disclaimers/spec.md b/openspec/specs/legal-disclaimers/spec.md
new file mode 100644
index 0000000..1363e8a
--- /dev/null
+++ b/openspec/specs/legal-disclaimers/spec.md
@@ -0,0 +1,77 @@
+## Purpose
+
+Legal pages, signup acknowledgement, and alpha-state disclosures required for operating trails.cool under German law (TMG, MStV, GDPR).
+
+## 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.
+
+#### Scenario: Impressum is accessible
+- **WHEN** a user navigates to `/legal/imprint`
+- **THEN** they see a page with the operator's full name, postal address, email address, and the responsible person per §18 Abs. 2 MStV
+
+#### Scenario: Impressum is linked from footer
+- **WHEN** a user views any page on the Journal or Planner
+- **THEN** the footer contains a link to the Impressum
+
+### 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.
+
+#### 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
+
+#### 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
+- **WHEN** a user reads the Terms page
+- **THEN** the Terms disclaim all warranties to the extent permitted by German consumer law
+
+#### 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
+
+### Requirement: Datenschutzerklärung
+The Journal SHALL serve a GDPR-compliant privacy policy at `/legal/privacy`, replacing the existing `/privacy` route.
+
+#### 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: 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)
+
+#### 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
+
+#### 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.
+
+#### 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
+
+### Requirement: Footer legal links
+Both the Journal and Planner SHALL render a footer with links to Impressum, Datenschutzerklärung, and Terms.
+
+#### 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
+
+#### 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)
From 6c1148d5b7c491f41355a06c517f4e9a5122c87b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:18:46 +0200
Subject: [PATCH 004/468] Quantize Overpass query bbox to align cache keys
across clients
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Near-identical viewports from different collaborators (slightly
different pan/zoom because Yjs awareness doesn't enforce pixel-exact
alignment) previously produced byte-different Overpass queries and
therefore different server-side cache keys. Each client missed cache
and triggered its own upstream fetch.
buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding
outward (south/west floor, north/east ceil) so the viewport is always
covered, and formats coords with 3 decimals for a stable string.
Trade-offs:
- Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on
each side. For a zoom-12 viewport (~11 km wide) that's <10% extra
data; bounded by the existing `[maxsize:1048576]` + `out 100` limits.
- Coordinates in the query are now 3-decimal strings regardless of the
caller's precision — existing buildQuery test updated to match.
Two new tests cover the cache-alignment property and the outward-
expansion invariant. Hit ratio impact will be visible on the Grafana
"Overpass Cache Hit Ratio" stat added in the prior PR.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/planner/app/lib/overpass.test.ts | 43 +++++++++++++++++++++++++--
apps/planner/app/lib/overpass.ts | 34 ++++++++++++++++++++-
2 files changed, 74 insertions(+), 3 deletions(-)
diff --git a/apps/planner/app/lib/overpass.test.ts b/apps/planner/app/lib/overpass.test.ts
index b462e5b..0e284e3 100644
--- a/apps/planner/app/lib/overpass.test.ts
+++ b/apps/planner/app/lib/overpass.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts";
+import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts";
import { poiCategories } from "@trails-cool/map-core";
describe("buildQuery", () => {
@@ -9,7 +9,7 @@ describe("buildQuery", () => {
const query = buildQuery(bbox, categories);
expect(query).toContain("[out:json]");
- expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]");
+ expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
expect(query).toContain('amenity"="drinking_water"');
expect(query).toContain("out center qt 100");
});
@@ -22,6 +22,45 @@ describe("buildQuery", () => {
expect(query).toContain("drinking_water");
expect(query).toContain("camp_site");
});
+
+ it("produces identical queries for near-identical viewports within one grid cell", () => {
+ const categories = poiCategories.filter((c) => c.id === "drinking_water");
+ const a = buildQuery(
+ { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
+ categories,
+ );
+ const b = buildQuery(
+ { south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 },
+ categories,
+ );
+ expect(a).toBe(b);
+ });
+
+ it("expands the bbox outward so the original viewport is always covered", () => {
+ const categories = poiCategories.filter((c) => c.id === "drinking_water");
+ const query = buildQuery(
+ { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 },
+ categories,
+ );
+ expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]");
+ });
+});
+
+describe("quantizeBbox", () => {
+ it("snaps south and west down, north and east up", () => {
+ const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01);
+ expect(q.south).toBeCloseTo(52.5, 10);
+ expect(q.west).toBeCloseTo(13.3, 10);
+ expect(q.north).toBeCloseTo(52.6, 10);
+ expect(q.east).toBeCloseTo(13.5, 10);
+ });
+
+ it("is idempotent on already-aligned coordinates", () => {
+ const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 };
+ const q = quantizeBbox(bbox, 0.01);
+ expect(q.south).toBeCloseTo(52.5, 10);
+ expect(q.north).toBeCloseTo(52.6, 10);
+ });
});
describe("parseResponse", () => {
diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts
index 6a14fbd..ac04eaa 100644
--- a/apps/planner/app/lib/overpass.ts
+++ b/apps/planner/app/lib/overpass.ts
@@ -2,6 +2,18 @@ import type { PoiCategory } from "@trails-cool/map-core";
const OVERPASS_PROXY = "/api/overpass";
+// Snap bbox coordinates to this grid before building the query so that two
+// users looking at nearly-identical viewports produce byte-identical queries
+// and therefore share a server-side cache entry. Expansion is outward
+// (south/west floor, north/east ceil) so the user's viewport is always
+// covered. ~0.01° ≈ 1 km at mid-latitudes.
+const BBOX_GRID_STEP = 0.01;
+
+// Decimals used when formatting the quantized bbox into the query string.
+// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP,
+// so the string is a stable representation of the quantized cell.
+const BBOX_DECIMALS = 3;
+
export interface Poi {
id: number;
lat: number;
@@ -18,11 +30,31 @@ export interface BBox {
east: number;
}
+/**
+ * Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward
+ * so the original bbox is fully contained. Returns a new BBox whose
+ * coordinates are cell-aligned.
+ */
+export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox {
+ return {
+ south: Math.floor(bbox.south / step) * step,
+ west: Math.floor(bbox.west / step) * step,
+ north: Math.ceil(bbox.north / step) * step,
+ east: Math.ceil(bbox.east / step) * step,
+ };
+}
+
/**
* Build an Overpass QL query combining all enabled categories into a union.
+ * The bbox is quantized to a fixed grid and formatted with a fixed number of
+ * decimals so near-identical viewports produce a byte-identical query — which
+ * the `/api/overpass` server-side cache keys on.
*/
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
- const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
+ const q = quantizeBbox(bbox);
+ const bboxStr = [q.south, q.west, q.north, q.east]
+ .map((n) => n.toFixed(BBOX_DECIMALS))
+ .join(",");
const unions = categories.map((c) => c.query).join("");
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
}
From e11c9ab58b74a0242128f1efc3673f52d9b01083 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:21:31 +0200
Subject: [PATCH 005/468] Fix /api/overpass 403 behind Caddy reverse proxy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The same-origin check compared the browser's Origin header against
new URL(request.url), but inside the Planner container request.url
is http://planner:3001/... while the browser sends Origin
https://planner.trails.cool — so production always 403'd.
Trust Caddy's X-Forwarded-Host and X-Forwarded-Proto headers when
present (both are set by Caddy's reverse_proxy directive by default)
to reconstruct the external origin the browser actually connected to.
Falls back to request.url for dev and any direct (non-proxied) access.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/planner/app/routes/api.overpass.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/apps/planner/app/routes/api.overpass.ts b/apps/planner/app/routes/api.overpass.ts
index e97b7c3..571e97a 100644
--- a/apps/planner/app/routes/api.overpass.ts
+++ b/apps/planner/app/routes/api.overpass.ts
@@ -50,9 +50,16 @@ export async function action({ request }: Route.ActionArgs) {
return new Response("Method not allowed", { status: 405 });
}
+ // Same-origin check. Trust Caddy's X-Forwarded-* headers when present so
+ // the check works behind the reverse proxy (request.url inside the container
+ // is http://planner:3001/..., but the browser Origin is https://planner.trails.cool).
const origin = request.headers.get("origin");
const requestUrl = new URL(request.url);
- const expectedOrigin = `${requestUrl.protocol}//${requestUrl.host}`;
+ const forwardedHost = request.headers.get("x-forwarded-host");
+ const forwardedProto = request.headers.get("x-forwarded-proto");
+ const host = forwardedHost ?? requestUrl.host;
+ const proto = forwardedProto ?? requestUrl.protocol.replace(":", "");
+ const expectedOrigin = `${proto}://${host}`;
if (!origin || origin !== expectedOrigin) {
return new Response("Forbidden", { status: 403 });
}
From 248849318fde0f414996aba034ac4a3db432ac24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:32:48 +0200
Subject: [PATCH 006/468] Align privacy manifest with Sentry + Overpass
hardening
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three sections drifted from the actual behaviour of the deployed apps:
Planner section:
- Spells out no cookies / no localStorage / no sessionStorage (we
removed i18n localStorage caching and alpha-banner sessionStorage).
- Notes the browser-side Planner does not load Sentry at all.
Sentry section:
- Drops the "session replays on error" claim — replay integration is
not installed and replaysSessionSampleRate / replaysOnErrorSampleRate
are both 0.
- Documents the actual scope: Journal server-side always; Journal
client-side only after login; Planner server-side only.
- Documents that IPs/cookies/headers are not sent (sendDefaultPii=false)
and that only the user ID is attached to logged-in Journal errors.
Third Parties section:
- Adds Overpass API with an explicit note that queries are proxied
through our own /api/overpass so the upstream host sees our server,
not end users.
Also bumps "Last updated" to today.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/journal/app/routes/legal.privacy.tsx | 32 ++++++++++++++++-------
1 file changed, 23 insertions(+), 9 deletions(-)
diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx
index c8c758d..70fcef7 100644
--- a/apps/journal/app/routes/legal.privacy.tsx
+++ b/apps/journal/app/routes/legal.privacy.tsx
@@ -13,7 +13,7 @@ export default function PrivacyPage() {
Datenschutzerklärung / Privacy Policy
-
Last updated: 2026-04-17
+
Last updated: 2026-04-18
{/* GDPR formal sections */}
@@ -134,8 +134,9 @@ export default function PrivacyPage() {
there are no user accounts, no tracking, and no analytics on your routes.
-
No cookies (except ephemeral session state)
+
No cookies, no localStorage, no sessionStorage
No user accounts or login
+
No browser-side error tracking (Sentry is not loaded in the Planner)
No route data is stored permanently without your action
Session data is automatically deleted after 7 days of inactivity
@@ -160,22 +161,28 @@ export default function PrivacyPage() {
Error Tracking (Sentry)
- Both apps use Sentry for
- error monitoring. This helps us find and fix bugs quickly.
+ We use Sentry for
+ error monitoring, scoped narrowly by design.
+
Where Sentry runs:
+
+
Journal, logged-out: server-side only. The browser-side Sentry SDK is not loaded until you log in.
+
Journal, logged-in: Sentry initialises in the browser after login and is torn down on logout.
+
Planner: server-side only. The browser-side Planner does not load Sentry at all.
+
What Sentry collects:
-
Error details: stack traces, error messages, browser/OS info
+
Error details: stack traces, error messages, browser/OS info derived from the User-Agent
Session replays on error: a recording of the session leading up to an error (DOM snapshots, not video)
-
User context (Journal only): only the user ID (not username or email) is attached to errors for debugging
-
Session ID (Planner only): the anonymous session ID is attached to errors
+
User ID (Journal, logged-in only): just the user ID — not username, email, or IP
What Sentry does NOT collect:
+
Session replays or DOM recordings — replay integration is not installed and sample rates are 0
+
IP addresses, cookies, or full HTTP headers — sendDefaultPii is set to false everywhere
Route or GPX data
Passwords or passkey credentials
-
Form input contents (masked in replays)
+
Form input contents
Data retention:
@@ -207,6 +214,13 @@ export default function PrivacyPage() {
Sentry (Functional Software Inc.) — error tracking, as described above
OpenStreetMap — map tiles are loaded from OSM tile servers. OSM's privacy policy applies to tile requests.
BRouter — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.
+
+ Overpass API — POI overlay data is fetched via the Overpass API. Requests are proxied
+ through our own server (/api/overpass), so the upstream Overpass host sees our server rather
+ than end users' IP addresses or browsers. The current upstream is{" "}
+ overpass.private.coffee,
+ which operates without query logging. A self-hosted Overpass instance is planned.
+
SMTP provider — transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.
From 278e74f08e050cba7f9e64bde8d4cf22f1486818 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:42:50 +0200
Subject: [PATCH 007/468] Fix Overpass health + cache-hit stat panels:
vector(0) not clamp_min
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The original formulas used clamp_min(denominator, 1) to avoid divide-
by-zero, but that inflates the denominator when request rate is < 1/s.
Real example from prod: ~0.04 req/s, all successful, displayed as
1.02% health (red) instead of ~100%.
Switch to (numerator or vector(0)) / denominator:
- Missing "hit" series (no cache hits yet) → numerator resolves to 0
instead of empty set, so the stat shows 0% instead of "No data".
- Zero traffic (empty denominator) → whole expr is empty, Grafana
renders "No data", which is the correct behaviour for a health
stat when nothing's happening.
- Non-zero low traffic → ratio is true ratio, not artificially
depressed by the clamp.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
infrastructure/grafana/dashboards/planner.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/infrastructure/grafana/dashboards/planner.json b/infrastructure/grafana/dashboards/planner.json
index 084fa8e..97aa026 100644
--- a/infrastructure/grafana/dashboards/planner.json
+++ b/infrastructure/grafana/dashboards/planner.json
@@ -109,7 +109,7 @@
},
"targets": [
{
- "expr": "sum(rate(overpass_upstream_requests_total{status=~\"2..\"}[5m])) / clamp_min(sum(rate(overpass_upstream_requests_total[5m])), 1)",
+ "expr": "(sum(rate(overpass_upstream_requests_total{status=~\"2..\"}[5m])) or vector(0)) / sum(rate(overpass_upstream_requests_total[5m]))",
"legendFormat": "success"
}
],
@@ -140,7 +140,7 @@
},
"targets": [
{
- "expr": "sum(rate(overpass_cache_events_total{result=\"hit\"}[5m])) / clamp_min(sum(rate(overpass_cache_events_total[5m])), 1)",
+ "expr": "(sum(rate(overpass_cache_events_total{result=\"hit\"}[5m])) or vector(0)) / sum(rate(overpass_cache_events_total[5m]))",
"legendFormat": "hit ratio"
}
],
From 1462c54df7b4464efbc498fe43515bb2cc88ae19 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?=
Date: Sat, 18 Apr 2026 02:57:29 +0200
Subject: [PATCH 008/468] Rewrite legal pages per legal review feedback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Imprint:
- Add explicit Streitbeilegung section with the EU ODR link and the
required statement that we don't participate in consumer dispute
resolution proceedings.
- Keep structure tight; drop the Haftungsausschluss boilerplate that
repeated what's already covered by §§ 7-10 TMG.
- Bilingual with EN summaries below each section.
Privacy:
- Reshape around proper GDPR structure: Controller → Data categories &
purposes → Legal bases → Server logs → Storage durations → Third
parties → Rights → Complaint authority → (plain-language) Privacy
Manifest at the end.
- FIX: acceptance of the Terms is NOT consent. Moved from Art. 6(1)(a)
to Art. 6(1)(b) (Vertragserfüllung), with an explicit note that
contract acceptance is not consent within the meaning of (1)(a).
- Add explicit Server-Logfiles section (IP / timestamp / method / path
/ status / user-agent; Art. 6(1)(f); 14-day retention).
- Add explicit Storage-duration list (account, Planner sessions,
magic-link tokens, logs, Sentry).
- Expand third-parties: Sentry, OpenStreetMap (explicit that the
browser sends IP+UA directly to OSM tile servers, with OSMF privacy
policy link), Overpass (via our proxy, upstream only sees our
server), BRouter (self-hosted), SMTP, hosting inside EU under DPA.
- Each section has a short "English." summary paragraph inline below
the German text, per the feedback format request.
- Privacy Manifest kept as a developer-friendly epilogue; trimmed and
aligned with current reality (no cookies/localStorage/sessionStorage
in Planner; no replays; no PII via Sentry).
Terms:
- Add § 2 Mindestalter (16) for Journal accounts; Planner anonymous
has no age requirement.
- Add § 3 Dienstverfügbarkeit (service availability): operator may
modify/limit/interrupt/discontinue the service or individual
accounts.
- Add § 6 Inhalte und Nutzungsrechte: user retains ownership; grants
operator a limited, non-transferable licence to store/process/
display content only for operating the service.
- Tighten §4 DB reset, §5 user backup responsibility, and §9 liability
(standard German DACH tiering: unlimited for intent/gross neg./life-
body-health; limited to foreseeable damages on cardinal-duty breach
for slight negligence; otherwise excluded).
- Add §7 rule: no bulk scraping to build competing services.
- Same bilingual format as the other two pages.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
apps/journal/app/routes/legal.imprint.tsx | 195 ++++++----
apps/journal/app/routes/legal.privacy.tsx | 450 +++++++++++++++-------
apps/journal/app/routes/legal.terms.tsx | 358 +++++++++--------
3 files changed, 628 insertions(+), 375 deletions(-)
diff --git a/apps/journal/app/routes/legal.imprint.tsx b/apps/journal/app/routes/legal.imprint.tsx
index d3bc4e0..adb0f3e 100644
--- a/apps/journal/app/routes/legal.imprint.tsx
+++ b/apps/journal/app/routes/legal.imprint.tsx
@@ -10,84 +10,141 @@ export function meta() {
export default function ImprintPage() {
return (
-
Impressum
+
Impressum / Legal Notice
+
+ Die deutsche Fassung ist maßgeblich. / The German version is authoritative.
+
- Die Inhalte dieser Seiten wurden mit größtmöglicher Sorgfalt erstellt.
- Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte
- können wir jedoch keine Gewähr übernehmen. trails.cool befindet sich
- in aktiver Entwicklung (Alpha) — Inhalte, Funktionen und Daten können
- sich jederzeit ändern oder entfallen.
-
+
+
+ Inhaltlich verantwortlich nach § 18 Abs. 2 MStV
+
- Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte
- auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach
- §§ 8 bis 10 TMG sind wir als Diensteanbieter jedoch nicht
- verpflichtet, übermittelte oder gespeicherte fremde Informationen zu
- überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige
- Tätigkeit hinweisen.
-
+ 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.
+
+
-
-
- Legal notice (English summary)
-
-
- trails.cool is operated from Germany by {operator.name}, reachable at
- the address above and at{" "}
-
- {operator.email}
-
- . The service is currently in alpha — see the{" "}
-
- Terms of Service
- {" "}
- for details.
-
+ {/* ------------------------------------------------------------------
+ English version
+ ------------------------------------------------------------------ */}
+
+
+
+ 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.
+
+ Stand / Last updated: 2026-04-18. Die deutsche Fassung ist maßgeblich.
+ The German version is authoritative; English summaries follow each
+ section.
+
- Wir verarbeiten personenbezogene Daten auf Grundlage folgender
- Rechtsgrundlagen:
+
+ 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).
-
+
- Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO):
- Kontoverwaltung, Speicherung von Routen und Aktivitäten, Anmeldung
- via Passkey / Magic Link.
+ Kontodaten (Journal): E-Mail-Adresse,
+ Benutzername, Anzeigename, Passkey-Public-Key. Zweck:
+ Kontoverwaltung und Anmeldung.
- Berechtigte Interessen (Art. 6 Abs. 1 lit. f DSGVO):
- Fehlerüberwachung (Sentry) zur Sicherstellung der
- Funktionsfähigkeit des Dienstes.
+ 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.
- Einwilligung (Art. 6 Abs. 1 lit. a DSGVO): Bei
- ausdrücklicher Zustimmung zu den Nutzungsbedingungen während der
- Registrierung.
+ 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.
+
+ 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).
+ Keine IP-Adressen, keine Cookies, 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.
+
- Als betroffene Person stehen Ihnen folgende Rechte nach DSGVO zu:
-
-
-
Recht auf Auskunft (Art. 15 DSGVO)
-
Recht auf Berichtigung (Art. 16 DSGVO)
-
Recht auf Löschung (Art. 17 DSGVO)
-
Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)
-
Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
-
Widerspruchsrecht (Art. 21 DSGVO)
-
Recht auf Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO)
+
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.
+
-
- Zur Ausübung dieser Rechte wenden Sie sich bitte an{" "}
+
+ 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.
+
+ 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.
+
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
+
+
+ English. Account and content kept until you delete them.
+ Ephemeral data (sessions, magic-link tokens, logs, Sentry events)
+ deleted automatically on the schedules above.
+
+ 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. Nicht übermittelt:
+ IP-Adresse, Cookies, vollständige HTTP-Header
+ (sendDefaultPii ist deaktiviert). Keine
+ Session-Replays. Hosting innerhalb der EU (Frankfurt).
+
+
+ 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.
+
+
+ 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.
+
+
+ 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, EU-hosted); 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 / Rights
+ ================================================================ */}
+
+
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{" "}
{operator.email}
- . Ein vollständiger Export Ihrer Daten ist jederzeit direkt über die
- Einstellungen Ihres Kontos möglich (Routen als GPX, Aktivitäten als
- GPX/JSON). Sie können Ihr Konto jederzeit selbst löschen.
+ . 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.
Sie haben das Recht, sich bei einer Datenschutzaufsichtsbehörde zu
- beschweren. Zuständig für uns ist:
+ beschweren. Für uns zuständig ist:
Berliner Beauftragte für Datenschutz und Informationsfreiheit
@@ -116,135 +316,89 @@ export default function PrivacyPage() {
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).
+
User ID (Journal, logged-in only): just the user ID — not username, email, or IP
-
-
What Sentry does NOT collect:
-
-
Session replays or DOM recordings — replay integration is not installed and sample rates are 0
-
IP addresses, cookies, or full HTTP headers — sendDefaultPii is set to false everywhere
-
Route or GPX data
-
Passwords or passkey credentials
-
Form input contents
-
-
Data retention:
-
- Sentry data is retained for 90 days and then automatically deleted.
- Sentry's servers are hosted in the EU (Frankfurt).
-
-
-
Email
-
- The Journal sends transactional emails for magic link login and welcome messages
- via SMTP. On the official instance, emails are sent through our own mail server.
-
-
What is sent via email:
-
-
Magic link: a one-time login link sent to your email address
-
Welcome email: a greeting after registration
+
+
What we don't do
+
+
Sell data
+
Show ads
+
Build user profiles
+
Run tracking pixels, analytics, or A/B tests
-
- Self-hosted instances can configure their own SMTP server. No email content
- is stored beyond what your mail server retains.
-
-
-
Third Parties
-
-
Sentry (Functional Software Inc.) — error tracking, as described above
-
OpenStreetMap — map tiles are loaded from OSM tile servers. OSM's privacy policy applies to tile requests.
-
BRouter — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.
+
+
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
- Overpass API — POI overlay data is fetched via the Overpass API. Requests are proxied
- through our own server (/api/overpass), so the upstream Overpass host sees our server rather
- than end users' IP addresses or browsers. The current upstream is{" "}
- overpass.private.coffee,
- which operates without query logging. A self-hosted Overpass instance is planned.
+ Vulnerability reports:{" "}
+
+ SECURITY.md
+
-
SMTP provider — transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.
-
-
-
-
-
What We Don't Do
-
-
We don't sell data
-
We don't show ads
-
We don't build user profiles
-
We don't use tracking pixels or analytics
-
We don't share data with anyone except as listed above
-
-
-
-
-
Security Practices
-
-
Authentication: Passkey (WebAuthn) and magic link login. No passwords stored.
-
Encryption: All traffic over HTTPS with HSTS preload. Cookies are httpOnly and secure.
-
Headers: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options on all responses.
-
Infrastructure: Docker containers run as non-root. Firewall restricts to HTTP/HTTPS/SSH only.
-
CI/CD: Gitleaks secret scanning and dependency auditing on every pull request.
-
Vulnerability reporting: See our SECURITY.md for responsible disclosure.
diff --git a/apps/journal/app/routes/legal.terms.tsx b/apps/journal/app/routes/legal.terms.tsx
index e36e6fc..b8538f8 100644
--- a/apps/journal/app/routes/legal.terms.tsx
+++ b/apps/journal/app/routes/legal.terms.tsx
@@ -1,6 +1,6 @@
export function meta() {
return [
- { title: "Terms of Service — trails.cool" },
+ { title: "Nutzungsbedingungen — trails.cool" },
{ name: "robots", content: "noindex" },
];
}
@@ -12,175 +12,217 @@ export default function TermsPage() {
Nutzungsbedingungen / Terms of Service
- Last updated: 2026-04-17 • Alpha version — subject to change
+ Stand / Last updated: 2026-04-18 • Alpha — subject to change. Die
+ deutsche Fassung ist maßgeblich. The German version is authoritative;
+ English summaries follow each section.
- {/* Deutsche Fassung (maßgebend) */}
-
-
Deutsche Fassung
+
+
+
+ 1. Gegenstand und Alpha-Status
+
+
+ trails.cool ist ein experimenteller, 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, free service in
+ active alpha development. Features, data, and availability can
+ change at any time without notice. No availability is guaranteed.
+
+
-
- 1. Gegenstand und Alpha-Status
-
-
- trails.cool ist ein experimenteller 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, gelöscht oder eingestellt
- werden. Eine Verfügbarkeitsgarantie besteht nicht.
-
+
+
+ 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.
+
+
-
- 2. 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)
- sichern.
-
+
+
+ 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.
+
+
-
- 3. Haftungsausschluss
-
-
- Der Dienst wird ohne jegliche Gewährleistung bereitgestellt. Eine Haftung
- für Schäden, die durch die Nutzung oder Nichtverfügbarkeit des Dienstes
- entstehen, ist ausgeschlossen, soweit dies gesetzlich zulässig ist. Bei
- Vorsatz und grober Fahrlässigkeit sowie bei Verletzung von Leben, Körper
- und Gesundheit gelten die gesetzlichen Vorschriften.
-
+
+
+ 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.
+
+
-
- 4. Nutzungsregeln
-
-
-
Keine Verbreitung rechtswidriger Inhalte
-
Keine missbräuchliche Nutzung (Spam, automatisierte Abfragen in
- hoher Frequenz, Denial-of-Service)
-
-
Kein Umgehen technischer Schutzmaßnahmen
-
+
+
+ 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.
+
+
-
- 5. Eigenverantwortung für Daten
-
-
- Nutzer:innen sind dafür verantwortlich, regelmäßig Sicherungskopien ihrer
- Routen und Aktivitäten anzufertigen. GPX-Exporte sind im Journal für
- jede Route und Aktivität verfügbar.
-
+
+
+ 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.
+
+
-
- 6. Beendigung
-
-
- Nutzer:innen können ihr Konto jederzeit über die Einstellungen löschen.
- Der Betreiber kann Konten bei Verstößen gegen diese Bedingungen sperren
- oder löschen.
-
+ Keine Verwendung des Dienstes zum Aufbau konkurrierender
+ Angebote durch massenhaftes Abziehen von Daten
+
+
+
+ English. No illegal content, no abuse (spam, flooding,
+ DoS), no circumvention of technical protections, no bulk scraping
+ to build competing services.
+
+
-
- 7. Änderungen
-
-
- 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.
-
+
+
+ 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.
+
+
-
- 8. Anwendbares Recht
-
-
- Es gilt deutsches Recht unter Ausschluss des UN-Kaufrechts. Verbraucher:innen
- mit gewöhnlichem Aufenthalt in der EU genießen den zwingenden Schutz
- ihres nationalen Verbraucherrechts.
-
-
+
+
+ 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.
+
+
- {/* English translation (informational) */}
-
-
English version
-
- The German version is authoritative. This English text is a translation
- for convenience.
-
-
-
- 1. Alpha status
-
-
- trails.cool is an experimental service for planning and documenting
- outdoor activities. It is under active development (alpha). Features,
- APIs, and stored data may change, be deleted, or be discontinued at any
- time without notice. No availability is guaranteed.
-
-
-
- 2. Database resets
-
-
- During alpha, the operator expressly reserves the right to reset the
- entire database or delete individual records without prior notice. Users
- should back up important routes and activities via the GPX export.
-
-
-
- 3. No warranty
-
-
- The service is provided without any warranty. Liability for damages
- arising from use or unavailability is excluded to the maximum extent
- permitted by law. Statutory provisions apply for intent, gross
- negligence, and injury to life, body, or health.
-
-
-
- 4. Acceptable use
-
-
-
No illegal content
-
No abusive use (spam, high-frequency automated requests, denial of service)
-
No circumvention of technical protections
-
-
-
- 5. Data responsibility
-
-
- Users are responsible for regularly backing up their routes and
- activities. GPX exports are available in the Journal for every route
- and activity.
-
-
-
- 6. Termination
-
-
- Users can delete their account at any time via settings. The operator
- may suspend or delete accounts that violate these terms.
-
-
-
- 7. Changes
-
-
- These terms may change during alpha. Registered users will be notified
- by email of material changes and must re-accept the updated terms.
-
-
-
- 8. Governing law
-
-
- German law applies, excluding the UN Convention on Contracts for the
- International Sale of Goods. Consumers habitually residing in the EU
- retain the mandatory protection of their national consumer law.
-
+
+
+ 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.
+
+
);
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 009/468] 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() {
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.
- 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 010/468] 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 011/468] 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 012/468] 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 013/468] 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 014/468] 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 015/468] 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 016/468] 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 (
+
+ );
+}
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 017/468] 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) {