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 1/3] 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 2/3] 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 3/3] 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"] +}