diff --git a/.claude/skills/cmux-browser/SKILL.md b/.agents/skills/cmux-browser/SKILL.md similarity index 100% rename from .claude/skills/cmux-browser/SKILL.md rename to .agents/skills/cmux-browser/SKILL.md diff --git a/.claude/skills/cmux-browser/agents/openai.yaml b/.agents/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-browser/agents/openai.yaml rename to .agents/skills/cmux-browser/agents/openai.yaml diff --git a/.claude/skills/cmux-browser/references/authentication.md b/.agents/skills/cmux-browser/references/authentication.md similarity index 100% rename from .claude/skills/cmux-browser/references/authentication.md rename to .agents/skills/cmux-browser/references/authentication.md diff --git a/.claude/skills/cmux-browser/references/commands.md b/.agents/skills/cmux-browser/references/commands.md similarity index 100% rename from .claude/skills/cmux-browser/references/commands.md rename to .agents/skills/cmux-browser/references/commands.md diff --git a/.claude/skills/cmux-browser/references/proxy-support.md b/.agents/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .claude/skills/cmux-browser/references/proxy-support.md rename to .agents/skills/cmux-browser/references/proxy-support.md diff --git a/.claude/skills/cmux-browser/references/session-management.md b/.agents/skills/cmux-browser/references/session-management.md similarity index 100% rename from .claude/skills/cmux-browser/references/session-management.md rename to .agents/skills/cmux-browser/references/session-management.md diff --git a/.claude/skills/cmux-browser/references/snapshot-refs.md b/.agents/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .claude/skills/cmux-browser/references/snapshot-refs.md rename to .agents/skills/cmux-browser/references/snapshot-refs.md diff --git a/.claude/skills/cmux-browser/references/video-recording.md b/.agents/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .claude/skills/cmux-browser/references/video-recording.md rename to .agents/skills/cmux-browser/references/video-recording.md diff --git a/.claude/skills/cmux-browser/templates/authenticated-session.sh b/.agents/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/authenticated-session.sh rename to .agents/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.claude/skills/cmux-browser/templates/capture-workflow.sh b/.agents/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/capture-workflow.sh rename to .agents/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.claude/skills/cmux-browser/templates/form-automation.sh b/.agents/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/form-automation.sh rename to .agents/skills/cmux-browser/templates/form-automation.sh diff --git a/.claude/skills/cmux-debug-windows/SKILL.md b/.agents/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .claude/skills/cmux-debug-windows/SKILL.md rename to .agents/skills/cmux-debug-windows/SKILL.md diff --git a/.claude/skills/cmux-debug-windows/agents/openai.yaml b/.agents/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-debug-windows/agents/openai.yaml rename to .agents/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.claude/skills/cmux-markdown/SKILL.md b/.agents/skills/cmux-markdown/SKILL.md similarity index 100% rename from .claude/skills/cmux-markdown/SKILL.md rename to .agents/skills/cmux-markdown/SKILL.md diff --git a/.claude/skills/cmux-markdown/agents/openai.yaml b/.agents/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-markdown/agents/openai.yaml rename to .agents/skills/cmux-markdown/agents/openai.yaml diff --git a/.claude/skills/cmux-markdown/references/commands.md b/.agents/skills/cmux-markdown/references/commands.md similarity index 100% rename from .claude/skills/cmux-markdown/references/commands.md rename to .agents/skills/cmux-markdown/references/commands.md diff --git a/.claude/skills/cmux-markdown/references/live-reload.md b/.agents/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .claude/skills/cmux-markdown/references/live-reload.md rename to .agents/skills/cmux-markdown/references/live-reload.md diff --git a/.claude/skills/cmux/SKILL.md b/.agents/skills/cmux/SKILL.md similarity index 100% rename from .claude/skills/cmux/SKILL.md rename to .agents/skills/cmux/SKILL.md diff --git a/.claude/skills/cmux/agents/openai.yaml b/.agents/skills/cmux/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux/agents/openai.yaml rename to .agents/skills/cmux/agents/openai.yaml diff --git a/.claude/skills/cmux/references/handles-and-identify.md b/.agents/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .claude/skills/cmux/references/handles-and-identify.md rename to .agents/skills/cmux/references/handles-and-identify.md diff --git a/.claude/skills/cmux/references/panes-surfaces.md b/.agents/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .claude/skills/cmux/references/panes-surfaces.md rename to .agents/skills/cmux/references/panes-surfaces.md diff --git a/.claude/skills/cmux/references/trigger-flash-and-health.md b/.agents/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .claude/skills/cmux/references/trigger-flash-and-health.md rename to .agents/skills/cmux/references/trigger-flash-and-health.md diff --git a/.claude/skills/cmux/references/windows-workspaces.md b/.agents/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .claude/skills/cmux/references/windows-workspaces.md rename to .agents/skills/cmux/references/windows-workspaces.md diff --git a/.claude/skills/crit-cli/SKILL.md b/.agents/skills/crit-cli/SKILL.md similarity index 100% rename from .claude/skills/crit-cli/SKILL.md rename to .agents/skills/crit-cli/SKILL.md diff --git a/.agents/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md new file mode 100644 index 0000000..8e323f9 --- /dev/null +++ b/.agents/skills/ia-review/SKILL.md @@ -0,0 +1,275 @@ +--- +name: ia-review +description: Take a snapshot of the apps' information architecture (sitemap, navigation, audience-gating per route) and write or refresh `docs/information-architecture.md`. Use when the user wants to review the IA, plan a navigation/surface redesign, or check for drift since the last review. +license: MIT +metadata: + author: trails.cool + version: "1.0" +--- + +Walk the apps' route table + navigation surfaces, build a sitemap, surface +tensions and open questions, and capture the result in +`docs/information-architecture.md` (fresh write or refresh against the +prior snapshot). + +The output is a *review document for the user* — not a unilateral plan. +Decisions are made by the user during the conversation that follows; the +doc captures the snapshot and the open questions that need answering. + +--- + +## When to use + +- The user explicitly asks for an IA review ("review the IA", "what's + the information architecture look like"). +- Before a navigation/surface redesign, so the redesign is informed by + a current snapshot rather than a vibe. +- After a chunk of new features — pages, modals, settings sections — + to check whether the IA is drifting (busy navbar, duplicated surfaces, + orphaned routes). +- When the user says "we should do another IA review" after the prior + doc has aged. + +--- + +## Steps + +1. **Detect mode (fresh vs refresh)** + + Check whether `docs/information-architecture.md` already exists. + + - **No file:** fresh review. Build the snapshot from scratch. + - **File exists:** refresh review. Read it first; preserve the + decisions/backlog the user has accumulated and only update the + snapshot sections (sitemap, navigation, observations, open + questions). Decisions previously crossed out stay crossed out. + + In refresh mode, also read the snapshot date at the top — anything + shipped *since* that date is what your refresh should focus on. + +2. **Identify the apps in scope** + + trails.cool ships two front-ends; the IA question lives mostly in + the Journal: + + - `apps/journal/` — user accounts, social, content. Main IA + surface. + - `apps/planner/` — anonymous, ephemeral. ~5 routes; include for + completeness but don't dwell. + + If the project structure has changed and there's a new app, include + it. + +3. **Read the route tables** + + For each app: + + ``` + apps//app/routes.ts + ``` + + This is the authoritative URL → route-file mapping. Both apps use + explicit registration (per CLAUDE.md), so `routes.ts` is complete. + +4. **Read the navigation surfaces** + + - `apps/journal/app/root.tsx` (and `apps/planner/app/root.tsx` if + it has navigation) — the top navbar lives here. Read both the + loader (to see what data the navbar consumes — counts, badges, + user fields) and the `NavBar` component (to see what entries + render). + - `apps/journal/app/components/Footer.tsx` — the footer. + - Any auth-gate / Terms-gate logic in the root loader. + +5. **Sample key route loaders to understand audience** + + For each top-level route, scan its loader to determine: + + - Does it require a session? (loaders typically `redirect("/auth/login")` + for anonymous visitors when so.) + - Does it serve different content per session? (e.g., `home.tsx` + branches on `user`.) + - Does it have an access rule beyond auth? (locked-account 404s, + visibility checks, etc.) + + You don't need to read every route — pick the top-level ones and + any that look like they might gate differently than the URL hints. + +6. **Build the sitemap** + + Group routes by audience: **Public surface** (anonymous-reachable) + and **Authenticated surface** (signed-in only). Within each group, + order by topic (auth, profile, content, settings, legal, etc.). + + Use plain code blocks with one URL per line and a one-line gloss + per entry. Keep the format scannable; don't repeat what the URL + already says. + +7. **Map navigation surfaces** + + Two short tables/snippets: + + - **Navbar (signed-in)** — entries left-to-right. + - **Navbar (signed-out)** — entries left-to-right. + - **Footer** — links + any meta text. + + Note any entry whose visibility is conditional (badge counts, etc.). + +8. **Identify "feed concepts" and other duplications** + + trails.cool has historically had multiple feed-like surfaces. Any + IA review should ask: how many lists of activities are there? Is + the same data reachable from multiple URLs? Is there a URL that + shows different products to different audiences? + + Capture these in a small table or section if they exist. + +9. **Map cross-app linking** + + Journal ↔ Planner cross-links (JWT callback URLs, "Try the + Planner" buttons, etc.). One short list. + +10. **List observations** + + Walk the snapshot and call out tensions worth discussing. Useful + prompts: + + - **Busy clusters** — three or more controls for the same concept + side-by-side in the navbar. + - **Redundant paths** — same destination reachable from multiple + surfaces with no clear reason. + - **Dead-end routes** — pages reachable only by typing the URL, + no in-app link. + - **Missing surfaces** — common user need with no in-app path + (e.g., "find people to follow" with no `/explore`). + - **Audience mismatch** — same URL serving meaningfully different + products to anon vs auth. + - **Visual inconsistency** — adjacent navbar entries with + different treatment (icon vs text, different baselines). + - **Mobile hazards** — clusters that will wrap badly under + small viewport widths. + + Each observation should be one short paragraph. Each is a + question for the user to answer, not a decision you've made. + +11. **List open IA questions** + + Distinct from observations: these are larger directional choices + where the answer determines what other observations even matter. + Examples: "Should `/` and `/feed` merge for signed-in users?", + "Where does an `/explore` page live, if at all?", "Mobile + pattern — hamburger? Bottom tab bar?" + + Keep these as bullets the user can answer in one line each. + +12. **Write the doc** + + Output to `docs/information-architecture.md`. Use this top + structure: + + ```markdown + # Information Architecture Review + + *Snapshot date: YYYY-MM-DD.* If the navbar, route table, or feed + model has shifted since then, treat this doc as stale and refresh + against `apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`. + + A snapshot of where every page lives, who sees it, and how visitors + navigate between them. Intended for review — flag anything that + doesn't make sense or should change. + + ## Apps + [...] + + ## Journal sitemap + ### Public surface (logged-out) + [...] + ### Authenticated surface (logged-in) + [...] + + ### Navigation surfaces + [...] + + ## Logged-in vs logged-out home + [...if `/` does double duty...] + + ## [Any "N feed concepts" / duplication sections] + [...] + + ## Cross-app linking + [...] + + ## Planner sitemap + [...short...] + + ## Observations worth discussing + [...] + + ## Open IA questions + [...] + ``` + + Use today's date for the snapshot. Reference the source-of-truth + files at the top so the next review knows what to compare against. + +13. **In refresh mode, preserve the user's accumulated decisions** + + The prior doc may already contain: + + - Resolved observations (struck through with a *Resolved: ...* + note). + - An "Implementation backlog" section with streams. + - An "Open exploration" section. + + These are the *user's work*, not the snapshot. Carry them forward + untouched unless one is plainly obsolete (e.g., the feature it + references no longer exists). When in doubt, leave it and let the + user prune. + + If a previously-flagged observation is no longer present in the + current code (e.g., it was implemented), update its status note + rather than removing it — preserves history. + +14. **Surface a ranked next-action list** + + After writing the doc, summarize in 4–6 lines what changed since + the prior review (or what the most actionable observations are if + fresh). End with a question: which open IA question does the user + want to tackle first? + + Don't start implementation work — this skill is for the + *snapshot*. The decisions and the implementation backlog grow + through the conversation that follows. + +--- + +## What this skill is NOT + +- **Not an implementation skill.** Don't write code, don't open PRs. + The doc is the deliverable; decisions and code follow in normal + conversation. +- **Not a unilateral redesign.** Observations are questions for the + user. Don't bake "decisions" into the snapshot — those go in the + backlog only when the user has actually answered the question. +- **Not a spec change.** OpenSpec specs describe what's shipped; this + doc describes the IA *as it stands* with tensions flagged. Any + resulting spec updates happen during implementation, not during the + review. + +--- + +## Guardrails + +- Always include the snapshot date at the top of the output doc — IA + drifts; future-you needs to know whether to trust the snapshot or + refresh it. +- Reference the source-of-truth files (`routes.ts`, `root.tsx`) so the + next review's diff is mechanical. +- Keep observations as questions, not decrees. The user makes the + call. +- In refresh mode, preserve the user's accumulated decisions verbatim. + Only the snapshot sections are yours to rewrite. +- Don't invent routes or features. If you can't find evidence for it + in the code, don't put it in the snapshot. +- Keep it scannable. The doc is for review; verbose explanations bury + the signal. diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md similarity index 82% rename from .claude/skills/openspec-apply-change/SKILL.md rename to .agents/skills/openspec-apply-change/SKILL.md index d474dc1..1375861 100644 --- a/.claude/skills/openspec-apply-change/SKILL.md +++ b/.agents/skills/openspec-apply-change/SKILL.md @@ -1,16 +1,19 @@ --- name: openspec-apply-change description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. +allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.6.0" --- Implement tasks from an OpenSpec change. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -30,6 +33,7 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** @@ -39,7 +43,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -51,7 +55,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read the files listed in `contextFiles` from the apply instructions output. + Read every file path listed under `contextFiles` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md similarity index 75% rename from .claude/skills/openspec-archive-change/SKILL.md rename to .agents/skills/openspec-archive-change/SKILL.md index 9b1f851..c0c169d 100644 --- a/.claude/skills/openspec-archive-change/SKILL.md +++ b/.agents/skills/openspec-archive-change/SKILL.md @@ -1,16 +1,19 @@ --- name: openspec-archive-change description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. +allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.6.0" --- Archive a completed change in the experimental workflow. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -30,6 +33,7 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -52,7 +56,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -67,19 +71,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -98,7 +102,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.claude/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md similarity index 84% rename from .claude/skills/openspec-explore/SKILL.md rename to .agents/skills/openspec-explore/SKILL.md index ffa10ca..771271a 100644 --- a/.claude/skills/openspec-explore/SKILL.md +++ b/.agents/skills/openspec-explore/SKILL.md @@ -1,12 +1,13 @@ --- name: openspec-explore description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. +allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.6.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -15,6 +16,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + --- ## The Stance @@ -56,10 +59,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -102,11 +105,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -114,14 +116,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |--------------|------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + |----------------------------|--------------------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" @@ -227,7 +229,7 @@ User: A CLI tool that tracks local dev environments You: That changes everything. ┌─────────────────────────────────────────────────┐ - │ CLI TOOL DATA STORAGE │ + │ CLI TOOL DATA STORAGE │ └─────────────────────────────────────────────────┘ Key constraints: diff --git a/.claude/skills/openspec-propose/SKILL.md b/.agents/skills/openspec-propose/SKILL.md similarity index 81% rename from .claude/skills/openspec-propose/SKILL.md rename to .agents/skills/openspec-propose/SKILL.md index d27bc53..716d2d3 100644 --- a/.claude/skills/openspec-propose/SKILL.md +++ b/.agents/skills/openspec-propose/SKILL.md @@ -1,12 +1,13 @@ --- name: openspec-propose description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +allowed-tools: Bash(openspec:*) license: MIT compatibility: Requires openspec CLI. metadata: author: openspec version: "1.0" - generatedBy: "1.2.0" + generatedBy: "1.6.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -20,6 +21,8 @@ When ready to implement, run /opsx:apply --- +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. **Steps** @@ -37,7 +40,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -46,6 +49,7 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -63,10 +67,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.agents/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md new file mode 100644 index 0000000..bf1cae9 --- /dev/null +++ b/.agents/skills/spec-drift-review/SKILL.md @@ -0,0 +1,216 @@ +--- +name: spec-drift-review +description: Walk every spec in `openspec/specs/`, compare it to the shipped code, and produce a categorized drift report (high/medium/low severity per spec, plus code-without-spec findings and structural suggestions). Use when the user wants to check spec drift, after a chunk of features has shipped, or when planning a spec catch-up PR. +license: MIT +metadata: + author: trails.cool + version: "1.0" +--- + +Walk the specs directory + the shipped code, compare them claim by +claim, and produce a structured drift report. The report is the +deliverable; fixes happen in a follow-up PR after the user has reviewed +the findings. + +The goal is to keep `openspec/specs/` honest — specs are useless if they +don't describe the actual product, and worse than useless if they +contradict it. + +--- + +## When to use + +- The user explicitly asks to check spec drift ("are the specs in sync", + "review specs against code"). +- After a multi-feature chunk has shipped without per-feature spec + promotion — drift accumulates fastest in catch-up phases. +- Before reorganizing the specs directory (split / merge / rename). +- When a spec contradicts the codebase and you're not sure which is + right. + +--- + +## Steps + +1. **Get the lay of the land** + + Run these in parallel: + + ```bash + ls openspec/specs/ + ls openspec/changes/ # in-flight work — NOT drift + cat openspec/CAPABILITIES.md # if it exists, it's the index + openspec list --json # any active changes that explain drift + ``` + + **Important:** any spec referenced in an active openspec change is + *expected* to drift from current code — that drift is the work in + progress. Note these and exclude them from the report. + +2. **Identify the code-side anchors per spec** + + For each spec at `openspec/specs//spec.md`, find the + primary code locations that implement it. Most capability specs map + to one or more of: + + - **Routes:** `apps/journal/app/routes/*.tsx` / `*.ts` — + authoritative for URL behavior, redirects, access gating. + - **Server lib:** `apps/journal/app/lib/.server.ts` — most + business logic. + - **Schema:** `packages/db/src/schema/journal.ts` — table shapes, + visibility values, defaults, indexes. + - **i18n:** `packages/i18n/src/locales/{en,de}.ts` — user-facing + strings, often telling. + - **Tests:** integration tests are a great oracle for the *intended* + behavior; mismatch with the spec usually means the spec is stale. + + Don't read every file. Pick the 1–3 anchors per spec that the + requirements most plausibly map to. + +3. **Compare claim by claim** + + For each requirement in a spec, ask: + + - **Does the code do this?** If not, is it because the requirement + was retired or because it was never shipped? + - **Does the code do *more* than this?** New scenarios shipped + without a spec update. + - **Does the code do this *differently*?** Different URL, + different default, different status code, different rule. + - **Does this requirement reference a name that no longer exists?** + Renamed routes, deleted helpers, removed tables. + + Track findings with severity: + + | Severity | What it means | + |----------|---------------| + | **High** | Spec actively misleads — claims a behavior the code does not exhibit. A reader implementing against the spec would write the wrong code. | + | **Medium** | Spec is incomplete — code has scenarios the spec doesn't describe. Reader gets less information than they should but isn't actively misled. | + | **Low** | Wording drift — comments/cross-refs mention a renamed thing, but the requirement statements are still accurate. | + +4. **Find code-without-spec** + + Walk the route tree in `apps/journal/app/routes.ts` and the topic + files in `apps/journal/app/lib/`. For each top-level concept ask: + + - Is this concept covered by a spec? + - If yes, does the spec mention this surface? + - If no, should it be? + + Genuine "no spec" cases are usually: new feature shipped without + spec promotion, or piece of infrastructure deemed too internal for a + spec. Both are valid; flag the former, leave the latter alone. + +5. **Structural review** + + Spec organization itself can drift: + + - **Specs that grew too big** — multiple unrelated requirements + under one spec. Candidates for a split (we previously split + `account-settings` into `profile-settings`, `account-management`, + `connected-services`). + - **Specs that overlap** — the same requirement appears in two + specs, or one spec keeps cross-referencing another. Candidate for + a merge or a clearer ownership boundary. + - **Specs that should exist but don't** — a capability is shipped + and substantial enough to merit its own spec but is currently + squeezed into another. (We added `sse-broker` and `notifications` + this way.) + - **`CAPABILITIES.md` drift** — if the index exists, check that + every spec dir has an entry and every entry points at a real + spec. The index is the easy thing to forget when adding a spec. + +6. **Compose the report** + + Output to the conversation as a markdown structure. Don't write a + doc unless the report is unusually large and the user asks for one — + most drift reports get acted on inside a single PR and don't need a + long-lived artifact. + + Suggested structure: + + ```markdown + # Spec drift review — YYYY-MM-DD + + **Active changes excluded from this review:** + - (touches: ) + + ## High-severity drift + + ### `` + - **Requirement: ** says ; code at `` does . + [link / one-line action] + - … + + ## Medium-severity drift + + ### `` + - + - … + + ## Low-severity drift (wording / cross-refs) + + - ``: + - … + + ## Code-without-spec + + - at `` — should this be in , or a new + spec? Recommendation: <…> + + ## Structural suggestions + + - Split: , + - Merge: + + - New spec: covering + - `CAPABILITIES.md` updates: + ``` + +7. **Propose next actions, then stop** + + End the review with three concrete options the user can pick from: + + - **Ship a catch-up PR for everything** — works when drift is + mostly low/medium and the fix is mechanical. + - **Fix high-severity first, defer the rest** — works when high + items are urgent and the rest can wait for natural per-feature + spec updates. + - **Restructure first, then catch up** — works when structural + suggestions (split / merge / new spec) are large enough that + fixing claims inside the wrong spec shape would just have to be + redone. + + Don't pick for them. Don't start fixing yet. + +--- + +## What this skill is NOT + +- **Not a fixer.** This skill produces a report. Apply happens after + the user has decided which findings to act on. +- **Not a CI check.** It's interactive — judgment calls (severity, + splits, merges) are part of the value, not an automation target. +- **Not for in-flight work.** Active openspec changes legitimately + cause spec/code mismatch; flag them in the "excluded" section and + move on. +- **Not a code review.** The question is "does the spec match the + code", not "is the code good." Code-quality observations belong in + PR review, not here. + +--- + +## Guardrails + +- Always start by reading active openspec changes — drift caused by + in-flight work is not drift. +- Don't write spec edits during the review. The user picks which + findings to act on; edits happen after. +- When code and spec disagree, the prevailing rule on this project is + **code is source of truth** unless the user says otherwise. Surface + the conflict; don't preemptively decide. +- Skip generated/scaffold files (e.g. `.react-router/types/**`) — they + are derived, not product. +- Don't pad the report with low-severity wording drift unless the user + asks for it. Concentrate signal. +- Keep observations specific (file + line + claim), not abstract. A + finding without an anchor isn't actionable. diff --git a/.claude/commands/opsx/apply.md b/.claude/commands/opsx/apply.md index bf23721..c6cb9b6 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -1,12 +1,15 @@ --- name: "OPSX: Apply" description: Implement tasks from an OpenSpec change (Experimental) +allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, artifacts, experimental] --- Implement tasks from an OpenSpec change. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -26,6 +29,7 @@ Implement tasks from an OpenSpec change. ``` Parse the JSON to understand: - `schemaName`: The workflow being used (e.g., "spec-driven") + - `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) 3. **Get apply instructions** @@ -35,7 +39,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - Context file paths (varies by schema) + - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -47,7 +51,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read the files listed in `contextFiles` from the apply instructions output. + Read every file path listed under `contextFiles` from the apply instructions output. The files depend on the schema being used: - **spec-driven**: proposal, specs, design, tasks - Other schemas: follow the contextFiles from CLI output diff --git a/.claude/commands/opsx/archive.md b/.claude/commands/opsx/archive.md index 5e91608..df8a2f2 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -1,12 +1,15 @@ --- name: "OPSX: Archive" description: Archive a completed change in the experimental workflow +allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, archive, experimental] --- Archive a completed change in the experimental workflow. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. **Steps** @@ -26,6 +29,7 @@ Archive a completed change in the experimental workflow. Parse the JSON to understand: - `schemaName`: The workflow being used + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context - `artifacts`: List of artifacts with their status (`done` or other) **If any artifacts are not `done`:** @@ -48,7 +52,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Check for delta specs at `openspec/changes//specs/`. If none exist, proceed without sync prompt. + Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. **If delta specs exist:** - Compare each delta spec with its corresponding main spec at `openspec/specs//spec.md` @@ -63,19 +67,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create the archive directory if it doesn't exist: + Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: ```bash - mkdir -p openspec/changes/archive + mkdir -p "/archive" ``` Generate target name using current date: `YYYY-MM-DD-` **Check if target already exists:** - If yes: Fail with error, suggest renaming existing archive or using different date - - If no: Move the change directory to archive + - If no: Move `changeRoot` to the archive directory ```bash - mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- + mv "" "/archive/YYYY-MM-DD-" ``` 6. **Display summary** @@ -94,7 +98,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -107,7 +111,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -120,7 +124,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ +**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -137,7 +141,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** openspec/changes/archive/YYYY-MM-DD-/ +**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 30d9c57..7558a63 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -1,6 +1,7 @@ --- name: "OPSX: Explore" description: "Enter explore mode - think through ideas, investigate problems, clarify requirements" +allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, explore, experimental, thinking] --- @@ -11,6 +12,8 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be: - A vague idea: "real-time collaboration" - A specific problem: "the auth system is getting unwieldy" @@ -59,10 +62,10 @@ Depending on what the user brings, you might: │ Use ASCII diagrams liberally │ ├─────────────────────────────────────────┤ │ │ -│ ┌────────┐ ┌────────┐ │ -│ │ State │────────▶│ State │ │ -│ │ A │ │ B │ │ -│ └────────┘ └────────┘ │ +│ ┌────────┐ ┌────────┐ │ +│ │ State │────────▶│ State │ │ +│ │ A │ │ B │ │ +│ └────────┘ └────────┘ │ │ │ │ System diagrams, state machines, │ │ data flows, architecture sketches, │ @@ -107,11 +110,10 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -1. **Read existing artifacts for context** - - `openspec/changes//proposal.md` - - `openspec/changes//design.md` - - `openspec/changes//tasks.md` - - etc. +1. **Resolve and read existing artifacts for context** + - Run `openspec status --change "" --json`. + - Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. + - Read existing files from `artifactPaths..existingOutputPaths`. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -119,14 +121,14 @@ If the user mentions a change or you detect one is relevant: 3. **Offer to capture when decisions are made** - | Insight Type | Where to Capture | - |--------------|------------------| - | New requirement discovered | `specs//spec.md` | - | Requirement changed | `specs//spec.md` | - | Design decision made | `design.md` | - | Scope changed | `proposal.md` | - | New work identified | `tasks.md` | - | Assumption invalidated | Relevant artifact | + | Insight Type | Where to Capture | + |----------------------------|--------------------------------| + | New requirement discovered | `specs//spec.md` | + | Requirement changed | `specs//spec.md` | + | Design decision made | `design.md` | + | Scope changed | `proposal.md` | + | New work identified | `tasks.md` | + | Assumption invalidated | Relevant artifact | Example offers: - "That's a design decision. Capture it in design.md?" diff --git a/.claude/commands/opsx/propose.md b/.claude/commands/opsx/propose.md index 05276f4..8d99588 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -1,6 +1,7 @@ --- name: "OPSX: Propose" description: Propose a new change - create it and generate all artifacts in one step +allowed-tools: Bash(openspec:*) category: Workflow tags: [workflow, artifacts, experimental] --- @@ -16,6 +17,8 @@ When ready to implement, run /opsx:apply --- +**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store ` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root. + **Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build. **Steps** @@ -33,7 +36,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -42,6 +45,7 @@ When ready to implement, run /opsx:apply Parse the JSON to get: - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `artifacts`: list of all artifacts with their status and dependencies + - `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths. 4. **Create artifacts in sequence until apply-ready** @@ -59,10 +63,10 @@ When ready to implement, run /opsx:apply - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) - `template`: The structure to use for your output file - `instruction`: Schema-specific guidance for this artifact type - - `outputPath`: Where to write the artifact + - `resolvedOutputPath`: Resolved path or pattern to write the artifact - `dependencies`: Completed artifacts to read for context - Read any completed dependency files for context - - Create the artifact file using `template` as the structure + - Create the artifact file using `template` as the structure and write it to `resolvedOutputPath` - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/projects/-Users-ullrich-Projects-trails-cool/memory/feedback_eas_builds.md b/.claude/projects/-Users-ullrich-Projects-trails-cool/memory/feedback_eas_builds.md new file mode 100644 index 0000000..272e011 --- /dev/null +++ b/.claude/projects/-Users-ullrich-Projects-trails-cool/memory/feedback_eas_builds.md @@ -0,0 +1,9 @@ +--- +name: EAS build credits +description: Avoid unnecessary EAS cloud builds — they consume limited credits +type: feedback +--- + +Don't trigger EAS builds casually. They consume limited cloud build credits. +**Why:** Free tier has a monthly cap; each `eas build` / `eas build:dev` costs credits. +**How to apply:** Only suggest or run EAS builds when adding/changing native dependencies. JS-only changes don't need a new build — the dev client hot-reloads via the bundler. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d2af30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Keep Docker build contexts small and hermetic. CI builds from a clean +# checkout so this mostly matters for local builds (e2e/federation +# harness), where node_modules would otherwise bloat the context to +# gigabytes — and worse, `COPY . .` would overlay host-installed +# node_modules over the image's own pnpm install. +**/node_modules +**/.turbo +**/build +**/.react-router +.git +e2e/results +playwright-report +test-results +**/*.log diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..4b73c10 --- /dev/null +++ b/.env.development.example @@ -0,0 +1,10 @@ +# Root-level local dev defaults. +# Copy to `.env.development` (gitignored) to override. +# All values here work out of the box — no changes needed for standard local dev. + +DATABASE_URL=postgres://trails:trails@localhost:5432/trails +BROUTER_URL=http://localhost:17777 + +# Change these for any environment that is not purely local. +JWT_SECRET=dev-secret-not-for-production +SESSION_SECRET=dev-secret-not-for-production diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a745f75 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +# trails.cool — Copilot Instructions + +For full project context — including architecture, principles, tech stack, dev commands, testing strategy, code conventions, git workflow, and deployment — see [`CLAUDE.md`](../CLAUDE.md) at the repository root. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a888efe..f2e30a4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,30 @@ updates: development: dependency-type: "development" open-pull-requests-limit: 10 + ignore: + # Keep @types/* aligned with the runtime major. Mismatched type + # packages (e.g. @types/jest 30 while jest is still 29) break + # typecheck without buying us anything. + - dependency-name: "@types/jest" + update-types: ["version-update:semver-major"] + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + # These packages are version-pinned by the Expo SDK (see + # expo/bundledNativeModules.json) — upgrade them via an Expo SDK + # bump / `npx expo install --fix`, never on their own. Dependabot + # bumping them past the SDK's expected version broke the native + # build (expo-modules-core macro mismatch, June 2026) because CI + # never compiles native code. `react` stays unignored: the web + # apps own its version via the workspace catalog, and apps/mobile + # excludes it from expo version checks. + - dependency-name: "react-native" + - dependency-name: "react-native-gesture-handler" + - dependency-name: "react-native-reanimated" + - dependency-name: "react-native-safe-area-context" + - dependency-name: "react-native-screens" + - dependency-name: "react-native-worklets" + - dependency-name: "@sentry/react-native" + - dependency-name: "jest-expo" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index ab3e130..a5a967e 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,6 +13,15 @@ concurrency: group: deploy-apps cancel-in-progress: true +# Public Sentry DSNs for the trails.cool flagship instance. Public by +# design — Sentry DSNs are transmitted unencrypted from the client JS +# bundle, embedding them in this workflow is no worse than embedding +# them in the runtime env. Self-hosted forks should either replace +# these with their own DSNs or remove the lines to ship without Sentry. +env: + SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" + SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" + jobs: build-images: name: Build & Push Docker Images @@ -25,7 +34,7 @@ jobs: matrix: app: [journal, planner] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: @@ -48,8 +57,12 @@ jobs: tags: | ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} + # VITE_SENTRY_DSN bakes the client-side DSN into the journal's + # built bundle. Only journal has a client Sentry init; planner + # ignores the build-arg if present. build-args: | SENTRY_RELEASE=${{ github.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} secrets: | SENTRY_AUTH_TOKEN=/tmp/sentry_token @@ -59,7 +72,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Decrypt secrets run: | @@ -68,6 +81,18 @@ jobs: SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env echo "DOMAIN=trails.cool" >> infrastructure/app.env + # Flagship marker — see cd-infra.yml for what this gates. + echo "IS_FLAGSHIP=true" >> infrastructure/app.env + # Federation on (social-federation rollout 12.5, flipped + # 2026-06-07 after the staging + Mastodon soak). The + # FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env + # decrypted above. Rollback: delete these two lines, merge, + # rerun cd-apps — instant off, federation surfaces 404. + echo "FEDERATION_ENABLED=true" >> infrastructure/app.env + echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env + # Sentry DSNs (public — see workflow top-level env for context). + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env - name: Copy files to server uses: appleboy/scp-action@v1 @@ -75,7 +100,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + source: "infrastructure/docker-compose.yml,infrastructure/caddy" target: /opt/trails-cool strip_components: 1 @@ -96,6 +121,10 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | + # Abort the deploy on the first failure. Without this, a failed + # schema push deploys new code against an old schema (the + # 2026-06-06 schema-drift incident). + set -euo pipefail cd /opt/trails-cool # Login to ghcr.io @@ -104,9 +133,73 @@ jobs: # Pull and deploy app containers docker compose --env-file app.env pull journal planner - docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force - docker compose --env-file app.env up -d journal planner + # Hand-written data migrations (idempotent) run BEFORE drizzle-kit + # push so unique-key reshapes can collapse duplicate rows first. + docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts + # drizzle-kit exits 0 even when it aborts on an interactive + # prompt it can't show (no TTY in CI) — that exact lie hid a + # month of staging schema drift. Treat any Error in its output + # as a failed deploy. + docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the deploy" + exit 1 + fi + # --remove-orphans cleans up containers whose service was deleted + # from the compose file, matching cd-infra's behaviour. + docker compose --env-file app.env up -d --remove-orphans journal planner - # Clean up - docker image prune -af + # Gate on container health: a deploy that leaves journal or + # planner unhealthy must fail loudly, not report green. + for svc in journal planner; do + for i in $(seq 1 24); do + status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing) + [ "$status" = "healthy" ] && break + sleep 5 + done + if [ "$status" != "healthy" ]; then + echo "$svc did not become healthy (last status: $status)" + docker compose --env-file app.env logs "$svc" --tail 50 || true + exit 1 + fi + done + + # Reload Caddy with the Caddyfile we just scp'd. cd-apps + # ships infrastructure/Caddyfile alongside docker-compose.yml + # (see scp step above), but containers don't auto-pick-up + # config changes. cd-infra reloads Caddy as part of its + # deploy; cd-apps did NOT, which meant any Caddyfile change + # touching only `apps/`/`packages/` paths sat on disk + # unapplied until the next cd-infra run. The reload is + # idempotent (Caddy validates first, swaps live, no + # downtime) so doing it on every cd-apps deploy is safe + # even when Caddyfile is unchanged. `|| true` keeps the + # deploy from failing if Caddy itself is unhealthy. + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + # Clean up (best-effort). This runs AFTER the containers are + # swapped + Caddy reloaded, so the deploy already succeeded — + # a transient "a prune operation is already running" collision + # with a concurrent deploy/disk-maintenance prune must NOT fail + # an otherwise-green deploy. disk-maintenance.yml is the real + # image-prune safety net. + docker image prune -af || true docker compose ps + + # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives + # in secrets.infra.env (decrypted by cd-infra.yml into the + # merged /opt/trails-cool/.env on the server). cd-apps's + # own app.env intentionally does NOT carry it — apps don't + # need it at runtime. So we read from the merged `.env` + # that cd-infra populated. If cd-infra has never run on + # this host, .env may not exist; the `2>/dev/null` and + # the `if -n` guard make the annotation a silent no-op + # rather than a deploy failure in that case. + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy ${{ github.sha }}","tags":["deploy","apps"]}' \ + http://localhost:3000/api/annotations || true + fi diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml index ba369df..5d28e86 100644 --- a/.github/workflows/cd-brouter.yml +++ b/.github/workflows/cd-brouter.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "docker/brouter/**" + - "infrastructure/brouter-host/**" workflow_dispatch: {} concurrency: @@ -19,7 +20,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: docker/login-action@v4 with: @@ -36,13 +37,87 @@ jobs: ghcr.io/trails-cool/brouter:${{ github.sha }} deploy: - name: Deploy BRouter + name: Deploy BRouter to dedicated host needs: [build] runs-on: ubuntu-latest + environment: infra steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - name: Deploy via SSH + - name: Decrypt shared secret + id: decrypt + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + # Extract ONLY BROUTER_AUTH_TOKEN from secrets.app.env — the + # rest of that file is app-only and has no business reaching + # the BRouter host. + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \ + | grep '^BROUTER_AUTH_TOKEN=' > infrastructure/brouter-host/.env + chmod 0600 infrastructure/brouter-host/.env + + # GHCR pull credential — not shipped to the host's filesystem; + # passed to the SSH step as an env var so it only lives in + # memory during `docker login`. + GHCR_TOKEN=$(SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env \ + | grep '^DEPLOY_GHCR_TOKEN=' | cut -d= -f2-) + echo "::add-mask::$GHCR_TOKEN" + echo "GHCR_TOKEN=$GHCR_TOKEN" >> $GITHUB_ENV + + - name: Deploy to dedicated host (single SSH connection) + # Consolidated into one SSH session on purpose: UFW on the + # dedicated host rate-limits port 2232 via `LIMIT` (~6 conns / + # 30s). `appleboy/scp-action` burns that budget across + # scp+mkdir+untar+cleanup conns and the final cleanup times + # out. One SSH here sidesteps the limit entirely. The tarball + # is piped in as a base64 env var so we don't fight over + # stdin (reserved for the heredoc script). + env: + SSH_KEY: ${{ secrets.BROUTER_DEPLOY_SSH_KEY }} + HOST: ${{ secrets.BROUTER_DEPLOY_HOST }} + PORT: ${{ secrets.BROUTER_DEPLOY_SSH_PORT }} + run: | + set -euo pipefail + + # SSH key to a locked-down file (env-var keys break openssh) + install -m 0600 /dev/null ~/id_deploy + printf '%s\n' "$SSH_KEY" > ~/id_deploy + + # Bundle the compose project + TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \ + docker-compose.yml Caddyfile promtail-config.yml \ + download-segments.sh .env \ + poi-extract \ + | base64 -w0) + + # One SSH session: untar, (re)start containers, report status + ssh -i ~/id_deploy -p "$PORT" \ + -o StrictHostKeyChecking=accept-new \ + -o UserKnownHostsFile=/tmp/known_hosts \ + -o ConnectTimeout=30 \ + trails@"$HOST" \ + "TARBALL_B64='$TARBALL_B64' GHCR_TOKEN='$GHCR_TOKEN' bash -s" <<'REMOTE' + set -euo pipefail + mkdir -p ~/brouter && cd ~/brouter + echo "$TARBALL_B64" | base64 -d | tar -xzf - + chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py + + # Segment seeding is a one-shot operator task (~10 GB, a few + # minutes). CD must not rerun it on every deploy. + if [ ! -d segments ] || [ -z "$(ls -A segments 2>/dev/null)" ]; then + echo "WARNING: segments/ is empty. BRouter will start but return 404 until segments are seeded." + mkdir -p segments + fi + + # GHCR brouter image is private; log in so pull works. + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + docker compose pull + docker compose up -d --remove-orphans + docker compose ps + REMOTE + + - name: Annotate deploy in flagship Grafana uses: appleboy/ssh-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} @@ -50,17 +125,11 @@ jobs: key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /opt/trails-cool - - # Download BRouter segments if missing - if [ ! -d /opt/trails-cool/segments ] || [ -z "$(ls /opt/trails-cool/segments/*.rd5 2>/dev/null)" ]; then - mkdir -p /opt/trails-cool/segments - echo "Downloading Germany BRouter segments..." - for tile in E5_N45 E5_N50 E10_N45 E10_N50; do - [ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \ - wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5" - done + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy brouter ${{ github.sha }}","tags":["deploy","brouter"]}' \ + http://localhost:3000/api/annotations || true fi - - docker pull ghcr.io/trails-cool/brouter:latest - docker compose up -d brouter - docker image prune -af diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 82194a8..df41b93 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest environment: infra steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Decrypt secrets run: | @@ -32,6 +32,10 @@ jobs: SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env echo "DOMAIN=trails.cool" >> infrastructure/.env + # Flagship marker — the Journal home renders the project + # marketing block when this is "true" and the self-host + # footer link when it's unset. + echo "IS_FLAGSHIP=true" >> infrastructure/.env - name: Copy configs to server uses: appleboy/scp-action@v1 @@ -39,7 +43,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" + source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts" target: /opt/trails-cool strip_components: 1 @@ -60,6 +64,11 @@ jobs: username: root key: ${{ secrets.DEPLOY_SSH_KEY }} script: | + # Abort on first failure. The 2026-06-06/07 outage: a network + # recreation stopped postgres, a later step failed, and the + # deploy left production down for ~9h while the job's partial + # progress looked plausible. Fail fast, verify health at the end. + set -euo pipefail cd /opt/trails-cool # .env was placed by the SCP step (decrypted app + infra secrets) @@ -68,13 +77,90 @@ jobs: GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN .env | cut -d= -f2-) echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin 2>/dev/null || true - # Restart services + # Setup Grafana read-only DB user (idempotent) + docker compose exec -T postgres psql -U trails -d trails -f /docker-entrypoint-initdb.d/init-grafana-user.sql 2>/dev/null || true + GRAFANA_DB_PW=$(grep GRAFANA_DB_PASSWORD .env | cut -d= -f2-) + if [ -n "$GRAFANA_DB_PW" ]; then + docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true + fi + + # Capture whether prometheus was recreated. A fresh container already + # reads the new config on startup; sending it SIGHUP immediately can + # kill Prometheus 3.10 during early boot (exit 2 observed on + # 2026-06-09). + PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + + # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else - # Restart infra services (except Caddy — just reload its config) - docker compose --env-file .env up -d postgres prometheus loki grafana - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + # Restart infra services (config reloads handled below). + # --remove-orphans cleans up containers whose service was deleted + # from the compose file (e.g., the flagship `brouter` removal in + # PR #297 left an orphan that had to be removed by hand). + docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor fi + PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + + # Apply config-only changes that `up -d` skips — it recreates a + # container only when its compose *definition* changes, not when a + # mounted config file's content changes. The configs are mounted as + # directories (not single files), so a reload/restart re-reads the + # freshly scp'd file; a single-file mount would have pinned the old + # inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when + # the same container stayed up; a recreated container already loaded + # the new config on startup. Loki and Promtail reload their main + # config only on restart; Caddy reloads gracefully (validates, swaps + # live, no downtime). + if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then + docker compose --env-file .env kill -s SIGHUP prometheus + fi + docker compose --env-file .env restart loki promtail + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + docker compose ps + + # Gate on the stack actually being up: postgres healthy, journal + # back to healthy after the DB bounce, and Prometheus answering + # /-/ready. A deploy that leaves any of them down must fail loudly + # (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus + # startup/HUP regression). + for ctr in trails-cool-postgres-1 trails-cool-journal-1; do + for i in $(seq 1 36); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing) + [ "$status" = "healthy" ] && break + sleep 5 + done + if [ "$status" != "healthy" ]; then + echo "$ctr did not become healthy (last status: $status)" + exit 1 + fi + done + + PROMETHEUS_READY= + for i in $(seq 1 36); do + prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) + if [ "$prom_status" = "running" ]; then + prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) + if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then + PROMETHEUS_READY=1 + break + fi + fi + sleep 5 + done + if [ -z "$PROMETHEUS_READY" ]; then + echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" + exit 1 + fi + + # Annotate deploy in Grafana + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) + if [ -n "$GRAFANA_TOKEN" ]; then + docker compose exec -T grafana curl -sf -X POST \ + -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"text":"Deploy infra ${{ github.sha }}","tags":["deploy","infra"]}' \ + http://localhost:3000/api/annotations || true + fi diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml new file mode 100644 index 0000000..e69dec6 --- /dev/null +++ b/.github/workflows/cd-staging.yml @@ -0,0 +1,520 @@ +name: CD Staging + +# Builds, deploys, and tears down the persistent staging stack and per-PR +# preview environments. See `openspec/changes/staging-environments/` for the +# design (decisions on shared Postgres, port allocation, per-PR Caddyfile +# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view. + +on: + push: + branches: [main] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + # Also redeploy when the staging plumbing itself changes, so a port + # bump or compose edit doesn't sit unapplied until the next apps/ push. + - "infrastructure/docker-compose.staging.yml" + - ".github/workflows/cd-staging.yml" + pull_request: + # `labeled`/`unlabeled` so toggling the `preview` label on an existing PR + # starts / tears down its preview (see the opt-in gate on the jobs below). + types: [opened, synchronize, reopened, closed, labeled, unlabeled] + paths: + - "apps/**" + - "packages/**" + - "pnpm-lock.yaml" + workflow_dispatch: {} + +# Per-target concurrency: persistent staging deploys serialize against +# themselves, each PR's preview lifecycle serializes against itself. +concurrency: + group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} + cancel-in-progress: true + +# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the +# "public by design" rationale. +env: + SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728" + SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208" + +jobs: + # ── Build ───────────────────────────────────────────────────────────── + # Tags: + # main push → :staging + : + # PR open/sync/reopen → :pr- + :pr-- + # Skipped entirely on PR close (teardown doesn't need new images). + build-images: + name: Build & Push Docker Images + # PR previews are opt-in to keep flagship disk in check (each preview is a + # journal container + database): build a PR's images only when it carries + # the `preview` label or a `` marker in its description. + # Main-push / dispatch always build. + if: > + github.event_name != 'pull_request' || + (github.event.action != 'closed' && + (contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, ''))) + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + packages: write + strategy: + matrix: + app: [journal, planner] + outputs: + tag_primary: ${{ steps.tags.outputs.primary }} + tag_sha: ${{ steps.tags.outputs.sha }} + steps: + - uses: actions/checkout@v7 + + - id: tags + name: Compute image tags + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + PRIMARY="pr-${{ github.event.number }}" + SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}" + else + PRIMARY="staging" + SHA="${{ github.sha }}" + fi + echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Decrypt Sentry auth token + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env + grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token + + - uses: docker/build-push-action@v7 + with: + context: . + file: apps/${{ matrix.app }}/Dockerfile + push: true + tags: | + ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }} + ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} + build-args: | + SENTRY_RELEASE=${{ steps.tags.outputs.sha }} + VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }} + secrets: | + SENTRY_AUTH_TOKEN=/tmp/sentry_token + + # ── Deploy persistent staging (main push or manual dispatch) ───────── + deploy-staging: + name: Deploy Staging + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + needs: [build-images] + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v7 + + - name: Decrypt secrets + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env + { + echo "DOMAIN=staging.trails.cool" + echo "STAGING_DATABASE=trails_staging" + # Loki binds 10.0.0.2:3100 on the vSwitch interface, so the + # staging stack can't share 3100 — see CLAUDE.md "Staging & + # Previews" port table. + echo "JOURNAL_HOST_PORT=3110" + echo "PLANNER_HOST_PORT=3111" + echo "JOURNAL_IMAGE_TAG=staging" + echo "PLANNER_IMAGE_TAG=staging" + echo "SENTRY_RELEASE=${{ github.sha }}" + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + # Federation soak on persistent staging only (social-federation + # rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the + # SOPS env decrypted above; previews never set this flag. + echo "FEDERATION_ENABLED=true" + # Verbose Fedify logs during the soak (signature verification + # detail). Dial down to info once federation is proven out. + echo "FEDERATION_LOG_LEVEL=debug" + } >> infrastructure/staging.env + + - name: Copy compose file + env to server + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + cd /opt/trails-cool + + GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-) + echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin + + # Bootstrap the shared network so cd-staging works regardless of + # whether cd-infra has already run with the new docker-compose.yml. + # Once cd-infra runs, postgres is permanently joined via compose; + # until then, attach it imperatively. + docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared + PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) + if [ -n "$PG_CONTAINER" ]; then + docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true + fi + + # Ensure trails_staging database exists with postgis. Production + # init scripts only run on first data-dir init, so a freshly + # created database has no extensions. + docker compose exec -T postgres psql -U trails -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='trails_staging'" \ + | grep -q 1 \ + || docker compose exec -T postgres createdb -U trails trails_staging + docker compose exec -T postgres psql -U trails -d trails_staging -c \ + "CREATE EXTENSION IF NOT EXISTS postgis" + + # Pull and deploy staging containers (journal + planner via "persistent" profile) + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull + # drizzle-kit exits 0 even when it aborts on an interactive + # prompt (no TTY in CI) — set -e alone can't catch it. That lie + # hid a month of staging schema drift (2026-06-06 incident). + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the deploy" + exit 1 + fi + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans + + # Reload Caddy so new staging routes (or Caddyfile changes shipped + # via cd-infra) are live. Idempotent. + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + # Reclaim superseded image layers. cd-apps prunes after its own + # deploys, but a day of staging/preview deploys while cd-apps is + # red can fill the disk on its own (2026-06-07: 100% full, + # postgres down). The 1h filter avoids racing layers another + # in-flight deploy just pulled. + docker image prune -af --filter "until=1h" || true + + docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps + + # ── PR preview deploy ──────────────────────────────────────────────── + deploy-preview: + name: Deploy PR Preview + # Opt-in only (see build-images): the `preview` label or a `` + # marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there + # is no app image to preview. + if: > + github.event_name == 'pull_request' && + github.event.action != 'closed' && + !startsWith(github.head_ref, 'dependabot/github_actions/') && + (contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, '')) + needs: [build-images] + runs-on: ubuntu-latest + environment: production + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v7 + + - id: ports + name: Compute preview ports + project name + run: | + PR=${{ github.event.number }} + # journal = 3200 + 2N, planner unused (PR previews are journal-only) + JOURNAL_PORT=$((3200 + 2 * PR)) + PLANNER_PORT=$((3201 + 2 * PR)) + echo "pr=$PR" >> "$GITHUB_OUTPUT" + echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT" + echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT" + echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT" + echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" + echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" + + - name: Decrypt secrets + assemble env + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + # Use a per-PR filename so concurrent SCP transfers don't overwrite each other. + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + { + echo "DOMAIN=${{ steps.ports.outputs.host }}" + echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" + echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}" + echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}" + echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" + echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}" + # PR-preview journals all share the persistent staging planner. + echo "PLANNER_URL=https://planner.staging.trails.cool" + echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" + echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" + echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + # Federation on previews too (social-federation rollout 12.4): + # makes any PR preview a second live trails instance that + # persistent staging can follow across — the trails-to-trails + # soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the + # SOPS env decrypted above. Preview teardown orphans remote + # follower rows on the other side; remotes handle dead + # instances via ordinary delivery-failure expiry. + echo "FEDERATION_ENABLED=true" + echo "FEDERATION_LOG_LEVEL=debug" + } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + + - name: Generate per-PR Caddyfile snippet + run: | + mkdir -p infrastructure/sites + cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile </tmp/trails-preview-deploy.lock + flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; } + + # Same network bootstrap as deploy-staging — see comment there. + docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared + PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1) + if [ -n "$PG_CONTAINER" ]; then + docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true + fi + + # Concurrent preview limit (max 3): if we're at the cap and this + # PR isn't already running, evict the oldest preview project. + ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true) + if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then + COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true) + if [ "$COUNT" -ge 3 ]; then + # Pick the oldest by container CreatedAt of any service in the project. + OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \ + | awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \ + | sort -k2 \ + | head -1 \ + | awk '{print $1}') + if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then + echo "At cap; evicting oldest preview: $OLDEST" + OLD_PR=${OLDEST#trails-pr-} + OLD_ENV="staging-pr-${OLD_PR}.env" + # Fall back to base secrets if the per-PR env was already cleaned up. + EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" ) + docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true + docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true + rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV" + fi + fi + fi + + # Ensure per-PR database exists with postgis. (See deploy-staging + # for why we have to enable the extension explicitly.) + docker compose exec -T postgres psql -U trails -d postgres -tAc \ + "SELECT 1 FROM pg_database WHERE datname='$DB'" \ + | grep -q 1 \ + || docker compose exec -T postgres createdb -U trails "$DB" + docker compose exec -T postgres psql -U trails -d "$DB" -c \ + "CREATE EXTENSION IF NOT EXISTS postgis" + + # Pull, migrate, deploy (journal-only — no --profile means planner skipped) + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal + # Same drizzle-kit exit-code-0-on-error guard as the persistent + # staging deploy above. + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log + if grep -q "Error:" /tmp/drizzle-push.log; then + echo "drizzle-kit push reported an error — failing the preview deploy" + exit 1 + fi + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal + + # Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step) + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile + + # Same disk-hygiene prune as the persistent staging deploy — + # preview pushes are the highest-volume image source. + docker image prune -af --filter "until=1h" || true + + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps + + # Find any prior preview comment so we can update it in place rather + # than spamming a new one each push. The marker line at the bottom of + # the body is what `body-includes` matches on. + - name: Find existing preview comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Upsert preview comment on PR + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.number }} + edit-mode: replace + body: | + 🚀 **PR preview deployed** + + - **Journal:** https://${{ steps.ports.outputs.host }} + - **Planner (shared staging):** https://planner.staging.trails.cool + - **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging) + - **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}` + + Updates automatically on push. Tears down when this PR closes. + + + + # ── PR preview teardown ────────────────────────────────────────────── + teardown-preview: + name: Tear Down PR Preview + # Tear down on close, or when the `preview` opt-in is removed (label pulled + # and no `` marker left) so a de-flagged PR doesn't orphan + # its preview stack on the flagship. + if: > + github.event_name == 'pull_request' && + (github.event.action == 'closed' || + (github.event.action == 'unlabeled' && + !(contains(github.event.pull_request.labels.*.name, 'preview') || + contains(github.event.pull_request.body, '')))) + runs-on: ubuntu-latest + environment: production + permissions: + pull-requests: write + steps: + - id: ports + name: Compute project + database name + run: | + PR=${{ github.event.number }} + echo "pr=$PR" >> "$GITHUB_OUTPUT" + echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" + echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + + - name: Decrypt secrets (needed to satisfy compose env vars during down) + run: | + curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 + chmod +x sops-v3.9.4.linux.amd64 + SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + { + echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool" + echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" + echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))" + echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))" + } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env + + - name: Copy compose + env (teardown still needs the file) + uses: appleboy/scp-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env" + target: /opt/trails-cool + strip_components: 1 + + - name: Tear down via SSH + uses: appleboy/ssh-action@v1 + env: + PR: ${{ steps.ports.outputs.pr }} + PROJECT: ${{ steps.ports.outputs.project }} + DB: ${{ steps.ports.outputs.database }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: PR,PROJECT,DB + script: | + set -euo pipefail + cd /opt/trails-cool + ENV_FILE="staging-pr-${PR}.env" + + # Stop and remove containers + volumes for this PR + docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true + + # Drop the per-PR database (idempotent) + docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true + + # Remove the per-PR Caddy snippet, env file, and reload + rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE" + docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true + + - name: Find existing preview comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.number }} + comment-author: "github-actions[bot]" + body-includes: "" + + - name: Update preview comment on close + if: steps.find-comment.outputs.comment-id + uses: peter-evans/create-or-update-comment@v5 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + 🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}). + + Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared. + + [Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c87ff..068ebc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,15 +19,16 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Gitleaks + if: github.actor != 'dependabot[bot]' uses: trails-cool/gitleaks-action@4cbc857b9cfa2a3297fe2be1078e196d30d1b424 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - - uses: pnpm/action-setup@v5 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -37,12 +38,32 @@ jobs: run: pnpm audit --audit-level=high continue-on-error: true + dockerfile-check: + name: Dockerfile Package Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: bash scripts/check-dockerfiles.sh + + openspec: + name: OpenSpec Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm openspec validate --all --strict --no-interactive + typecheck: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -54,8 +75,8 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -67,8 +88,8 @@ jobs: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -80,8 +101,8 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 @@ -89,6 +110,72 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build + visual-tests: + name: Visual Tests + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Playwright deps only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Run visual regression tests + id: visual-tests + run: pnpm --filter @trails-cool/planner test:visual + + - name: Post diff comment on PR + if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort) + if [ -z "$diffs" ]; then exit 0; fi + + artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + body="## Visual regression failures"$'\n\n' + body+="The following tests produced screenshot diffs:"$'\n\n' + for diff in $diffs; do + name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g') + body+="- \`$name\`"$'\n' + done + body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n' + body+="To update snapshots if the change is intentional:"$'\n' + body+="\`\`\`"$'\n' + body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n' + body+="\`\`\`" + + gh pr comment ${{ github.event.pull_request.number }} --body "$body" + + - name: Upload screenshots on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: visual-snapshots-diff + path: apps/planner/.vitest-attachments/ + include-hidden-files: true + retention-days: 7 + e2e: name: E2E Tests needs: build @@ -96,72 +183,20 @@ jobs: env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Cache PostGIS Docker image - id: postgis-cache - uses: actions/cache@v5 - with: - path: /tmp/postgis-image.tar - key: postgis-16-3.4 - - - name: Load or pull PostGIS image - run: | - if [ -f /tmp/postgis-image.tar ]; then - docker load < /tmp/postgis-image.tar - else - docker pull postgis/postgis:16-3.4 - docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar - fi - - - name: Start PostgreSQL - run: | - docker run -d --name postgres \ - -e POSTGRES_USER=trails \ - -e POSTGRES_PASSWORD=trails \ - -e POSTGRES_DB=trails \ - -p 5432:5432 \ - postgis/postgis:16-3.4 - # Wait for pg_isready - for i in $(seq 1 30); do - docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break - sleep 1 - done - # Wait for PostGIS extension to be ready - for i in $(seq 1 10); do - docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break - sleep 1 - done - - - name: Push database schema - run: pnpm db:push - - - name: Build and cache BRouter - id: brouter-cache - uses: actions/cache@v5 - with: - path: /tmp/brouter - key: brouter-1.7.8 - - - name: Download BRouter - if: steps.brouter-cache.outputs.cache-hit != 'true' - run: | - mkdir -p /tmp/brouter - wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip - cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip - - name: Cache BRouter segment id: segment-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: /tmp/brouter-segments - key: brouter-segment-E10_N50 + key: brouter-segment-E10_N50-v1.7.9 - name: Download Berlin segment if: steps.segment-cache.outputs.cache-hit != 'true' @@ -169,26 +204,54 @@ jobs: mkdir -p /tmp/brouter-segments wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 - - name: Start BRouter + - name: Pre-seed BRouter segment volume run: | - cd /tmp/brouter - java -Xmx256M -Xms64M \ - -DmaxRunningTime=300 \ - -cp brouter-1.7.8-all.jar \ - btools.server.RouteServer \ - /tmp/brouter-segments profiles2 profiles2 \ - 17777 2 & - # Wait for BRouter to start - for i in $(seq 1 30); do - curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break - sleep 2 - done + docker volume create trails_brouter_segments + docker run --rm \ + -v /tmp/brouter-segments:/src:ro \ + -v trails_brouter_segments:/dst \ + alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5" + + - name: Start services + run: docker compose -f docker-compose.dev.yml up -d --wait --build env: BROUTER_URL: http://localhost:17777 + - name: Wait for BRouter routing + run: | + for i in $(seq 1 60); do + curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break + [ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1 + sleep 2 + done + + - name: Push database schema + run: pnpm db:push + + - name: Seed database + run: pnpm db:seed + + - name: Run integration tests + # These talk to real Postgres. The unit-test job has no DB so + # the `*.integration.test.ts` files skip there; this job has + # the DB up + schema pushed, so flip the gate env vars to "1" + # and let them run. Each gate is read by one file — see + # `runIntegration` in each test. + # + # --no-file-parallelism: integration tests share the journal + # schema and clean up by `DELETE FROM ... WHERE email LIKE + # '%@example.test'`. Parallel files step on each other's rows + # and trip FK constraints. Running sequentially is still <3s. + run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts + env: + EXPLORE_INTEGRATION: "1" + FOLLOW_INTEGRATION: "1" + DEMO_BOT_INTEGRATION: "1" + NOTIFICATIONS_INTEGRATION: "1" + - name: Cache Playwright browsers id: playwright-cache - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -210,6 +273,12 @@ jobs: run: pnpm test:e2e env: BROUTER_URL: http://localhost:17777 + # E2E=true is the explicit opt-out from the fail-loud + # requireSecret() / getDatabaseUrl() guards — playwright boots + # the server via `react-router serve` (NODE_ENV=production) but + # against the local dev Postgres + local cookie secrets. + E2E: "true" + INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }} - name: Playwright job summary if: ${{ !cancelled() }} @@ -247,3 +316,87 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 + + journal-image-smoke: + # Build the journal's *production* Docker image (the `runtime` stage) + # and actually boot it. Nothing else in CI does this: typecheck / + # lint / test / build all run against the source tree, and the e2e + # job boots the journal via `react-router-serve`, not the production + # `node server.ts` entrypoint. The runtime stage copies source files + # in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor + # that adds a file `server.ts` imports — without a matching COPY — + # builds green everywhere and only crash-loops once deployed + # (ERR_MODULE_NOT_FOUND). That has taken prod down more than once + # (app/lib, app/jobs, serve-static.ts). Booting the real image and + # hitting /api/health closes that gap: a missing static OR dynamic + # import never reaches a healthy 200. + name: Journal Image Smoke Test + runs-on: ubuntu-latest + services: + postgres: + image: imresamu/postgis:16-3.4 + env: + POSTGRES_USER: trails + POSTGRES_PASSWORD: trails + POSTGRES_DB: trails + ports: + - 5432:5432 + # The postgis image restarts mid-init while it creates the + # extension; the health check only passes once the final server + # is up, so dependents don't race the init restart. + options: >- + --health-cmd "pg_isready -U trails" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + env: + DATABASE_URL: postgres://trails:trails@localhost:5432/trails + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + + # seedOAuthClient + the demo/notifications job worker run on boot + # and write to real tables, so the image needs a schema to come up + # healthy. The postgis extension is auto-created by the image. + - name: Push database schema + run: pnpm db:push + + - name: Build journal runtime image + run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke . + + - name: Boot image and wait for healthy + run: | + # --network host: reach the service Postgres at localhost:5432 + # and publish the server on localhost:3000 in one shot. + # E2E=true is the documented opt-out from the fail-loud + # getDatabaseUrl() prod guard (CI points at a local Postgres). + docker run -d --name journal-smoke --network host \ + -e NODE_ENV=production -e E2E=true \ + -e DATABASE_URL="$DATABASE_URL" \ + journal-smoke + code=000 + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000) + echo "attempt $i: /api/health -> $code" + [ "$code" = "200" ] && break + if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then + echo "::error::journal container exited during boot" + break + fi + sleep 2 + done + if [ "$code" != "200" ]; then + echo "::error::journal production image failed to boot healthy (see logs below)" + docker logs journal-smoke 2>&1 || true + exit 1 + fi + echo "journal production image booted healthy" + + - name: Container logs + if: always() + run: docker logs journal-smoke 2>&1 | tail -40 || true diff --git a/.github/workflows/dependabot-auto-fix.yml b/.github/workflows/dependabot-auto-fix.yml new file mode 100644 index 0000000..85e110f --- /dev/null +++ b/.github/workflows/dependabot-auto-fix.yml @@ -0,0 +1,94 @@ +name: Dependabot auto-fix + +# Post-processing that runs on every dependabot PR and pushes the result +# back to the PR branch. Two fixups, in one workflow so there is a single +# checkout / commit / push (two workflows racing to push the same branch +# would collide on a non-fast-forward): +# +# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a +# package (e.g. two versions of i18next, each holding their own +# singleton state). That split caused a hydration mismatch on #272 +# until a manual `pnpm dedupe` collapsed them. +# +# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*) +# and opsx slash commands (.claude/commands/opsx/*) are generated files +# stamped with the CLI version that produced them. When dependabot bumps +# @fission-ai/openspec they go stale until regenerated (see PR #567, +# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force` +# rewrites them to match the freshly-installed CLI. +# +# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a +# fine-grained PAT (or GitHub App token) with `contents: write` on +# this repo. The default `GITHUB_TOKEN` would work for the push but +# would NOT trigger a subsequent CI run on that push (GitHub's +# anti-loop safeguard), leaving the PR with stale green CI from +# before the fixup commit. A PAT re-triggers CI so the reviewer +# sees test results for the state they'd actually be merging. + +on: + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + autofix: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Require DEPENDABOT_DEDUPE_TOKEN + env: + TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + if [ -z "$TOKEN" ]; then + echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow" + echo "::error::requires a PAT so the fixup commit re-triggers CI." + echo "::error::See .github/workflows/dependabot-auto-fix.yml header." + exit 1 + fi + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + # With `persist-credentials: true`, this PAT is stashed by the + # action (under $RUNNER_TEMP in recent versions) and made + # available to subsequent git operations in this workspace — + # so the later `git push` authenticates as the PAT, which is + # what gets CI to re-trigger on the fixup commit. `true` is + # the checkout default; pinned explicitly here because it's + # load-bearing for this workflow. + token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + persist-credentials: true + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile=false + - name: Dedupe lockfile + run: pnpm dedupe + - name: Regenerate OpenSpec tool files + # --force so the generated files always match the installed CLI, + # even if `update` would otherwise consider them up to date. No-op + # (no diff) when @fission-ai/openspec wasn't bumped in this PR. + run: pnpm exec openspec update --force + - name: Commit fixups + env: + GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }} + run: | + git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx + if git diff --cached --quiet; then + echo "Nothing to fix up — lockfile deduped and OpenSpec files current." + exit 0 + fi + # Build a message naming only the parts that actually changed. + parts="" + git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe" + if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then + parts="${parts:+$parts + }openspec update" + fi + git config user.name "dependabot[bot]" + git config user.email "49699333+dependabot[bot]@users.noreply.github.com" + git commit -m "[github-actions] $parts" + git push + echo "Pushed fixups: $parts" diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml new file mode 100644 index 0000000..d8f6a09 --- /dev/null +++ b/.github/workflows/disk-maintenance.yml @@ -0,0 +1,56 @@ +name: Disk Maintenance + +# Daily safety net for flagship disk usage. The deploy workflows prune +# superseded image layers after their own runs, but that protection +# disappears exactly when it's needed most: a deploy that fails early +# never reaches its prune step, while other workflows keep pulling +# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging +# deploys, disk 100% full, postgres down on a Saturday morning). +# +# Also doubles as a redundant alert channel: the run FAILS when the +# disk is still above the threshold after pruning, so a scheduled-run +# failure email lands even if the Grafana disk alert drowns in other +# noise (which is what happened during the incident). + +on: + schedule: + # Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00) + - cron: "30 4 * * *" + workflow_dispatch: {} + +concurrency: + group: disk-maintenance + cancel-in-progress: false + +jobs: + prune-flagship: + name: Prune unused images (flagship) + runs-on: ubuntu-latest + environment: production + steps: + - name: Prune and check disk headroom + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + echo "before: $(df -h / | tail -1)" + # 12h filter: never touch layers a same-day deploy may still + # be assembling; running containers' images are never pruned. + # `|| true`: a concurrent deploy's prune can make this collide + # with "a prune operation is already running" — that's benign + # (the other prune is freeing space too), and the disk-% gate + # below is the real assertion, so don't fail on the collision. + docker image prune -af --filter "until=12h" || true + echo "after: $(df -h / | tail -1)" + + PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9') + if [ "$PCT" -ge 85 ]; then + echo "Disk still at ${PCT}% after pruning — needs a human." + echo "Largest docker consumers:" + docker system df + exit 1 + fi + echo "Disk at ${PCT}% — healthy." diff --git a/.github/workflows/staging-cleanup.yml b/.github/workflows/staging-cleanup.yml new file mode 100644 index 0000000..bed2fd4 --- /dev/null +++ b/.github/workflows/staging-cleanup.yml @@ -0,0 +1,112 @@ +name: Staging Cleanup + +# Sweeps the production server for orphaned PR-preview resources whose PRs +# have closed without the cd-staging teardown job running (e.g., the +# teardown failed, the workflow file was changed mid-flight, or the PR was +# closed while runners were down). Runs weekly and can be triggered ad-hoc. + +on: + schedule: + # Every Monday at 04:00 UTC + - cron: "0 4 * * 1" + workflow_dispatch: {} + +concurrency: + group: staging-cleanup + cancel-in-progress: false + +jobs: + cleanup: + name: Sweep orphaned previews + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + pull-requests: read + steps: + - name: List active preview projects + id: list + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + script_stop: true + script: | + cd /opt/trails-cool + # Emit one project name per line, e.g. "trails-pr-123" + docker compose ls --format json --filter "name=trails-pr-" \ + | python3 -c 'import json,sys + try: + data = json.load(sys.stdin) + except Exception: + data = [] + for d in data: + n = d.get("Name","") + if n.startswith("trails-pr-"): + print(n)' \ + || true + + - name: Determine which PRs are still open + id: orphans + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROJECTS: ${{ steps.list.outputs.stdout }} + run: | + set -euo pipefail + ORPHANS=() + if [ -z "${PROJECTS:-}" ]; then + echo "No active preview projects." + echo "orphans=" >> "$GITHUB_OUTPUT" + exit 0 + fi + while IFS= read -r project; do + [ -z "$project" ] && continue + pr="${project#trails-pr-}" + # If gh can't find the PR (deleted) or it's not OPEN, treat as orphan. + state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING") + if [ "$state" != "OPEN" ]; then + echo "Orphan: PR #$pr (state=$state) → tear down $project" + ORPHANS+=("$pr") + fi + done <<< "${PROJECTS}" + IFS=, + echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT" + + - name: Tear down orphans + if: steps.orphans.outputs.orphans != '' + env: + ORPHANS: ${{ steps.orphans.outputs.orphans }} + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: root + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: ORPHANS + script: | + set -euo pipefail + cd /opt/trails-cool + IFS=, read -ra PRS <<< "$ORPHANS" + for PR in "${PRS[@]}"; do + [ -z "$PR" ] && continue + PROJECT="trails-pr-$PR" + DB="trails_pr_$PR" + echo "→ tearing down $PROJECT" + # `down` needs the same env file the deploy used; staging.env on + # disk may belong to a different PR, so synthesize a minimal one. + cat > /tmp/cleanup.env < +# +# Running locally: +# pnpm --filter @trails-cool/planner test:visual # run tests +# pnpm --filter @trails-cool/planner test:visual:update # update snapshots +# +# Platform note: +# Snapshots are generated on ubuntu-latest to keep CI and local results +# consistent. Snapshots generated on macOS or Windows will have subtle +# font-rendering differences and will fail on CI. Always use this workflow +# (or a Linux machine / Docker) to produce the canonical snapshots. + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to update snapshots on" + required: false + default: "" + + pull_request: + types: [labeled] + +jobs: + update-snapshots: + # Only run for manual dispatch, or when the label is "update-snapshots" + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots') + + runs-on: ubuntu-latest + + permissions: + contents: write # needed to push snapshot commits back + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }} + # Use a token with push rights so the commit-back step can push + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install chromium --with-deps + + - name: Update visual snapshots + run: pnpm --filter @trails-cool/planner test:visual:update + + - name: Commit updated snapshots + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: "chore: update visual snapshots [skip ci]" + file_pattern: "apps/planner/app/**/__screenshots__/**" + commit_user_name: "github-actions[bot]" + commit_user_email: "github-actions[bot]@users.noreply.github.com" + + - name: Upload snapshots as artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: visual-snapshots + path: apps/planner/app/**/__screenshots__/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 04b7ae3..450df80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,10 @@ dist/ .crit.json e2e/results/ test-results/ +.env.development playwright-report/ playwright-results.json .claude/worktrees/ .claude/settings.local.json +.claude/scheduled_tasks.lock +docs/reviews/internal/ diff --git a/CLAUDE.md b/CLAUDE.md index c18798d..2109593 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,9 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with Full architecture: `docs/architecture.md` Philosophy: `docs/philosophy.md` -OpenSpec change: `openspec/changes/phase-1-mvp/` +Roadmap: `docs/roadmap.md` +Ideas (pre-spec explorations): `docs/ideas/` +OpenSpec changes: `openspec/changes/` ## Principles @@ -25,7 +27,7 @@ OpenSpec change: `openspec/changes/phase-1-mvp/` - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Maps**: Leaflet + OpenStreetMap tiles - **CRDT**: Yjs + y-websocket (Planner only) -- **Federation**: Fedify (Journal only, Phase 2) +- **Federation**: Fedify (Journal only) - **Database**: PostgreSQL + PostGIS - **Media storage**: S3-compatible (Garage) - **Routing engine**: BRouter (Java, runs as separate Docker container) @@ -39,11 +41,15 @@ apps/ planner/ — Planner app (React Router 7) journal/ — Journal app (React Router 7 + Fedify) packages/ - types/ — Shared TypeScript interfaces (Route, Activity, Waypoint) - ui/ — Shared React components (Tailwind) - map/ — Leaflet map wrappers and tile layer configs + types/ — Shared wire types both apps exchange (Waypoint) + map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side gpx/ — GPX parsing, generation, validation + fit/ — FIT file generation (Wahoo route push) i18n/ — react-i18next config + translations + api/ — Shared API contracts (endpoints, pagination, error types, versioning) + db/ — Drizzle schema, database client, migration helpers + jobs/ — pg-boss setup, worker, and background job types + sentry-config/ — Shared Sentry configuration infrastructure/ — Terraform + Docker Compose openspec/ — OpenSpec specs and changes docs/ — Architecture, philosophy, tooling docs @@ -68,6 +74,40 @@ pnpm db:push # Push Drizzle schema to local PostgreSQL pnpm db:studio # Open Drizzle Studio (DB browser) ``` +### Local HTTPS dev (rare — most contributors never need this) + +The default dev loop runs the journal on plain HTTP at +`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the +Terms gate, SSE — everything works over HTTP because the WebAuthn spec +treats `localhost` as a secure context regardless of scheme. CI's e2e +suite runs over plain HTTP too. + +There is exactly one feature that requires local HTTPS: **Wahoo OAuth**. +Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the +`/api/sync/connect/wahoo` callback flow can only complete against an +HTTPS dev server. To run that flow: + +```bash +HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev +``` + +`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN +HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the +WebAuthn server expect the HTTPS origin (set this together with HTTPS=1 +or you'll get origin-mismatch errors). Use `pnpm --filter` (not +`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to +its `globalPassThroughEnv` — `pnpm --filter` bypasses turbo entirely. + +**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env` +unless you intend to always run with `HTTPS=1`.** Mismatched values +break the e2e suite and generic dev. See +`apps/journal/.env.example` for what each var means. + +If you find yourself wanting `HTTPS=1` for any reason other than Wahoo +testing, write it down here so the assumption stays auditable — +"everything but Wahoo works over HTTP locally" is what keeps CI and +local config symmetric. + ## Testing Strategy - **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic. @@ -81,9 +121,10 @@ pnpm db:studio # Open Drizzle Studio (DB browser) ## Code Conventions +- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build. - All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings) -- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions -- Map components go in `@trails-cool/map`, not in individual apps +- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types` +- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them - GPX parsing/generation goes in `@trails-cool/gpx` - Database schemas: `planner.*` for Planner data, `journal.*` for Journal data - Route geometry must be stored as PostGIS LineString (extracted from GPX on save) @@ -140,6 +181,91 @@ before pushing to an existing PR branch. ### Emergency override Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a hotfix). Document the reason in the commit message. +## Deployment + +Five CD workflows triggered by path or event: + +| Workflow | Triggers on | Deploys | Target | +|----------|-------------|---------|--------| +| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) | +| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) | +| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) | +| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) | +| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship | + +### Hosts + +trails.cool runs on two Hetzner boxes in the same Falkenstein datacenter: + +- **Flagship** — Hetzner Cloud `cx23`, public IP + vSwitch IP `10.0.0.2`. Runs Journal, Planner, Postgres, Caddy, Prometheus, Loki, Grafana. +- **BRouter host** — Hetzner Dedicated `ullrich.is`, public IP `176.9.150.227` + vSwitch IP `10.0.1.10`. Shared self-hosted box; trails.cool owns only a non-root `trails` user with docker-group rights, scoped to `~trails/brouter/`. SSH is on port **2232**. + +The two hosts are bridged via Hetzner vSwitch #80672 (VLAN 4000). Planner → BRouter traffic crosses it; BRouter → Loki traffic (for log shipping) crosses it back. + +### Secrets +All secrets are SOPS-encrypted: `infrastructure/secrets.app.env` (apps + BRouter shared token), `infrastructure/secrets.infra.env` (flagship infra only). Edit with `sops infrastructure/secrets.app.env`. GitHub Actions secrets: `AGE_SECRET_KEY`, `DEPLOY_HOST` / `DEPLOY_SSH_KEY` (flagship), `BROUTER_DEPLOY_HOST` / `BROUTER_DEPLOY_SSH_KEY` / `BROUTER_DEPLOY_SSH_PORT` (dedicated). + +### Full restart +To restart **all** containers on the flagship (not just the ones a workflow normally touches): +```bash +gh workflow run cd-infra.yml -f restart_all=true +``` + +### Server access +```bash +# Flagship — root, standard port, deploy key +ssh -i ~/.ssh/trails-cool-deploy root@trails.cool + +# BRouter host — trails user, non-standard port, different deploy key +ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is +``` + +### Grafana +`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org) + +### Staging & Previews + +A persistent staging stack and ephemeral PR previews share the flagship server with production. + +| Surface | URL | Database | Triggered by | +|---------|-----|----------|--------------| +| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` | +| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` | +| PR preview journal | `https://pr-.staging.trails.cool` | `trails_pr_` | PR open/sync | + +PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it. + +**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`): +- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface) +- PR `` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews) + +**Compose project namespacing** keeps each preview isolated: +- Persistent staging: `-p trails-staging` +- PR ``: `-p trails-pr-` + +The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile. + +**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert. + +**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews. + +**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying. + +**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran. + +**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr- logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. + +## Agent Skills & Config + +Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink). + +``` +.agents/skills/ ← canonical location +.claude/skills ← symlink → ../.agents/skills +``` + +Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup. + ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..e48200e --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,198 @@ +# trails.cool domain glossary + +This file names the domain concepts used in the codebase. New terms get added +here as decisions crystallize during architecture work; the goal is that one +concept has one name everywhere — specs, code, conversations. + +If you're naming a new module, a new column, or a new UI surface, look here +first. If the term you need isn't here, propose it (don't invent a synonym). + +--- + +## GPX Save + +The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction. + +### gpx-save module +`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly. + +### GpxValidationError +Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat −90..90, lon −180..180). Callers catch this to return a user-facing 400. + +### validateGpx +`(gpx: string) → Promise`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write. + +### atomic GPX save +The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path. + +--- + +## Connected Services + +The user-facing surface for linking external accounts and devices to a Journal +account. Spec: `openspec/specs/connected-services/`. + +### ConnectedService +A single linked external account or device, owned by one user. Stored in the +`connected_services` table (renamed from `sync_connections`). At most one row +per `(user_id, provider)`. + +### provider +String identifier for the external system: `wahoo`, `komoot`, `apple-health`, +and future `coros`, `garmin`, `strava`. The provider determines the +`credential_kind` and which capabilities (import / push / webhook) the +connection has, via the provider's manifest. + +### credential kind +Discriminator on `connected_services` describing the credential shape stored +in the `credentials` JSONB blob. Three kinds today: + +- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the + expected shape for Coros / Garmin / Strava. +- **web-login** — email + encrypted password + session jar. Komoot. No + official API; we authenticate against the provider's normal web login and + reuse the resulting session cookies. Refresh = re-login. Web-login + breakage (form changes, captchas, password rotation) surfaces at the + import layer, not at the credential layer. +- **device** — no remote credential. Apple Health (and future Health Connect + on Android). Data arrives via authenticated mobile API uploads, not + server-initiated pulls. The `credentials` blob is empty; the connection + exists so the UI can show "Apple Health is paired." + +Credential kind is determined by the provider via its manifest, but stored +explicitly on the row so queries don't need to join the manifest. + +### granted_scopes +Column on `connected_services`, populated only for `credential_kind = oauth`, +NULL otherwise. Lists the OAuth scopes the user actually granted (e.g. +`routes_write`). Feature gates query this column directly; missing a scope +triggers re-authorization. + +### provider_user_id +The external service's identifier for the user. Used to route incoming +webhooks to the right local user. Nullable (Apple Health has none in the +remote-id sense). + +### CredentialAdapter +Per-kind module that knows how to maintain credentials of that kind: + +- `oauth.refresh(creds) → creds | NeedsRelink` +- `web-login.relogin(creds) → creds | InvalidCredentials` +- `device` — no-op + +Adapters do not import data, push routes, or handle webhooks. They only own +the credential lifecycle. + +### ConnectedServiceManager +The deep module callers see. Owns: + +- `link(userId, provider, credentials)` / `unlink(serviceId)` +- `withFreshCredentials(serviceId, fn)` — refreshes via the right + `CredentialAdapter` if expired, calls `fn(creds)`, marks the connection + `needs_relink` if refresh fails. +- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook + layers when they observe a credential failure (e.g. Komoot web-login + fails, Wahoo returns 401 after a successful refresh). + +Per-provider importers / pushers / webhook handlers always go through +`withFreshCredentials` — they never read the `credentials` JSONB directly. + +### provider manifest +Per-provider declaration co-located with the provider's code +(`providers/wahoo/manifest.ts`, etc.). Declares: + +- the provider's `credential_kind` +- which capabilities the provider implements (import? push? webhook?) +- references to the per-capability modules + +A small `providers/registry.ts` imports each manifest. Adding a provider is +one new directory plus one import line. + +## Sync Capabilities + +Three orthogonal capabilities a provider may implement. Each is its own seam +when there are ≥2 adapters; today most are single-adapter and held to a +named shape so the second adapter doesn't reshape the interface. + +### Importer +Pulls workouts / activities from the external service into the Journal. +Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all +implement this, with very different mechanics. Dedup via `sync_imports`. + +### RoutePusher +Pushes a Journal route out to the external service. One adapter today +(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it. + +The public seam is `pushRoute(connectedService, route) → {remoteId, version}`. +Provider-specific concerns — FIT Course conversion, the `route:` +`external_id` convention, the PUT→POST-on-404 fallback — are **handled +internally by the adapter**, not exposed on the seam. Idempotency is tracked +via `sync_pushes`. + +### WebhookReceiver +Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming +webhooks to the right user via `provider_user_id`. Unknown +`provider_user_id` returns 200 and is silently dropped (no leak). + +## Storage tables + +- `connected_services` — user ↔ provider links (renamed from + `sync_connections`). +- `sync_imports` — dedup cache for imported workouts, keyed by + `(user_id, provider, workout_id)`. +- `sync_pushes` — push state per `(user_id, route_id, provider)` → + `remote_id`, `last_pushed_version`. + +--- + +## Authentication + +User identity in the Journal. Two **authentication methods** are supported +and are intentionally the entire surface (see ADR-0005): **passkey** +(WebAuthn) as the preferred method and **magic-link + 6-digit code** +(email) as a fallback for users without passkey support or for cross-device +sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys +already deliver the one-tap UX, and adding centralized identity providers +would conflict with the privacy-first ethos and ActivityPub federation. + +OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication +method. It is a **session transport** for native clients: users still +authenticate via passkey or magic-link in a WebView, then the mobile app +exchanges the resulting authorization code for long-lived bearer tokens. +The peer of OAuth2 transport is the cookie session, not passkey or +magic-link. + +### completeAuth +The single chokepoint for the post-verify orchestration of every web +auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see +ADR-0004). Called by every route handler that has just verified a +user's identity (passkey login finish, magic-link code verify, magic +link consumer). Does three things in order: + +1. If `isNewRegistration`, records the accepted Terms version + (`recordTermsAcceptance`). +2. Creates the session cookie via `createSession`. +3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie` + header attached. + +Identity-method-specific work (WebAuthn ceremony verification, magic +token consumption) stays in the per-method functions and runs *before* +`completeAuth`. The chokepoint deliberately knows nothing about how +identity was proved. + +### Terms gate +Cross-cutting middleware enforcing that `users.terms_version` matches +the current `TERMS_VERSION` constant before any non-allow-listed +authenticated request succeeds. Two enforcement points: + +- **Web (cookie sessions)**: the root loader redirects stale-terms users + to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`, + `/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the + allow-list, so OAuth code issuance is gated by this same redirect + before mobile sees an authorization code. +- **API (bearer tokens)**: `requireApiUser` returns + `403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms + bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.) + +`completeAuth` only **records** terms on registration; it does not +enforce them. Enforcement remains middleware's job. diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000..aa4b88f --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,198 @@ +# Federation protocol + +trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/), +implemented with [Fedify](https://fedify.dev). This document describes the +wire protocol precisely enough for another implementation to interoperate +deliberately — actor discovery, the object and activity types we emit and +accept, addressing, signatures, deduplication, delivery retry, and +moderation. Examples use `trails.example` for our instance and +`remote.example` for a peer. + +Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off, +every federation surface returns 404 — a disabled instance is +indistinguishable from one without the feature. Only users with +`profile_visibility = 'public'` federate; a private user's actor, +WebFinger, inbox, and outbox all 404, so their existence never leaks. + +## Actor discovery + +### WebFinger + +`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a +handle to an actor IRI: + +```json +{ + "subject": "acct:alice@trails.example", + "links": [ + { "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" } + ] +} +``` + +### Actor + +`GET https://trails.example/users/alice` with `Accept: application/activity+json` +returns a `Person`. The actor IRI and the human profile `url` are the same +by design (browsers get HTML at that URL via content negotiation): + +```json +{ + "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], + "id": "https://trails.example/users/alice", + "type": "Person", + "preferredUsername": "alice", + "name": "Alice", + "summary": "trail runner", + "url": "https://trails.example/users/alice", + "inbox": "https://trails.example/users/alice/inbox", + "outbox": "https://trails.example/users/alice/outbox", + "publicKey": { + "id": "https://trails.example/users/alice#main-key", + "owner": "https://trails.example/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n" + }, + "assertionMethod": [ { "type": "Multikey", "…": "…" } ], + "attachment": [ + { "type": "PropertyValue", "name": "🥾 trails.cool", "value": "trails.example/users/alice" }, + { "type": "PropertyValue", "name": "Instance", "value": "trails.example" } + ] +} +``` + +`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification; +`assertionMethod` carries the same keys as Multikeys for newer stacks. + +### NodeInfo (software discovery) + +`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`: + +```json +{ + "version": "2.1", + "software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" }, + "protocols": ["activitypub"], + "usage": { "users": {}, "localPosts": 0, "localComments": 0 } +} +``` + +`software.name` is the machine-readable "this is a trails instance" marker +used by the trails-to-trails outbound check. Usage counts are deliberately +zeroed — publishing per-instance counts is a privacy decision we have not +made. + +## Objects and activities + +Activities correspond to a user's journal entries. The object model is +deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content` +summarizes the activity and whose `url` links to the journal detail page. +(A first-class `trails:Route` object type is planned with route-federation; +today everything is a Note.) + +### Note + +```json +{ + "id": "https://trails.example/activities/01H…", + "type": "Note", + "attributedTo": "https://trails.example/users/alice", + "content": "

Morning trail run — 12.4 km, 480 m up

", + "url": "https://trails.example/activities/01H…", + "published": "2026-07-13T07:12:00Z", + "to": ["https://www.w3.org/ns/activitystreams#Public"] +} +``` + +The Note IRI (`/activities/`) is dereferenceable and serves +`application/activity+json` — Mastodon's search-fetch and strict re-fetch +of pushed objects both rely on this. + +### Create / Delete + +A publish is a `Create` wrapping the Note; the activity id is the object IRI +with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone` +at the same object IRI: + +```json +{ "id": "https://trails.example/activities/01H…#create", "type": "Create", + "actor": "https://trails.example/users/alice", + "object": { "…": "the Note above" }, + "published": "2026-07-13T07:12:00Z", + "to": ["https://www.w3.org/ns/activitystreams#Public"] } +``` + +Note that a `Delete` poisons the object URI on the remote forever (remotes +tombstone it); re-publishing the same URI after a Delete is silently +refused by strict remotes. + +### Follow graph + +The inbox is **narrow** — only follow-graph activities are processed; +anything else is acknowledged (`202`) and dropped. + +| Inbound | Effect | +|---|---| +| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` | +| `Undo(Follow)` | removes the follow | +| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll | +| `Reject(Follow)` | drops our pending outgoing Follow | + +## Addressing + +Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public` +and **push-delivered** to each accepted remote follower's inbox (fan-out, +one delivery per follower). We do not implement shared-inbox delivery. +Remotes do not backfill history — only pushed or individually-fetched +objects appear on a peer. + +## Signatures + +All inbound activities must carry a valid HTTP Signature; Fedify verifies it +against the sending actor's `publicKey` (fetched and cached). Unsigned or +badly-signed requests are rejected. Outbound deliveries are signed with the +sending user's key. An actor changing keys requires the remote to re-fetch +the actor document. + +## Deduplication + +Delivery is at-least-once, so receivers must be idempotent. trails dedups +inbound activities two ways: + +- **`Create(Note)`** — idempotent via a unique constraint on the activity's + origin IRI (`remote_origin_iri`); a redelivered Create is a no-op. +- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the + activity IRI is recorded in `federation_processed_activities` on first + receipt (insert-or-drop before any side effect); a redelivery is dropped + and counted (`federation_inbox_dropped_total{reason="duplicate"}`). + Records are retained ≥ 30 days, which comfortably exceeds the + HTTP-Signature date-freshness window, then swept. + +## Delivery retry + +Delivery queueing and retry state are **durable** — they survive process +restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the +retry policy). On a `5xx` or timeout, a delivery retries with exponential +backoff, giving up after a bounded budget (~8 attempts spanning roughly a +day) before a permanent failure is logged. Deliveries are paced to at most +1 request/second per remote host. Metrics: +`federation_delivery_total{outcome}` and `federation_queue_depth`. + +## Moderation + +An operator can block a federation instance by domain (exact-host match). +A blocked instance is **inert in both directions**: + +- its inbound activities are silently dropped (`202`, no error oracle) and + counted (`federation_inbox_dropped_total{reason="blocked"}`); +- it receives no deliveries (blocked recipients are filtered from fan-out); +- we never fetch its actors or outboxes. + +Blocking is effective immediately (checked per request / per job, no cache). +The operator procedure (a SQL insert/delete against +`journal.federation_blocked_instances`) is documented in the +[deployment runbook](docs/deployment.md#blocking-an-instance). + +--- + +*Kept current as federation capabilities change. Specs: +`openspec/specs/social-federation` and `openspec/specs/federation-operations`.* diff --git a/README.md b/README.md index af848bf..1bc4215 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ This project uses AI-assisted, spec-driven development. See |------|---------| | [cmux](https://cmux.dev) | Native macOS terminal for running multiple AI coding sessions | | [Claude Code](https://claude.ai/claude-code) | AI coding assistant (primary development tool) | +| [GitHub Copilot](https://github.com/features/copilot) | AI coding assistant | | [Crit](https://github.com/tomasz-tomczyk/crit) | Browser-based inline code review | | [OpenSpec](https://openspec.dev) | Spec-driven development workflow | @@ -95,6 +96,14 @@ docker compose up -d See [docs/architecture.md](docs/architecture.md) for details on self-hosting configuration. +## Federation + +The Journal federates over ActivityPub. The wire protocol — actor +discovery, object/activity types with JSON examples, addressing, +signatures, deduplication, delivery retry, and moderation — is documented +in [FEDERATION.md](FEDERATION.md), which is precise enough for another +implementation to interoperate against. + ## Philosophy - **Privacy by design** — The Planner collects zero user data diff --git a/apps/journal/.env.example b/apps/journal/.env.example new file mode 100644 index 0000000..8a51ed0 --- /dev/null +++ b/apps/journal/.env.example @@ -0,0 +1,63 @@ +# Journal local-dev environment. +# Copy to `.env` (gitignored) and edit. Most contributors don't need +# anything in this file — the journal app boots on plain HTTP at +# http://localhost:3000 with sensible defaults. + +# ──────────────────────────────────────────────────────────────────── +# DO NOT SET unless you know you need it +# ──────────────────────────────────────────────────────────────────── +# +# `ORIGIN` is what the WebAuthn ceremony uses as `expectedOrigin`. +# When unset it defaults to `http://localhost:3000`, which is what +# Playwright sends and what the browser sees in plain-HTTP dev. +# +# The ONLY reason to set this is if you're running the dev server +# over HTTPS (see HTTPS=1 below) and want passkey registration to +# succeed against the HTTPS origin. If you set it to `https://...` +# without also running the server over HTTPS, you'll get +# "Unexpected registration response origin" errors in dev and the +# local e2e suite (which always hits HTTP) will fail registration. +# +# ORIGIN=https://localhost:3000 + +# Flagship marker. Renders the project marketing block on the +# anonymous home and gates a few "this is the canonical instance" +# behaviors. Self-hosted instances leave this unset; flagship CI +# sets it to `true`. Either is fine for local dev — set it if you +# want to see the full marketing layout, leave it unset if you +# want the self-host home view. +# IS_FLAGSHIP=true + +# ──────────────────────────────────────────────────────────────────── +# Wahoo OAuth (only needed when actively testing the Wahoo import) +# ──────────────────────────────────────────────────────────────────── +# +# Wahoo's OAuth flow is the one feature that genuinely needs HTTPS +# locally: the provider rejects `http://` redirect URIs. To exercise +# the connect/disconnect/import flow end-to-end, you need: +# +# 1. Local HTTPS dev server: run `HTTPS=1 pnpm --filter +# @trails-cool/journal dev` (the basic-ssl plugin in +# vite.config.ts wires up the cert; see the comment there for +# the ALPN workaround). +# 2. ORIGIN=https://localhost:3000 (uncomment above). +# 3. Wahoo client credentials below, and a redirect URI of +# `https://localhost:3000/api/sync/callback/wahoo` registered +# in your Wahoo developer dashboard. +# +# WAHOO_CLIENT_ID= +# WAHOO_CLIENT_SECRET= +# WAHOO_WEBHOOK_TOKEN= + +# Garmin Connect Developer Program credentials (spec: garmin-import). +# Requires an approved program application; without these the Garmin +# provider is hidden on /settings/connections. The OAuth callback to +# register with Garmin is `/api/sync/callback/garmin`, the +# notification endpoint `/api/sync/webhook/garmin`. +# GARMIN_CLIENT_ID= +# GARMIN_CLIENT_SECRET= + +# Integration test secret (only needed if running the integration +# test suite that drives the API directly). Generate with +# `openssl rand -hex 32`. +# INTEGRATION_SECRET= diff --git a/apps/journal/Dockerfile b/apps/journal/Dockerfile index 51fcd14..306948b 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,5 +1,7 @@ -FROM node:25-slim AS base -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +FROM node:26-slim AS base +# curl is used by the docker-compose healthcheck (node:25-slim is Debian +# trixie-slim and ships neither curl nor wget by default). +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* RUN npm install -g pnpm@10.6.5 WORKDIR /app @@ -7,20 +9,29 @@ FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/journal/package.json apps/journal/ COPY packages/types/package.json packages/types/ -COPY packages/ui/package.json packages/ui/ -COPY packages/map/package.json packages/map/ COPY packages/gpx/package.json packages/gpx/ COPY packages/i18n/package.json packages/i18n/ +COPY packages/sentry-config/package.json packages/sentry-config/ +COPY packages/api/package.json packages/api/ +COPY packages/map-core/package.json packages/map-core/ +COPY packages/ui/package.json packages/ui/ COPY packages/db/package.json packages/db/ +COPY packages/jobs/package.json packages/jobs/ +COPY packages/fit/package.json packages/fit/ RUN pnpm install --frozen-lockfile FROM base AS build ARG SENTRY_RELEASE +# Client-side Sentry DSN baked into the bundle at build time. Empty (or +# unset) produces a Sentry-free client. Public-by-design: the DSN +# appears in the shipped client JS regardless. +ARG VITE_SENTRY_DSN="" COPY --from=deps /app/ ./ COPY . . RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \ SENTRY_RELEASE="$SENTRY_RELEASE" \ + VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \ pnpm --filter @trails-cool/journal build FROM base AS runtime @@ -28,6 +39,10 @@ RUN addgroup --system app && adduser --system --ingroup app app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules COPY --from=build /app/apps/journal/build ./apps/journal/build +COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts +COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts +COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib +COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json COPY --from=build /app/packages ./packages @@ -35,4 +50,4 @@ WORKDIR /app/apps/journal USER app EXPOSE 3000 ENV PORT=3000 -CMD ["npx", "react-router-serve", "./build/server/index.js"] +CMD ["node", "--experimental-strip-types", "server.ts"] diff --git a/apps/journal/app/components/AccountDropdown.tsx b/apps/journal/app/components/AccountDropdown.tsx new file mode 100644 index 0000000..fd12a9e --- /dev/null +++ b/apps/journal/app/components/AccountDropdown.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { Form, Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "./Avatar"; + +interface Props { + user: { username: string; displayName: string | null }; +} + +export function AccountDropdown({ user }: Props) { + const { t } = useTranslation("journal"); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + // Click-outside + Escape close. Mounted only while open to keep the + // listener cost zero in the steady state. + useEffect(() => { + if (!open) return; + const onClick = (e: MouseEvent) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onClick); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onClick); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( +
+ + + {open && ( +
+
+

+ {user.displayName ?? user.username} +

+

@{user.username}

+
+ setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.profile")} + + setOpen(false)} + className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" + > + {t("nav.settings")} + +
+ +
+
+ )} +
+ ); +} diff --git a/apps/journal/app/components/AlphaBanner.tsx b/apps/journal/app/components/AlphaBanner.tsx new file mode 100644 index 0000000..16ca223 --- /dev/null +++ b/apps/journal/app/components/AlphaBanner.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from "react-i18next"; + +export function AlphaBanner() { + const { t } = useTranslation("journal"); + return ( +
+

{t("alpha.message")}

+
+ ); +} diff --git a/apps/journal/app/components/Avatar.tsx b/apps/journal/app/components/Avatar.tsx new file mode 100644 index 0000000..cc74702 --- /dev/null +++ b/apps/journal/app/components/Avatar.tsx @@ -0,0 +1,42 @@ +// Initials-only avatar. We don't have an image-upload story yet (the +// `users` table has no avatar URL column), so initials are the +// implementation. When images land, this component is the single +// place to add the image fallback. + +interface Props { + displayName: string | null; + username: string; + size?: "sm" | "md" | "lg"; + className?: string; +} + +function initialsOf(displayName: string | null, username: string): string { + const source = (displayName ?? username).trim(); + if (source.length === 0) return "?"; + // Two-letter initials: first letter of the first two whitespace- + // separated words, falling back to the first two characters when + // the source is a single token. + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); + } + return source.slice(0, 2).toUpperCase(); +} + +const SIZE_CLASS: Record, string> = { + sm: "h-7 w-7 text-[11px]", + md: "h-9 w-9 text-sm", + lg: "h-12 w-12 text-base", +}; + +export function Avatar({ displayName, username, size = "md", className = "" }: Props) { + const initials = initialsOf(displayName, username); + return ( + + ); +} diff --git a/apps/journal/app/components/ClientDate.tsx b/apps/journal/app/components/ClientDate.tsx index e2fcf06..300b376 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -1,15 +1,16 @@ -import { useState, useEffect } from "react"; +import { useLocale } from "./LocaleContext"; /** - * Renders a date only on the client to avoid SSR hydration mismatches. - * Server renders empty, client fills in with user's locale. + * Renders a date formatted with the server-detected locale, + * ensuring SSR and client output match (no hydration flicker). + * Pass `withTime` for surfaces where the hour/minute matter + * (e.g. notifications), keeping plain dates as the default. */ -export function ClientDate({ iso }: { iso: string }) { - const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US")); - - useEffect(() => { - setFormatted(new Date(iso).toLocaleDateString()); - }, [iso]); - - return ; +export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) { + const locale = useLocale(); + const d = new Date(iso); + const text = withTime + ? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" }) + : d.toLocaleDateString(locale); + return ; } diff --git a/apps/journal/app/components/ClientMap.tsx b/apps/journal/app/components/ClientMap.tsx new file mode 100644 index 0000000..774bf1b --- /dev/null +++ b/apps/journal/app/components/ClientMap.tsx @@ -0,0 +1,21 @@ +import { useState, useEffect, type ComponentProps } from "react"; + +type MapProps = ComponentProps; + +export function ClientMap(props: MapProps) { + const [Component, setComponent] = useState>(); + + useEffect(() => { + import("./RouteMapThumbnail.client").then((m) => setComponent(() => m.RouteMapThumbnail)); + }, []); + + if (!Component) { + return ( +
+ Loading map... +
+ ); + } + + return ; +} diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx new file mode 100644 index 0000000..2632b44 --- /dev/null +++ b/apps/journal/app/components/CollectionPage.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; + +interface Entry { + username: string; + displayName: string | null; + domain: string; + /** Local path (`/users/x`) or, for federated entries, the remote profile URL. */ + profileUrl: string; + remote: boolean; +} + +interface Props { + kind: "followers" | "following"; + user: { username: string; displayName: string | null }; + entries: Entry[]; + page: number; + total: number; +} + +const PAGE_SIZE = 50; + +export function CollectionPage({ kind, user, entries, page, total }: Props) { + const { t } = useTranslation("journal"); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const heading = t(`social.${kind}.heading`, { + user: user.displayName ?? user.username, + }); + + return ( +
+ +

{heading}

+

{t(`social.${kind}.count`, { count: total })}

+ + {entries.length === 0 ? ( +

+ {t(`social.${kind}.empty`)} +

+ ) : ( + + )} + + {totalPages > 1 && ( + + )} +
+ ); +} diff --git a/apps/journal/app/components/ElevationProfile.test.tsx b/apps/journal/app/components/ElevationProfile.test.tsx new file mode 100644 index 0000000..fbf02fc --- /dev/null +++ b/apps/journal/app/components/ElevationProfile.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { ElevationProfile } from "./ElevationProfile.tsx"; +import type { ElevationSample } from "@trails-cool/gpx"; + +afterEach(cleanup); + +const labels = { highest: "Highest", lowest: "Lowest" }; + +const series: ElevationSample[] = [ + { d: 0, e: 100, lat: 0, lng: 0 }, + { d: 500, e: 160, lat: 0, lng: 0.005 }, + { d: 1000, e: 120, lat: 0, lng: 0.01 }, +]; + +describe("ElevationProfile", () => { + it("renders nothing for a too-short series", () => { + const { container } = render( + {}} onSeek={() => {}} labels={labels} />, + ); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("renders the chart and highest/lowest summary", () => { + const { container, getByText } = render( + {}} onSeek={() => {}} labels={labels} />, + ); + expect(container.querySelector("svg")).not.toBeNull(); + // area + line paths + expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2); + expect(getByText("160 m")).toBeTruthy(); // highest + expect(getByText("100 m")).toBeTruthy(); // lowest + }); + + it("draws an active marker when activeIndex is set", () => { + const { container } = render( + {}} onSeek={() => {}} labels={labels} />, + ); + expect(container.querySelector("circle")).not.toBeNull(); + expect(container.querySelector("line")).not.toBeNull(); + }); + + it("reports seek on pointer down", () => { + const onSeek = vi.fn(); + const { container } = render( + {}} onSeek={onSeek} labels={labels} />, + ); + const svg = container.querySelector("svg")!; + // jsdom getBoundingClientRect returns zeros; we only assert the handler fires. + fireEvent.pointerDown(svg, { clientX: 10 }); + expect(onSeek).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/journal/app/components/ElevationProfile.tsx b/apps/journal/app/components/ElevationProfile.tsx new file mode 100644 index 0000000..335a9b7 --- /dev/null +++ b/apps/journal/app/components/ElevationProfile.tsx @@ -0,0 +1,122 @@ +import { useRef } from "react"; +import type { ElevationSample } from "@trails-cool/gpx"; +import { formatElevationM, formatDistanceKm } from "~/lib/stats"; + +const W = 1000; +const H = 220; +const PAD = { top: 16, right: 8, bottom: 22, left: 46 }; +const PLOT_W = W - PAD.left - PAD.right; +const PLOT_H = H - PAD.top - PAD.bottom; + +/** + * Read-only elevation profile chart (SVG, responsive via viewBox). Distance on + * x, elevation on y, with a gradient area fill. Reports the hovered sample + * index via `onActive` (for the map marker) and the clicked index via `onSeek` + * (centre the map), and draws a marker at `activeIndex` (set by the map when the + * route line is hovered). Renders nothing for an empty/too-short series. + */ +export function ElevationProfile({ + series, + activeIndex, + onActive, + onSeek, + labels, + className, +}: { + series: ElevationSample[]; + activeIndex: number | null; + onActive: (index: number | null) => void; + onSeek: (index: number) => void; + labels: { highest: string; lowest: string }; + className?: string; +}) { + const ref = useRef(null); + if (series.length < 2) return null; + + const maxD = series[series.length - 1]!.d || 1; + let minE = Infinity; + let maxE = -Infinity; + for (const s of series) { + if (s.e < minE) minE = s.e; + if (s.e > maxE) maxE = s.e; + } + const eRange = Math.max(1, maxE - minE); + const baseY = PAD.top + PLOT_H; + + const x = (d: number) => PAD.left + (d / maxD) * PLOT_W; + const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H; + + const linePath = series + .map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`) + .join(" "); + const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`; + + const active = activeIndex != null ? series[activeIndex] : null; + + function indexFromClientX(clientX: number): number { + const rect = ref.current!.getBoundingClientRect(); + const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units + const d = ((px - PAD.left) / PLOT_W) * maxD; + let best = 0; + let bestDelta = Infinity; + for (let i = 0; i < series.length; i++) { + const delta = Math.abs(series[i]!.d - d); + if (delta < bestDelta) { + bestDelta = delta; + best = i; + } + } + return best; + } + + return ( +
+
+ + {labels.highest} {formatElevationM(maxE)} + {" · "} + {labels.lowest} {formatElevationM(minE)} + + {active && ( + + {formatDistanceKm(active.d)} · {formatElevationM(active.e)} + + )} +
+ onActive(indexFromClientX(e.clientX))} + onPointerLeave={() => onActive(null)} + onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))} + > + + + + + + + + + {active && ( + + + + + )} + +
+ ); +} diff --git a/apps/journal/app/components/FollowButton.tsx b/apps/journal/app/components/FollowButton.tsx new file mode 100644 index 0000000..78b40be --- /dev/null +++ b/apps/journal/app/components/FollowButton.tsx @@ -0,0 +1,89 @@ +import { useState, useTransition } from "react"; +import { useTranslation } from "react-i18next"; + +interface FollowState { + following: boolean; + pending: boolean; +} + +interface Props { + username: string; + // Whether the followed profile is private/locked. Drives the "Request to + // follow" label vs. plain "Follow" before any click happens. + isPrivateTarget: boolean; + initialState: FollowState | null; +} + +type Display = "follow" | "request" | "pending" | "unfollow"; + +function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display { + if (state?.following) return "unfollow"; + if (state?.pending) return "pending"; + return isPrivateTarget ? "request" : "follow"; +} + +export function FollowButton({ username, isPrivateTarget, initialState }: Props) { + const { t } = useTranslation("journal"); + const [state, setState] = useState( + initialState ?? { following: false, pending: false }, + ); + const [isInFlight, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const display = displayFor(state, isPrivateTarget); + + const onClick = () => { + setError(null); + startTransition(async () => { + // For "pending" we treat the click as cancel-request: same /unfollow + // endpoint deletes the row whether it's accepted or pending. + const path = state.following || state.pending + ? `/api/users/${username}/unfollow` + : `/api/users/${username}/follow`; + try { + const res = await fetch(path, { method: "POST" }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error ?? "Failed"); + return; + } + const body = (await res.json()) as { following: boolean; pending: boolean }; + setState({ following: body.following, pending: body.pending }); + } catch (e) { + setError((e as Error).message); + } + }); + }; + + const label = (() => { + switch (display) { + case "unfollow": + return t("social.unfollow"); + case "pending": + return t("social.pendingCancel"); + case "request": + return t("social.requestToFollow"); + case "follow": + default: + return t("social.follow"); + } + })(); + + const baseClass = display === "follow" || display === "request" + ? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" + : "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"; + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/apps/journal/app/components/Footer.tsx b/apps/journal/app/components/Footer.tsx new file mode 100644 index 0000000..f1f242d --- /dev/null +++ b/apps/journal/app/components/Footer.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; + +export function Footer() { + const { t } = useTranslation("journal"); + return ( + + ); +} diff --git a/apps/journal/app/components/LocaleContext.tsx b/apps/journal/app/components/LocaleContext.tsx new file mode 100644 index 0000000..15902c8 --- /dev/null +++ b/apps/journal/app/components/LocaleContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +const LocaleContext = createContext("en"); + +export function LocaleProvider({ + locale, + children, +}: { + locale: string; + children: React.ReactNode; +}) { + return {children}; +} + +export function useLocale() { + return useContext(LocaleContext); +} diff --git a/apps/journal/app/components/MobileNavMenu.tsx b/apps/journal/app/components/MobileNavMenu.tsx new file mode 100644 index 0000000..cf81241 --- /dev/null +++ b/apps/journal/app/components/MobileNavMenu.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { Form, Link, useLocation } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "./Avatar"; + +interface Props { + user: { username: string; displayName: string | null }; +} + +// Mobile drawer for the navbar. Replaces the "everything in a row" +// desktop layout with a hamburger trigger and a slide-out panel that +// holds all the same destinations. Bell + the dropdown's account +// section move inside; the bell badge still surfaces on the trigger +// itself via the parent navbar. +export function MobileNavMenu({ user }: Props) { + const { t } = useTranslation("journal"); + const [open, setOpen] = useState(false); + const location = useLocation(); + + // Close the drawer on navigation. React Router pushes a new location + // when a Link is followed; this effect picks that up. + useEffect(() => { + setOpen(false); + }, [location.pathname]); + + // Close on Escape; lock body scroll while open. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKey); + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + }; + }, [open]); + + const linkClass = (path: string) => { + const active = location.pathname === path || location.pathname.startsWith(path + "/"); + return `block rounded-md px-3 py-2 text-base font-medium ${ + active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50" + }`; + }; + + return ( + <> + + + {open && ( +
+ {/* Backdrop */} + +
+ + + + + )} + + ); +} diff --git a/apps/journal/app/components/ProfileStats.test.tsx b/apps/journal/app/components/ProfileStats.test.tsx new file mode 100644 index 0000000..f4077e0 --- /dev/null +++ b/apps/journal/app/components/ProfileStats.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { ProfileStats } from "./ProfileStats.tsx"; + +afterEach(cleanup); + +describe("ProfileStats", () => { + it("renders nothing when there are no activities", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders formatted totals", () => { + const { container, getByText } = render( + , + ); + // Values come from the formatters (i18n-independent). + expect(getByText("42")).toBeTruthy(); + expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer + expect(getByText("↑ 5120 m")).toBeTruthy(); + expect(getByText("2h 30m")).toBeTruthy(); + // last-4-weeks line present when > 0 + expect(container.querySelector("p")).not.toBeNull(); + }); + + it("omits the last-4-weeks line when zero", () => { + const { container } = render( + , + ); + expect(container.querySelector("p")).toBeNull(); + }); +}); diff --git a/apps/journal/app/components/ProfileStats.tsx b/apps/journal/app/components/ProfileStats.tsx new file mode 100644 index 0000000..349d18d --- /dev/null +++ b/apps/journal/app/components/ProfileStats.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import { StatRow } from "./StatRow.tsx"; +import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats"; + +// Structural shape (mirrors ActivityStats from activities.server) so this +// presentational component doesn't import from a `.server` module. +export interface ProfileStatsData { + count: number; + distance: number; + elevationGain: number; + duration: number; + last4Weeks: number; +} + +/** + * Lifetime roll-up header on the profile: activity count · distance · ascent · + * elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the + * (viewer-scoped) count is zero. + */ +export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) { + const { t } = useTranslation("journal"); + if (stats.count === 0) return null; + + return ( +
+ + {stats.last4Weeks > 0 && ( +

+ {t("profileStats.last4Weeks", { count: stats.last4Weeks })} +

+ )} +
+ ); +} diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx new file mode 100644 index 0000000..1d03175 --- /dev/null +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef } from "react"; +import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet"; +import L from "leaflet"; +import type { GeoJsonObject } from "geojson"; +import "leaflet/dist/leaflet.css"; + +/** Marker shown at the position the elevation chart is pointing at. */ +function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) { + if (!point) return null; + return ( + + ); +} + +/** Reports the route sample nearest the cursor so the chart can highlight it. */ +function HoverTracker({ + series, + onHoverIndex, +}: { + series: Array<[number, number]>; + onHoverIndex: (index: number | null) => void; +}) { + useMapEvents({ + mousemove(e) { + const { lat, lng } = e.latlng; + let best = -1; + let bestDelta = Infinity; + for (let i = 0; i < series.length; i++) { + const [slat, slng] = series[i]!; + const delta = (slat - lat) ** 2 + (slng - lng) ** 2; + if (delta < bestDelta) { + bestDelta = delta; + best = i; + } + } + onHoverIndex(best >= 0 ? best : null); + }, + mouseout() { + onHoverIndex(null); + }, + }); + return null; +} + +/** Pans the map when the chart is clicked (centerOn.v bumps per click). */ +function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) { + const map = useMap(); + const lastV = useRef(null); + useEffect(() => { + if (!centerOn || centerOn.v === lastV.current) return; + lastV.current = centerOn.v; + map.panTo([centerOn.lat, centerOn.lng], { animate: true }); + }, [centerOn, map]); + return null; +} + +function FitBounds({ data }: { data: GeoJsonObject }) { + const map = useMap(); + const fitted = useRef(false); + useEffect(() => { + if (fitted.current) return; + const layer = L.geoJSON(data); + const bounds = layer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [20, 20] }); + fitted.current = true; + } + }, [data, map]); + return null; +} + +function FlyToSegment({ segments, highlightedDay, fullData }: { + segments: Array<{ coords: number[][] }>; + highlightedDay: number | null | undefined; + fullData: GeoJsonObject; +}) { + const map = useMap(); + const fullBoundsRef = useRef(null); + + useEffect(() => { + // Cache full route bounds on first render + if (!fullBoundsRef.current) { + const layer = L.geoJSON(fullData); + fullBoundsRef.current = layer.getBounds(); + } + + if (highlightedDay != null && highlightedDay >= 1 && highlightedDay <= segments.length) { + const seg = segments[highlightedDay - 1]!; + // coords are [lon, lat] GeoJSON format + const latlngs = seg.coords.map((c) => L.latLng(c[1]!, c[0]!)); + const bounds = L.latLngBounds(latlngs); + if (bounds.isValid()) { + map.flyToBounds(bounds, { padding: [40, 40], duration: 0.2 }); + } + } else if (fullBoundsRef.current?.isValid()) { + map.flyToBounds(fullBoundsRef.current, { padding: [20, 20], duration: 0.2 }); + } + }, [highlightedDay, segments, fullData, map]); + + return null; +} + +const DAY_COLORS = ["#2563eb", "#8B6D3A", "#059669", "#9333ea", "#dc2626", "#0891b2"]; + +function extractGeometry(data: GeoJsonObject): number[][] | null { + const coords = (data as { type: string; coordinates?: number[][] }).coordinates + ?? ((data as { features?: Array<{ geometry: { coordinates: number[][] } }> }).features?.[0]?.geometry?.coordinates); + return coords && coords.length >= 2 ? coords : null; +} + +function splitGeometry(data: GeoJsonObject, numDays: number): Array<{ coords: number[][] }> { + const geometry = extractGeometry(data); + if (!geometry) return []; + const totalPoints = geometry.length; + const pointsPerDay = Math.ceil(totalPoints / numDays); + const segments: Array<{ coords: number[][] }> = []; + for (let d = 0; d < numDays; d++) { + const start = d * pointsPerDay; + const end = Math.min((d + 1) * pointsPerDay + 1, totalPoints); + if (start >= totalPoints) break; + segments.push({ coords: geometry.slice(start, end) }); + } + return segments; +} + +interface RouteMapProps { + geojson: string; + interactive?: boolean; + className?: string; + dayBreaks?: number[]; + /** 1-based day number to highlight, or null for no highlight */ + highlightedDay?: number | null; + /** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */ + activePoint?: { lat: number; lng: number } | null; + hoverSeries?: Array<[number, number]>; + onHoverIndex?: (index: number | null) => void; + centerOn?: { lat: number; lng: number; v: number } | null; +} + +export function RouteMapThumbnail({ + geojson, + interactive, + className, + dayBreaks, + highlightedDay, + activePoint, + hoverSeries, + onHoverIndex, + centerOn, +}: RouteMapProps) { + const data: GeoJsonObject = JSON.parse(geojson); + + return ( + + OpenStreetMap' : undefined} + /> + {dayBreaks && dayBreaks.length > 0 ? ( + + ) : ( + + )} + + {dayBreaks && dayBreaks.length > 0 && ( + + )} + + {hoverSeries && hoverSeries.length > 0 && onHoverIndex && ( + + )} + + + ); +} + +function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObject; dayBreaks: number[]; highlightedDay?: number | null }) { + const geometry = extractGeometry(data); + if (!geometry) { + return ; + } + + const numDays = dayBreaks.length + 1; + const rawSegments = splitGeometry(data, numDays); + const segments = rawSegments.map((seg, i) => ({ + ...seg, + color: DAY_COLORS[i % DAY_COLORS.length]!, + })); + + const isHighlighting = highlightedDay != null; + + return ( + <> + {segments.map((seg, i) => { + const dayNum = i + 1; + const isActive = highlightedDay === dayNum; + const segData: GeoJsonObject = { + type: "Feature", + geometry: { type: "LineString", coordinates: seg.coords }, + properties: {}, + } as GeoJsonObject; + return ( + + ); + })} + + ); +} diff --git a/apps/journal/app/components/SportBadge.tsx b/apps/journal/app/components/SportBadge.tsx new file mode 100644 index 0000000..e1a130e --- /dev/null +++ b/apps/journal/app/components/SportBadge.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import type { SportType } from "@trails-cool/db/schema/journal"; + +// Emoji glyphs as lightweight, dependency-free sport icons. The localized +// label carries the meaning; the glyph is decorative (aria-hidden). +const SPORT_EMOJI: Record = { + hike: "🥾", + walk: "🚶", + run: "🏃", + ride: "🚴", + gravel: "🚲", + mtb: "🚵", + ski: "⛷️", + other: "📍", +}; + +/** + * Small pill (glyph + localized label) shown next to an activity title on the + * detail page, feed cards, and the profile list. Renders nothing when the + * sport type is unset. + */ +export function SportBadge({ + sportType, + className, +}: { + sportType: SportType | null | undefined; + className?: string; +}) { + const { t } = useTranslation("journal"); + if (!sportType) return null; + return ( + + {SPORT_EMOJI[sportType]} + {t(`activities.sport.${sportType}`)} + + ); +} diff --git a/apps/journal/app/components/StatRow.test.tsx b/apps/journal/app/components/StatRow.test.tsx new file mode 100644 index 0000000..cdde2cf --- /dev/null +++ b/apps/journal/app/components/StatRow.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { StatRow } from "./StatRow.tsx"; + +afterEach(cleanup); + +describe("StatRow", () => { + it("renders nothing for an empty item list", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders each item's value and label, in order", () => { + const { container } = render( + , + ); + const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent); + const values = [...container.querySelectorAll("dd")].map((el) => el.textContent); + expect(labels).toEqual(["Distance", "Time", "Avg speed"]); + expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]); + }); +}); diff --git a/apps/journal/app/components/StatRow.tsx b/apps/journal/app/components/StatRow.tsx new file mode 100644 index 0000000..8d8d33e --- /dev/null +++ b/apps/journal/app/components/StatRow.tsx @@ -0,0 +1,34 @@ +import type { StatItem } from "~/lib/stats"; + +/** + * The one shared headline-stat presentation. Surfaces pass an ordered list of + * {value, label} items (built via `activityStatItems`); the component never + * fetches or formats. `size="lg"` is the detail-page treatment; the default + * compact size is for feed cards and the profile list. + */ +export function StatRow({ + items, + size = "sm", + className, +}: { + items: StatItem[]; + size?: "sm" | "lg"; + className?: string; +}) { + if (items.length === 0) return null; + const valueCls = + size === "lg" + ? "text-2xl font-bold text-gray-900" + : "text-sm font-semibold text-gray-900"; + const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1"; + return ( +
+ {items.map((it) => ( +
+
{it.value}
+
{it.label}
+
+ ))} +
+ ); +} diff --git a/apps/journal/app/components/SurfaceBreakdown.test.tsx b/apps/journal/app/components/SurfaceBreakdown.test.tsx new file mode 100644 index 0000000..521bbc0 --- /dev/null +++ b/apps/journal/app/components/SurfaceBreakdown.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx"; + +afterEach(cleanup); + +describe("SurfaceBreakdown", () => { + it("renders nothing without a breakdown", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when every bucket is zero", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders a legend item per non-zero category with its percentage", () => { + const { container, getByText } = render( + , + ); + // 2 surface + 1 waytype = 3 legend entries + expect(container.querySelectorAll("li")).toHaveLength(3); + expect(getByText(/60% · 6\.0 km/)).toBeTruthy(); + expect(getByText(/40% · 4\.0 km/)).toBeTruthy(); + expect(getByText(/100% · 10\.0 km/)).toBeTruthy(); + }); + + it("sorts segments largest-first within a dimension", () => { + const { container } = render( + , + ); + // first bar's first segment is the larger (asphalt 70%) + const firstBar = container.querySelector("div.flex.h-3"); + const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width); + expect(widths[0]).toBe("70%"); + expect(widths[1]).toBe("30%"); + }); +}); diff --git a/apps/journal/app/components/SurfaceBreakdown.tsx b/apps/journal/app/components/SurfaceBreakdown.tsx new file mode 100644 index 0000000..868e713 --- /dev/null +++ b/apps/journal/app/components/SurfaceBreakdown.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from "react-i18next"; +import { + SURFACE_COLORS, + DEFAULT_SURFACE_COLOR, + HIGHWAY_COLORS, + DEFAULT_HIGHWAY_COLOR, +} from "@trails-cool/map-core"; +import { formatDistanceKm } from "~/lib/stats"; + +export interface SurfaceBreakdownData { + surface: Record; + highway: Record; +} + +function Bar({ + title, + data, + colorFor, +}: { + title: string; + data: Record; + colorFor: (category: string) => string; +}) { + const { t } = useTranslation("journal"); + const entries = Object.entries(data) + .filter(([, m]) => m > 0) + .sort((a, b) => b[1] - a[1]); + const total = entries.reduce((s, [, m]) => s + m, 0); + if (total <= 0) return null; + + const label = (cat: string) => + cat === "unknown" + ? t("surface.other") + : t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") }); + + return ( +
+

{title}

+
+ {entries.map(([cat, m]) => ( +
+ ))} +
+
    + {entries.map(([cat, m]) => ( +
  • + + {label(cat)} + + {Math.round((m / total) * 100)}% · {formatDistanceKm(m)} + +
  • + ))} +
+
+ ); +} + +/** + * Surface + waytype proportion bars (route-surface-breakdown). Renders nothing + * when there's no breakdown data. Colours come from the shared map-core + * palettes; unknown tags collapse into "other". + */ +export function SurfaceBreakdown({ + breakdown, + className, +}: { + breakdown: SurfaceBreakdownData | null | undefined; + className?: string; +}) { + const { t } = useTranslation("journal"); + if (!breakdown) return null; + const hasSurface = Object.values(breakdown.surface).some((m) => m > 0); + const hasHighway = Object.values(breakdown.highway).some((m) => m > 0); + if (!hasSurface && !hasHighway) return null; + + return ( +
+ SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR} + /> + HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR} + /> +
+ ); +} diff --git a/apps/journal/app/components/WeeklyDistanceChart.test.tsx b/apps/journal/app/components/WeeklyDistanceChart.test.tsx new file mode 100644 index 0000000..9ae069c --- /dev/null +++ b/apps/journal/app/components/WeeklyDistanceChart.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, fireEvent } from "@testing-library/react"; +import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx"; + +afterEach(cleanup); + +const weeks = (distances: number[]) => + distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance })); + +describe("WeeklyDistanceChart", () => { + it("renders nothing when every week is zero", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders the chart with a bar only for non-zero weeks", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeNull(); + // bars only for the two non-zero weeks; empty weeks keep their slot via the track rect + expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2); + }); + + it("tops the y-scale at the busiest week", () => { + const { getByText } = render(); + // peak gridline label = 10 km (>= 10 → integer) + expect(getByText("10 km")).toBeTruthy(); + // half gridline + expect(getByText("5.0 km")).toBeTruthy(); + }); + + it("shows a hover readout with the week's distance", () => { + // max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the + // readout for week 0, so it only appears once that week is hovered. + const { container, queryByText } = render(); + expect(queryByText("3.0 km")).toBeNull(); + const hit = container.querySelectorAll("rect[fill='transparent']")[0]!; + fireEvent.mouseEnter(hit); + expect(queryByText("3.0 km")).not.toBeNull(); + }); +}); diff --git a/apps/journal/app/components/WeeklyDistanceChart.tsx b/apps/journal/app/components/WeeklyDistanceChart.tsx new file mode 100644 index 0000000..cfeec61 --- /dev/null +++ b/apps/journal/app/components/WeeklyDistanceChart.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { formatDistanceKm } from "~/lib/stats"; + +export interface WeeklyDistanceBucket { + weekStart: string; + distance: number; +} + +// SVG layout (user units; rendered responsive via viewBox). +const W = 480; +const H = 132; +const PAD = { top: 10, right: 6, bottom: 18, left: 36 }; +const PLOT_W = W - PAD.left - PAD.right; +const PLOT_H = H - PAD.top - PAD.bottom; +const BASE_Y = PAD.top + PLOT_H; + +function axisLabel(km: number): string { + if (km <= 0) return "0"; + return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`; +} + +/** + * Weekly-distance bar chart for the profile (last N weeks, oldest → newest). + * Gridlines + a y-scale topped at the busiest week, faint per-week tracks so + * the 12-week axis is always visible (empty weeks read as gaps, not nothing), + * and a hover readout naming the week + its distance. Hidden when there is no + * distance in the window. + */ +export function WeeklyDistanceChart({ + weeks, + className, +}: { + weeks: WeeklyDistanceBucket[]; + className?: string; +}) { + const { t, i18n } = useTranslation("journal"); + const [hover, setHover] = useState(null); + + const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0); + if (maxM <= 0) return null; + + const maxKm = maxM / 1000; + const n = weeks.length; + const colW = PLOT_W / n; + const barW = colW * 0.6; + const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2; + const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H; + + const fmtWeek = (iso: string) => + new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" }); + + const gridFracs = [0, 0.5, 1]; + const active = hover != null ? weeks[hover] : null; + const first = weeks[0]; + const last = weeks[n - 1]; + const firstLabel = first ? fmtWeek(first.weekStart) : ""; + const lastLabel = last ? fmtWeek(last.weekStart) : ""; + + return ( +
+
+ {t("profileStats.weeklyDistance")} + {active && ( + + {t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "} + {formatDistanceKm(active.distance)} + + )} +
+ setHover(null)} + > + {/* gridlines + y-axis labels (0 · half · peak) */} + {gridFracs.map((f) => { + const yy = BASE_Y - f * PLOT_H; + return ( + + + + {axisLabel(maxKm * f)} + + + ); + })} + {weeks.map((w, i) => { + const isActive = hover === i; + return ( + + {/* faint week-slot track so empty weeks stay visible */} + + {/* distance bar */} + {w.distance > 0 && ( + + )} + {/* full-height hover hit area */} + setHover(i)} + > + {`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`} + + + ); + })} + +
+ {firstLabel} + {lastLabel} +
+
+ ); +} diff --git a/apps/journal/app/entry.client.tsx b/apps/journal/app/entry.client.tsx index 1efd2c4..d5b4d57 100644 --- a/apps/journal/app/entry.client.tsx +++ b/apps/journal/app/entry.client.tsx @@ -1,31 +1,9 @@ -import * as Sentry from "@sentry/react"; -import { useEffect } from "react"; import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { HydratedRouter } from "react-router/dom"; -import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { initI18nClient } from "@trails-cool/i18n"; -const sentryEnvironment = import.meta.env.VITE_SENTRY_ENVIRONMENT ?? - (import.meta.env.PROD ? "production" : "development"); - -Sentry.init({ - dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", - integrations: [ - Sentry.reactRouterV7BrowserTracingIntegration({ - useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - }), - Sentry.replayIntegration(), - ], - environment: sentryEnvironment, - tracesSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysSessionSampleRate: sentryEnvironment === "ci" ? 0 : 1.0, - replaysOnErrorSampleRate: 1.0, - enabled: import.meta.env.PROD && sentryEnvironment !== "ci", -}); +initI18nClient(); startTransition(() => { hydrateRoot( diff --git a/apps/journal/app/entry.server.tsx b/apps/journal/app/entry.server.tsx index 01dbf3d..5db5d23 100644 --- a/apps/journal/app/entry.server.tsx +++ b/apps/journal/app/entry.server.tsx @@ -6,22 +6,7 @@ import { ServerRouter } from "react-router"; import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; - -const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development"); - -Sentry.init({ - dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", - release: process.env.SENTRY_RELEASE, - environment: sentryEnvironment, - tracesSampleRate: 1.0, - enabled: process.env.NODE_ENV === "production" && !process.env.CI, - beforeSend(event) { - // Drop 404s — they're expected (scanners, typos), not bugs - const serialized = event.extra?.__serialized__ as Record | undefined; - if (serialized?.status === 404) return null; - return event; - }, -}); +import { initI18nServer, detectLanguage } from "@trails-cool/i18n"; export const streamTimeout = 5_000; @@ -32,6 +17,8 @@ export default function handleRequest( routerContext: EntryContext, _loadContext: AppLoadContext, ) { + initI18nServer(detectLanguage(request)); + if (request.method.toUpperCase() === "HEAD") { return new Response(null, { status: responseStatusCode, diff --git a/apps/journal/app/hooks/useSurfaceBackfill.ts b/apps/journal/app/hooks/useSurfaceBackfill.ts new file mode 100644 index 0000000..ea0aacc --- /dev/null +++ b/apps/journal/app/hooks/useSurfaceBackfill.ts @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import { useRevalidator } from "react-router"; + +/** + * Live-updates a route/activity detail page when its async surface backfill + * completes. Subscribes to `/api/events` and, on a `surface_breakdown` event + * matching this row, re-runs the loader (so the bars appear without a reload). + * + * Enable only while it's worth it — i.e. the viewer is the owner (the backfill + * emits to the owner's user stream) and the breakdown isn't present yet. Once + * the breakdown lands, the loader revalidates, `enabled` flips false, and the + * connection is torn down. + */ +export function useSurfaceBackfillUpdates( + kind: "route" | "activity", + id: string, + enabled: boolean, +): void { + const revalidator = useRevalidator(); + useEffect(() => { + if (!enabled) return; + if (typeof EventSource === "undefined") return; + const es = new EventSource("/api/events"); + const onEvent = (e: MessageEvent) => { + try { + const parsed = JSON.parse(e.data) as { kind?: string; id?: string }; + if (parsed.kind === kind && parsed.id === id) revalidator.revalidate(); + } catch { + // Malformed payload — ignore. + } + }; + es.addEventListener("surface_breakdown", onEvent as EventListener); + return () => { + es.removeEventListener("surface_breakdown", onEvent as EventListener); + es.close(); + }; + }, [kind, id, enabled, revalidator]); +} diff --git a/apps/journal/app/hooks/useUnreadNotifications.ts b/apps/journal/app/hooks/useUnreadNotifications.ts new file mode 100644 index 0000000..7286262 --- /dev/null +++ b/apps/journal/app/hooks/useUnreadNotifications.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +/** + * Subscribes to /api/events for live `notifications.unread` updates. + * Returns the live unread count, seeded with the loader-provided + * baseline so the badge renders correctly before the SSE handshake + * completes. Native EventSource handles reconnects with the + * server-suggested `retry:` interval. + */ +export function useUnreadNotifications(initialCount: number, signedIn: boolean): number { + const [count, setCount] = useState(initialCount); + + // Keep the in-component count in sync if the loader-provided baseline + // changes (e.g., on navigation). + useEffect(() => { + setCount(initialCount); + }, [initialCount]); + + useEffect(() => { + if (!signedIn) return; + if (typeof EventSource === "undefined") return; + const es = new EventSource("/api/events"); + const onUnread = (e: MessageEvent) => { + try { + const parsed = JSON.parse(e.data) as { count: number }; + if (typeof parsed.count === "number") setCount(parsed.count); + } catch { + // Malformed payload — ignore. + } + }; + es.addEventListener("notifications.unread", onUnread as EventListener); + return () => { + es.removeEventListener("notifications.unread", onUnread as EventListener); + es.close(); + }; + }, [signedIn]); + + return count; +} diff --git a/apps/journal/app/jobs/backfill-user-keypairs.ts b/apps/journal/app/jobs/backfill-user-keypairs.ts new file mode 100644 index 0000000..1f79e4b --- /dev/null +++ b/apps/journal/app/jobs/backfill-user-keypairs.ts @@ -0,0 +1,26 @@ +import { defineJournalJob } from "./payloads.ts"; +import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * One-shot backfill: generate a federation keypair for every user who + * predates federation (spec: "Existing-user backfill at deploy"). The + * server enqueues this once at startup whenever FEDERATION_ENABLED is + * on (singleton-keyed, so repeat startups don't stack runs); each run + * only touches users whose public_key IS NULL, so re-runs are no-ops. + * New users get keys at registration and never appear in this workload. + */ +export const backfillUserKeypairsJob = defineJournalJob({ + name: "backfill-user-keypairs", + retryLimit: 3, + expireInSeconds: 300, + async handler() { + const ids = await listUsersWithoutKeypair(); + let generated = 0; + for (const id of ids) { + if (await ensureUserKeypair(id)) generated++; + } + logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs"); + return { candidates: ids.length, generated }; + }, +}); diff --git a/apps/journal/app/jobs/consumed-jti-sweep.ts b/apps/journal/app/jobs/consumed-jti-sweep.ts new file mode 100644 index 0000000..5be848c --- /dev/null +++ b/apps/journal/app/jobs/consumed-jti-sweep.ts @@ -0,0 +1,31 @@ +import { defineJournalJob } from "./payloads.ts"; +import { lt } from "drizzle-orm"; +import { consumedJwtJti } from "@trails-cool/db/schema/journal"; +import { getDb } from "../lib/db.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily cleanup for the JWT replay-protection table. Each row was + * inserted by `verifyRouteToken` to mark a token as consumed; once + * the token's `exp` claim has passed, the row is no longer useful + * (the JWT itself would fail signature verification before reaching + * the consume step). Bound the table to keep it tiny. + * + * See planner-audit #2 Phase B. + */ +export const consumedJtiSweepJob = defineJournalJob({ + name: "consumed-jti-sweep", + cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge) + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const db = getDb(); + const result = await db + .delete(consumedJwtJti) + .where(lt(consumedJwtJti.expiresAt, new Date())) + .returning({ jti: consumedJwtJti.jti }); + const purged = result.length; + logger.info({ purged }, "consumed-jti-sweep"); + return { purged }; + }, +}); diff --git a/apps/journal/app/jobs/deliver-activity.ts b/apps/journal/app/jobs/deliver-activity.ts new file mode 100644 index 0000000..30eb001 --- /dev/null +++ b/apps/journal/app/jobs/deliver-activity.ts @@ -0,0 +1,134 @@ +import { defineJournalJob } from "./payloads.ts"; +import { and, eq } from "drizzle-orm"; +import { activities, users } from "@trails-cool/db/schema/journal"; +import { getDb } from "../lib/db.ts"; +import { getOrigin } from "../lib/config.server.ts"; +import { getFederation } from "../lib/federation.server.ts"; +import { + activityToCreate, + activityToDelete, + type FederatableActivity, +} from "../lib/federation-objects.server.ts"; +import { + getCachedRemoteActor, + upsertRemoteActor, + type DeliveryPayload, +} from "../lib/federation-delivery.server.ts"; +import { logger } from "../lib/logger.server.ts"; +import { federationDeliveryTotal } from "../lib/metrics.server.ts"; + +/** + * Outbound pacing (spec 5.5): never exceed 1 request/second per remote + * host. In-process map is sufficient — pg-boss works this queue + * sequentially in the single journal process. + */ +const lastSendPerHost = new Map(); +const MIN_INTERVAL_MS = 1000; + +async function paceHost(host: string): Promise { + const last = lastSendPerHost.get(host) ?? 0; + const wait = last + MIN_INTERVAL_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastSendPerHost.set(host, Date.now()); +} + +/** + * Deliver one activity to one remote follower's inbox (spec 5.3/5.4). + * One job per (activity, recipient) so each delivery retries with + * exponential backoff independently (configured at enqueue time — + * see enqueueActivityDeliveries). A thrown error marks the attempt + * failed and pg-boss retries; exhausting the budget is the permanent + * failure, logged by the final catch. + */ +export const deliverActivityJob = defineJournalJob({ + name: "deliver-activity", + expireInSeconds: 60, + async handler(jobs) { + for (const job of jobs) { + const p = job.data; + try { + const outcome = await deliverOne(p); + federationDeliveryTotal.inc({ outcome }); + } catch (err) { + federationDeliveryTotal.inc({ outcome: "failed" }); + logger.warn( + { err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri }, + "deliver-activity attempt failed (pg-boss will retry until budget exhausted)", + ); + throw err; + } + } + }, +}); + +async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> { + const federation = getFederation(); + const ctx = federation.createContext(new URL(getOrigin()), undefined); + + // Build the activity to send. For `create`, re-read the row at + // delivery time: if it was deleted or un-publicized since enqueue, + // skip rather than leak. + let activity; + if (p.action === "create") { + const db = getDb(); + const [row] = await db + .select() + .from(activities) + .where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public"))) + .limit(1); + if (!row) { + logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping"); + return "skipped"; + } + // Spec 9.3: flipping the profile to private stops federation — also + // for deliveries already enqueued when the flip happened. + const [owner] = await db + .select({ profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.username, p.ownerUsername)) + .limit(1); + if (!owner || owner.profileVisibility !== "public") { + logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping"); + return "skipped"; + } + activity = activityToCreate(row as FederatableActivity, p.ownerUsername); + } else { + activity = activityToDelete(p.objectIri, p.ownerUsername); + } + + // Resolve the recipient's inbox: cached remote_actors row first, + // actor-document fetch as fallback (which also primes the cache). + const recipientIri = new URL(p.recipientActorIri); + let inboxUrl: URL; + const cached = await getCachedRemoteActor(p.recipientActorIri); + if (cached?.inboxUrl) { + inboxUrl = new URL(cached.inboxUrl); + } else { + await paceHost(recipientIri.host); + const actor = await ctx.lookupObject(recipientIri); + const fetchedInbox = + actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null; + if (!fetchedInbox) { + throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`); + } + inboxUrl = fetchedInbox; + await upsertRemoteActor({ + actorIri: p.recipientActorIri, + inboxUrl: inboxUrl.href, + domain: recipientIri.host, + }); + } + + await paceHost(inboxUrl.host); + await ctx.sendActivity( + // Identifier and username are the same thing in our actor model. + { identifier: p.ownerUsername }, + { id: recipientIri, inboxId: inboxUrl }, + activity, + ); + logger.info( + { action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri }, + "deliver-activity: delivered", + ); + return "delivered"; +} diff --git a/apps/journal/app/jobs/demo-bot-generate.ts b/apps/journal/app/jobs/demo-bot-generate.ts new file mode 100644 index 0000000..7257a3d --- /dev/null +++ b/apps/journal/app/jobs/demo-bot-generate.ts @@ -0,0 +1,60 @@ +import { defineJournalJob } from "./payloads.ts"; +import { + DEMO_BACKFILL_TARGET, + DEMO_DAILY_CAP, + countSyntheticRoutesRecent, + countSyntheticRoutesTotal, + ensureDemoUser, + generateOneWalk, + isDemoBotEnabled, + refreshDemoBotGauges, + shouldWalkNow, +} from "../lib/demo-bot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Generate job. Fires every 30 minutes via pg-boss cron. Handler steps: + * + * 1. Bail if `DEMO_BOT_ENABLED` is not "true". + * 2. On first run (no synthetic rows yet) produce a small backfill so the + * demo user's profile has content immediately. + * 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and + * daily cap; on pass, insert one route+activity via `generateOneWalk`. + */ +export const demoBotGenerateJob = defineJournalJob({ + name: "demo-bot-generate", + cron: "0,30 * * * *", + retryLimit: 1, + expireInSeconds: 120, + async handler() { + if (!isDemoBotEnabled()) return { skipped: "disabled" }; + + const ownerId = await ensureDemoUser(); + + const total = await countSyntheticRoutesTotal(); + if (total === 0) { + let inserted = 0; + for (let i = 0; i < DEMO_BACKFILL_TARGET; i++) { + const id = await generateOneWalk(ownerId); + if (id) inserted++; + } + logger.info({ inserted }, "demo-bot backfill complete"); + await refreshDemoBotGauges(); + return { mode: "backfill", inserted }; + } + + const recent = await countSyntheticRoutesRecent(14); + if (recent >= DEMO_DAILY_CAP) { + logger.info({ recent, cap: DEMO_DAILY_CAP }, "demo-bot cap hit, skipping"); + return { skipped: "cap", recent }; + } + + const now = new Date(); + if (!shouldWalkNow(now)) return { skipped: "gate" }; + + const id = await generateOneWalk(ownerId, { now }); + logger.info({ inserted: id ? 1 : 0, routeId: id }, "demo-bot walk"); + await refreshDemoBotGauges(); + return { mode: "single", routeId: id }; + }, +}); diff --git a/apps/journal/app/jobs/demo-bot-prune.ts b/apps/journal/app/jobs/demo-bot-prune.ts new file mode 100644 index 0000000..b065bf8 --- /dev/null +++ b/apps/journal/app/jobs/demo-bot-prune.ts @@ -0,0 +1,27 @@ +import { defineJournalJob } from "./payloads.ts"; +import { + demoRetentionDays, + isDemoBotEnabled, + pruneSynthetic, + refreshDemoBotGauges, +} from "../lib/demo-bot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily prune. Deletes synthetic rows older than + * `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users. + */ +export const demoBotPruneJob = defineJournalJob({ + name: "demo-bot-prune", + cron: "15 3 * * *", + retryLimit: 1, + expireInSeconds: 60, + async handler() { + if (!isDemoBotEnabled()) return { skipped: "disabled" }; + const days = demoRetentionDays(); + const counts = await pruneSynthetic(days); + logger.info({ days, ...counts }, "demo-bot prune"); + await refreshDemoBotGauges(); + return { days, ...counts }; + }, +}); diff --git a/apps/journal/app/jobs/federation-dedup-sweep.ts b/apps/journal/app/jobs/federation-dedup-sweep.ts new file mode 100644 index 0000000..8fe3980 --- /dev/null +++ b/apps/journal/app/jobs/federation-dedup-sweep.ts @@ -0,0 +1,22 @@ +import { defineJournalJob } from "./payloads.ts"; +import { sweepProcessedActivities } from "../lib/federation-replay.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily cleanup of federation_processed_activities rows older than 30 + * days (spec: federation-operations "Inbound replay defense"). Replays of + * activities that old are already rejected by HTTP-signature date + * freshness, so the dedup record is no longer needed; this keeps the + * table bounded. + */ +export const federationDedupSweepJob = defineJournalJob({ + name: "federation-dedup-sweep", + cron: "30 4 * * *", // daily at 04:30 UTC (offset from federation-kv-sweep at 04:15) + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const purged = await sweepProcessedActivities(); + logger.info({ purged }, "federation-dedup-sweep"); + return { purged }; + }, +}); diff --git a/apps/journal/app/jobs/federation-kv-sweep.ts b/apps/journal/app/jobs/federation-kv-sweep.ts new file mode 100644 index 0000000..6bec691 --- /dev/null +++ b/apps/journal/app/jobs/federation-kv-sweep.ts @@ -0,0 +1,20 @@ +import { defineJournalJob } from "./payloads.ts"; +import { PostgresKvStore } from "../lib/federation-kv.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily cleanup of expired federation_kv rows (Fedify replay-protection + * nonces and caches carry TTLs; reads already filter expired rows, this + * keeps the table from growing unbounded). + */ +export const federationKvSweepJob = defineJournalJob({ + name: "federation-kv-sweep", + cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps) + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const purged = await new PostgresKvStore().sweepExpired(); + logger.info({ purged }, "federation-kv-sweep"); + return { purged }; + }, +}); diff --git a/apps/journal/app/jobs/garmin-import-activity.ts b/apps/journal/app/jobs/garmin-import-activity.ts new file mode 100644 index 0000000..b396150 --- /dev/null +++ b/apps/journal/app/jobs/garmin-import-activity.ts @@ -0,0 +1,30 @@ +import { defineJournalJob } from "./payloads.ts"; +import { logger } from "../lib/logger.server.ts"; +import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts"; + +// Garmin webhook notifications enqueue here (spec: garmin-import, +// "Push-notification activity import"): the webhook answers 200 +// immediately and this job does the slow part — authorized file +// download, FIT→GPX, activity creation. Backfill bursts deliver many +// notifications at once; the queue absorbs them and pg-boss retries +// transient download failures. +export const garminImportActivityJob = defineJournalJob({ + name: "garmin-import-activity", + retryLimit: 3, + expireInSeconds: 300, + async handler(jobs) { + const batch = Array.isArray(jobs) ? jobs : [jobs]; + for (const job of batch) { + const data = job.data; + try { + await runGarminActivityImport(data); + } catch (err) { + logger.warn( + { err, externalId: data.externalId }, + "garmin-import-activity failed (pg-boss will retry)", + ); + throw err; + } + } + }, +}); diff --git a/apps/journal/app/jobs/import-batches-sweep.ts b/apps/journal/app/jobs/import-batches-sweep.ts new file mode 100644 index 0000000..5bee88e --- /dev/null +++ b/apps/journal/app/jobs/import-batches-sweep.ts @@ -0,0 +1,42 @@ +import { defineJournalJob } from "./payloads.ts"; +import { and, lt, inArray, count } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal"; +import { logger } from "../lib/logger.server.ts"; + +const STALE_MS = 10 * 60 * 1000; +const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"]; + +export const importBatchesSweepJob = defineJournalJob({ + name: "import-batches-sweep", + cron: "* * * * *", + retryLimit: 0, + expireInSeconds: 55, + async handler() { + const db = getDb(); + const cutoff = new Date(Date.now() - STALE_MS); + const staleFilter = and( + inArray(importBatches.status, STALE_STATUSES), + lt(importBatches.startedAt, cutoff), + ); + + // Skip the write when nothing is stale to avoid an unconditional UPDATE every minute. + const rows = await db + .select({ staleCount: count() }) + .from(importBatches) + .where(staleFilter); + if ((rows[0]?.staleCount ?? 0) === 0) return; + + const result = await db + .update(importBatches) + .set({ + status: "failed" satisfies ImportBatchStatus, + errorMessage: "Import timed out — the server may have restarted mid-import. Click 'Run again' to retry.", + completedAt: new Date(), + }) + .where(staleFilter) + .returning({ id: importBatches.id }); + + logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed"); + }, +}); diff --git a/apps/journal/app/jobs/komoot-bulk-import.test.ts b/apps/journal/app/jobs/komoot-bulk-import.test.ts new file mode 100644 index 0000000..bcf3d42 --- /dev/null +++ b/apps/journal/app/jobs/komoot-bulk-import.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../lib/logger.server.ts", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const withFreshCredentials = vi.fn(); +vi.mock("../lib/connected-services/manager.ts", () => ({ + withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args), +})); + +const runKomootBulkImport = vi.fn(); +const markBatchFailed = vi.fn(); +vi.mock("../lib/komoot-bulk-import.server.ts", () => ({ + runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args), + markBatchFailed: (...args: unknown[]) => markBatchFailed(...args), +})); + +import { komootBulkImportJob } from "./komoot-bulk-import.ts"; + +type HandlerJobs = Parameters[0]; + +function jobWith(data: unknown): HandlerJobs { + return [{ id: "j1", data }] as unknown as HandlerJobs; +} + +const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" }; + +describe("komoot-bulk-import job", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("resolves credentials through the manager — only the serviceId crosses the queue", async () => { + const creds = { mode: "public", komootUserId: "k1" }; + withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds)); + runKomootBulkImport.mockResolvedValue(undefined); + + await komootBulkImportJob.handler(jobWith(PAYLOAD)); + + expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function)); + expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds); + expect(markBatchFailed).not.toHaveBeenCalled(); + }); + + it("marks the batch failed and rethrows when credential resolution fails", async () => { + withFreshCredentials.mockRejectedValue(new Error("needs relink")); + + await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink"); + + expect(runKomootBulkImport).not.toHaveBeenCalled(); + expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink"); + }); + + it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => { + withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" })); + runKomootBulkImport.mockRejectedValue(new Error("komoot 500")); + + await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500"); + expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500"); + }); +}); diff --git a/apps/journal/app/jobs/komoot-bulk-import.ts b/apps/journal/app/jobs/komoot-bulk-import.ts new file mode 100644 index 0000000..fa0c9a2 --- /dev/null +++ b/apps/journal/app/jobs/komoot-bulk-import.ts @@ -0,0 +1,36 @@ +import { defineJournalJob } from "./payloads.ts"; +import { logger } from "../lib/logger.server.ts"; +import { withFreshCredentials } from "../lib/connected-services/manager.ts"; +import { + markBatchFailed, + runKomootBulkImport, + type KomootCreds, +} from "../lib/komoot-bulk-import.server.ts"; + +export const komootBulkImportJob = defineJournalJob({ + name: "komoot-bulk-import", + retryLimit: 1, + expireInSeconds: 1800, + async handler(jobs) { + const batch = Array.isArray(jobs) ? jobs : [jobs]; + for (const job of batch) { + const { batchId, userId, serviceId } = job.data; + logger.info({ batchId, userId }, "komoot bulk import job started"); + try { + // Credentials are resolved through the ConnectedServiceManager at + // execution time — the payload carries only the serviceId, so + // nothing credential-shaped sits in the job table and a relink + // between enqueue and execution is picked up here. + await withFreshCredentials(serviceId, (creds) => + runKomootBulkImport(batchId, userId, creds as KomootCreds), + ); + } catch (err) { + // runKomootBulkImport marks its own failures; this covers errors + // before it ran (service missing/not active/needs relink), where + // the batch would otherwise stay "pending" forever. + await markBatchFailed(batchId, err instanceof Error ? err.message : String(err)); + throw err; + } + } + }, +}); diff --git a/apps/journal/app/jobs/notifications-fanout.integration.test.ts b/apps/journal/app/jobs/notifications-fanout.integration.test.ts new file mode 100644 index 0000000..e25d011 --- /dev/null +++ b/apps/journal/app/jobs/notifications-fanout.integration.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "../lib/db.ts"; +import { activities, follows, users } from "@trails-cool/db/schema/journal"; +import { fanout } from "./notifications-fanout.ts"; +import { listForUser } from "../lib/notifications.server.ts"; + +// Same opt-in flag as the rest of the notifications integration tests. +const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) { + const db = getDb(); + const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u; + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri: `https://test.local/users/${followedUsername}`, + followedUserId: followedId, + acceptedAt: opts.accepted ? new Date() : null, + }); +} + +async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") { + const db = getDb(); + const id = randomUUID(); + await db.insert(activities).values({ + id, + ownerId, + name, + visibility, + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("notifications-fanout integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`); + }); + afterEach(wipe); + + it("inserts exactly one row per accepted follower; pending followers are skipped", async () => { + const owner = await makeUser({ username: `nf_o_${Date.now()}` }); + const a1 = await makeUser({ username: `nf_a1_${Date.now()}` }); + const a2 = await makeUser({ username: `nf_a2_${Date.now()}` }); + const p1 = await makeUser({ username: `nf_p1_${Date.now()}` }); + const p2 = await makeUser({ username: `nf_p2_${Date.now()}` }); + await makeFollow(a1, owner, { accepted: true }); + await makeFollow(a2, owner, { accepted: true }); + await makeFollow(p1, owner, { accepted: false }); + await makeFollow(p2, owner, { accepted: false }); + + const activityId = await makeActivity(owner, "public", "Public Walk"); + await fanout(activityId); + + expect((await listForUser(a1)).rows.length).toBe(1); + expect((await listForUser(a2)).rows.length).toBe(1); + expect((await listForUser(p1)).rows.length).toBe(0); + expect((await listForUser(p2)).rows.length).toBe(0); + + const a1Rows = (await listForUser(a1)).rows; + expect(a1Rows[0]?.type).toBe("activity_published"); + expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk"); + }); + + it("skips fan-out for non-public activities (defense in depth)", async () => { + const owner = await makeUser({ username: `nf_np_o_${Date.now()}` }); + const f = await makeUser({ username: `nf_np_f_${Date.now()}` }); + await makeFollow(f, owner, { accepted: true }); + + const privateAct = await makeActivity(owner, "private"); + const unlistedAct = await makeActivity(owner, "unlisted"); + await fanout(privateAct); + await fanout(unlistedAct); + + expect((await listForUser(f)).rows.length).toBe(0); + }); + + it("is idempotent under retry — second fanout doesn't double-insert", async () => { + const owner = await makeUser({ username: `nf_id_o_${Date.now()}` }); + const f = await makeUser({ username: `nf_id_f_${Date.now()}` }); + await makeFollow(f, owner, { accepted: true }); + + const activityId = await makeActivity(owner, "public"); + await fanout(activityId); + await fanout(activityId); + + expect((await listForUser(f)).rows.length).toBe(1); + }); +}); diff --git a/apps/journal/app/jobs/notifications-fanout.ts b/apps/journal/app/jobs/notifications-fanout.ts new file mode 100644 index 0000000..70caae9 --- /dev/null +++ b/apps/journal/app/jobs/notifications-fanout.ts @@ -0,0 +1,99 @@ +import { defineJournalJob } from "./payloads.ts"; +import { and, eq, isNotNull } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { activities, follows, users } from "@trails-cool/db/schema/journal"; +import { createNotification } from "../lib/notifications.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Fan out an `activity_published` notification to every accepted + * follower of the activity's owner. Idempotent at the DB level via the + * `(recipient_user_id, type, subject_id)` unique partial index — a + * retry after partial failure won't double-insert. + */ +export const notificationsFanoutJob = defineJournalJob({ + name: "notifications-fanout", + retryLimit: 3, + expireInSeconds: 300, + async handler(job) { + // pg-boss v12: `job` may be an array (batch) per its docs; we + // process whichever shape we get. + const batch = Array.isArray(job) ? job : [job]; + for (const item of batch) { + await fanout(item.data.activityId); + } + }, +}); + +export async function fanout(activityId: string): Promise { + const db = getDb(); + + // Load the activity + owner info needed for the payload snapshot. + const [row] = await db + .select({ + id: activities.id, + name: activities.name, + visibility: activities.visibility, + ownerId: activities.ownerId, + ownerUsername: users.username, + ownerDisplayName: users.displayName, + }) + .from(activities) + .innerJoin(users, eq(activities.ownerId, users.id)) + .where(eq(activities.id, activityId)); + + if (!row) { + logger.warn({ activityId }, "fanout: activity not found, skipping"); + return; + } + // Remote-ingested rows have no local owner and never fan out locally + // (the users innerJoin already excludes them; this narrows the type). + if (row.ownerId === null) return; + // Defense in depth — the create-side guard already filters this, but + // recheck here so the job doesn't mistakenly fan out a row that was + // later flipped to private/unlisted before the job ran. + if (row.visibility !== "public") { + logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity"); + return; + } + + // Find every accepted *local* follower of the owner. Remote + // followers (follower_id NULL, follower_actor_iri set) get the + // activity via federation push delivery, not via notifications. + const recipients = await db + .select({ followerId: follows.followerId }) + .from(follows) + .where( + and( + eq(follows.followedUserId, row.ownerId), + isNotNull(follows.acceptedAt), + isNotNull(follows.followerId), + ), + ); + + let inserted = 0; + for (const r of recipients) { + // Don't notify the owner about their own activity if they happen + // to follow themselves (shouldn't happen — followUser refuses + // self-follow — but defense in depth). + if (r.followerId === row.ownerId || r.followerId === null) continue; + const created = await createNotification({ + type: "activity_published", + recipientUserId: r.followerId, + actorUserId: row.ownerId, + subjectId: row.id, + payload: { + activityId: row.id, + activityName: row.name, + ownerUsername: row.ownerUsername, + ownerDisplayName: row.ownerDisplayName, + }, + }); + if (created) inserted += 1; + } + + logger.info( + { activityId, recipients: recipients.length, inserted }, + "notifications-fanout completed", + ); +} diff --git a/apps/journal/app/jobs/notifications-purge.ts b/apps/journal/app/jobs/notifications-purge.ts new file mode 100644 index 0000000..625689c --- /dev/null +++ b/apps/journal/app/jobs/notifications-purge.ts @@ -0,0 +1,21 @@ +import { defineJournalJob } from "./payloads.ts"; +import { purgeReadOlderThan } from "../lib/notifications.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Daily retention pass. Drops notifications whose `read_at` is older + * than 90 days; unread rows are kept indefinitely so users never miss + * an event. + */ +export const notificationsPurgeJob = defineJournalJob({ + name: "notifications-purge", + cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load) + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const days = 90; + const purged = await purgeReadOlderThan(days); + logger.info({ days, purged }, "notifications-purge"); + return { days, purged }; + }, +}); diff --git a/apps/journal/app/jobs/payloads.test.ts b/apps/journal/app/jobs/payloads.test.ts new file mode 100644 index 0000000..00e4844 --- /dev/null +++ b/apps/journal/app/jobs/payloads.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; + +// Importing every job module pulls in their (transitively heavy) +// server deps; mock the leaf modules with side effects so this stays a +// unit test of the registry wiring. +import { vi } from "vitest"; +vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() })); +vi.mock("../lib/logger.server.ts", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe("job registry", () => { + it("every job's queue name is unique and a key of JobPayloads", async () => { + const modules = await Promise.all([ + import("./backfill-user-keypairs.ts"), + import("./consumed-jti-sweep.ts"), + import("./deliver-activity.ts"), + import("./demo-bot-generate.ts"), + import("./demo-bot-prune.ts"), + import("./federation-kv-sweep.ts"), + import("./garmin-import-activity.ts"), + import("./import-batches-sweep.ts"), + import("./komoot-bulk-import.ts"), + import("./notifications-fanout.ts"), + import("./notifications-purge.ts"), + import("./poll-remote-actor.ts"), + import("./poll-remote-outboxes.ts"), + import("./send-welcome-email.ts"), + ]); + + const names = modules.flatMap((m) => + Object.values(m as Record) + .filter( + (v): v is { name: string; handler: unknown } => + typeof v === "object" && v !== null && "handler" in v && "name" in v, + ) + .map((def) => def.name), + ); + + expect(names).toHaveLength(14); + expect(new Set(names).size).toBe(names.length); + // pg-boss v11+ queue-name constraint, mirrored from packages/jobs + for (const name of names) { + expect(name).toMatch(/^[A-Za-z0-9_.-]+$/); + } + }); +}); diff --git a/apps/journal/app/jobs/payloads.ts b/apps/journal/app/jobs/payloads.ts new file mode 100644 index 0000000..e3ba254 --- /dev/null +++ b/apps/journal/app/jobs/payloads.ts @@ -0,0 +1,43 @@ +import { defineJob, type JobDefinition, type TypedJobDefinition } from "@trails-cool/jobs"; +import type { DeliveryPayload } from "../lib/federation-delivery.server.ts"; +import type { GarminImportData } from "../lib/connected-services/providers/garmin/import.server.ts"; + +/** + * Every journal job queue and its payload shape, in one place. The + * typed `enqueue` / `enqueueOptional` in boss.server.ts and the + * `defineJournalJob` helper below both key off this map, so an enqueue + * site and its handler cannot drift apart, and a queue-name typo is a + * compile error instead of an orphaned queue. + * + * `void` marks cron-only jobs that are never enqueued with data. + */ +export interface JobPayloads { + "backfill-user-keypairs": Record; + "consumed-jti-sweep": void; + "deliver-activity": DeliveryPayload; + "demo-bot-generate": void; + "demo-bot-prune": void; + "federation-dedup-sweep": void; + "federation-kv-sweep": void; + "garmin-import-activity": GarminImportData; + "import-batches-sweep": void; + "komoot-bulk-import": { batchId: string; userId: string; serviceId: string }; + "notifications-fanout": { activityId: string }; + "notifications-purge": void; + "poll-remote-actor": { actorIri: string }; + "poll-remote-outboxes": void; + "send-welcome-email": { email: string; username: string }; + "surface-backfill": { kind: "route" | "activity"; id: string }; +} + +export type JobName = keyof JobPayloads; + +/** + * defineJob, constrained to the journal's queue map: the name must be + * a known queue and the handler's payload type follows from it. + */ +export function defineJournalJob( + definition: TypedJobDefinition & { name: K }, +): JobDefinition { + return defineJob(definition); +} diff --git a/apps/journal/app/jobs/poll-remote-actor.ts b/apps/journal/app/jobs/poll-remote-actor.ts new file mode 100644 index 0000000..18dc1d7 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-actor.ts @@ -0,0 +1,23 @@ +import { defineJournalJob } from "./payloads.ts"; +import { pollRemoteActor } from "../lib/federation-ingest.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Poll one remote trails actor's outbox (spec §7). Enqueued by the + * inbox Accept(Follow) listener (first poll, 7.5) and fanned out by + * the poll-remote-outboxes cron sweep (7.1). + */ +export const pollRemoteActorJob = defineJournalJob({ + name: "poll-remote-actor", + retryLimit: 2, + expireInSeconds: 120, + async handler(jobs) { + for (const job of jobs) { + // Defensive: jobs enqueued before the typed seam may carry no data. + const actorIri = job.data?.actorIri; + if (!actorIri) continue; + const result = await pollRemoteActor(actorIri); + logger.info({ actorIri, result }, "poll-remote-actor"); + } + }, +}); diff --git a/apps/journal/app/jobs/poll-remote-outboxes.ts b/apps/journal/app/jobs/poll-remote-outboxes.ts new file mode 100644 index 0000000..1847ad2 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-outboxes.ts @@ -0,0 +1,25 @@ +import { defineJournalJob } from "./payloads.ts"; +import { listActorsDuePolling } from "../lib/federation-ingest.server.ts"; +import { enqueueOptional } from "../lib/boss.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Cron sweep (spec 7.1): every 5 minutes, find remote trails actors + * that at least one local user follows (accepted) and that haven't + * been polled within the last hour, and fan out one poll-remote-actor + * job each. Per-host pacing lives in the poll itself. + */ +export const pollRemoteOutboxesJob = defineJournalJob({ + name: "poll-remote-outboxes", + cron: "*/5 * * * *", + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const due = await listActorsDuePolling(); + for (const actorIri of due) { + await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" }); + } + if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep"); + return { due: due.length }; + }, +}); diff --git a/apps/journal/app/jobs/send-welcome-email.ts b/apps/journal/app/jobs/send-welcome-email.ts new file mode 100644 index 0000000..69551d3 --- /dev/null +++ b/apps/journal/app/jobs/send-welcome-email.ts @@ -0,0 +1,27 @@ +import { defineJournalJob } from "./payloads.ts"; +import { sendWelcome } from "../lib/email.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Queue-backed welcome email send. The old code did + * `sendWelcome(...).catch(log)` inline, which silently dropped failures. + * pg-boss retries on transient failure (3 attempts) and surfaces persistent + * failures via the dead-letter queue / logs. + */ +export const sendWelcomeEmailJob = defineJournalJob({ + name: "send-welcome-email", + retryLimit: 3, + expireInSeconds: 120, + async handler(job) { + const batch = Array.isArray(job) ? job : [job]; + for (const item of batch) { + const { email, username } = item.data; + try { + await sendWelcome(email, username); + } catch (err) { + logger.error({ err, email }, "send-welcome-email job failed"); + throw err; + } + } + }, +}); diff --git a/apps/journal/app/jobs/surface-backfill.test.ts b/apps/journal/app/jobs/surface-backfill.test.ts new file mode 100644 index 0000000..41caec3 --- /dev/null +++ b/apps/journal/app/jobs/surface-backfill.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execute = vi.fn(); +vi.mock("../lib/db.ts", () => ({ getDb: () => ({ execute }) })); + +const fetchWaysInBbox = vi.fn(); +vi.mock("../lib/overpass-ways.server.ts", () => ({ fetchWaysInBbox: (...a: unknown[]) => fetchWaysInBbox(...a) })); + +const emitTo = vi.fn(); +vi.mock("../lib/events.server.ts", () => ({ emitTo: (...a: unknown[]) => emitTo(...a) })); + +vi.mock("../lib/logger.server.ts", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +import { runSurfaceBackfill } from "./surface-backfill.ts"; + +const lineString = (coords: number[][]) => JSON.stringify({ type: "LineString", coordinates: coords }); + +beforeEach(() => { + execute.mockReset(); + fetchWaysInBbox.mockReset(); + emitTo.mockReset(); +}); + +describe("runSurfaceBackfill", () => { + it("matches ways, stores the breakdown, and emits to the owner", async () => { + execute.mockResolvedValueOnce([ + { geojson: lineString([[0, 0], [0.001, 0], [0.002, 0]]), ownerId: "user-1", hasBreakdown: false }, + ]); // SELECT + execute.mockResolvedValueOnce(undefined); // UPDATE + fetchWaysInBbox.mockResolvedValue([ + { highway: "residential", surface: "asphalt", geometry: [{ lat: 0, lon: 0 }, { lat: 0, lon: 0.01 }] }, + ]); + + await runSurfaceBackfill("activity", "act-1"); + + expect(fetchWaysInBbox).toHaveBeenCalledOnce(); + expect(execute).toHaveBeenCalledTimes(2); // SELECT + UPDATE + expect(emitTo).toHaveBeenCalledWith("user-1", "surface_breakdown", { kind: "activity", id: "act-1" }); + }); + + it("skips a row that already has a breakdown", async () => { + execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [1, 0]]), ownerId: "u", hasBreakdown: true }]); + await runSurfaceBackfill("route", "r-1"); + expect(fetchWaysInBbox).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(1); // only the SELECT + expect(emitTo).not.toHaveBeenCalled(); + }); + + it("does not store or emit when Overpass returns no ways", async () => { + execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [0.001, 0]]), ownerId: "u", hasBreakdown: false }]); + fetchWaysInBbox.mockResolvedValue([]); + await runSurfaceBackfill("activity", "a-2"); + expect(execute).toHaveBeenCalledTimes(1); // SELECT only + expect(emitTo).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/jobs/surface-backfill.ts b/apps/journal/app/jobs/surface-backfill.ts new file mode 100644 index 0000000..4087045 --- /dev/null +++ b/apps/journal/app/jobs/surface-backfill.ts @@ -0,0 +1,101 @@ +import { sql } from "drizzle-orm"; +import { defineJournalJob } from "./payloads.ts"; +import { getDb } from "../lib/db.ts"; +import { computeSurfaceBreakdown } from "@trails-cool/map-core"; +import { fetchWaysInBbox, type Bbox } from "../lib/overpass-ways.server.ts"; +import { matchSurfaces } from "../lib/surface-match.server.ts"; +import { emitTo } from "../lib/events.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +// Cap coordinates fed to the matcher; the breakdown still weights by the actual +// segment length, so downsampling barely shifts proportions. +const MAX_COORDS = 250; +const BBOX_PAD_DEG = 0.0008; // ~80 m, so ways just off the line are considered + +/** + * Derive a surface/waytype breakdown for a route/activity that has geometry but + * no breakdown yet (imports, uploads, pre-existing rows), by map-matching its + * geometry to OSM ways via Overpass. Idempotent (skips if already set), + * best-effort (Overpass failure throws → pg-boss retries; an empty/oversized + * bbox is a no-op). On success it stores the breakdown and pushes an SSE event + * to the owner so an open detail page fills the bars in live. + */ +export const surfaceBackfillJob = defineJournalJob({ + name: "surface-backfill", + retryLimit: 3, + expireInSeconds: 120, + async handler(job) { + const batch = Array.isArray(job) ? job : [job]; + for (const item of batch) { + await runSurfaceBackfill(item.data.kind, item.data.id); + } + }, +}); + +export async function runSurfaceBackfill(kind: "route" | "activity", id: string): Promise { + const db = getDb(); + const tableName = kind === "route" ? sql`journal.routes` : sql`journal.activities`; + + const loaded = (await db.execute(sql` + SELECT ST_AsGeoJSON(geom) AS geojson, + owner_id AS "ownerId", + (surface_breakdown IS NOT NULL) AS "hasBreakdown" + FROM ${tableName} WHERE id = ${id} LIMIT 1 + `)) as unknown as Array<{ geojson: string | null; ownerId: string | null; hasBreakdown: boolean }>; + const row = loaded[0]; + + if (!row) { + logger.warn({ kind, id }, "surface-backfill: row not found"); + return; + } + if (row.hasBreakdown) return; // idempotent + if (!row.geojson) return; // no geometry to match + + let coords: number[][]; + try { + coords = (JSON.parse(row.geojson) as { coordinates?: number[][] }).coordinates ?? []; + } catch { + return; + } + if (coords.length < 2) return; + + if (coords.length > MAX_COORDS) { + const stride = Math.ceil(coords.length / MAX_COORDS); + const out: number[][] = []; + for (let i = 0; i < coords.length; i += stride) out.push(coords[i]!); + const last = coords[coords.length - 1]!; + if (out[out.length - 1] !== last) out.push(last); + coords = out; + } + + let south = coords[0]![1]!, north = south, west = coords[0]![0]!, east = west; + for (const c of coords) { + const lon = c[0]!, lat = c[1]!; + if (lat < south) south = lat; + if (lat > north) north = lat; + if (lon < west) west = lon; + if (lon > east) east = lon; + } + const bbox: Bbox = { + south: south - BBOX_PAD_DEG, + west: west - BBOX_PAD_DEG, + north: north + BBOX_PAD_DEG, + east: east + BBOX_PAD_DEG, + }; + + const ways = await fetchWaysInBbox(bbox); + if (ways.length === 0) { + logger.info({ kind, id }, "surface-backfill: no ways (bbox empty/oversized) — skipping"); + return; + } + + const { surfaces, highways } = matchSurfaces(coords, ways); + const breakdown = computeSurfaceBreakdown(coords, surfaces, highways); + if (Object.keys(breakdown.surface).length === 0 && Object.keys(breakdown.highway).length === 0) return; + + await db.execute(sql` + UPDATE ${tableName} SET surface_breakdown = ${JSON.stringify(breakdown)}::jsonb WHERE id = ${id} + `); + if (row.ownerId) emitTo(row.ownerId, "surface_breakdown", { kind, id }); + logger.info({ kind, id }, "surface-backfill: stored breakdown"); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e51c7b4..6a97b80 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,103 +1,500 @@ import { randomUUID } from "node:crypto"; -import { eq, desc } from "drizzle-orm"; +import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; import { getDb } from "./db.ts"; -import { activities, routes } from "@trails-cool/db/schema/journal"; -import { parseGpxAsync } from "@trails-cool/gpx"; +import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import type { Visibility, SportType } from "@trails-cool/db/schema/journal"; +import { processGpx, writeGeom } from "./gpx-save.server.ts"; +import type { ProcessedGpx } from "./gpx-save.server.ts"; +import { enqueueOptional } from "./boss.server.ts"; +import type { OwnedRef } from "./ownership.server.ts"; +import { + enqueueActivityDeliveries, + visibilityTransitionAction, +} from "./federation-delivery.server.ts"; export interface ActivityInput { name: string; description?: string; + sportType?: SportType | null; gpx?: string; routeId?: string; + distance?: number | null; + duration?: number | null; + startedAt?: Date | null; + visibility?: Visibility; + synthetic?: boolean; +} + +export async function updateActivityVisibility( + ownedActivity: OwnedRef, + visibility: Visibility, +): Promise { + const { id, ownerId } = ownedActivity; + const db = getDb(); + // Read the previous visibility first: the federation action depends on + // the *transition*, not the new value alone (a gratuitous Delete + // permanently tombstones the object's URI on Mastodon — see + // visibilityTransitionAction). + const [existing] = await db + .select({ visibility: activities.visibility }) + .from(activities) + .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))) + .limit(1); + if (!existing) return false; + + await db + .update(activities) + .set({ visibility }) + .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))); + + // Notify followers when an activity becomes public. The unique + // (recipient, type, subject_id) partial index makes the fan-out + // idempotent, so toggling private→public→private→public won't spam + // followers (only the first transition per activity emits). + if (visibility === "public") { + await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" }); + } + + // Federation: Create on (re-)publish, Delete(Tombstone) only when the + // activity actually was public before — remotes never saw anything + // else, and an unnecessary Delete poisons the URI forever. + const action = visibilityTransitionAction(existing.visibility, visibility); + if (action !== null) { + await enqueueActivityDeliveries(ownerId, id, action); + } + + return true; } export async function createActivity(ownerId: string, input: ActivityInput) { const db = getDb(); const id = randomUUID(); - let distance: number | null = null; + let processed: ProcessedGpx | null = null; + let distance: number | null = input.distance ?? null; let elevationGain: number | null = null; let elevationLoss: number | null = null; - let startedAt: Date | null = null; + let startedAt: Date | null = input.startedAt ?? null; + const duration: number | null = input.duration ?? null; if (input.gpx) { - try { - const gpxData = await parseGpxAsync(input.gpx); - const profile = gpxData.elevation.profile; - distance = profile.length > 0 ? Math.round(profile[profile.length - 1]!.distance) : null; - elevationGain = gpxData.elevation.gain; - elevationLoss = gpxData.elevation.loss; - - // Try to extract start time from first track point - if (gpxData.tracks[0]?.[0]?.time) { - startedAt = new Date(gpxData.tracks[0][0].time); - } - } catch { - // Continue without stats if GPX parsing fails - } + processed = await processGpx(input.gpx); + // GPX-derived distance wins unless it is zero; caller input is the fallback + distance = processed.stats.distance || distance; + elevationGain = processed.stats.elevationGain; + elevationLoss = processed.stats.elevationLoss; + startedAt = startedAt ?? processed.stats.startTime; } - await db.insert(activities).values({ - id, - ownerId, - routeId: input.routeId ?? null, - name: input.name, - description: input.description ?? "", - gpx: input.gpx, - distance, - elevationGain, - elevationLoss, - startedAt, + await db.transaction(async (tx) => { + await tx.insert(activities).values({ + id, + ownerId, + routeId: input.routeId ?? null, + name: input.name, + description: input.description ?? "", + sportType: input.sportType ?? null, + gpx: input.gpx, + distance, + duration, + elevationGain, + elevationLoss, + startedAt, + ...(input.visibility ? { visibility: input.visibility } : {}), + ...(input.synthetic ? { synthetic: true } : {}), + }); + + if (input.gpx && processed) { + await writeGeom(tx, id, "activities", processed.coords); + } }); + // Public activities at creation also fan out (matches the + // updateActivityVisibility path for the case where visibility is set + // up-front rather than flipped later). + if (input.visibility === "public") { + await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" }); + // Federation push delivery to accepted remote followers (spec 5.3). + await enqueueActivityDeliveries(ownerId, id, "create"); + } + + // Activities arrive without surface waytags (bare GPX); kick off the async + // Overpass backfill (route-surface-breakdown Path 2). Skipped for synthetic + // demo content. Best-effort — never blocks creation. + if (input.gpx && !input.synthetic) { + await enqueueOptional( + "surface-backfill", + { kind: "activity", id }, + { source: "createActivity" }, + { singletonKey: `surface:activity:${id}` }, + ); + } + return id; } export async function getActivity(id: string) { const db = getDb(); const [activity] = await db.select().from(activities).where(eq(activities.id, id)); - return activity ?? null; + if (!activity) return null; + const geojson = await getActivityGeojson(id); + const importSource = await getImportSource(id); + return { ...activity, geojson, importSource }; } -export async function listActivities(ownerId: string) { +export async function deleteActivity(ownedActivity: OwnedRef): Promise { + const { id, ownerId } = ownedActivity; const db = getDb(); - return db + // The WHERE ownerId clause stays as defense in depth even though the + // OwnedRef brand already proves ownership. + const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities) + .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))); + if (!activity) return false; + // Enqueue the federation retraction *before* the row disappears — + // the Delete payload carries everything it needs (object IRI + + // owner), so it survives the deletion (spec 5.6). + if (activity.visibility === "public") { + await enqueueActivityDeliveries(ownerId, id, "delete"); + } + await db.delete(activities).where(eq(activities.id, id)); + return true; +} + +async function getImportSource(activityId: string): Promise<{ provider: string; externalWorkoutId: string } | null> { + const db = getDb(); + const [row] = await db + .select({ provider: syncImports.provider, externalWorkoutId: syncImports.externalWorkoutId }) + .from(syncImports) + .where(eq(syncImports.activityId, activityId)); + return row ?? null; +} + +export interface ActivityStats { + count: number; + /** metres */ + distance: number; + /** metres */ + elevationGain: number; + /** seconds, elapsed */ + duration: number; + /** activities started in the last 28 days */ + last4Weeks: number; +} + +/** + * Aggregate roll-up for a profile. One indexed aggregate over stored columns + * (no GPX parsing) — `owner_id` leads two existing indexes, so this is cheap + * even for power users (see profile-stats design §D1). `publicOnly` scopes the + * roll-up to what a visitor may see; the owner passes `false` for full totals. + */ +export async function getActivityStats( + ownerId: string, + opts: { publicOnly: boolean }, +): Promise { + const db = getDb(); + const conditions = [eq(activities.ownerId, ownerId)]; + if (opts.publicOnly) conditions.push(eq(activities.visibility, "public")); + + const [row] = await db + .select({ + count: sql`count(*)`, + distance: sql`coalesce(sum(${activities.distance}), 0)`, + elevationGain: sql`coalesce(sum(${activities.elevationGain}), 0)`, + duration: sql`coalesce(sum(${activities.duration}), 0)`, + last4Weeks: sql`count(*) filter (where coalesce(${activities.startedAt}, ${activities.createdAt}) >= now() - interval '28 days')`, + }) + .from(activities) + .where(and(...conditions)); + + // Postgres returns count/sum as strings via the driver; coerce to numbers. + return { + count: Number(row?.count ?? 0), + distance: Number(row?.distance ?? 0), + elevationGain: Number(row?.elevationGain ?? 0), + duration: Number(row?.duration ?? 0), + last4Weeks: Number(row?.last4Weeks ?? 0), + }; +} + +export interface WeeklyDistanceBucket { + /** ISO date (YYYY-MM-DD) of the week's Monday */ + weekStart: string; + /** metres */ + distance: number; +} + +/** + * Distance per week for the last `weeks` weeks (oldest → newest), gap-filled so + * every week is present (zero when no activity). The contiguous axis is built + * in SQL via `generate_series` + a LEFT JOIN — that guarantees the week + * boundaries match Postgres `date_trunc('week', …)` exactly (no JS/Postgres + * boundary drift) and keeps it one cheap query over the owner_id index + * (profile-weekly-distance design §D1–D2). `publicOnly` scopes it like + * `getActivityStats`. + */ +export async function getWeeklyDistance( + ownerId: string, + opts: { publicOnly: boolean; weeks?: number }, +): Promise { + const weeks = opts.weeks ?? 12; + const db = getDb(); + // Filter conditions live in the LEFT JOIN's ON clause so empty weeks survive. + const visibility = opts.publicOnly ? sql` AND a.visibility = 'public'` : sql``; + const result = await db.execute(sql` + WITH weeks AS ( + SELECT generate_series( + date_trunc('week', now()) - (${weeks - 1} * interval '1 week'), + date_trunc('week', now()), + interval '1 week' + ) AS wk + ) + SELECT to_char(w.wk, 'YYYY-MM-DD') AS week_start, + coalesce(sum(a.distance), 0) AS distance + FROM weeks w + LEFT JOIN journal.activities a + ON date_trunc('week', coalesce(a.started_at, a.created_at)) = w.wk + AND a.owner_id = ${ownerId}${visibility} + GROUP BY w.wk + ORDER BY w.wk + `); + const rows = result as unknown as Array<{ week_start: string; distance: string | number }>; + return rows.map((r) => ({ weekStart: r.week_start, distance: Number(r.distance) })); +} + +export async function listActivities( + ownerId: string, + sort: "startedAt" | "addedAt" = "startedAt", +) { + const db = getDb(); + const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt); + const rows = await db .select() .from(activities) .where(eq(activities.ownerId, ownerId)) - .orderBy(desc(activities.createdAt)); + .orderBy(order); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } -export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { +/** + * List the *public* activities of a given owner. Used for cross-user + * listings (the public profile page); never includes `unlisted` or + * `private` content. + */ +export async function listPublicActivitiesForOwner( + ownerId: string, + sort: "startedAt" | "addedAt" = "startedAt", + limit: number = 100, +) { + const db = getDb(); + const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt); + const rows = await db + .select() + .from(activities) + .where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public"))) + .orderBy(order) + .limit(limit); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + +/** + * Social feed (spec: social-federation §8): aggregated activities from + * actors that `followerId` follows with an *accepted* follow — local + * users and remote trails actors alike. Reverse-chronological on + * COALESCE(remote_published_at, created_at). + * + * Audience rules: + * - local rows: `visibility = 'public'` only (unlisted/private never + * appear regardless of follow state) + * - remote rows: `audience = 'public'` or `followers-only` — the + * latter gated structurally by joining the *viewer's own* accepted + * follow against the originating actor (spec: "Followers-only remote + * content reaches only the right viewer") + * Pending follows contribute nothing (accepted_at IS NOT NULL on both + * branches — previously missing on the local branch). + */ +export async function listSocialFeed(followerId: string, limit: number = 50) { + const db = getDb(); + + const local = db + .select({ + id: activities.id, + name: activities.name, + sportType: activities.sportType, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + sortTime: sql`${activities.createdAt}`.as("sort_time"), + ownerUsername: sql`${users.username}`.as("owner_username"), + ownerDisplayName: sql`${users.displayName}`.as("owner_display_name"), + ownerDomain: sql`${users.domain}`.as("owner_domain"), + externalUrl: sql`NULL`.as("external_url"), + remote: sql`false`.as("remote"), + }) + .from(activities) + .innerJoin(follows, eq(follows.followedUserId, activities.ownerId)) + .innerJoin(users, eq(activities.ownerId, users.id)) + .where( + and( + eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), + eq(activities.visibility, "public"), + ), + ); + + const remote = db + .select({ + id: activities.id, + name: activities.name, + sportType: activities.sportType, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + sortTime: sql`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"), + ownerUsername: sql`${remoteActors.username}`.as("owner_username"), + ownerDisplayName: sql`${remoteActors.displayName}`.as("owner_display_name"), + ownerDomain: sql`${remoteActors.domain}`.as("owner_domain"), + externalUrl: sql`${activities.remoteOriginIri}`.as("external_url"), + remote: sql`true`.as("remote"), + }) + .from(activities) + .innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri)) + .leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), + // public reaches every accepted follower; followers-only is + // already gated by joining the viewer's own accepted follow. + sql`${activities.audience} IN ('public', 'followers-only')`, + ), + ); + + const rows = await unionAll(local, remote) + .orderBy(sql`sort_time DESC`) + .limit(limit); + + const localIds = rows.filter((r) => !r.remote).map((r) => r.id); + const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + +/** + * Instance-wide public activity feed. Joins users so the caller can + * render "by " without a second round-trip. Used by the + * Journal home to give arriving visitors something concrete to look at. + * Unlisted activities are excluded: they're reachable by direct URL + * only and shouldn't surface in any listing. + */ +export async function listRecentPublicActivities(limit: number = 20) { + const db = getDb(); + const rows = await db + .select({ + id: activities.id, + name: activities.name, + sportType: activities.sportType, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + ownerUsername: users.username, + ownerDisplayName: users.displayName, + }) + .from(activities) + .innerJoin(users, eq(activities.ownerId, users.id)) + .where(eq(activities.visibility, "public")) + .orderBy(desc(activities.createdAt)) + .limit(limit); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + +export async function linkActivityToRoute(ownedActivity: OwnedRef, ownedRoute: OwnedRef) { const db = getDb(); await db .update(activities) - .set({ routeId }) - .where(eq(activities.id, activityId)); + .set({ routeId: ownedRoute.id }) + .where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId))); } -export async function createRouteFromActivity(activityId: string, ownerId: string): Promise { +export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise { + const { id: activityId, ownerId } = ownedActivity; const db = getDb(); - const [activity] = await db.select().from(activities).where(eq(activities.id, activityId)); + const [activity] = await db + .select() + .from(activities) + .where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId))); if (!activity?.gpx) return null; + const { coords } = await processGpx(activity.gpx); const routeId = randomUUID(); - await db.insert(routes).values({ - id: routeId, - ownerId, - name: `Route from: ${activity.name}`, - description: `Created from activity "${activity.name}"`, - gpx: activity.gpx, - distance: activity.distance, - elevationGain: activity.elevationGain, - elevationLoss: activity.elevationLoss, - }); - // Link the activity to the new route - await db - .update(activities) - .set({ routeId }) - .where(eq(activities.id, activityId)); + await db.transaction(async (tx) => { + await tx.insert(routes).values({ + id: routeId, + ownerId, + name: `Route from: ${activity.name}`, + description: `Created from activity "${activity.name}"`, + gpx: activity.gpx, + distance: activity.distance, + elevationGain: activity.elevationGain, + elevationLoss: activity.elevationLoss, + }); + + await writeGeom(tx, routeId, "routes", coords); + + await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId)); + }); return routeId; } + +async function getActivityGeojson(id: string): Promise { + try { + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; + } catch { + return null; + } +} + +async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + // Use the query builder for the id list: a raw `ANY(${ids}::text[])` + // makes drizzle expand the array to `($1,$2,...)`, yielding the invalid + // `ANY((...)::text[])` — which throws and silently drops every preview. + const rows = await db + .select({ + id: activities.id, + geojson: sql`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`, + }) + .from(activities) + .where(and(inArray(activities.id, ids), isNotNull(activities.geom))); + for (const row of rows) { + if (row.geojson) map.set(row.id, row.geojson); + } + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/lib/actor-iri.ts b/apps/journal/app/lib/actor-iri.ts new file mode 100644 index 0000000..06da49d --- /dev/null +++ b/apps/journal/app/lib/actor-iri.ts @@ -0,0 +1,8 @@ +import { getOrigin } from "./config.server.ts"; + +// Canonical ActivityPub actor IRI for a local user. Used as the key in +// `follows.followed_actor_iri` so the column shape is identical for local +// and (future) federated follows. +export function localActorIri(username: string): string { + return `${getOrigin()}/users/${username}`; +} diff --git a/apps/journal/app/lib/api-guard.server.test.ts b/apps/journal/app/lib/api-guard.server.test.ts new file mode 100644 index 0000000..695086b --- /dev/null +++ b/apps/journal/app/lib/api-guard.server.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TERMS_VERSION } from "./legal"; + +const mockGetAuthenticatedUser = vi.fn(); + +vi.mock("./oauth.server.ts", () => ({ + getAuthenticatedUser: mockGetAuthenticatedUser, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("requireApiUser", () => { + it("returns 401 when unauthenticated", async () => { + mockGetAuthenticatedUser.mockResolvedValue(null); + const { requireApiUser } = await import("./api-guard.server.ts"); + + try { + await requireApiUser(new Request("http://localhost/api/v1/routes")); + expect.fail("should throw"); + } catch (err) { + expect(err).toBeInstanceOf(Response); + expect((err as Response).status).toBe(401); + const body = await (err as Response).json(); + expect(body.code).toBe("UNAUTHORIZED"); + } + }); + + it("returns the user when termsVersion matches", async () => { + const user = { id: "u1", termsVersion: TERMS_VERSION }; + mockGetAuthenticatedUser.mockResolvedValue(user); + const { requireApiUser } = await import("./api-guard.server.ts"); + + const result = await requireApiUser(new Request("http://localhost/api/v1/routes")); + expect(result).toBe(user); + }); + + it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => { + mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" }); + const { requireApiUser } = await import("./api-guard.server.ts"); + + try { + await requireApiUser(new Request("http://localhost/api/v1/routes")); + expect.fail("should throw"); + } catch (err) { + expect(err).toBeInstanceOf(Response); + expect((err as Response).status).toBe(403); + const body = await (err as Response).json(); + expect(body.code).toBe("TERMS_OUTDATED"); + expect(body.currentTermsVersion).toBe(TERMS_VERSION); + } + }); + + it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => { + mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null }); + const { requireApiUser } = await import("./api-guard.server.ts"); + + try { + await requireApiUser(new Request("http://localhost/api/v1/routes")); + expect.fail("should throw"); + } catch (err) { + expect(err).toBeInstanceOf(Response); + expect((err as Response).status).toBe(403); + const body = await (err as Response).json(); + expect(body.code).toBe("TERMS_OUTDATED"); + expect(body.currentTermsVersion).toBe(TERMS_VERSION); + } + }); +}); diff --git a/apps/journal/app/lib/api-guard.server.ts b/apps/journal/app/lib/api-guard.server.ts new file mode 100644 index 0000000..7ac8466 --- /dev/null +++ b/apps/journal/app/lib/api-guard.server.ts @@ -0,0 +1,55 @@ +import type { z } from "zod"; +import { getAuthenticatedUser } from "./oauth.server.ts"; +import { TERMS_VERSION } from "./legal.ts"; +import { ERROR_CODES } from "@trails-cool/api"; + +/** + * Require authentication for an API route. Returns the user or throws a + * Response: 401 if unauthenticated, 403 with `TERMS_OUTDATED` if the user's + * stored `terms_version` is missing or stale relative to the current + * `TERMS_VERSION`. Mirrors the cookie-session terms gate enforced by the + * root loader, so bearer-token API traffic can't bypass it. + */ +export async function requireApiUser(request: Request) { + const user = await getAuthenticatedUser(request); + if (!user) { + throw Response.json( + { error: "Unauthorized", code: ERROR_CODES.UNAUTHORIZED }, + { status: 401 }, + ); + } + if (user.termsVersion !== TERMS_VERSION) { + throw Response.json( + { + error: "Terms of Service have been updated and must be re-accepted", + code: ERROR_CODES.TERMS_OUTDATED, + currentTermsVersion: TERMS_VERSION, + }, + { status: 403 }, + ); + } + return user; +} + +/** + * Return a structured API error response. + */ +export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) { + return Response.json({ error: message, code, fields }, { status }); +} + +/** + * Respond with a payload validated against its @trails-cool/api + * contract. The schema is enforced, not advisory: a handler whose + * payload drifts from the contract fails its unit tests / e2e run with + * a ZodError instead of silently shipping a different wire shape. + * Parsing also strips unknown keys, so the response is exactly the + * contract — nothing extra leaks. + */ +export function apiJson( + schema: S, + payload: z.input, + init?: ResponseInit, +): Response { + return Response.json(schema.parse(payload), init); +} diff --git a/apps/journal/app/lib/auth.server.ts b/apps/journal/app/lib/auth.server.ts index 8905332..a68aab9 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -12,11 +12,16 @@ import type { AuthenticatorTransportFuture, } from "@simplewebauthn/types"; import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { federationEnabled } from "./federation.server.ts"; +import { ensureUserKeypair } from "./federation-keys.server.ts"; +import { logger } from "./logger.server.ts"; import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; const RP_NAME = "trails.cool"; const RP_ID = process.env.DOMAIN ?? "localhost"; -const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`; +const ORIGIN = getOrigin(); // --- Registration --- @@ -53,6 +58,7 @@ export async function finishRegistration( username: string, response: RegistrationResponseJSON, challenge: string, + termsVersion: string, ) { const db = getDb(); @@ -76,20 +82,40 @@ export async function finishRegistration( email, username, domain, + termsAcceptedAt: new Date(), + termsVersion, }); await db.insert(credentials).values({ id: randomUUID(), userId, - credentialId: Buffer.from(credential.id), + credentialId: Buffer.from(credential.id, "base64url"), publicKey: Buffer.from(credential.publicKey), counter: credential.counter, transports: response.response.transports, }); + await generateFederationKeysBestEffort(userId); + return userId; } +/** + * Spec (social-federation): new users get federation signing keys at + * registration. Best-effort — a keygen failure must never fail the + * registration itself; the `backfill-user-keypairs` job is the safety + * net for any user this skips. No-op while federation is off (the + * backfill covers everyone when the flag first flips on). + */ +async function generateFederationKeysBestEffort(userId: string): Promise { + if (!federationEnabled()) return; + try { + await ensureUserKeypair(userId); + } catch (err) { + logger.warn({ err, userId }, "federation keypair generation failed at registration; backfill will retry"); + } +} + // --- Add Passkey to Existing Account --- export async function addPasskeyStart(userId: string) { @@ -137,13 +163,59 @@ export async function addPasskeyFinish( await db.insert(credentials).values({ id: randomUUID(), userId, - credentialId: Buffer.from(credential.id), + credentialId: Buffer.from(credential.id, "base64url"), publicKey: Buffer.from(credential.publicKey), counter: credential.counter, transports: response.response.transports, }); } +// --- Registration via Magic Link (no passkey) --- + +export async function registerWithMagicLink( + email: string, + username: string, + termsVersion: string, +): Promise<{ token: string; code: string }> { + const db = getDb(); + + const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); + if (existingEmail) throw new Error("Email already in use"); + + const [existingUsername] = await db.select().from(users).where(eq(users.username, username)); + if (existingUsername) throw new Error("Username already taken"); + + const userId = randomUUID(); + const domain = process.env.DOMAIN ?? "localhost"; + + await db.insert(users).values({ + id: userId, + email, + username, + domain, + termsAcceptedAt: new Date(), + termsVersion, + }); + + await generateFederationKeysBestEffort(userId); + + // Same shape as login's createMagicToken — token for the click-through + // link, 6-digit code for paste-from-email/SMS flows (mobile). + const token = randomBytes(32).toString("base64url"); + const code = generateLoginCode(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + await db.insert(magicTokens).values({ + id: randomUUID(), + email, + token, + code, + expiresAt, + }); + + return { token, code }; +} + // --- Passkey Login --- export async function startAuthentication() { @@ -199,20 +271,85 @@ export async function finishAuthentication( // --- Magic Links --- -export async function createMagicToken(email: string): Promise { +function generateLoginCode(): string { + // 6-digit numeric code, zero-padded + const num = randomBytes(3).readUIntBE(0, 3) % 1_000_000; + return String(num).padStart(6, "0"); +} + +export async function createMagicToken( + email: string, +): Promise<{ token: string; code: string } | null> { const db = getDb(); - // Check user exists + // Returns null (not an error) when no account matches, so the caller + // can respond identically whether or not the email is registered — + // closing the account-enumeration oracle on the public login form. const [user] = await db.select().from(users).where(eq(users.email, email)); - if (!user) throw new Error("No account found for this email"); + if (!user) return null; const token = randomBytes(32).toString("base64url"); + const code = generateLoginCode(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes await db.insert(magicTokens).values({ id: randomUUID(), email, token, + code, + expiresAt, + }); + + return { token, code }; +} + +export async function verifyLoginCode(email: string, code: string): Promise { + const db = getDb(); + + // Consume atomically: a single UPDATE…RETURNING ensures only one + // concurrent request wins. The old select-then-update sequence was a + // TOCTOU window where two clients could both see `used_at IS NULL` + // and both succeed. + const [record] = await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where( + and( + eq(magicTokens.email, email), + eq(magicTokens.code, code), + eq(magicTokens.purpose, "login"), + gt(magicTokens.expiresAt, new Date()), + isNull(magicTokens.usedAt), + ), + ) + .returning(); + + if (!record) throw new Error("Invalid or expired code"); + + const [user] = await db.select().from(users).where(eq(users.email, record.email)); + if (!user) throw new Error("User not found"); + + return user.id; +} + +export async function initiateEmailChange(userId: string, newEmail: string): Promise { + const db = getDb(); + + const [existing] = await db.select().from(users).where(eq(users.email, newEmail)); + if (existing) throw new Error("Email already in use"); + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + if (!user) throw new Error("User not found"); + if (user.email === newEmail) throw new Error("This is already your email"); + + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + await db.insert(magicTokens).values({ + id: randomUUID(), + email: newEmail, + purpose: "email-change", + token, expiresAt, }); @@ -222,67 +359,122 @@ export async function createMagicToken(email: string): Promise { export async function verifyMagicToken(token: string): Promise { const db = getDb(); + // Atomic consume — see verifyLoginCode for rationale. const [record] = await db - .select() - .from(magicTokens) + .update(magicTokens) + .set({ usedAt: new Date() }) .where( and( eq(magicTokens.token, token), + eq(magicTokens.purpose, "login"), gt(magicTokens.expiresAt, new Date()), isNull(magicTokens.usedAt), ), - ); + ) + .returning(); if (!record) throw new Error("Invalid or expired magic link"); - // Mark as used - await db - .update(magicTokens) - .set({ usedAt: new Date() }) - .where(eq(magicTokens.id, record.id)); - - // Find user const [user] = await db.select().from(users).where(eq(users.email, record.email)); if (!user) throw new Error("User not found"); return user.id; } -// --- Sessions --- - -import { createCookieSessionStorage } from "react-router"; - -const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production"; - -export const sessionStorage = createCookieSessionStorage({ - cookie: { - name: "__session", - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60 * 24 * 30, // 30 days - secrets: [sessionSecret], - }, -}); - -export async function createSession(userId: string, request: Request) { - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - session.set("userId", userId); - return sessionStorage.commitSession(session); -} - -export async function getSessionUser(request: Request) { +export async function verifyEmailChange(token: string, userId: string): Promise { const db = getDb(); - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - const userId = session.get("userId"); - if (!userId) return null; - const [user] = await db.select().from(users).where(eq(users.id, userId)); - return user ?? null; + // Atomic consume — see verifyLoginCode for rationale. The token is + // marked used regardless of whether the email is still available; + // re-checking availability after the consume keeps the token + // single-use even if the new email was claimed in between (the + // original implementation also marked it used in that case). + const [record] = await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where( + and( + eq(magicTokens.token, token), + eq(magicTokens.purpose, "email-change"), + gt(magicTokens.expiresAt, new Date()), + isNull(magicTokens.usedAt), + ), + ) + .returning(); + + if (!record) throw new Error("Invalid or expired verification link"); + + const newEmail = record.email; + + // Re-check email availability at verification time — someone may have + // registered with this email between initiation and verification. + const [existing] = await db.select().from(users).where(eq(users.email, newEmail)); + if (existing) { + throw new Error("This email is now in use by another account"); + } + + await db + .update(users) + .set({ email: newEmail }) + .where(eq(users.id, userId)); + + return newEmail; } -export async function destroySession(request: Request) { - const session = await sessionStorage.getSession(request.headers.get("Cookie")); - return sessionStorage.destroySession(session); +// Cookie session storage lives at `./auth/session.ts` (see ADR-0004 + the +// `auth/completion.ts` chokepoint). Import session helpers directly from +// there; this file owns only per-method identity verification (passkey +// ceremony + magic-token lifecycle) and Terms recording. + +/** + * A row that carries the minimum a visibility check needs. + */ +export interface Viewable { + ownerId: string; + visibility: Visibility; } + +/** + * The caller's identity. `null` represents a logged-out visitor. + */ +export interface Viewer { + id: string; +} + +/** + * Decide whether a viewer may see a piece of content. + * + * - `public` content is viewable by anyone. + * - `unlisted` content is viewable only on direct-link access — listings + * should omit it. Callers rendering a detail page pass `asDirectLink: + * true`; listings default to `false`. + * - `private` content is viewable only by the owner. + * + * Centralised here so detail loaders and listing queries use the same + * rule. Intentionally does not throw; callers handle the `false` case + * (usually by returning HTTP 404 rather than 403 to avoid leaking + * existence). + */ +export function canView( + content: Viewable, + viewer: Viewer | null, + { asDirectLink = false }: { asDirectLink?: boolean } = {}, +): boolean { + if (content.visibility === "public") return true; + if (content.visibility === "unlisted" && asDirectLink) return true; + return viewer?.id === content.ownerId; +} + +/** + * Record the user's acceptance of the current Terms version. Updates both + * `terms_accepted_at` (NOW) and `terms_version`. Used when an existing user + * 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)); +} + diff --git a/apps/journal/app/lib/auth/completion.server.test.ts b/apps/journal/app/lib/auth/completion.server.test.ts new file mode 100644 index 0000000..f5400b9 --- /dev/null +++ b/apps/journal/app/lib/auth/completion.server.test.ts @@ -0,0 +1,112 @@ +// Contract tests for the completeAuth chokepoint. See ADR-0004 + +// docs/adr/0005-no-authmethod-polymorphism.md for why this exists. + +import { describe, it, expect } from "vitest"; +import { completeAuth } from "./completion.server.ts"; + +function reqWith(headers: Record = {}) { + return new Request("https://localhost/whatever", { headers }); +} + +describe("completeAuth (mode=redirect)", () => { + it("redirects to returnTo when it's a same-origin path", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/profile", + }); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/profile"); + }); + + it("redirects to / when returnTo is missing", async () => { + const res = await completeAuth({ userId: "u1", request: reqWith() }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("redirects to / when returnTo is null", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: null, + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects protocol-relative returnTo (//evil.com/x) → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "//evil.com/x", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects absolute-URL returnTo (https://evil.com) → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "https://evil.com", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("rejects returnTo that doesn't start with / → /", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "javascript:alert(1)", + }); + expect(res.headers.get("Location")).toBe("/"); + }); + + it("attaches a __session Set-Cookie carrying the userId", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/", + }); + const setCookie = res.headers.get("Set-Cookie"); + expect(setCookie).not.toBeNull(); + expect(setCookie!).toMatch(/^__session=/); + // HttpOnly is required for a session cookie. + expect(setCookie!.toLowerCase()).toContain("httponly"); + }); +}); + +describe("completeAuth (mode=json)", () => { + it("returns 200 JSON with sanitized redirectTo and Set-Cookie", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "/profile", + mode: "json", + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + expect(res.headers.get("Set-Cookie")).toMatch(/^__session=/); + const body = (await res.json()) as { ok: boolean; step: string; redirectTo: string }; + expect(body).toEqual({ ok: true, step: "done", redirectTo: "/profile" }); + }); + + it("falls back to / when returnTo is unsafe (json mode)", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + returnTo: "https://evil.com", + mode: "json", + }); + const body = (await res.json()) as { ok: boolean; redirectTo: string }; + expect(body.redirectTo).toBe("/"); + }); + + it("redirectTo defaults to / when no returnTo (json mode)", async () => { + const res = await completeAuth({ + userId: "u1", + request: reqWith(), + mode: "json", + }); + const body = (await res.json()) as { ok: boolean; redirectTo: string }; + expect(body.redirectTo).toBe("/"); + }); +}); diff --git a/apps/journal/app/lib/auth/completion.server.ts b/apps/journal/app/lib/auth/completion.server.ts new file mode 100644 index 0000000..cbf7dcf --- /dev/null +++ b/apps/journal/app/lib/auth/completion.server.ts @@ -0,0 +1,76 @@ +// completeAuth — the single chokepoint that every successful web auth +// flow uses to mint the cookie session and produce the response. See +// ADR-0004 + CONTEXT.md (Authentication section). +// +// Two response shapes, picked via `mode`: +// - 'redirect' (loader callers / direct browser navigation): +// 302 redirect to the sanitized target, Set-Cookie attached. +// - 'json' (action callers from imperative fetch in client forms): +// 200 JSON { ok: true, redirectTo } with Set-Cookie. Client reads +// redirectTo and does window.location = redirectTo. +// +// Both modes share identity-agnostic behaviour: createSession + same- +// origin sanitization + Set-Cookie. Per ADR-0005, this function knows +// nothing about how identity was proved (passkey, magic-link, etc.) — +// the caller does its own per-method verification first. +// +// Terms recording is NOT here: both registration paths (finishRegistration +// for passkey, registerWithMagicLink for magic-link) record terms at +// user-creation time, before any path can reach completeAuth. + +import { redirect } from "react-router"; +import { createSession } from "./session.server.ts"; + +export type CompleteAuthMode = "redirect" | "json"; + +export interface CompleteAuthInput { + userId: string; + request: Request; + /** + * Optional caller-supplied redirect target. Validated as a same-origin + * path; anything else falls back to "/". Pass through whatever you + * received from the client — sanitization is centralized here. + */ + returnTo?: string | null; + /** + * Response shape: + * - 'redirect' (default): 302 redirect Response; right for loaders + * (direct browser navigation, e.g. auth.verify.tsx loader). + * - 'json': 200 JSON `{ ok: true, redirectTo }`; right for action + * handlers called by imperative fetch from client form code + * (e.g. api.auth.register, api.auth.login). Client navigates + * using `data.redirectTo`. + */ + mode?: CompleteAuthMode; +} + +/** + * Same-origin path check. Accepts only paths that start with a single + * "/" — rejects: + * - Empty / null / undefined + * - Protocol-relative ("//evil.com/x") + * - Absolute URLs ("https://evil.com") + * - Anything not starting with "/" + */ +function safeReturnTo(value: string | null | undefined): string | null { + if (typeof value !== "string" || value.length === 0) return null; + if (!value.startsWith("/")) return null; + if (value.startsWith("//")) return null; + return value; +} + +export async function completeAuth(input: CompleteAuthInput): Promise { + const cookie = await createSession(input.userId, input.request); + const target = safeReturnTo(input.returnTo) ?? "/"; + const headers = { "Set-Cookie": cookie }; + if (input.mode === "json") { + // `step: "done"` retained for compatibility with existing client + // form checks (auth.register.tsx, auth.login.tsx). New code should + // read `redirectTo` and navigate there. + return Response.json( + { ok: true, step: "done", redirectTo: target }, + { headers }, + ); + } + return redirect(target, { headers }); +} diff --git a/apps/journal/app/lib/auth/session.server.test.ts b/apps/journal/app/lib/auth/session.server.test.ts new file mode 100644 index 0000000..ff387a9 --- /dev/null +++ b/apps/journal/app/lib/auth/session.server.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), +})); + +vi.mock("../db.ts", () => ({ getDb: mocks.getDb })); + +import { + requireSessionUser, + requireSessionUserJson, + sessionStorage, +} from "./session.server.ts"; + +async function requestWithSession(userId: string | null): Promise { + const session = await sessionStorage.getSession(); + if (userId) session.set("userId", userId); + const cookie = await sessionStorage.commitSession(session); + return new Request("http://test.local/", { + headers: { cookie }, + }); +} + +describe("requireSessionUser", () => { + beforeEach(() => { + mocks.getDb.mockReset(); + }); + + it("throws a redirect to /auth/login when no session cookie", async () => { + const req = new Request("http://test.local/"); + try { + await requireSessionUser(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const resp = thrown as Response; + expect(resp.status).toBe(302); + expect(resp.headers.get("location")).toBe("/auth/login"); + } + }); + + it("throws a redirect when session userId points at a missing user", async () => { + mocks.getDb.mockReturnValue({ + select: () => ({ from: () => ({ where: () => Promise.resolve([]) }) }), + }); + const req = await requestWithSession("ghost-user"); + try { + await requireSessionUser(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + expect((thrown as Response).headers.get("location")).toBe("/auth/login"); + } + }); + + it("returns the user when the session is valid", async () => { + const user = { id: "u1", username: "alice" }; + mocks.getDb.mockReturnValue({ + select: () => ({ from: () => ({ where: () => Promise.resolve([user]) }) }), + }); + const req = await requestWithSession("u1"); + const result = await requireSessionUser(req); + expect(result).toEqual(user); + }); +}); + +describe("requireSessionUserJson", () => { + beforeEach(() => { + mocks.getDb.mockReset(); + }); + + it("throws a 401 JSON Response when unauthenticated", async () => { + const req = new Request("http://test.local/"); + try { + await requireSessionUserJson(req); + throw new Error("should have thrown"); + } catch (thrown) { + expect(thrown).toBeInstanceOf(Response); + const resp = thrown as Response; + expect(resp.status).toBe(401); + const body = (await resp.json()) as { error: string }; + expect(body.error).toBe("Unauthorized"); + } + }); +}); diff --git a/apps/journal/app/lib/auth/session.server.ts b/apps/journal/app/lib/auth/session.server.ts new file mode 100644 index 0000000..5c6edbd --- /dev/null +++ b/apps/journal/app/lib/auth/session.server.ts @@ -0,0 +1,75 @@ +// Cookie session storage. Lives here (separate from auth.server.ts) so +// the post-verify chokepoint (./completion.ts) can compose it without +// dragging the entire auth surface in. +// +// The legacy import path `~/lib/auth.server` continues to re-export +// these symbols for backwards compat — see auth.server.ts. + +import { createCookieSessionStorage, redirect } from "react-router"; +import { eq } from "drizzle-orm"; +import { users } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { requireSecret } from "../config.server.ts"; + +const sessionSecret = requireSecret("SESSION_SECRET", "dev-secret-change-in-production"); + +export const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, // 30 days + secrets: [sessionSecret], + }, +}); + +export async function createSession(userId: string, request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", userId); + return sessionStorage.commitSession(session); +} + +export async function getSessionUser(request: Request) { + const db = getDb(); + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + const userId = session.get("userId"); + if (!userId) return null; + + const [user] = await db.select().from(users).where(eq(users.id, userId)); + return user ?? null; +} + +/** + * Loader/action helper: return the session user or throw a redirect to + * /auth/login. Centralizes the repeated + * const user = await getSessionUser(request); + * if (!user) return redirect("/auth/login"); + * pattern across page loaders and form actions. + */ +export async function requireSessionUser(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw redirect("/auth/login"); + } + return user; +} + +/** + * Same as requireSessionUser but throws a 401 JSON response instead of a + * redirect. For fetcher/JSON endpoints (`/api/*` non-v1) where redirecting + * would confuse the client-side caller. + */ +export async function requireSessionUserJson(request: Request) { + const user = await getSessionUser(request); + if (!user) { + throw Response.json({ error: "Unauthorized" }, { status: 401 }); + } + return user; +} + +export async function destroySession(request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + return sessionStorage.destroySession(session); +} diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts new file mode 100644 index 0000000..61c0182 --- /dev/null +++ b/apps/journal/app/lib/boss.server.ts @@ -0,0 +1,109 @@ +// Process-level pg-boss singleton. server.ts initializes the boss + starts +// the worker; feature code (e.g., activities.server.ts) calls `getBoss()` +// to enqueue jobs against the same instance. The singleton's lifecycle +// is bound to the Node process — startWorker calls boss.start(); the +// SIGTERM handler stops it. +// +// The instance lives on globalThis, NOT in a module-local variable. In +// production there are TWO copies of this module in the process: +// server.ts imports the TypeScript source directly (node +// --experimental-strip-types), while route handlers live in the +// bundled build/server/index.js with its own module instance. A +// module-local `_boss` set by server.ts is invisible to the bundle, so +// every request-path enqueue (notifications fan-out, federation +// deliveries, komoot imports) silently failed in production with +// "pg-boss not initialized". Symbol.for() gives both copies the same +// global registry key. + +import { logger } from "./logger.server.ts"; +import type { JobName, JobPayloads } from "../jobs/payloads.ts"; + +// Structurally typed so we don't have to pull pg-boss into the journal +// app's dep graph just for the typedef. Kept to the subset feature code +// actually uses: `send` for enqueues, plus `work`/`offWork`/`createQueue`/ +// `getQueue` for the Fedify message-queue adapter (federation-queue.server.ts). +export interface BossSendOptions { + retryLimit?: number; + retryBackoff?: boolean; + retryDelay?: number; + /** Seconds to defer the job (pg-boss `startAfter`). */ + startAfter?: number; + /** pg-boss dedup: collapses enqueues sharing a key into one active job. */ + singletonKey?: string; +} + +/** A pg-boss job as our handlers see it (subset of pg-boss's `Job`). */ +export interface BossJob { + id: string; + name: string; + data: T; +} + +/** Queue depth counters (subset of pg-boss's `QueueResult`). */ +export interface BossQueueDepth { + queuedCount: number; + readyCount: number; + deferredCount: number; +} + +interface BossLike { + send(queueName: string, data: unknown, options?: BossSendOptions): Promise; + work(queueName: string, handler: (jobs: BossJob[]) => Promise): Promise; + offWork(queueName: string): Promise; + createQueue(queueName: string, options?: unknown): Promise; + getQueue(queueName: string): Promise; +} + +const BOSS_KEY = Symbol.for("trails-cool.journal.pg-boss"); + +type BossGlobal = { [BOSS_KEY]?: BossLike | null }; + +/** Set by server.ts once the boss is created + started. */ +export function setBoss(boss: BossLike | null): void { + (globalThis as BossGlobal)[BOSS_KEY] = boss; +} + +/** + * Get the started pg-boss instance. Throws if called before + * server.ts has initialized it (i.e., outside a running Journal + * server context). + */ +export function getBoss(): BossLike { + const boss = (globalThis as BossGlobal)[BOSS_KEY]; + if (!boss) { + throw new Error("pg-boss not initialized — getBoss called before server bootstrap"); + } + return boss; +} + +/** + * Enqueue a job. The queue name must be a key of JobPayloads and the + * payload must match its declared shape. Throws if the queue is down — + * use this when the caller's correctness depends on the job existing + * (e.g. a batch row that would otherwise wait forever). + */ +export async function enqueue( + queue: K, + data: JobPayloads[K], + options?: BossSendOptions, +): Promise { + await getBoss().send(queue, data, options); +} + +/** + * Best-effort enqueue: log + swallow errors so a downstream queue + * outage doesn't fail the user-visible request that triggered the + * fan-out. Use this for "fire and forget" notifications work. + */ +export async function enqueueOptional( + queue: K, + data: JobPayloads[K], + ctx: Record = {}, + options?: BossSendOptions, +): Promise { + try { + await enqueue(queue, data, options); + } catch (err) { + logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing"); + } +} diff --git a/apps/journal/app/lib/canView.test.ts b/apps/journal/app/lib/canView.test.ts new file mode 100644 index 0000000..ec7e259 --- /dev/null +++ b/apps/journal/app/lib/canView.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { canView, type Viewable, type Viewer } from "./auth.server.ts"; + +const owner: Viewer = { id: "owner-id" }; +const other: Viewer = { id: "other-id" }; + +function row(visibility: "private" | "unlisted" | "public"): Viewable { + return { ownerId: owner.id, visibility }; +} + +describe("canView", () => { + describe("public content", () => { + it("is viewable by the owner", () => { + expect(canView(row("public"), owner)).toBe(true); + }); + it("is viewable by another logged-in user", () => { + expect(canView(row("public"), other)).toBe(true); + }); + it("is viewable by a logged-out visitor", () => { + expect(canView(row("public"), null)).toBe(true); + }); + it("ignores asDirectLink (always viewable)", () => { + expect(canView(row("public"), null, { asDirectLink: false })).toBe(true); + expect(canView(row("public"), null, { asDirectLink: true })).toBe(true); + }); + }); + + describe("unlisted content", () => { + it("is viewable by the owner", () => { + expect(canView(row("unlisted"), owner)).toBe(true); + }); + it("is viewable by another user on a direct link", () => { + expect(canView(row("unlisted"), other, { asDirectLink: true })).toBe(true); + }); + it("is viewable by a logged-out visitor on a direct link", () => { + expect(canView(row("unlisted"), null, { asDirectLink: true })).toBe(true); + }); + it("is NOT visible in a listing to another user", () => { + expect(canView(row("unlisted"), other, { asDirectLink: false })).toBe(false); + }); + it("is NOT visible in a listing to a logged-out visitor", () => { + expect(canView(row("unlisted"), null, { asDirectLink: false })).toBe(false); + }); + it("defaults to the listing rule when asDirectLink is omitted", () => { + expect(canView(row("unlisted"), other)).toBe(false); + expect(canView(row("unlisted"), null)).toBe(false); + }); + }); + + describe("private content", () => { + it("is viewable by the owner", () => { + expect(canView(row("private"), owner)).toBe(true); + expect(canView(row("private"), owner, { asDirectLink: true })).toBe(true); + }); + it("is NOT viewable by another user", () => { + expect(canView(row("private"), other)).toBe(false); + expect(canView(row("private"), other, { asDirectLink: true })).toBe(false); + }); + it("is NOT viewable by a logged-out visitor", () => { + expect(canView(row("private"), null)).toBe(false); + expect(canView(row("private"), null, { asDirectLink: true })).toBe(false); + }); + }); +}); diff --git a/apps/journal/app/lib/config.server.test.ts b/apps/journal/app/lib/config.server.test.ts new file mode 100644 index 0000000..37b9d11 --- /dev/null +++ b/apps/journal/app/lib/config.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("getOrigin", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns ORIGIN env when set", async () => { + vi.stubEnv("ORIGIN", "https://example.com"); + const { getOrigin } = await import("./config.server.ts"); + expect(getOrigin()).toBe("https://example.com"); + }); + + it("falls back to localhost when unset", async () => { + delete process.env.ORIGIN; + const { getOrigin } = await import("./config.server.ts"); + expect(getOrigin()).toBe("http://localhost:3000"); + }); +}); + +describe("requireSecret", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("returns the env value when set in any environment", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("MY_SECRET", "real-secret"); + const { requireSecret } = await import("./config.server.ts"); + expect(requireSecret("MY_SECRET", "dev-fallback")).toBe("real-secret"); + }); + + it("returns the dev fallback when unset in development", async () => { + vi.stubEnv("NODE_ENV", "development"); + delete process.env.MY_SECRET; + const { requireSecret } = await import("./config.server.ts"); + expect(requireSecret("MY_SECRET", "dev-fallback")).toBe("dev-fallback"); + }); + + it("throws in production when the secret is unset", async () => { + vi.stubEnv("NODE_ENV", "production"); + delete process.env.MY_SECRET; + const { requireSecret } = await import("./config.server.ts"); + expect(() => requireSecret("MY_SECRET", "dev-fallback")).toThrow(/MY_SECRET/); + }); + + it("throws in production when the secret matches the dev fallback", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("MY_SECRET", "dev-fallback"); + const { requireSecret } = await import("./config.server.ts"); + expect(() => requireSecret("MY_SECRET", "dev-fallback")).toThrow(/dev fallback/); + }); +}); diff --git a/apps/journal/app/lib/config.server.ts b/apps/journal/app/lib/config.server.ts new file mode 100644 index 0000000..c4222bc --- /dev/null +++ b/apps/journal/app/lib/config.server.ts @@ -0,0 +1,35 @@ +// Centralized access to the canonical origin for this Journal instance. +// `ORIGIN` is set in production to the public HTTPS URL; in dev it falls +// back to http://localhost:3000. Use the helper everywhere so the default +// can be changed in one place if needed. +export function getOrigin(): string { + return process.env.ORIGIN ?? "http://localhost:3000"; +} + +/** + * Read a required secret from the environment. Returns the env value when + * set. In production, throws if the env var is missing or matches the + * known-public dev fallback — silently shipping a default secret to prod + * is a credential leak. In dev/test, returns the supplied fallback so the + * local loop keeps working without ceremony. + * + * Use this for any value where a leaked default would be a security + * incident: signing keys, session secrets, database credentials. + */ +export function requireSecret(name: string, devFallback: string): string { + const value = process.env[name]; + // Playwright runs `react-router serve` (NODE_ENV=production) against a + // local stack. E2E=true is the explicit opt-out so the guard still + // bites in real prod deploys. + const isProd = process.env.NODE_ENV === "production" && process.env.E2E !== "true"; + if (isProd) { + if (!value || value === devFallback) { + throw new Error( + `Refusing to start: ${name} is unset or matches the known-public dev fallback. ` + + `Set ${name} to a strong, unique value in production.`, + ); + } + return value; + } + return value ?? devFallback; +} diff --git a/apps/journal/app/lib/connected-services/credential-adapters/noop.ts b/apps/journal/app/lib/connected-services/credential-adapters/noop.ts new file mode 100644 index 0000000..f68bd26 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/noop.ts @@ -0,0 +1,11 @@ +// No-op CredentialAdapter for providers whose credentials never expire and +// cannot be refreshed (e.g. Komoot basic-auth, public-mode connections). + +import type { CredentialAdapter, Credentials } from "../types.ts"; + +export const noopCredentialAdapter: CredentialAdapter = { + isExpired: () => false, + async refresh(creds) { + return creds; + }, +}; diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts new file mode 100644 index 0000000..ae0b707 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { oauthCredentialAdapter } from "./oauth.ts"; +import { + NeedsRelinkError, + type OAuthCredentials, + type ProviderOAuthConfig, +} from "../types.ts"; + +const config: ProviderOAuthConfig = { + tokenUrl: "https://example.test/oauth/token", + clientId: "cid", + clientSecret: "secret", + revokeUrl: "https://example.test/v1/permissions", +}; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; +}); + +describe("oauthCredentialAdapter.isExpired", () => { + it("returns true when expires_at is in the past", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() - 1000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(true); + }); + + it("returns true within the refresh skew window (60s)", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 30_000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(true); + }); + + it("returns false when expires_at is comfortably in the future", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; + expect(oauthCredentialAdapter.isExpired(creds)).toBe(false); + }); +}); + +describe("oauthCredentialAdapter.refresh", () => { + const stale: OAuthCredentials = { + access_token: "old", + refresh_token: "rt", + expires_at: new Date(Date.now() - 1000).toISOString(), + }; + + it("posts refresh_token grant and returns new credentials", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const fresh = await oauthCredentialAdapter.refresh(stale, config); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(config.tokenUrl); + expect((init as RequestInit).method).toBe("POST"); + const body = (init as RequestInit).body as string; + expect(body).toContain("grant_type=refresh_token"); + expect(body).toContain("refresh_token=rt"); + expect(body).toContain("client_id=cid"); + expect(fresh.access_token).toBe("new-access"); + expect(fresh.refresh_token).toBe("new-refresh"); + expect(new Date(fresh.expires_at).getTime()).toBeGreaterThan(Date.now() + 3500_000); + }); + + it("retains the original refresh_token when the response omits one", async () => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ access_token: "new-access", expires_in: 3600 }), + { status: 200 }, + ), + ); + const fresh = await oauthCredentialAdapter.refresh(stale, config); + expect(fresh.refresh_token).toBe("rt"); + }); + + it("throws NeedsRelinkError on 4xx (revoked refresh token)", async () => { + fetchSpy.mockResolvedValue(new Response("invalid_grant", { status: 400 })); + await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.toBeInstanceOf( + NeedsRelinkError, + ); + }); + + it("throws a regular Error on 5xx (transient)", async () => { + fetchSpy.mockResolvedValue(new Response("server error", { status: 503 })); + await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.not.toBeInstanceOf( + NeedsRelinkError, + ); + }); +}); + +describe("oauthCredentialAdapter.revoke", () => { + const creds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date().toISOString(), + }; + + it("DELETEs the revoke URL with the access token", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); + await oauthCredentialAdapter.revoke!(creds, config); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(config.revokeUrl); + expect((init as RequestInit).method).toBe("DELETE"); + expect(((init as RequestInit).headers as Record).Authorization).toBe( + "Bearer a", + ); + }); + + it("swallows network errors silently", async () => { + fetchSpy.mockRejectedValue(new Error("network down")); + await expect(oauthCredentialAdapter.revoke!(creds, config)).resolves.toBeUndefined(); + }); + + it("is a no-op when no revokeUrl is configured", async () => { + await oauthCredentialAdapter.revoke!(creds, { ...config, revokeUrl: undefined }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts new file mode 100644 index 0000000..5e92dc3 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts @@ -0,0 +1,82 @@ +// OAuth2 CredentialAdapter. Implements the standard refresh_token + revoke +// flow against any provider whose token endpoint follows the OAuth2 RFC. +// +// Provider-specific URLs and client credentials come from the provider's +// manifest via ProviderOAuthConfig — Wahoo, future Strava/Garmin/Coros +// share this adapter and supply their own endpoints. +// +// The adapter does not write to the database; it returns refreshed +// credentials for ConnectedServiceManager to persist. + +import { + NeedsRelinkError, + type CredentialAdapter, + type OAuthCredentials, +} from "../types.ts"; + +// Refresh credentials slightly before they actually expire so a token in +// the middle of a long-running operation doesn't tip over mid-request. +const REFRESH_SKEW_MS = 60_000; + +function parseExpiresAt(iso: string): Date { + const d = new Date(iso); + if (isNaN(d.getTime())) { + throw new Error(`OAuth credentials have invalid expires_at: ${iso}`); + } + return d; +} + +export const oauthCredentialAdapter: CredentialAdapter = { + isExpired(creds) { + return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS; + }, + + async refresh(creds, providerConfig): Promise { + const resp = await fetch(providerConfig.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: providerConfig.clientId, + client_secret: providerConfig.clientSecret, + grant_type: "refresh_token", + refresh_token: creds.refresh_token, + }).toString(), + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + // 4xx on refresh = revoked / invalidated refresh token. Caller must re-link. + // 5xx = transient; surface as a regular Error so caller can retry the operation. + if (resp.status >= 400 && resp.status < 500) { + throw new NeedsRelinkError( + `OAuth refresh rejected (${resp.status}): ${text || "no body"}`, + ); + } + throw new Error(`OAuth refresh failed (${resp.status}): ${text}`); + } + + const data = (await resp.json()) as { + access_token: string; + refresh_token?: string; + expires_in: number; + }; + + return { + access_token: data.access_token, + // Some providers omit refresh_token on refresh (it stays the same). + refresh_token: data.refresh_token ?? creds.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }; + }, + + async revoke(creds, providerConfig) { + if (!providerConfig.revokeUrl) return; + // Best-effort. Caller deletes the local row regardless of outcome. + await fetch(providerConfig.revokeUrl, { + method: "DELETE", + headers: { Authorization: `Bearer ${creds.access_token}` }, + }).catch(() => { + // Swallow — local unlink proceeds. + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/fit.test.ts b/apps/journal/app/lib/connected-services/fit.test.ts new file mode 100644 index 0000000..317ceac --- /dev/null +++ b/apps/journal/app/lib/connected-services/fit.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock fit-file-parser to return whatever synthetic parsed object a test sets, +// so we exercise the converter's logic (segmentation, validation, sport) over +// the parser's OUTPUT shape without needing binary fixtures. `h.parsed` is the +// { records, events, sessions } object fit-file-parser would produce. +const h = vi.hoisted(() => ({ parsed: {} as Record })); +vi.mock("fit-file-parser", () => ({ + default: class { + parse(_buffer: unknown, cb: (err: unknown, data: unknown) => void) { + cb(null, h.parsed); + } + }, +})); + +const { fitToGpx } = await import("./fit.ts"); + +const buf = Buffer.from(""); +const trksegCount = (gpx: string) => (gpx.match(//g) ?? []).length; +const trkptCount = (gpx: string) => (gpx.match(/ = {}) => ({ + position_lat: 52.5 + secs * 1e-5, + position_long: 13.4 + secs * 1e-5, + altitude: 40 + secs, + timestamp: new Date(BASE + secs * 1000), + ...extra, +}); + +describe("fitToGpx — extraction & validation", () => { + it("converts GPS records to a GPX track", async () => { + h.parsed = { records: [rec(0), rec(10), rec(20)] }; + const { gpx } = await fitToGpx(buf, "ride"); + expect(gpx).not.toBeNull(); + expect(trksegCount(gpx!)).toBe(1); + expect(trkptCount(gpx!)).toBe(3); + expect(gpx!).toContain("52.5"); + }); + + it("returns null gpx for a file with no GPS records (indoor)", async () => { + h.parsed = { + records: [{ altitude: 40, timestamp: new Date(BASE) }, { altitude: 41, timestamp: new Date(BASE + 1000) }], + sessions: [{ sport: "cycling", sub_sport: "indoor_cycling" }], + }; + const { gpx, sport } = await fitToGpx(buf, "trainer"); + expect(gpx).toBeNull(); + expect(sport).toBe("ride"); // sport still surfaced + }); + + it("drops out-of-range coords and records without a timestamp", async () => { + h.parsed = { + records: [ + rec(0), + { position_lat: 999, position_long: 13.4, timestamp: new Date(BASE + 5000) }, // bad lat + { position_lat: 52.5, position_long: 13.4 }, // no timestamp + rec(10), + rec(20), + ], + }; + const { gpx } = await fitToGpx(buf, "r"); + expect(trkptCount(gpx!)).toBe(3); // only the 3 valid records + }); + + it("prefers enhanced_altitude and drops non-finite altitude", async () => { + h.parsed = { + records: [ + rec(0, { altitude: 40, enhanced_altitude: 100 }), + rec(10, { altitude: Infinity }), + rec(20, { altitude: 42 }), + ], + }; + const { gpx } = await fitToGpx(buf, "r"); + expect(gpx!).toContain("100"); // enhanced wins + expect(trkptCount(gpx!)).toBe(3); // non-finite altitude → point kept, no ele + // the middle point has no + expect((gpx!.match(//g) ?? []).length).toBe(2); + }); +}); + +describe("fitToGpx — segmentation", () => { + it("splits on a timer stop event (short pause, no gap)", async () => { + h.parsed = { + records: [rec(0), rec(10), rec(120), rec(130)], + events: [{ event: "timer", event_type: "stop_all", timestamp: new Date(BASE + 15_000) }], + }; + const { gpx } = await fitToGpx(buf, "r"); + expect(trksegCount(gpx!)).toBe(2); // split at the pause, not merged + }); + + it("splits on a record gap > 5 min when there are no events", async () => { + h.parsed = { records: [rec(0), rec(10), rec(400), rec(410)] }; // 390s gap between #2 and #3 + const { gpx } = await fitToGpx(buf, "r"); + expect(trksegCount(gpx!)).toBe(2); + }); + + it("keeps a single segment for a continuous ride", async () => { + h.parsed = { records: [rec(0), rec(10), rec(20), rec(30)] }; + const { gpx } = await fitToGpx(buf, "r"); + expect(trksegCount(gpx!)).toBe(1); + }); + + it("emits per-session segments for a multisport file (one activity)", async () => { + h.parsed = { + records: [rec(0), rec(10), rec(600), rec(610)], + sessions: [ + { start_time: new Date(BASE), total_elapsed_time: 60, sport: "running" }, + { start_time: new Date(BASE + 600_000), total_elapsed_time: 60, sport: "cycling" }, + ], + }; + const { gpx } = await fitToGpx(buf, "tri"); + expect(trksegCount(gpx!)).toBe(2); // one segment per session window + }); +}); + +describe("fitToGpx — sport mapping", () => { + const sportOf = async (sport?: string, sub_sport?: string) => { + h.parsed = { records: [rec(0), rec(10)], sessions: [{ sport, sub_sport }] }; + return (await fitToGpx(buf, "r")).sport; + }; + + it("maps base sports and sub-sport refinements", async () => { + expect(await sportOf("running")).toBe("run"); + expect(await sportOf("cycling")).toBe("ride"); + expect(await sportOf("cycling", "gravel_cycling")).toBe("gravel"); + expect(await sportOf("cycling", "mountain")).toBe("mtb"); + expect(await sportOf("running", "trail")).toBe("run"); + expect(await sportOf("hiking")).toBe("hike"); + expect(await sportOf("walking")).toBe("walk"); + expect(await sportOf("alpine_skiing")).toBe("ski"); + expect(await sportOf("swimming")).toBe("other"); + }); + + it("returns null for generic/absent sport (caller falls back to provider)", async () => { + expect(await sportOf("generic")).toBeNull(); + expect(await sportOf(undefined)).toBeNull(); + h.parsed = { records: [rec(0), rec(10)] }; // no sessions at all + expect((await fitToGpx(buf, "r")).sport).toBeNull(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/fit.ts b/apps/journal/app/lib/connected-services/fit.ts new file mode 100644 index 0000000..0a684a2 --- /dev/null +++ b/apps/journal/app/lib/connected-services/fit.ts @@ -0,0 +1,249 @@ +// Shared FIT file → GPX converter for provider importers. +// +// FIT is an open standard (Garmin/ANT+) used by Wahoo, Garmin, Coros, and +// others. This module is the single conversion seam for every provider that +// produces FIT files. +// +// Beyond flattening records to a track, it is pause- and session-aware: +// - timer stop/start events (or, as a fallback, record gaps > 5 min) split +// the output into multiple s, so downstream moving-time and speed +// math never bridges a pause; +// - multi-session (multisport) files emit one-or-more segments per session, +// records sliced to each session's time window (still one activity); +// - it prefers the corrected `enhanced_altitude`, validates coordinates, and +// surfaces the file's sport mapped to the Journal's SportType. +// +// Validation policy intentionally mirrors gpx-parser-robustness so both import +// formats behave identically downstream. + +import FitParser from "fit-file-parser"; +import { generateGpx, type TrackPoint } from "@trails-cool/gpx"; +import type { SportType } from "@trails-cool/db/schema/journal"; + +// A record-to-record timestamp gap larger than this starts a new segment in +// files without (reliable) timer events. Matches movingTime's MAX_GAP +// philosophy at a coarser grain. +const GAP_SPLIT_MS = 5 * 60 * 1000; + +// --- Raw fit-file-parser shapes (only the fields we consume) --- + +interface FitRecord { + position_lat?: number; // degrees (parser converts semicircles) + position_long?: number; + altitude?: number; + enhanced_altitude?: number; + timestamp?: string | Date; +} + +interface FitEvent { + event?: string; // "timer" + event_type?: string; // "stop_all" | "stop" | "start" + timestamp?: string | Date; +} + +interface FitSession { + start_time?: string | Date; + total_elapsed_time?: number; // seconds, wall-clock (includes pauses) + total_timer_time?: number; // seconds, moving + sport?: string; + sub_sport?: string; +} + +interface ParsedFit { + records?: FitRecord[]; + events?: FitEvent[]; + sessions?: FitSession[]; +} + +/** A record that passed validation, with its timestamp resolved to epoch ms. */ +interface ValidPoint { + lat: number; + lon: number; + ele?: number; + t: number; // epoch ms + time: string; // ISO 8601 +} + +export interface FitConversion { + /** GPX string, or null when the file has no usable GPS track. */ + gpx: string | null; + /** The file's sport mapped to a Journal SportType, or null if unknown. */ + sport: SportType | null; +} + +function parse(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + const parser = new FitParser({ force: true }); + // fit-file-parser's typing requires `Buffer` specifically; + // a generic Node `Buffer` is structurally `Buffer`. + // The runtime accepts either, so coerce the underlying buffer slot. + parser.parse(buffer as Buffer, (error, data) => { + if (error) reject(error); + else resolve((data ?? {}) as ParsedFit); + }); + }); +} + +function toMillis(value: string | Date | undefined): number | null { + if (value instanceof Date) { + const t = value.getTime(); + return Number.isFinite(t) ? t : null; + } + if (typeof value === "string") { + const t = Date.parse(value); + return Number.isFinite(t) ? t : null; + } + return null; +} + +/** + * Validate + normalize one record. Drops records with missing/non-finite/ + * out-of-range coordinates or no timestamp; prefers enhanced altitude; treats + * non-finite altitude as absent (point kept). Records without a position are + * dropped (indoor/no-GPS samples never become track points). + */ +function toValidPoint(r: FitRecord): ValidPoint | null { + const { position_lat: lat, position_long: lon } = r; + if (lat == null || lon == null) return null; + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + + const t = toMillis(r.timestamp); + if (t === null) return null; // FIT records are timestamped by spec; absence = corruption + + const rawEle = r.enhanced_altitude ?? r.altitude; + const ele = typeof rawEle === "number" && Number.isFinite(rawEle) ? rawEle : undefined; + + return { lat, lon, ele, t, time: new Date(t).toISOString() }; +} + +/** Split a session's ordered points at pauses (timer stops) and long gaps. */ +function splitByPausesAndGaps(points: ValidPoint[], stopTimes: number[]): ValidPoint[][] { + const segments: ValidPoint[][] = []; + let current: ValidPoint[] = []; + for (const p of points) { + if (current.length > 0) { + const prev = current[current.length - 1]!; + const gap = p.t - prev.t > GAP_SPLIT_MS; + const pausedBetween = stopTimes.some((ts) => ts >= prev.t && ts < p.t); + if (gap || pausedBetween) { + segments.push(current); + current = []; + } + } + current.push(p); + } + if (current.length > 0) segments.push(current); + return segments; +} + +/** `[start, end]` epoch-ms windows per session, sorted by start. */ +function sessionWindows(sessions: FitSession[]): Array<[number, number]> { + const starts = sessions + .map((s) => ({ + start: toMillis(s.start_time), + dur: s.total_elapsed_time ?? s.total_timer_time, + })) + .filter((s): s is { start: number; dur: number | undefined } => s.start !== null) + .sort((a, b) => a.start - b.start); + + return starts.map(({ start, dur }, i) => { + const end = + typeof dur === "number" && Number.isFinite(dur) + ? start + dur * 1000 + 1000 // +1s tolerance for rounding + : i + 1 < starts.length + ? starts[i + 1]!.start - 1 + : Number.POSITIVE_INFINITY; + return [start, end] as [number, number]; + }); +} + +function buildSegments( + points: ValidPoint[], + sessions: FitSession[], + events: FitEvent[], +): ValidPoint[][] { + const sorted = [...points].sort((a, b) => a.t - b.t); + + const stopTimes = events + .filter((e) => e.event === "timer" && (e.event_type === "stop_all" || e.event_type === "stop")) + .map((e) => toMillis(e.timestamp)) + .filter((t): t is number => t !== null); + + const windows = sessionWindows(sessions); + if (windows.length === 0) { + // No session info — one implicit session covering all points. + return splitByPausesAndGaps(sorted, stopTimes); + } + + const segments: ValidPoint[][] = []; + for (const [start, end] of windows) { + const inWindow = sorted.filter((p) => p.t >= start && p.t <= end); + for (const seg of splitByPausesAndGaps(inWindow, stopTimes)) segments.push(seg); + } + return segments; +} + +/** + * Map the file's session sport/sub-sport to a Journal SportType. First session + * wins (multisport still imports as one activity). Returns null when there's no + * session or the sport is generic/unknown, so callers can fall back to the + * provider's own type. + */ +function mapSport(sessions: FitSession[]): SportType | null { + const s = sessions[0]; + if (!s) return null; + const sport = String(s.sport ?? "").toLowerCase(); + const sub = String(s.sub_sport ?? "").toLowerCase(); + + // Sub-sport refinements take precedence over the base sport. + if (sub.includes("trail")) return "run"; + if (sub.includes("gravel")) return "gravel"; + if (sub.includes("mountain")) return "mtb"; + if (sub.includes("backcountry") || sub.includes("alpine")) return "ski"; + + switch (sport) { + case "running": + return "run"; + case "cycling": + return "ride"; + case "hiking": + case "mountaineering": + return "hike"; + case "walking": + return "walk"; + case "alpine_skiing": + case "cross_country_skiing": + case "backcountry_skiing": + case "skiing": + return "ski"; + case "": + case "generic": + return null; + default: + return "other"; + } +} + +export async function fitToGpx(buffer: Buffer, name: string): Promise { + const parsed = await parse(buffer); + const records = parsed.records ?? []; + const events = parsed.events ?? []; + const sessions = parsed.sessions ?? []; + + const sport = mapSport(sessions); + + const points = records + .map(toValidPoint) + .filter((p): p is ValidPoint => p !== null); + + if (points.length < 2) return { gpx: null, sport }; + + const tracks: TrackPoint[][] = buildSegments(points, sessions, events) + .map((seg) => seg.map((p): TrackPoint => ({ lat: p.lat, lon: p.lon, ele: p.ele, time: p.time }))) + // A lone point isn't a drawable track segment; GPX needs >= 2 per . + .filter((seg) => seg.length >= 2); + + if (tracks.length === 0) return { gpx: null, sport }; + return { gpx: generateGpx({ name, tracks }), sport }; +} diff --git a/apps/journal/app/lib/connected-services/index.ts b/apps/journal/app/lib/connected-services/index.ts new file mode 100644 index 0000000..a3bb78c --- /dev/null +++ b/apps/journal/app/lib/connected-services/index.ts @@ -0,0 +1,9 @@ +// Public entry point for the connected-services module. Importing from +// here guarantees provider manifests are registered before any caller +// looks them up. + +import "./providers/index.ts"; + +export * from "./manager.ts"; +export * from "./registry.ts"; +export * from "./types.ts"; diff --git a/apps/journal/app/lib/connected-services/manager.test.ts b/apps/journal/app/lib/connected-services/manager.test.ts new file mode 100644 index 0000000..e8e8d28 --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ConnectionNotActiveError, + NeedsRelinkError, + type CredentialAdapter, + type OAuthCredentials, + type ProviderOAuthConfig, +} from "./types.ts"; +import type { ProviderManifest } from "./registry.ts"; + +// ---- DB mock ---- +type Row = { + id: string; + userId: string; + provider: string; + credentialKind: string; + credentials: unknown; + status: string; + providerUserId: string | null; + grantedScopes: string[]; + createdAt: Date; +}; + +const rows: Row[] = []; + +const mockDb = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(rows.slice()), + }), + }), + insert: () => ({ + values: (v: Row) => { + rows.push(v); + return Promise.resolve(); + }, + }), + update: () => ({ + set: (patch: Partial) => ({ + where: (cond: unknown) => { + // The cond from drizzle is opaque to us; in this test we always + // patch the most recently-inserted row. + void cond; + const row = rows[rows.length - 1]; + if (row) Object.assign(row, patch); + return Promise.resolve(); + }, + }), + }), + delete: () => ({ + where: () => { + rows.length = 0; + return Promise.resolve(); + }, + }), +}; + +vi.mock("../db.ts", () => ({ getDb: () => mockDb })); + +// ---- Manifest / adapter stubs ---- +const refreshSpy = vi.fn(); +const revokeSpy = vi.fn(); +const isExpiredSpy = vi.fn(); + +const stubAdapter: CredentialAdapter = { + isExpired: (creds) => isExpiredSpy(creds), + refresh: (creds, cfg) => refreshSpy(creds, cfg), + revoke: (creds, cfg) => revokeSpy(creds, cfg), +}; + +const stubOauthConfig: ProviderOAuthConfig = { + tokenUrl: "https://example.test/oauth/token", + clientId: "cid", + clientSecret: "secret", + revokeUrl: "https://example.test/v1/permissions", +}; + +const stubManifest: ProviderManifest = { + id: "stub", + displayName: "Stub", + credentialKind: "oauth", + credentialAdapter: stubAdapter as CredentialAdapter, + oauthConfig: stubOauthConfig, +}; + +vi.mock("./registry.ts", async () => { + const actual = await vi.importActual("./registry.ts"); + return { + ...actual, + getManifest: (id: string) => (id === "stub" ? stubManifest : null), + }; +}); + +// Import after mocks are wired. +const { + link, + unlink, + unlinkByUserProvider, + markNeedsRelink, + withFreshCredentials, + getService, +} = await import("./manager.ts"); + +beforeEach(() => { + rows.length = 0; + refreshSpy.mockReset(); + revokeSpy.mockReset(); + isExpiredSpy.mockReset(); +}); + +describe("ConnectedServiceManager.link", () => { + it("inserts a row with the active status and supplied fields", async () => { + const svc = await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + providerUserId: "pu1", + grantedScopes: ["read"], + }); + expect(svc.userId).toBe("u1"); + expect(svc.provider).toBe("stub"); + expect(svc.status).toBe("active"); + expect(svc.grantedScopes).toEqual(["read"]); + expect(rows).toHaveLength(1); + }); +}); + +describe("ConnectedServiceManager.withFreshCredentials", () => { + const freshCreds: OAuthCredentials = { + access_token: "a", + refresh_token: "r", + expires_at: new Date(Date.now() + 3600_000).toISOString(), + }; + + async function seed(status: "active" | "needs_relink" | "revoked" = "active") { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: freshCreds, + }); + if (status !== "active") rows[0]!.status = status; + return rows[0]!.id; + } + + it("calls fn directly when credentials are not expired", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(false); + + const result = await withFreshCredentials(id, async (creds) => { + expect(creds).toEqual(freshCreds); + return "ok"; + }); + + expect(result).toBe("ok"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("refreshes credentials and persists new blob when expired", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(true); + const newCreds: OAuthCredentials = { + access_token: "a2", + refresh_token: "r2", + expires_at: new Date(Date.now() + 7200_000).toISOString(), + }; + refreshSpy.mockResolvedValue(newCreds); + + const got = await withFreshCredentials(id, async (creds) => creds as OAuthCredentials); + + expect(refreshSpy).toHaveBeenCalledWith(freshCreds, stubOauthConfig); + expect(got).toEqual(newCreds); + expect(rows[0]!.credentials).toEqual(newCreds); + }); + + it("flips status to needs_relink and re-throws when refresh raises NeedsRelinkError", async () => { + const id = await seed(); + isExpiredSpy.mockReturnValue(true); + refreshSpy.mockRejectedValue(new NeedsRelinkError("revoked")); + + await expect( + withFreshCredentials(id, async () => "never"), + ).rejects.toBeInstanceOf(NeedsRelinkError); + expect(rows[0]!.status).toBe("needs_relink"); + }); + + it("rejects with ConnectionNotActiveError when status is not active", async () => { + const id = await seed("needs_relink"); + isExpiredSpy.mockReturnValue(false); + + await expect( + withFreshCredentials(id, async () => "never"), + ).rejects.toBeInstanceOf(ConnectionNotActiveError); + expect(refreshSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ConnectedServiceManager.markNeedsRelink", () => { + it("flips the row's status", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + await markNeedsRelink(rows[0]!.id, "test"); + expect(rows[0]!.status).toBe("needs_relink"); + }); +}); + +describe("ConnectedServiceManager.unlink", () => { + it("calls the credential adapter's revoke before deletion", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + revokeSpy.mockResolvedValue(undefined); + await unlink(rows[0]?.id ?? "missing"); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it("swallows revoke failures and still deletes locally", async () => { + await link({ + userId: "u1", + provider: "stub", + credentialKind: "oauth", + credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() }, + }); + const id = rows[0]!.id; + revokeSpy.mockRejectedValue(new Error("provider down")); + await expect(unlink(id)).resolves.toBeUndefined(); + expect(rows).toHaveLength(0); + }); +}); + +// Reference unused symbols to keep the linter happy under strict noUnused rules. +void unlinkByUserProvider; +void getService; diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts new file mode 100644 index 0000000..ae95b42 --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -0,0 +1,256 @@ +// ConnectedServiceManager — owns credential lifecycle for every provider. +// +// Importers, route pushers, and webhook handlers obtain credentials +// EXCLUSIVELY through withFreshCredentials. They never read the +// `credentials` JSONB blob directly. +// +// See docs/adr/0001-0003 and CONTEXT.md (Connected Services). + +import { randomUUID } from "node:crypto"; +import { eq, and } from "drizzle-orm"; +import { connectedServices } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { + ConnectionNotActiveError, + NeedsRelinkError, + type ConnectedService, + type CredentialKind, + type Credentials, + type ConnectionStatus, +} from "./types.ts"; +import { getManifest, type CapabilityContext } from "./registry.ts"; + +// --------------------------------------------------------------------------- +// Row mapping +// --------------------------------------------------------------------------- + +type Row = typeof connectedServices.$inferSelect; + +function toModel(row: Row): ConnectedService { + return { + id: row.id, + userId: row.userId, + provider: row.provider, + credentialKind: row.credentialKind as CredentialKind, + credentials: row.credentials as Credentials, + status: row.status as ConnectionStatus, + providerUserId: row.providerUserId, + grantedScopes: row.grantedScopes, + createdAt: row.createdAt, + }; +} + +// --------------------------------------------------------------------------- +// Lookups +// --------------------------------------------------------------------------- + +export async function getService( + userId: string, + provider: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where( + and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)), + ); + return row ? toModel(row) : null; +} + +export async function getServiceById( + serviceId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where(eq(connectedServices.id, serviceId)); + return row ? toModel(row) : null; +} + +export async function getServiceByProviderUser( + provider: string, + providerUserId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select() + .from(connectedServices) + .where( + and( + eq(connectedServices.provider, provider), + eq(connectedServices.providerUserId, providerUserId), + ), + ); + return row ? toModel(row) : null; +} + +// --------------------------------------------------------------------------- +// Mutation: link / unlink / status +// --------------------------------------------------------------------------- + +export interface LinkInput { + userId: string; + provider: string; + credentialKind: CredentialKind; + credentials: Credentials; + providerUserId?: string | null; + grantedScopes?: string[]; +} + +// Upsert the (user_id, provider) row with fresh credentials. The DB-level +// unique constraint on (user_id, provider) guarantees at most one row per +// pair; we delete-then-insert to keep the row id stable per link, which +// also resets `created_at`. +export async function link(input: LinkInput): Promise { + const db = getDb(); + await db + .delete(connectedServices) + .where( + and( + eq(connectedServices.userId, input.userId), + eq(connectedServices.provider, input.provider), + ), + ); + const id = randomUUID(); + await db.insert(connectedServices).values({ + id, + userId: input.userId, + provider: input.provider, + credentialKind: input.credentialKind, + credentials: input.credentials, + status: "active", + providerUserId: input.providerUserId ?? null, + grantedScopes: input.grantedScopes ?? [], + }); + const row = await getServiceById(id); + if (!row) throw new Error("Failed to load just-linked service"); + return row; +} + +export async function unlink(serviceId: string): Promise { + const db = getDb(); + // Best-effort revoke at the provider before deleting locally. + const service = await getServiceById(serviceId); + if (service) { + const manifest = getManifest(service.provider); + if (manifest?.credentialAdapter.revoke && manifest.oauthConfig) { + await manifest.credentialAdapter + .revoke(service.credentials, manifest.oauthConfig) + .catch(() => { + // Swallow — local delete proceeds regardless. + }); + } + } + await db.delete(connectedServices).where(eq(connectedServices.id, serviceId)); +} + +export async function unlinkByUserProvider( + userId: string, + provider: string, +): Promise { + const service = await getService(userId, provider); + if (!service) return; + await unlink(service.id); +} + +export async function markNeedsRelink( + serviceId: string, + reason: string, +): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "needs_relink" }) + .where(eq(connectedServices.id, serviceId)); + // Reason is logged but not persisted yet — add a column if/when we surface it in UI. + // Bounded: `reason` can carry provider-side error text, so cap its length + // to avoid dumping a large/sensitive blob into logs. + const safeReason = reason.length > 200 ? `${reason.slice(0, 200)}…` : reason; + console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${safeReason}`); +} + +// Provider-side revocation (e.g. a Garmin deregistration notification): +// keep the row for audit, flip to 'revoked' so every subsequent +// withFreshCredentials short-circuits and the UI shows a re-connect +// prompt. Imported activities are untouched. +export async function markRevoked(serviceId: string): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "revoked" }) + .where(eq(connectedServices.id, serviceId)); +} + +export async function updateGrantedScopes( + serviceId: string, + grantedScopes: string[], +): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ grantedScopes }) + .where(eq(connectedServices.id, serviceId)); +} + +// --------------------------------------------------------------------------- +// withFreshCredentials — the chokepoint +// --------------------------------------------------------------------------- + +// Loads the connection, refreshes credentials if expired, calls fn with the +// fresh credentials. On a NeedsRelinkError from the adapter, flips the +// connection to needs_relink and re-throws so the caller can surface a +// re-link prompt. +// +// Capability adapters use this exclusively — they never read the +// credentials JSONB directly. +export async function withFreshCredentials( + serviceId: string, + fn: (credentials: unknown) => Promise, +): Promise { + const service = await getServiceById(serviceId); + if (!service) throw new Error(`Connected service ${serviceId} not found`); + if (service.status !== "active") { + throw new ConnectionNotActiveError(service.status); + } + + const manifest = getManifest(service.provider); + if (!manifest) { + throw new Error(`No manifest registered for provider ${service.provider}`); + } + const adapter = manifest.credentialAdapter; + + let creds = service.credentials; + if (adapter.isExpired(creds)) { + if (!manifest.oauthConfig && service.credentialKind === "oauth") { + throw new Error( + `Provider ${service.provider} has no oauthConfig; cannot refresh`, + ); + } + try { + creds = await adapter.refresh(creds, manifest.oauthConfig!); + const db = getDb(); + await db + .update(connectedServices) + .set({ credentials: creds }) + .where(eq(connectedServices.id, serviceId)); + } catch (err) { + if (err instanceof NeedsRelinkError) { + await markNeedsRelink(serviceId, err.reason); + } + throw err; + } + } + + return fn(creds); +} + +// Build a CapabilityContext bound to a service id. Capability adapters +// receive this from the route handler / job that invoked them. +export function capabilityContextFor(serviceId: string): CapabilityContext { + return { + serviceId, + withFreshCredentials: (fn) => withFreshCredentials(serviceId, fn), + }; +} diff --git a/apps/journal/app/lib/connected-services/oauth-flow.server.test.ts b/apps/journal/app/lib/connected-services/oauth-flow.server.test.ts new file mode 100644 index 0000000..f627ebb --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-flow.server.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createHash } from "node:crypto"; + +const link = vi.fn(); +vi.mock("./manager.ts", () => ({ + link: (...args: unknown[]) => link(...args), +})); +vi.mock("../config.server.ts", () => ({ + getOrigin: () => "https://journal.test", +})); + +import { initiateOAuthFlow, completeOAuthFlow } from "./oauth-flow.server.ts"; +import { decodeOAuthState } from "./oauth-state.server.ts"; +import type { ProviderManifest } from "./registry.ts"; + +function fakeManifest(overrides: Partial = {}): ProviderManifest { + return { + id: "fakeprov", + displayName: "Fake Provider", + credentialKind: "oauth", + credentialAdapter: { isExpired: () => false, refresh: vi.fn() }, + buildAuthUrl: vi.fn( + (redirectUri: string, state: string, extras?: { codeChallenge?: string }) => + `https://provider.test/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}` + + (extras?.codeChallenge ? `&code_challenge=${extras.codeChallenge}` : ""), + ), + exchangeCode: vi.fn(), + ...overrides, + } as unknown as ProviderManifest; +} + +function callbackRequest(params: Record, cookie?: string): Request { + const url = new URL("https://journal.test/api/sync/callback/fakeprov"); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return new Request(url, { headers: cookie ? { Cookie: cookie } : {} }); +} + +beforeEach(() => vi.clearAllMocks()); + +describe("initiateOAuthFlow", () => { + it("redirects to the provider with the intent in the state, no cookie for non-PKCE", () => { + const manifest = fakeManifest(); + const resp = initiateOAuthFlow(manifest, { returnTo: "/settings/connections" }); + + expect(resp.status).toBe(302); + const location = new URL(resp.headers.get("Location")!); + expect(location.origin).toBe("https://provider.test"); + expect(location.searchParams.get("redirect_uri")).toBe( + "https://journal.test/api/sync/callback/fakeprov", + ); + expect(decodeOAuthState(location.searchParams.get("state"))).toEqual({ + returnTo: "/settings/connections", + }); + expect(resp.headers.get("Set-Cookie")).toBeNull(); + }); + + it("PKCE: sets the verifier cookie and sends its S256 challenge", () => { + const manifest = fakeManifest({ pkce: true }); + const resp = initiateOAuthFlow(manifest, { pushAfter: { routeId: "r-1" }, returnTo: "/r" }); + + const cookie = resp.headers.get("Set-Cookie")!; + expect(cookie).toContain("__oauth_pkce="); + expect(cookie).toContain("HttpOnly"); + const verifier = cookie.match(/__oauth_pkce=([^;]+)/)![1]!; + + const location = new URL(resp.headers.get("Location")!); + const expectedChallenge = createHash("sha256").update(verifier).digest("base64url"); + expect(location.searchParams.get("code_challenge")).toBe(expectedChallenge); + // push intent rides the state even on the PKCE path + expect(decodeOAuthState(location.searchParams.get("state"))).toEqual({ + pushAfter: { routeId: "r-1" }, + returnTo: "/r", + }); + }); +}); + +describe("completeOAuthFlow", () => { + it("maps access_denied to denied with decoded state", async () => { + const manifest = fakeManifest(); + const state = new URL( + initiateOAuthFlow(manifest, { returnTo: "/x" }).headers.get("Location")!, + ).searchParams.get("state")!; + + const result = await completeOAuthFlow( + manifest, + callbackRequest({ error: "access_denied", state }), + "user-1", + ); + expect(result).toEqual({ status: "denied", state: { returnTo: "/x" } }); + expect(link).not.toHaveBeenCalled(); + }); + + it("returns missing_code when no code param", async () => { + const result = await completeOAuthFlow(fakeManifest(), callbackRequest({}), "user-1"); + expect(result.status).toBe("missing_code"); + }); + + it("PKCE: returns missing_verifier when the cookie is gone", async () => { + const result = await completeOAuthFlow( + fakeManifest({ pkce: true }), + callbackRequest({ code: "abc" }), + "user-1", + ); + expect(result.status).toBe("missing_verifier"); + }); + + it("exchanges the code and links the connection", async () => { + const manifest = fakeManifest(); + (manifest.exchangeCode as ReturnType).mockResolvedValue({ + credentials: { accessToken: "t" }, + providerUserId: "prov-9", + grantedScopes: ["routes_write"], + }); + + const result = await completeOAuthFlow(manifest, callbackRequest({ code: "abc" }), "user-1"); + + expect(result.status).toBe("linked"); + expect(manifest.exchangeCode).toHaveBeenCalledWith( + "abc", + "https://journal.test/api/sync/callback/fakeprov", + undefined, + ); + expect(link).toHaveBeenCalledWith({ + userId: "user-1", + provider: "fakeprov", + credentialKind: "oauth", + credentials: { accessToken: "t" }, + providerUserId: "prov-9", + grantedScopes: ["routes_write"], + }); + }); + + it("PKCE: passes the cookie verifier to exchangeCode", async () => { + const manifest = fakeManifest({ pkce: true }); + (manifest.exchangeCode as ReturnType).mockResolvedValue({ + credentials: {}, + providerUserId: "p", + grantedScopes: [], + }); + + await completeOAuthFlow( + manifest, + callbackRequest({ code: "abc" }, "__oauth_pkce=my-verifier"), + "user-1", + ); + expect(manifest.exchangeCode).toHaveBeenCalledWith( + "abc", + expect.any(String), + { codeVerifier: "my-verifier" }, + ); + }); + + it("maps exchange failures to error with the provider code", async () => { + const manifest = fakeManifest(); + (manifest.exchangeCode as ReturnType).mockRejectedValue( + Object.assign(new Error("nope"), { code: "rate_limited" }), + ); + + const result = await completeOAuthFlow(manifest, callbackRequest({ code: "x" }), "user-1"); + expect(result).toMatchObject({ status: "error", code: "rate_limited" }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/oauth-flow.server.ts b/apps/journal/app/lib/connected-services/oauth-flow.server.ts new file mode 100644 index 0000000..df67786 --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-flow.server.ts @@ -0,0 +1,128 @@ +// The OAuth connect → callback lifecycle as one module. Routes stay +// thin adapters: they look up the manifest, call initiate/complete, +// and map the result to redirects. Everything protocol-shaped — +// state encoding, PKCE pair generation, the verifier cookie's +// lifecycle, redirect-URI construction, code exchange, linking the +// connection — lives here, so the next OAuth provider (Coros, Strava) +// reuses the flow instead of the pattern. + +import { redirect } from "react-router"; +import { getOrigin } from "../config.server.ts"; +import { link } from "./manager.ts"; +import type { ProviderManifest } from "./registry.ts"; +import { + clearPkceCookieHeader, + decodeOAuthState, + encodeOAuthState, + generatePkcePair, + pkceCookieHeader, + readPkceVerifier, + type PushOAuthState, +} from "./oauth-state.server.ts"; + +function callbackUri(manifest: ProviderManifest): string { + return `${getOrigin()}/api/sync/callback/${manifest.id}`; +} + +/** + * Build the provider authorization redirect. Encodes post-callback + * intent into the state param and, for PKCE providers, sets the + * httpOnly verifier cookie — including when the flow is initiated + * from a push-resume (the old inline code only handled PKCE on the + * plain connect path). + * + * Caller must have verified `manifest.buildAuthUrl` exists. + */ +export function initiateOAuthFlow( + manifest: ProviderManifest, + intent: PushOAuthState, +): Response { + if (!manifest.buildAuthUrl) { + throw new Error(`Provider ${manifest.id} has no buildAuthUrl`); + } + const state = encodeOAuthState(intent); + const redirectUri = callbackUri(manifest); + + if (manifest.pkce) { + const { verifier, challenge } = generatePkcePair(); + return redirect(manifest.buildAuthUrl(redirectUri, state, { codeChallenge: challenge }), { + headers: { "Set-Cookie": pkceCookieHeader(verifier) }, + }); + } + return redirect(manifest.buildAuthUrl(redirectUri, state)); +} + +export type OAuthCompletion = + /** Provider sent the user back with access_denied. */ + | { status: "denied"; state: PushOAuthState } + /** No authorization code in the callback URL. */ + | { status: "missing_code"; state: PushOAuthState } + /** PKCE provider but the verifier cookie is gone (expired / cleared). */ + | { status: "missing_verifier"; state: PushOAuthState } + /** Code exchange or linking failed; `code` is provider-specific or "sync_failed". */ + | { status: "error"; code: string; state: PushOAuthState } + /** Connection linked. */ + | { status: "linked"; state: PushOAuthState }; + +/** + * Consume the provider callback: decode state, recover the PKCE + * verifier, exchange the code, and link the connection. Never throws — + * every outcome is a variant the route maps to a redirect. The spent + * verifier cookie should be cleared on whatever response the route + * returns (`clearPkceCookieHeader`). + */ +export async function completeOAuthFlow( + manifest: ProviderManifest, + request: Request, + userId: string, +): Promise { + if (!manifest.exchangeCode) { + throw new Error(`Provider ${manifest.id} has no exchangeCode`); + } + const url = new URL(request.url); + const state = decodeOAuthState(url.searchParams.get("state")); + + if (url.searchParams.get("error") === "access_denied") { + return { status: "denied", state }; + } + + const code = url.searchParams.get("code"); + if (!code) return { status: "missing_code", state }; + + const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null; + if (manifest.pkce && !codeVerifier) { + return { status: "missing_verifier", state }; + } + + try { + const exchange = await manifest.exchangeCode( + code, + callbackUri(manifest), + codeVerifier ? { codeVerifier } : undefined, + ); + await link({ + userId, + provider: manifest.id, + credentialKind: manifest.credentialKind, + credentials: exchange.credentials as Record, + providerUserId: exchange.providerUserId, + grantedScopes: exchange.grantedScopes, + }); + return { status: "linked", state }; + } catch (e) { + // Log only the error message, never the raw exception object — a + // provider's thrown error can embed the authorization code or token + // exchange response, which would then land in logs / Sentry. + console.error( + `OAuth callback failed for ${manifest.id}:`, + e instanceof Error ? e.message : String(e), + ); + const code = + typeof (e as { code?: string }).code === "string" + ? (e as { code: string }).code + : "sync_failed"; + return { status: "error", code, state }; + } +} + +export { clearPkceCookieHeader }; diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts new file mode 100644 index 0000000..ae0fb8a --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -0,0 +1,59 @@ +// OAuth state encoding for the connect/callback flow. The state param is +// reflected back to the callback unchanged, so we use it to carry +// post-callback intent (where to return to, whether a push should resume). + +export interface PushOAuthState { + pushAfter?: { routeId: string }; + returnTo?: string; +} + +export function encodeOAuthState(state: PushOAuthState): string { + return Buffer.from(JSON.stringify(state), "utf8").toString("base64url"); +} + +export function decodeOAuthState(raw: string | null | undefined): PushOAuthState { + if (!raw) return {}; + try { + const json = Buffer.from(raw, "base64url").toString("utf8"); + const parsed = JSON.parse(json) as PushOAuthState; + return typeof parsed === "object" && parsed != null ? parsed : {}; + } catch { + return {}; + } +} + +// --- PKCE (RFC 7636) ---------------------------------------------------- +// +// Providers with `pkce: true` (Garmin) need a code verifier that survives +// the connect → provider → callback redirect without ever appearing in a +// URL (the whole point of PKCE is that the verifier stays out of the +// authorization response). The `state` param is visible in redirects, so +// the verifier rides a short-lived httpOnly cookie scoped to the callback +// path instead. + +import { createHash, randomBytes } from "node:crypto"; + +const PKCE_COOKIE = "__oauth_pkce"; +const PKCE_MAX_AGE_S = 600; + +export function generatePkcePair(): { verifier: string; challenge: string } { + // 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128). + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function pkceCookieHeader(verifier: string): string { + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`; +} + +export function clearPkceCookieHeader(): string { + return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`; +} + +export function readPkceVerifier(request: Request): string | null { + const cookie = request.headers.get("Cookie") ?? ""; + const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} diff --git a/apps/journal/app/lib/connected-services/oauth-state.test.ts b/apps/journal/app/lib/connected-services/oauth-state.test.ts new file mode 100644 index 0000000..36d9f90 --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.test.ts @@ -0,0 +1,61 @@ +// PKCE helper tests (RFC 7636 S256) — spec: garmin-import, task 1.2. + +import { createHash } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { + generatePkcePair, + pkceCookieHeader, + readPkceVerifier, + clearPkceCookieHeader, + encodeOAuthState, + decodeOAuthState, +} from "./oauth-state.server.ts"; + +describe("generatePkcePair", () => { + it("produces an RFC 7636-compliant verifier and matching S256 challenge", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); // base64url charset + const expected = createHash("sha256").update(verifier).digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("is unique per call", () => { + expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier); + }); +}); + +describe("PKCE cookie round trip", () => { + it("verifier set on connect is readable on callback", () => { + const { verifier } = generatePkcePair(); + const setCookie = pkceCookieHeader(verifier); + // Browser reflects the cookie value back on the callback request. + const cookieValue = setCookie.split(";")[0]!; + const request = new Request("https://x.example/api/sync/callback/garmin", { + headers: { Cookie: `other=1; ${cookieValue}` }, + }); + expect(readPkceVerifier(request)).toBe(verifier); + }); + + it("returns null without the cookie; clear header expires it", () => { + const request = new Request("https://x.example/", { headers: { Cookie: "other=1" } }); + expect(readPkceVerifier(request)).toBeNull(); + expect(clearPkceCookieHeader()).toContain("Max-Age=0"); + }); + + it("cookie is httpOnly and scoped to the callback path", () => { + const header = pkceCookieHeader("v"); + expect(header).toContain("HttpOnly"); + expect(header).toContain("Path=/api/sync/callback"); + }); +}); + +describe("oauth state encoding (pre-existing behavior)", () => { + it("round-trips and tolerates garbage", () => { + const encoded = encodeOAuthState({ returnTo: "/settings/connections" }); + expect(decodeOAuthState(encoded)).toEqual({ returnTo: "/settings/connections" }); + expect(decodeOAuthState("%%%not-base64%%%")).toEqual({}); + expect(decodeOAuthState(null)).toEqual({}); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts new file mode 100644 index 0000000..9c1be9e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts @@ -0,0 +1,42 @@ +// Unit tests for Garmin backfill chunking + request fan-out +// (spec: garmin-import, "Historical import via backfill"). + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ withFreshCredentials: vi.fn() })); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { chunkRange, BACKFILL_CHUNK_MS } = await import("./backfill.ts"); + +const DAY = 24 * 60 * 60 * 1000; + +describe("chunkRange", () => { + it("returns a single chunk for ranges within the cap", () => { + const chunks = chunkRange(0, 30 * DAY); + expect(chunks).toEqual([{ fromMs: 0, toMs: 30 * DAY }]); + }); + + it("splits ranges larger than the cap, last chunk clamped", () => { + const chunks = chunkRange(0, 200 * DAY); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ fromMs: 0, toMs: BACKFILL_CHUNK_MS }); + expect(chunks[2]!.toMs).toBe(200 * DAY); + // contiguous, no gaps or overlaps + expect(chunks[1]!.fromMs).toBe(chunks[0]!.toMs); + expect(chunks[2]!.fromMs).toBe(chunks[1]!.toMs); + }); + + it("returns [] for empty or inverted ranges", () => { + expect(chunkRange(5, 5)).toEqual([]); + expect(chunkRange(10, 5)).toEqual([]); + }); + + it("covers exactly the requested range at chunk boundaries", () => { + const chunks = chunkRange(0, 2 * BACKFILL_CHUNK_MS); + expect(chunks).toHaveLength(2); + expect(chunks[1]).toEqual({ + fromMs: BACKFILL_CHUNK_MS, + toMs: 2 * BACKFILL_CHUNK_MS, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts new file mode 100644 index 0000000..1055a78 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts @@ -0,0 +1,111 @@ +// Garmin historical import via the Activity API backfill endpoint +// (spec: garmin-import, "Historical import via backfill"). +// +// Garmin has no list-activities endpoint: you ask for a time range and +// Garmin re-delivers those activities asynchronously through the same +// notification pipeline the live webhook uses. Each accepted request +// returns 202; the data arrives whenever Garmin gets to it. + +import { randomUUID } from "node:crypto"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { GARMIN_API } from "./constants.ts"; + +// Garmin caps a single backfill request's window. 90 days per the +// Activity API docs; if program onboarding reveals a different cap for +// our key, this constant is the only thing to change (design.md, open +// questions). +export const BACKFILL_CHUNK_MS = 90 * 24 * 60 * 60 * 1000; + +const BACKFILL_URL = `${GARMIN_API}/wellness-api/rest/backfill/activities`; + +/** + * Split [from, to] into Garmin-sized chunks (inclusive bounds, ms). + * Returns [] for empty/inverted ranges. + */ +export function chunkRange( + fromMs: number, + toMs: number, + chunkMs: number = BACKFILL_CHUNK_MS, +): Array<{ fromMs: number; toMs: number }> { + if (!(fromMs < toMs) || chunkMs <= 0) return []; + const chunks: Array<{ fromMs: number; toMs: number }> = []; + for (let start = fromMs; start < toMs; start += chunkMs) { + chunks.push({ fromMs: start, toMs: Math.min(start + chunkMs, toMs) }); + } + return chunks; +} + +export interface BackfillDeps { + requestChunk( + serviceId: string, + fromSec: number, + toSec: number, + ): Promise; +} + +function defaultDeps(): BackfillDeps { + return { + async requestChunk(serviceId, fromSec, toSec) { + await withFreshCredentials(serviceId, async (credentials) => { + const creds = credentials as OAuthCredentials; + const url = `${BACKFILL_URL}?summaryStartTimeInSeconds=${fromSec}&summaryEndTimeInSeconds=${toSec}`; + const resp = await fetchWithTimeout(url, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + // 202 = accepted. 409 = an identical/overlapping request is + // already in flight — fine, the data will arrive either way + // and sync_imports dedupes. + if (!resp.ok && resp.status !== 409) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin backfill request failed: ${resp.status} ${text}`); + } + }); + }, + }; +} + +/** + * Issue backfill requests covering [from, to] and persist one + * import_batches row describing the whole request (progress UX reads + * it back on the import page). + */ +export async function requestBackfill( + service: { id: string; userId: string }, + from: Date, + to: Date, + deps: BackfillDeps = defaultDeps(), +): Promise<{ batchId: string; chunks: number }> { + const chunks = chunkRange(from.getTime(), to.getTime()); + if (chunks.length === 0) throw new Error("Empty backfill range"); + + for (const chunk of chunks) { + await deps.requestChunk( + service.id, + Math.floor(chunk.fromMs / 1000), + Math.floor(chunk.toMs / 1000), + ); + } + + // Record the request for the import page. Lazy import keeps the DB + // out of this module's graph for pure-function tests (chunkRange). + const { getDb } = await import("../../../db.ts"); + const { importBatches } = await import("@trails-cool/db/schema/journal"); + const batchId = randomUUID(); + await getDb() + .insert(importBatches) + .values({ + id: batchId, + userId: service.userId, + connectionId: service.id, + provider: "garmin", + // Garmin delivers asynchronously — the batch is "running" from + // our perspective until the operator-facing page stops caring. + // totalFound is unknowable up front (no list endpoint). + status: "running", + rangeStart: from, + rangeEnd: to, + }); + return { batchId, chunks: chunks.length }; +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/constants.ts b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts new file mode 100644 index 0000000..955a191 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts @@ -0,0 +1,8 @@ +// Garmin endpoint constants — own module so manifest.ts, webhook.ts, +// import.server.ts, and backfill.ts can all use them without forming +// an import cycle (manifest → webhook → import.server must never loop +// back into manifest for a value needed at module-eval time). + +export const GARMIN_API = "https://apis.garmin.com"; +export const GARMIN_AUTHORIZE = "https://connect.garmin.com/oauth2Confirm"; +export const GARMIN_TOKEN = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts new file mode 100644 index 0000000..0eff891 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts @@ -0,0 +1,87 @@ +// Garmin activity import — the slow half of the webhook pipeline. +// Runs inside the `garmin-import-activity` pg-boss job: download the +// activity file from the notification's callback URL (Authorized via +// the user's token), convert to GPX, create the activity, record the +// dedupe row. Stats-only when there is no file. + +import { fitToGpx, type FitConversion } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { logger } from "../../../logger.server.ts"; +import { GARMIN_API } from "./constants.ts"; + +// SSRF guard: notification callback URLs are attacker-controllable +// input until proven otherwise — only Garmin's API host is fetchable. +const ALLOWED_CALLBACK_HOSTS = new Set([new URL(GARMIN_API).host]); + +export function isAllowedGarminCallback(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && ALLOWED_CALLBACK_HOSTS.has(parsed.host); + } catch { + return false; + } +} + +export interface GarminImportData { + serviceId: string; + userId: string; + externalId: string; + callbackUrl: string | null; + fileType: string | null; + name: string | null; + startedAt: string | null; + duration: number | null; + distance: number | null; +} + +export async function runGarminActivityImport(data: GarminImportData): Promise { + // Connection may have been revoked/relinked between enqueue and run. + const service = await getServiceById(data.serviceId); + if (!service || service.status !== "active") { + logger.info({ serviceId: data.serviceId }, "garmin import: connection not active — skipped"); + return; + } + + if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return; + + let gpx: string | undefined; + let sport: FitConversion["sport"] = null; + if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) { + const buffer = await withFreshCredentials(service.id, async (credentials) => { + const creds = credentials as OAuthCredentials; + const resp = await fetchWithTimeout(data.callbackUrl!, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) { + throw new Error(`Garmin file download failed: ${resp.status}`); + } + return Buffer.from(await resp.arrayBuffer()); + }); + if (data.fileType === "GPX") { + // Garmin can serve GPX directly; createActivity validates it. + gpx = buffer.toString("utf8"); + } else { + // FIT (default) — shared provider-agnostic converter. + const conversion = await fitToGpx(buffer, data.name ?? "Garmin activity"); + gpx = conversion.gpx ?? undefined; + sport = conversion.sport; + } + } + + await importActivity(data.userId, "garmin", data.externalId, { + name: data.name ?? "Garmin activity", + gpx, + distance: data.distance, + duration: data.duration, + startedAt: data.startedAt ? new Date(data.startedAt) : null, + // FIT session sport (GPX imports carry none); provider-explicit would win. + sportType: sport ?? undefined, + }); + logger.info( + { externalId: data.externalId, hadFile: !!gpx }, + "garmin import: activity imported", + ); +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts new file mode 100644 index 0000000..531f181 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts @@ -0,0 +1,66 @@ +// Manifest contract tests (spec: garmin-import, "Connect Garmin +// account" + PKCE parameters + env gating). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: vi.fn(), + markRevoked: vi.fn(), + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ enqueueOptional: vi.fn() })); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { garminManifest } = await import("./manifest.ts"); + +const ENV_KEYS = ["GARMIN_CLIENT_ID", "GARMIN_CLIENT_SECRET"] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("garminManifest", () => { + it("declares oauth + PKCE, no pick-list importer, custom import page", () => { + expect(garminManifest.id).toBe("garmin"); + expect(garminManifest.credentialKind).toBe("oauth"); + expect(garminManifest.pkce).toBe(true); + expect(garminManifest.importer).toBeUndefined(); + expect(garminManifest.importUrl).toBe("/sync/import/garmin"); + expect(garminManifest.webhookReceiver).toBeDefined(); + }); + + it("is hidden without instance credentials, shown with them", () => { + delete process.env.GARMIN_CLIENT_ID; + expect(garminManifest.configured!()).toBe(false); + process.env.GARMIN_CLIENT_ID = "test-client"; + expect(garminManifest.configured!()).toBe(true); + }); + + it("buildAuthUrl carries the S256 code challenge", () => { + process.env.GARMIN_CLIENT_ID = "test-client"; + const url = new URL( + garminManifest.buildAuthUrl!( + "https://journal.example/api/sync/callback/garmin", + "state-123", + { codeChallenge: "challenge-abc" }, + ), + ); + expect(url.origin + url.pathname).toBe("https://connect.garmin.com/oauth2Confirm"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-abc"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-123"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts new file mode 100644 index 0000000..c9a9398 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts @@ -0,0 +1,120 @@ +// Garmin provider manifest (spec: garmin-import). OAuth2 + PKCE on the +// shared oauth credential adapter — PKCE is a handshake detail (see +// design.md), the stored blob is plain OAuthCredentials. +// +// Garmin is push-first: there is no list-activities endpoint, so this +// manifest declares no `importer`. Ingestion happens via the webhook +// receiver (ping/push notifications) and history via backfill requests +// (see backfill.ts + the /sync/import/garmin page). +// +// Endpoint references: Garmin Connect Developer Program, Activity API. +// Exact notification shapes are normalized tolerantly in webhook.ts — +// Garmin's docs shift between API versions (design.md, Risks). + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { garminWebhook } from "./webhook.ts"; +import { GARMIN_API, GARMIN_AUTHORIZE, GARMIN_TOKEN } from "./constants.ts"; + +function clientId(): string { + return process.env.GARMIN_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.GARMIN_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return GARMIN_TOKEN; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, +}; + +export const garminManifest: ProviderManifest = { + id: "garmin", + displayName: "Garmin", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + pkce: true, + // No instance credentials → no Garmin row on the connections page. + // (Garmin program keys are per-operator; self-hosted instances + // without one must not render a dead Connect button.) + configured: () => clientId().length > 0, + // Backfill requester, not a pick list — Garmin has no list endpoint. + importUrl: "/sync/import/garmin", + + buildAuthUrl(redirectUri, state, extras): string { + const params = new URLSearchParams({ + client_id: clientId(), + response_type: "code", + redirect_uri: redirectUri, + state, + }); + if (extras?.codeChallenge) { + params.set("code_challenge", extras.codeChallenge); + params.set("code_challenge_method", "S256"); + } + return `${GARMIN_AUTHORIZE}?${params}`; + }, + + async exchangeCode( + code, + redirectUri, + extras, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(GARMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId(), + client_secret: clientSecret(), + code, + code_verifier: extras?.codeVerifier ?? "", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin token exchange failed: ${resp.status} ${text}`); + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + + // Garmin user id — needed to route webhook notifications to the + // right local user. + const userResp = await fetch(`${GARMIN_API}/wellness-api/rest/user/id`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { userId?: string }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.userId ?? null, + grantedScopes: data.scope ? data.scope.split(" ") : [], + }; + }, + + webhookReceiver: garminWebhook, +}; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts new file mode 100644 index 0000000..c360717 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts @@ -0,0 +1,194 @@ +// Contract tests for the Garmin WebhookReceiver (spec: garmin-import). +// +// Seam: parseWebhook(body) -> WebhookEvent[] (Garmin batches notifications) +// handle(event) -> void (enqueues the import job; deregistration +// revokes; SSRF-suspicious callback URLs are dropped) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockGetServiceByProviderUser = vi.fn(); +const mockMarkRevoked = vi.fn(); +const mockEnqueueOptional = vi.fn(); + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + markRevoked: mockMarkRevoked, + // imported transitively via import.server.ts + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ + enqueueOptional: mockEnqueueOptional, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +beforeEach(() => { + mockGetServiceByProviderUser.mockReset(); + mockMarkRevoked.mockReset(); + mockEnqueueOptional.mockReset(); +}); + +const { garminWebhook } = await import("./webhook.ts"); +const { isAllowedGarminCallback } = await import("./import.server.ts"); + +describe("garminWebhook.parseWebhook", () => { + it("parses a ping-style activityFiles batch into file events", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { + userId: "g-user-1", + summaryId: "s-1", + activityId: 1001, + activityName: "Morning Run", + callbackURL: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + startTimeInSeconds: 1780000000, + durationInSeconds: 3600, + distanceInMeters: 10000, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: expect.stringContaining("apis.garmin.com"), + name: "Morning Run", + duration: 3600, + distance: 10000, + fileType: "FIT", + }); + }); + + it("parses push-style activity summaries (FIT-less, stats-only path)", () => { + const events = garminWebhook.parseWebhook({ + activities: [ + { + userId: "g-user-1", + summaryId: "s-2", + activityId: 1002, + activityName: "Indoor Row", + durationInSeconds: 1800, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-summary", + workoutId: "1002", + duration: 1800, + }); + expect(events[0]!.fileUrl).toBeUndefined(); + }); + + it("parses deregistrations", () => { + const events = garminWebhook.parseWebhook({ + deregistrations: [{ userId: "g-user-1" }], + }); + expect(events).toEqual([ + { + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }, + ]); + }); + + it("handles mixed batches and skips malformed entries", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { userId: "u1", activityId: 1 }, + { activityId: 2 }, // no userId — skipped + { userId: "u3" }, // no id — skipped + ], + deregistrations: [{}, { userId: "u4" }], + somethingGarminAddedLater: [{ userId: "u5" }], + }); + expect(events.map((e) => e.eventType)).toEqual([ + "garmin:activity-file", + "garmin:deregistration", + ]); + }); + + it("returns no events for non-object bodies", () => { + expect(garminWebhook.parseWebhook(null)).toEqual([]); + expect(garminWebhook.parseWebhook("x")).toEqual([]); + }); +}); + +describe("garminWebhook.handle", () => { + const service = { id: "svc-g1", userId: "u1", provider: "garmin" }; + + it("enqueues the import job for a known user's file event", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + name: "Morning Run", + }); + + expect(mockEnqueueOptional).toHaveBeenCalledWith( + "garmin-import-activity", + expect.objectContaining({ + serviceId: "svc-g1", + userId: "u1", + externalId: "1001", + callbackUrl: expect.stringContaining("apis.garmin.com"), + fileType: "FIT", + }), + expect.anything(), + ); + }); + + it("silently skips unknown users (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "nobody", + workoutId: "1", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + expect(mockMarkRevoked).not.toHaveBeenCalled(); + }); + + it("drops events whose callback URL is not Garmin's API host (SSRF guard)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://attacker.example/steal?token=", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); + + it("revokes the connection on deregistration", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }); + expect(mockMarkRevoked).toHaveBeenCalledWith("svc-g1"); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); +}); + +describe("isAllowedGarminCallback", () => { + it("allows only https URLs on Garmin's API host", () => { + expect(isAllowedGarminCallback("https://apis.garmin.com/wellness-api/rest/x")).toBe(true); + expect(isAllowedGarminCallback("http://apis.garmin.com/x")).toBe(false); + expect(isAllowedGarminCallback("https://apis.garmin.com.evil.example/x")).toBe(false); + expect(isAllowedGarminCallback("https://evil.example/apis.garmin.com")).toBe(false); + expect(isAllowedGarminCallback("not a url")).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts new file mode 100644 index 0000000..aded436 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts @@ -0,0 +1,154 @@ +// Garmin WebhookReceiver capability adapter (spec: garmin-import, +// "Push-notification activity import" + "Deregistration handling"). +// +// Garmin POSTs notifications to /api/sync/webhook/garmin in batches: +// - `activityFiles`: ping-style entries with a callbackURL to a FIT/GPX +// file (the main import path) +// - `activities` / `activityDetails`: summary entries (stats; used for +// FIT-less activities) +// - `deregistrations`: the user revoked access on Garmin's side +// +// The webhook must answer fast (Garmin retries and throttles slow +// consumers), so `handle` only validates + enqueues; the download and +// FIT→GPX conversion happen in the `garmin-import-activity` pg-boss job +// (same lesson as federation's inbox: never do slow work in an inbound +// hook). Deregistrations are the exception — a single UPDATE is cheap +// and must not be lost to a queue hiccup. +// +// Notification shapes are under-documented and drift between Garmin API +// versions, so parsing is tolerant: unknown keys are ignored, malformed +// entries are skipped, and nothing here ever throws on bad input. + +import { logger } from "../../../logger.server.ts"; +import { enqueueOptional } from "../../../boss.server.ts"; +import { getServiceByProviderUser, markRevoked } from "../../manager.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; +import { isAllowedGarminCallback } from "./import.server.ts"; + +interface GarminNotificationEntry { + userId?: string; + summaryId?: string | number; + activityId?: string | number; + activityName?: string; + activityType?: string; + callbackURL?: string; + fileType?: string; + startTimeInSeconds?: number; + durationInSeconds?: number; + distanceInMeters?: number; +} + +interface GarminWebhookBody { + activityFiles?: GarminNotificationEntry[]; + activities?: GarminNotificationEntry[]; + activityDetails?: GarminNotificationEntry[]; + deregistrations?: { userId?: string }[]; +} + +const EVENT_FILE = "garmin:activity-file"; +const EVENT_SUMMARY = "garmin:activity-summary"; +const EVENT_DEREGISTRATION = "garmin:deregistration"; + +function externalId(entry: GarminNotificationEntry): string { + const id = entry.activityId ?? entry.summaryId; + return id == null ? "" : String(id); +} + +function entryToEvent( + entry: GarminNotificationEntry, + eventType: string, +): WebhookEvent | null { + if (!entry.userId || !externalId(entry)) return null; + return { + eventType, + providerUserId: entry.userId, + workoutId: externalId(entry), + fileUrl: entry.callbackURL, + name: entry.activityName, + startedAt: + entry.startTimeInSeconds != null + ? new Date(entry.startTimeInSeconds * 1000).toISOString() + : undefined, + duration: entry.durationInSeconds ?? null, + distance: entry.distanceInMeters ?? null, + fileType: entry.fileType, + }; +} + +export const garminWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent[] { + if (typeof body !== "object" || body === null) return []; + const payload = body as GarminWebhookBody; + const events: WebhookEvent[] = []; + + for (const entry of payload.activityFiles ?? []) { + const event = entryToEvent(entry, EVENT_FILE); + if (event) events.push(event); + } + // Summaries cover FIT-less activities (stats-only import). A FIT + // notification for the same activityId wins via sync_imports dedupe + // ordering being first-come — both paths import once. + for (const entry of [...(payload.activities ?? []), ...(payload.activityDetails ?? [])]) { + const event = entryToEvent(entry, EVENT_SUMMARY); + if (event) events.push(event); + } + for (const dereg of payload.deregistrations ?? []) { + if (dereg.userId) { + events.push({ + eventType: EVENT_DEREGISTRATION, + providerUserId: dereg.userId, + workoutId: "", + }); + } + } + return events; + }, + + async handle(event: WebhookEvent): Promise { + // Unknown users: silent 200 — never reveal user existence. + const service = await getServiceByProviderUser("garmin", event.providerUserId); + if (!service) return; + + if (event.eventType === EVENT_DEREGISTRATION) { + // Spec: provider-side revocation keeps the row (audit) but stops + // every subsequent Garmin call for this user. + await markRevoked(service.id); + logger.info({ serviceId: service.id }, "garmin: deregistration — connection revoked"); + return; + } + + // SSRF guard: a callback URL that doesn't point at Garmin's API is + // dropped here, before it can ever be fetched (design.md, Risks). + if (event.fileUrl && !isAllowedGarminCallback(event.fileUrl)) { + logger.warn( + { host: safeHost(event.fileUrl) }, + "garmin: notification callback URL not on the Garmin allowlist — dropped", + ); + return; + } + + await enqueueOptional( + "garmin-import-activity", + { + serviceId: service.id, + userId: service.userId, + externalId: event.workoutId, + callbackUrl: event.fileUrl ?? null, + fileType: event.fileType ?? (event.eventType === EVENT_FILE ? "FIT" : null), + name: event.name ?? null, + startedAt: event.startedAt ?? null, + duration: event.duration ?? null, + distance: event.distance ?? null, + }, + { reason: "garmin webhook notification" }, + ); + }, +}; + +function safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts new file mode 100644 index 0000000..ba54534 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -0,0 +1,15 @@ +// Provider barrel. Imports every provider manifest and registers it with +// the registry. Adding a provider: import its manifest here and add the +// `registerManifest(...)` call. + +import { registerManifest } from "../registry.ts"; +import { wahooManifest } from "./wahoo/manifest.ts"; +import { komootManifest } from "./komoot/manifest.ts"; +import { garminManifest } from "./garmin/manifest.ts"; + +registerManifest(wahooManifest); +registerManifest(komootManifest); +registerManifest(garminManifest); + +// Re-export so callers (mostly tests) can grab a manifest directly. +export { wahooManifest, komootManifest, garminManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts b/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts new file mode 100644 index 0000000..5292962 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock server-only deps before import +vi.mock("../../../crypto.server.ts", () => ({ + decrypt: vi.fn((s: string) => `decrypted:${s}`), + encrypt: vi.fn((s: string) => `encrypted:${s}`), +})); + +vi.mock("../../../komoot.server.ts", () => ({ + fetchKomootTours: vi.fn(), + fetchKomootTourGpx: vi.fn(), +})); + +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), + recordImport: vi.fn(), +})); + +vi.mock("../../manager.ts", () => ({ + getServiceById: vi.fn(), +})); + +import { komootImporter } from "./importer.ts"; +import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById } from "../../manager.ts"; + +const mockFetchTours = vi.mocked(fetchKomootTours); +const mockFetchGpx = vi.mocked(fetchKomootTourGpx); +const mockIsAlreadyImported = vi.mocked(isAlreadyImported); +const mockImportActivity = vi.mocked(importActivity); +const mockGetServiceById = vi.mocked(getServiceById); + +function makeCtx(creds: unknown, serviceId = "svc-1") { + return { + serviceId, + withFreshCredentials: async (fn: (c: unknown) => Promise) => fn(creds), + }; +} + +describe("komootImporter.listImportable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists public tours without auth token", async () => { + mockFetchTours.mockResolvedValueOnce({ + tours: [ + { id: "111", name: "Morning ride", sport: "bike", date: "2024-01-01T00:00:00Z", distance: 30000, duration: 5400, elevationUp: 200, elevationDown: 190 }, + ], + totalPages: 1, + }); + + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.listImportable(ctx, 1); + + expect(mockFetchTours).toHaveBeenCalledWith("999", 1, undefined); + expect(result.workouts).toHaveLength(1); + expect(result.workouts[0]!.id).toBe("111"); + }); + + it("passes basic auth token for authenticated mode", async () => { + mockFetchTours.mockResolvedValueOnce({ tours: [], totalPages: 1 }); + + const ctx = makeCtx({ + mode: "authenticated", + email: "test@example.com", + encryptedPassword: "enc-pw", + komootUserId: "888", + }); + await komootImporter.listImportable(ctx, 1); + + // decrypt returns `decrypted:enc-pw`, so basic token = base64(test@example.com:decrypted:enc-pw) + const expectedToken = Buffer.from("test@example.com:decrypted:enc-pw").toString("base64"); + expect(mockFetchTours).toHaveBeenCalledWith("888", 1, expectedToken); + }); +}); + +describe("komootImporter.importOne", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServiceById.mockResolvedValue({ + id: "svc-1", + userId: "user-1", + provider: "komoot", + credentialKind: "public", + credentials: { mode: "public", komootUserId: "999" }, + status: "active", + providerUserId: "999", + grantedScopes: [], + createdAt: new Date(), + }); + mockIsAlreadyImported.mockResolvedValue(false); + mockImportActivity.mockResolvedValue({ activityId: "act-123" }); + mockFetchTours.mockResolvedValue({ tours: [{ id: "tour-1", name: "Test Tour", sport: "hike", date: "2024-01-01T00:00:00Z", distance: 10000, duration: 3600, elevationUp: 100, elevationDown: 100 }], totalPages: 1 }); + mockFetchGpx.mockResolvedValue("..."); + }); + + it("imports a tour with GPX", async () => { + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.importOne(ctx, "tour-1"); + + expect(result.activityId).toBe("act-123"); + expect(result.hadGeometry).toBe(true); + expect(mockImportActivity).toHaveBeenCalledWith("user-1", "komoot", "tour-1", { + name: "Test Tour", + gpx: "...", + }); + }); + + it("throws when tour is already imported", async () => { + mockIsAlreadyImported.mockResolvedValue(true); + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + await expect(komootImporter.importOne(ctx, "tour-1")).rejects.toThrow("already imported"); + }); + + it("imports without geometry when GPX fetch fails", async () => { + mockFetchGpx.mockRejectedValue(new Error("404")); + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.importOne(ctx, "tour-1"); + expect(result.hadGeometry).toBe(false); + expect(mockImportActivity).toHaveBeenCalledWith("user-1", "komoot", "tour-1", { + name: "Test Tour", + gpx: undefined, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/komoot/importer.ts b/apps/journal/app/lib/connected-services/providers/komoot/importer.ts new file mode 100644 index 0000000..0d89f42 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/importer.ts @@ -0,0 +1,97 @@ +// Komoot Importer capability adapter. Implements the Importer seam against +// the Komoot tours API. +// +// Two credential modes: +// public — unauthenticated, public tours only +// authenticated — email/password (password AES-256-GCM encrypted at rest) + +import { decrypt } from "../../../crypto.server.ts"; +import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById } from "../../manager.ts"; +import type { + CapabilityContext, + ImportableList, + ImportResult, + Importer, +} from "../../registry.ts"; + +type KomootCreds = + | { mode: "public"; komootUserId: string } + | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; + +function getBasicAuthToken(creds: KomootCreds): string | undefined { + if (creds.mode !== "authenticated") return undefined; + const password = decrypt(creds.encryptedPassword); + return Buffer.from(`${creds.email}:${password}`).toString("base64"); +} + +export const komootImporter: Importer = { + async listImportable(ctx: CapabilityContext, page: number): Promise { + return ctx.withFreshCredentials(async (rawCreds) => { + const creds = rawCreds as KomootCreds; + const basicAuthToken = getBasicAuthToken(creds); + let result: Awaited>; + try { + result = await fetchKomootTours(creds.komootUserId, page, basicAuthToken); + } catch { + // Komoot API unavailable or user not found — show empty list + return { workouts: [], total: 0, page, perPage: 50 }; + } + return { + workouts: result.tours.map((t) => ({ + id: t.id, + name: t.name, + type: t.sport, + startedAt: t.date, + duration: t.duration > 0 ? t.duration : null, + distance: t.distance > 0 ? t.distance : null, + // fileUrl not used for Komoot — GPX is fetched directly in the import route + })), + total: result.tours.length * result.totalPages, + page, + perPage: 50, + }; + }); + }, + + async importOne(ctx: CapabilityContext, tourId: string): Promise { + return ctx.withFreshCredentials(async (rawCreds) => { + const creds = rawCreds as KomootCreds; + + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const userId = service.userId; + if (await isAlreadyImported(userId, "komoot", tourId)) { + throw new Error(`Tour ${tourId} already imported`); + } + + const basicAuthToken = getBasicAuthToken(creds); + + let gpx: string | undefined; + try { + gpx = await fetchKomootTourGpx(tourId, basicAuthToken); + } catch { + // GPX unavailable — import activity without geometry + } + + // Fetch first page to find tour name; fall back to generic name + let tourName = `Komoot tour ${tourId}`; + try { + const result = await fetchKomootTours(creds.komootUserId, 1, basicAuthToken); + const tour = result.tours.find((t) => t.id === tourId); + if (tour) tourName = tour.name; + } catch { + // Ignore — use fallback name + } + + const { activityId } = await importActivity(userId, "komoot", tourId, { + name: tourName, + gpx, + }); + + return { activityId, hadGeometry: !!gpx }; + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts b/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts new file mode 100644 index 0000000..dd142e8 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts @@ -0,0 +1,20 @@ +// Komoot provider manifest. +// +// Komoot supports two credential modes: +// public — unauthenticated ownership verification via bio link +// authenticated — email + AES-256-GCM encrypted password +// +// Neither mode uses OAuth; credentials are managed via a custom connect page. + +import { noopCredentialAdapter } from "../../credential-adapters/noop.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import { komootImporter } from "./importer.ts"; + +export const komootManifest: ProviderManifest = { + id: "komoot", + displayName: "Komoot", + credentialKind: "web-login", + credentialAdapter: noopCredentialAdapter, + connectUrl: "/settings/connections/komoot", + importer: komootImporter, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts new file mode 100644 index 0000000..d1980c0 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts @@ -0,0 +1,205 @@ +// Contract tests for the Wahoo Importer capability adapter. +// +// The seam under test is `Importer` from registry.ts: +// listImportable(ctx, page) -> ImportableList +// importOne(ctx, workoutId) -> ImportResult +// +// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy +// wahoo.test.ts and follow the code as it's reorganized. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(creds), + }; +} + +// Importer is loaded after the implementation file exists. Using a dynamic +// import isolates the test from module load order during initial red. +const { wahooImporter } = await import("./importer.ts"); + +describe("wahooImporter.listImportable", () => { + it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { + id: 42, + name: "Morning ride", + workout_type: "biking", + starts: "2026-05-01T07:00:00Z", + workout_summary: { + duration_active_accum: 3600, + distance_accum: 25000, + file: { url: "https://cdn.example/42.fit" }, + }, + }, + ], + total: 1, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toContain("/v1/workouts"); + expect(((init as RequestInit).headers as Record).Authorization).toBe( + "Bearer fake-token", + ); + expect(result.workouts).toHaveLength(1); + expect(result.workouts[0]!.id).toBe("42"); + expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit"); + expect(result.total).toBe(1); + }); + + it("filters out third-party workouts (fitness_app_id >= 1000)", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + { + id: 2, + name: "Third-party", + workout_type: "biking", + starts: "2026-05-01T08:00:00Z", + fitness_app_id: 1234, + }, + ], + total: 2, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + + const result = await wahooImporter.listImportable(ctxWith(), 1); + expect(result.workouts.map((w) => w.id)).toEqual(["1"]); + }); +}); + +describe("wahooImporter.importOne pagination", () => { + function fitToGpxMock() { + // The importer calls fitToGpx — short-circuit it so the test focuses + // on pagination, not FIT parsing. + return Promise.resolve({ gpx: "", sport: null }); + } + + it("paginates past page 1 until it finds the target workout", async () => { + vi.resetModules(); + vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock })); + vi.doMock("../../../sync/imports.server.ts", () => ({ + importActivity: vi.fn().mockResolvedValue({ activityId: "a-1" }), + isAlreadyImported: vi.fn().mockResolvedValue(false), + })); + vi.doMock("../../manager.ts", () => ({ + getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }), + })); + + // Page 1: workouts 1..30, Page 2: workouts 31..50 (the target = 42) + fetchSpy.mockImplementation((url) => { + const u = String(url); + if (u.includes("page=2")) { + return Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { + id: 42, + name: "Old ride", + workout_type: "biking", + starts: "2025-12-01T07:00:00Z", + workout_summary: { file: { url: "https://cdn.example/42.fit" } }, + }, + ], + total: 50, + page: 2, + per_page: 30, + }), + { status: 200 }, + ), + ); + } + if (u.includes("/42.fit")) { + return Promise.resolve(new Response(new ArrayBuffer(4), { status: 200 })); + } + // page 1 by default — does NOT contain id 42 + return Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + ], + total: 50, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + }); + + const { wahooImporter } = await import("./importer.ts"); + const result = await wahooImporter.importOne(ctxWith(), "42"); + expect(result.activityId).toBe("a-1"); + // Fetched at least pages 1 and 2 of the /v1/workouts endpoint + const workoutCalls = fetchSpy.mock.calls.filter(([u]) => + String(u).includes("/v1/workouts?"), + ); + expect(workoutCalls.length).toBeGreaterThanOrEqual(2); + }); + + it("throws a clear error when the workout is not found on any page", async () => { + vi.resetModules(); + vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock })); + vi.doMock("../../../sync/imports.server.ts", () => ({ + importActivity: vi.fn(), + isAlreadyImported: vi.fn().mockResolvedValue(false), + })); + vi.doMock("../../manager.ts", () => ({ + getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }), + })); + + fetchSpy.mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + ], + total: 1, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ), + ); + + const { wahooImporter } = await import("./importer.ts"); + await expect(wahooImporter.importOne(ctxWith(), "999")).rejects.toThrow(/not found/); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts new file mode 100644 index 0000000..46c59bc --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts @@ -0,0 +1,160 @@ +// Wahoo Importer capability adapter. Implements the Importer seam against +// Wahoo's /v1/workouts API. +// +// Credentials always flow through ctx.withFreshCredentials — this module +// never reads the connected_services credentials JSONB directly. + +import { fitToGpx, type FitConversion } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { importActivity, isAlreadyImported } from "../../../sync/imports.server.ts"; +import { getServiceById } from "../../manager.ts"; +import type { + CapabilityContext, + ImportableList, + ImportResult, + Importer, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooWorkout { + id: number; + name: string; + workout_type: string; + starts: string; + fitness_app_id?: number; + workout_summary?: { + duration_active_accum?: number; + distance_accum?: number; + file?: { url?: string }; + }; +} + +async function fetchWahooWorkoutPage( + creds: OAuthCredentials, + page: number, +): Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; +}> { + const params = new URLSearchParams({ page: String(page), per_page: "30" }); + const resp = await fetchWithTimeout(`${WAHOO_API}/v1/workouts?${params}`, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`); + return resp.json() as Promise<{ + workouts: WahooWorkout[]; + total: number; + page: number; + per_page: number; + }>; +} + +function toImportable(w: WahooWorkout) { + return { + id: String(w.id), + name: w.name || `Workout ${w.id}`, + type: w.workout_type ?? "unknown", + startedAt: w.starts, + duration: w.workout_summary?.duration_active_accum + ? Math.round(w.workout_summary.duration_active_accum) + : null, + distance: w.workout_summary?.distance_accum + ? Math.round(w.workout_summary.distance_accum) + : null, + fileUrl: w.workout_summary?.file?.url, + }; +} + +async function downloadFit(fileUrl: string): Promise { + // Wahoo CDN URLs are pre-signed; no auth header needed (and adding one + // breaks them). + const resp = await fetchWithTimeout(fileUrl); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); +} + +export const wahooImporter: Importer = { + async listImportable( + ctx: CapabilityContext, + page: number, + ): Promise { + const data = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, page), + ); + + // Wahoo does not share workout data from third-party apps + // (fitness_app_id >= 1000). + const wahooOnly = data.workouts.filter( + (w) => !w.fitness_app_id || w.fitness_app_id < 1000, + ); + + return { + workouts: wahooOnly.map(toImportable), + total: data.total, + page: data.page, + perPage: data.per_page, + }; + }, + + async importOne( + ctx: CapabilityContext, + workoutId: string, + ): Promise { + // Wahoo doesn't expose a direct /v1/workouts/ endpoint with file + // URL, so we paginate /v1/workouts looking for the target. Bound by + // the total / per_page Wahoo returns on page 1, with a hard ceiling + // so a misbehaving API can't loop us forever. + const MAX_PAGES = 100; + let workout: WahooWorkout | undefined; + let totalPages = 1; + for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) { + const list = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, page), + ); + // perPage may not divide total cleanly; ceil so we don't stop one + // page short. + if (page === 1 && list.per_page > 0) { + totalPages = Math.ceil(list.total / list.per_page); + } + const found = list.workouts.find((w) => String(w.id) === workoutId); + if (found) { + workout = found; + break; + } + if (list.workouts.length === 0) break; + } + if (!workout) throw new Error(`Wahoo workout ${workoutId} not found`); + + // Resolve the connected service's user id via the capability context. + // The caller (route handler) supplies userId out-of-band — for now the + // route handler bridges the gap. We use the manager's getServiceById. + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const userId = service.userId; + if (await isAlreadyImported(userId, "wahoo", workoutId)) { + throw new Error(`Workout ${workoutId} already imported`); + } + + let gpx: string | null = null; + let sport: FitConversion["sport"] = null; + if (workout.workout_summary?.file?.url) { + const buffer = await downloadFit(workout.workout_summary.file.url); + ({ gpx, sport } = await fitToGpx(buffer, workout.name || "Wahoo workout")); + } + + const { activityId } = await importActivity(userId, "wahoo", workoutId, { + name: workout.name || `Wahoo workout ${workoutId}`, + gpx: gpx ?? undefined, + // Wahoo's list API sends no explicit sport type, so the FIT session + // sport is our best signal (provider-explicit would take precedence). + sportType: sport ?? undefined, + }); + + return { activityId, hadGeometry: gpx !== null }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts new file mode 100644 index 0000000..8f5eaf2 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts @@ -0,0 +1,130 @@ +// Wahoo provider manifest. Declares credential kind, OAuth config, +// authorization/exchange flows, and capability adapters. +// +// Adding to the registry happens via providers/index.ts which imports each +// provider's barrel. Don't `import`-cycle this file from registry.ts. + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { + ProviderManifest, + CapabilityContext, +} from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { wahooImporter } from "./importer.ts"; +import { wahooPusher } from "./pusher.ts"; +import { wahooWebhook } from "./webhook.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; +const WAHOO_AUTH = "https://api.wahooligan.com/oauth"; + +const SCOPES = [ + "workouts_read", + "user_read", + "offline_data", + "routes_read", + "routes_write", +]; + +function clientId(): string { + return process.env.WAHOO_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.WAHOO_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return `${WAHOO_AUTH}/token`; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, + get revokeUrl() { + return `${WAHOO_API}/v1/permissions`; + }, +}; + +export const wahooManifest: ProviderManifest = { + id: "wahoo", + displayName: "Wahoo", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + scopes: SCOPES, + + buildAuthUrl(redirectUri: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId(), + redirect_uri: redirectUri, + response_type: "code", + scope: SCOPES.join(" "), + state, + }); + return `${WAHOO_AUTH}/authorize?${params}`; + }, + + async exchangeCode( + code: string, + redirectUri: string, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(`${WAHOO_AUTH}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId(), + client_secret: clientSecret(), + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + const err = new Error(`Wahoo token exchange failed: ${resp.status} ${text}`); + // Attach a code so callers can distinguish the "too many tokens" sandbox case. + (err as Error & { code?: string }).code = text.includes("Too many unrevoked access tokens") + ? "too_many_tokens" + : "generic"; + throw err; + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + // Pull provider user id so webhook routing works. + const userResp = await fetch(`${WAHOO_API}/v1/user`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { id: number }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.id != null ? String(user.id) : null, + // Wahoo does not return a `scope` field and grants scopes + // all-or-nothing, so the requested set is the granted set. + grantedScopes: SCOPES, + }; + }, + + importer: wahooImporter, + routePusher: wahooPusher, + webhookReceiver: wahooWebhook, +}; + +// Re-export the capability adapters for direct testing access if needed. +export type { CapabilityContext }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts new file mode 100644 index 0000000..4bb2a30 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts @@ -0,0 +1,208 @@ +// Contract tests for the Wahoo RoutePusher capability adapter. +// +// Seam: pushRoute(ctx, input) -> {remoteId, version} +// +// Wahoo workarounds (FIT conversion, route: external_id, PUT-vs-POST, +// PUT->POST-on-404 fallback) are tested here as adapter-internal — they +// must not surface on the seam. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CapabilityContext } from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +// ---- mocks ---- + +const fetchSpy = vi.fn(); + +const mockFitConvert = vi.fn(); +vi.mock("@trails-cool/fit", () => ({ + gpxToFitCourse: (input: unknown) => mockFitConvert(input), +})); + +// sync_pushes idempotency state — manipulated per-test. +let existingPushRow: + | { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; + } + | null = null; +const insertedPushes: unknown[] = []; +const updatedPushes: unknown[] = []; + +const mockDb = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(existingPushRow ? [existingPushRow] : []), + }), + }), + }), + insert: () => ({ + values: (v: unknown) => { + insertedPushes.push(v); + return Promise.resolve(); + }, + }), + update: () => ({ + set: (patch: unknown) => ({ + where: () => { + updatedPushes.push(patch); + return Promise.resolve(); + }, + }), + }), +}; + +vi.mock("../../../db.ts", () => ({ getDb: () => mockDb })); + +vi.mock("../../manager.ts", () => ({ + getServiceById: async () => ({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + credentialKind: "oauth", + credentials: {}, + status: "active", + providerUserId: null, + grantedScopes: [], + createdAt: new Date(), + }), +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockFitConvert.mockReset(); + mockFitConvert.mockResolvedValue(new Uint8Array([0xfe, 0xed, 0xfa, 0xce])); + existingPushRow = null; + insertedPushes.length = 0; + updatedPushes.length = 0; +}); + +const stubCreds: OAuthCredentials = { + access_token: "fake-token", + refresh_token: "rt", + expires_at: new Date(Date.now() + 3600_000).toISOString(), +}; + +function ctx(): CapabilityContext { + return { + serviceId: "svc-1", + withFreshCredentials: async (fn) => fn(stubCreds), + }; +} + +const pushInput = { + routeId: "route-abc", + routeName: "Berlin loop", + description: "morning ride", + gpx: ` + 34 + 40 + `, + startLat: 52.5, + startLng: 13.4, + distance: 1234, + ascent: 56, + localVersion: 3, +}; + +const { wahooPusher } = await import("./pusher.ts"); + +describe("wahooPusher.pushRoute — first push (no existing row)", () => { + it("POSTs to /v1/routes with FIT body and external_id=route:", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9001 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes"); + expect((init as RequestInit).method).toBe("POST"); + const body = (init as RequestInit).body as string; + expect(body).toContain("route%5Bexternal_id%5D=route%3Aroute-abc"); + expect(body).toContain("route%5Bfile%5D=data%3Aapplication%2Fvnd.fit%3Bbase64%2C"); + expect(insertedPushes).toHaveLength(1); + }); +}); + +describe("wahooPusher.pushRoute — re-push of an unchanged route", () => { + it("short-circuits without calling Wahoo when last_pushed_version matches", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 3, + pushedAt: new Date("2026-01-01"), + error: null, + }; + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("wahooPusher.pushRoute — re-push after edit", () => { + it("PUTs to /v1/routes/ when lastPushedVersion < localVersion", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9001"); + expect(result.version).toBe(3); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(String(url)).toBe("https://api.wahooligan.com/v1/routes/9001"); + expect((init as RequestInit).method).toBe("PUT"); + }); +}); + +describe("wahooPusher.pushRoute — PUT 404 fallback", () => { + it("falls back to POST and overwrites remoteId when PUT returns 404", async () => { + existingPushRow = { + id: "existing", + userId: "u1", + routeId: "route-abc", + provider: "wahoo", + remoteId: "9001", + lastPushedVersion: 2, + pushedAt: new Date("2026-01-01"), + error: null, + }; + fetchSpy + .mockResolvedValueOnce(new Response("not found", { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 9999 }), { status: 201 }), + ); + + const result = await wahooPusher.pushRoute(ctx(), pushInput); + + expect(result.remoteId).toBe("9999"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [, secondInit] = fetchSpy.mock.calls[1]!; + expect((secondInit as RequestInit).method).toBe("POST"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts new file mode 100644 index 0000000..365347b --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts @@ -0,0 +1,217 @@ +// Wahoo RoutePusher capability adapter. +// +// Exposes the seam shape `pushRoute(ctx, input) -> {remoteId, version}` per +// ADR-0003. Wahoo specifics — FIT-Course conversion, the `route:` +// external_id convention, the PUT-vs-POST decision based on sync_pushes, +// and the PUT-on-404 → POST fallback — live entirely inside this module +// and never appear on the seam. +// +// Idempotency state lives in `journal.sync_pushes`. The seam returns the +// remote id and the local version that was pushed; the caller is free to +// inspect the table for richer state. + +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { gpxToFitCourse } from "@trails-cool/fit"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getDb } from "../../../db.ts"; +import { getServiceById } from "../../manager.ts"; +import type { + CapabilityContext, + RoutePushInput, + RoutePushResult, + RoutePusher, +} from "../../registry.ts"; +import type { OAuthCredentials } from "../../types.ts"; + +const WAHOO_API = "https://api.wahooligan.com"; + +interface WahooErrorShape { + status: number; + body: string; +} + +class WahooHttpError extends Error { + shape: WahooErrorShape; + constructor(shape: WahooErrorShape) { + super(`Wahoo route ${shape.status}: ${shape.body}`); + this.shape = shape; + } +} + +function externalIdFor(routeId: string): string { + return `route:${routeId}`; +} + +function buildBody( + fit: Uint8Array, + input: RoutePushInput, +): URLSearchParams { + // Wahoo expects route[file] as a data URI, not raw base64. Sending raw + // base64 results in a route with file.url = null; the app shows + // metadata but renders no track. + const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`; + const body = new URLSearchParams({ + "route[external_id]": externalIdFor(input.routeId), + "route[provider_updated_at]": new Date().toISOString(), + "route[name]": input.routeName, + "route[workout_type_family_id]": "0", + "route[start_lat]": input.startLat.toString(), + "route[start_lng]": input.startLng.toString(), + "route[distance]": input.distance.toString(), + "route[ascent]": input.ascent.toString(), + "route[file]": fitDataUri, + }); + if (input.description) body.set("route[description]", input.description); + return body; +} + +async function postOrPut( + method: "POST" | "PUT", + url: string, + accessToken: string, + body: URLSearchParams, + fallbackRemoteId?: string, +): Promise<{ remoteId: string }> { + const resp = await fetch(url, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (resp.ok) { + let remoteId: string | undefined; + if (resp.status !== 204) { + const data = (await resp.json().catch(() => null)) as { id?: number | string } | null; + remoteId = data?.id?.toString(); + } + remoteId ??= fallbackRemoteId; + if (!remoteId) throw new Error(`Wahoo response missing route id`); + return { remoteId }; + } + + const text = await resp.text().catch(() => ""); + throw new WahooHttpError({ status: resp.status, body: text }); +} + +interface ExistingPush { + id: string; + userId: string; + routeId: string; + provider: string; + remoteId: string | null; + lastPushedVersion: number | null; + pushedAt: Date | null; + error: string | null; +} + +async function findExistingPush( + userId: string, + routeId: string, +): Promise { + const db = getDb(); + const rows = (await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, userId), + eq(syncPushes.routeId, routeId), + eq(syncPushes.provider, "wahoo"), + ), + ) + .limit(1)) as ExistingPush[]; + return rows[0] ?? null; +} + +async function recordPush( + existing: ExistingPush | null, + userId: string, + input: RoutePushInput, + remoteId: string, +): Promise { + const db = getDb(); + const now = new Date(); + if (existing) { + await db + .update(syncPushes) + .set({ + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + updatedAt: now, + }) + .where(eq(syncPushes.id, existing.id)); + } else { + await db.insert(syncPushes).values({ + id: randomUUID(), + userId, + routeId: input.routeId, + provider: "wahoo", + externalId: externalIdFor(input.routeId), + remoteId, + lastPushedVersion: input.localVersion, + pushedAt: now, + error: null, + }); + } +} + +export const wahooPusher: RoutePusher = { + async pushRoute( + ctx: CapabilityContext, + input: RoutePushInput, + ): Promise { + // Resolve the user from the connected service so we can read/write + // sync_pushes for the right (user, route, provider) tuple. + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const existing = await findExistingPush(service.userId, input.routeId); + if ( + existing?.pushedAt && + existing.remoteId && + existing.lastPushedVersion === input.localVersion + ) { + return { remoteId: existing.remoteId, version: input.localVersion }; + } + + const fit = await gpxToFitCourse({ + gpx: input.gpx, + name: input.routeName, + description: input.description, + }); + const body = buildBody(fit, input); + + const result = await ctx.withFreshCredentials(async (creds) => { + const accessToken = (creds as OAuthCredentials).access_token; + // PUT-vs-POST: PUT in place when we have a remoteId on file. + if (existing?.remoteId) { + try { + return await postOrPut( + "PUT", + `${WAHOO_API}/v1/routes/${encodeURIComponent(existing.remoteId)}`, + accessToken, + body, + existing.remoteId, + ); + } catch (err) { + // 404 means the user deleted the route on Wahoo's side. Fall + // back to POST and overwrite the local remoteId. + if (err instanceof WahooHttpError && err.shape.status === 404) { + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + } + throw err; + } + } + return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body); + }); + + await recordPush(existing, service.userId, input, result.remoteId); + return { remoteId: result.remoteId, version: input.localVersion }; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts new file mode 100644 index 0000000..6d03966 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -0,0 +1,118 @@ +// Contract tests for the Wahoo WebhookReceiver capability adapter. +// +// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable) +// handle(event) -> void (creates an activity if file present, dedups via sync_imports) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const fetchSpy = vi.fn(); +const mockImportActivity = vi.fn(); +const mockIsAlreadyImported = vi.fn(); +const mockGetServiceByProviderUser = vi.fn(); +const mockWithFreshCredentials = vi.fn(); + +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: mockIsAlreadyImported, + importActivity: mockImportActivity, +})); +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + withFreshCredentials: mockWithFreshCredentials, +})); + +beforeEach(() => { + fetchSpy.mockReset(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + mockImportActivity.mockReset(); + mockIsAlreadyImported.mockReset(); + mockGetServiceByProviderUser.mockReset(); + mockWithFreshCredentials.mockReset(); +}); + +const { wahooWebhook } = await import("./webhook.ts"); + +describe("wahooWebhook.parseWebhook", () => { + it("returns a WebhookEvent for workout_summary payloads", () => { + const event = wahooWebhook.parseWebhook({ + event_type: "workout_summary", + user: { id: 7 }, + workout_summary: { + workout: { id: 42 }, + file: { url: "https://cdn.example/42.fit" }, + }, + }); + expect(event).toEqual([ + { + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }, + ]); + }); + + it("returns no events for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]); + }); + + it("returns no events when user.id is missing", () => { + expect( + wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), + ).toEqual([]); + }); +}); + +describe("wahooWebhook.handle", () => { + it("creates an activity and records the import for a known user", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(false); + mockImportActivity.mockResolvedValue({ activityId: "act-1" }); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + // no fileUrl — the activity is created without GPX + }); + + expect(mockImportActivity).toHaveBeenCalledWith( + "u1", + "wahoo", + "42", + expect.objectContaining({ name: expect.stringContaining("Wahoo") }), + ); + }); + + it("silently skips when the providerUserId is unknown (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "999", + workoutId: "42", + }); + + expect(mockImportActivity).not.toHaveBeenCalled(); + }); + + it("silently skips when the workout was already imported (idempotency)", async () => { + mockGetServiceByProviderUser.mockResolvedValue({ + id: "svc-1", + userId: "u1", + provider: "wahoo", + }); + mockIsAlreadyImported.mockResolvedValue(true); + + await wahooWebhook.handle({ + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + }); + + expect(mockImportActivity).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts new file mode 100644 index 0000000..db05f14 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -0,0 +1,71 @@ +// Wahoo WebhookReceiver capability adapter. +// +// Wahoo posts `workout_summary` events to /api/sync/webhook/wahoo when a +// workout completes. We route the event to the right local user via +// provider_user_id, deduplicate via sync_imports, then download + convert +// the FIT file (if present) and create an activity. + +import { fitToGpx, type FitConversion } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; + +interface WahooWebhookBody { + event_type?: string; + user?: { id?: number }; + workout_summary?: { + id?: number; + workout?: { id?: number }; + file?: { url?: string }; + }; +} + + +export const wahooWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent[] { + const payload = body as WahooWebhookBody; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return []; + + return [ + { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }, + ]; + }, + + async handle(event: WebhookEvent): Promise { + // Match incoming webhooks to local users via provider_user_id. + // Unknown users return silently — no leak. + const service = await getServiceByProviderUser("wahoo", event.providerUserId); + if (!service) return; + + if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return; + + let gpx: string | null = null; + let sport: FitConversion["sport"] = null; + if (event.fileUrl) { + // Wahoo CDN URLs are pre-signed; no auth header needed. We still go + // through withFreshCredentials so the manager has a chance to refresh + // a near-expired credential before any subsequent Wahoo call this + // handler might make. + const buffer = await withFreshCredentials(service.id, async () => { + const resp = await fetchWithTimeout(event.fileUrl!); + if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); + return Buffer.from(await resp.arrayBuffer()); + }); + ({ gpx, sport } = await fitToGpx(buffer, "Wahoo workout")); + } + + await importActivity(service.userId, "wahoo", event.workoutId, { + name: "Wahoo workout", + gpx: gpx ?? undefined, + sportType: sport ?? undefined, + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/push-action.server.ts b/apps/journal/app/lib/connected-services/push-action.server.ts new file mode 100644 index 0000000..eb17cd0 --- /dev/null +++ b/apps/journal/app/lib/connected-services/push-action.server.ts @@ -0,0 +1,117 @@ +// Orchestrates a route push: load route, check ownership, check scopes, +// build the RoutePushInput, and invoke the provider's RoutePusher +// capability. Replaces the legacy pushRouteToProvider in lib/sync. +// +// The pusher (per-provider) handles HTTP, FIT conversion, idempotency, +// and PUT/POST/404 fallback. This module is the orchestration layer +// callers use (route handlers, OAuth callback resume). + +import { desc, eq } from "drizzle-orm"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { routeVersions } from "@trails-cool/db/schema/journal"; +import { getDb } from "../db.ts"; +import { loadOwnedRoute } from "../ownership.server.ts"; +import { + ConnectionNotActiveError, + NeedsRelinkError, +} from "./types.ts"; +import { capabilityContextFor, getService } from "./manager.ts"; +import { getManifest, type RoutePushInput } from "./registry.ts"; + +export type PushOutcome = + | { status: "success"; remoteId: string; pushedAt: Date } + | { status: "scope_missing" } + | { status: "no_connection" } + | { status: "not_owner" } + | { status: "not_found" } + | { status: "unsupported_provider" } + | { status: "no_geometry" } + | { status: "needs_relink" } + | { + status: "error"; + code: "validation" | "rate_limit" | "token_expired" | "generic"; + message: string; + }; + +export interface PushRouteOptions { + userId: string; + providerId: string; + routeId: string; +} + +export async function pushRouteToProvider( + opts: PushRouteOptions, +): Promise { + const { userId, providerId, routeId } = opts; + const db = getDb(); + + const manifest = getManifest(providerId); + if (!manifest) return { status: "not_found" }; + if (!manifest.routePusher) return { status: "unsupported_provider" }; + + const loaded = await loadOwnedRoute(routeId, userId); + if (!loaded.ok) { + return { status: loaded.reason === "not_found" ? "not_found" : "not_owner" }; + } + const route = loaded.entity; + + const service = await getService(userId, providerId); + if (!service) return { status: "no_connection" }; + if (!service.grantedScopes.includes("routes_write")) { + return { status: "scope_missing" }; + } + + // Pull the locked-in version GPX (not routes.gpx, which is the working copy). + const [latestVersion] = await db + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, routeId)) + .orderBy(desc(routeVersions.version)) + .limit(1); + + const versionGpx = latestVersion?.gpx ?? route.gpx; + const versionNumber = latestVersion?.version ?? 1; + if (!versionGpx) return { status: "no_geometry" }; + + const parsed = await parseGpxAsync(versionGpx); + const points = parsed.tracks.flat(); + if (points.length === 0) return { status: "no_geometry" }; + + const input: RoutePushInput = { + routeId, + routeName: route.name, + description: route.description ?? undefined, + gpx: versionGpx, + startLat: points[0]!.lat, + startLng: points[0]!.lon, + distance: parsed.distance, + ascent: parsed.elevation.gain, + localVersion: versionNumber, + }; + + try { + const ctx = capabilityContextFor(service.id); + const result = await manifest.routePusher.pushRoute(ctx, input); + return { + status: "success", + remoteId: result.remoteId, + pushedAt: new Date(), + }; + } catch (err) { + if (err instanceof NeedsRelinkError) return { status: "needs_relink" }; + if (err instanceof ConnectionNotActiveError) return { status: "needs_relink" }; + const message = err instanceof Error ? err.message : String(err); + // Map known HTTP-shape errors. The pusher throws Error / WahooHttpError; + // we don't try to recover further here. + if (message.includes("422")) { + return { status: "error", code: "validation", message }; + } + if (message.includes("429")) { + return { status: "error", code: "rate_limit", message }; + } + if (message.includes("401") || message.includes("403")) { + return { status: "error", code: "token_expired", message }; + } + return { status: "error", code: "generic", message }; + } +} diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts new file mode 100644 index 0000000..3bac1c7 --- /dev/null +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -0,0 +1,170 @@ +// Provider registry. Each provider lives in providers// with a +// manifest.ts that declares its credential_kind and capability adapters. +// Adding a provider is one new directory plus one import line below. +// +// See docs/adr/0002 (no unified SyncProvider interface; capabilities are +// separate seams) and CONTEXT.md (Connected Services). + +import type { + CredentialAdapter, + CredentialKind, + ProviderOAuthConfig, +} from "./types.ts"; + +// Capability seams. A provider implements only the subset that applies. + +export interface ImportableList { + workouts: ImportableWorkout[]; + total: number; + page: number; + perPage: number; +} + +export interface ImportableWorkout { + id: string; + name: string; + type: string; + startedAt: string; + duration: number | null; + distance: number | null; + fileUrl?: string; +} + +export interface ImportResult { + activityId: string; + hadGeometry: boolean; +} + +export interface RoutePushInput { + routeId: string; + routeName: string; + description?: string; + gpx: string; + startLat: number; + startLng: number; + distance: number; + ascent: number; + localVersion: number; +} + +export interface RoutePushResult { + remoteId: string; + // Local route version that was pushed — written into sync_pushes.last_pushed_version + // by the caller for idempotency. + version: number; +} + +export interface WebhookEvent { + eventType: string; + providerUserId: string; + workoutId: string; + fileUrl?: string; + // Optional summary stats carried by providers whose notifications + // include them (Garmin pushes summaries; a FIT-less activity is still + // importable stats-only). Providers without summaries leave these out. + name?: string; + startedAt?: string; + duration?: number | null; + distance?: number | null; + // File format behind fileUrl when the provider says (FIT | GPX | TCX). + fileType?: string; +} + +// CapabilityContext gives capability adapters the tools they need without +// exposing the manager's internals. Adapters call ctx.withFreshCredentials +// to obtain valid credentials for any provider HTTP call. +export interface CapabilityContext { + serviceId: string; + withFreshCredentials( + fn: (credentials: unknown) => Promise, + ): Promise; +} + +export interface Importer { + listImportable(ctx: CapabilityContext, page: number): Promise; + importOne(ctx: CapabilityContext, workoutId: string): Promise; +} + +export interface RoutePusher { + pushRoute(ctx: CapabilityContext, input: RoutePushInput): Promise; +} + +export interface WebhookReceiver { + // One provider POST can carry many events (Garmin batches + // notifications). Single-event providers return a one-element array; + // an empty array means "nothing actionable" and the route 200s. + parseWebhook(body: unknown): WebhookEvent[]; + handle(event: WebhookEvent): Promise; +} + +export interface ProviderManifest { + id: string; + displayName: string; + credentialKind: CredentialKind; + // Per-kind credential adapter. Multiple providers can share the same + // adapter (oauth, web-login, device). + credentialAdapter: CredentialAdapter; + // OAuth-specific config; only required when credentialKind === 'oauth'. + oauthConfig?: ProviderOAuthConfig; + // OAuth scopes requested at connect time. Wahoo grants all-or-nothing. + scopes?: string[]; + // Custom connect page URL. When set, the connections settings page links + // here instead of the default OAuth connect endpoint. + connectUrl?: string; + // Custom import page URL. When set, the connections settings page links + // here instead of the generic /sync/import/ pick-list page (Garmin + // has no list endpoint — its import page is a backfill requester). + importUrl?: string; + // When defined and returning false, the provider is hidden from the + // connections settings page (e.g. instance has no API credentials for + // it). Undefined = always shown. + configured?: () => boolean; + // OAuth2 PKCE: when true, the connect route generates a code verifier + // (carried in an httpOnly cookie across the redirect) and passes the + // S256 challenge to buildAuthUrl / the verifier to exchangeCode. + pkce?: boolean; + // OAuth authorization URL builder (for the connect flow). + buildAuthUrl?: ( + redirectUri: string, + state: string, + extras?: { codeChallenge?: string }, + ) => string; + // OAuth code exchange (for the callback). Returns the credential blob to + // store and the granted scopes. + exchangeCode?: ( + code: string, + redirectUri: string, + extras?: { codeVerifier?: string }, + ) => Promise<{ + credentials: unknown; + providerUserId: string | null; + grantedScopes: string[]; + }>; + // Capability adapters. Each is optional — providers implement only what + // they support. + importer?: Importer; + routePusher?: RoutePusher; + webhookReceiver?: WebhookReceiver; +} + +// The registry. Imported manifests are kept in an internal map so callers +// can look up by provider id. Adding a provider: import its manifest below +// and add it to PROVIDERS. +// +// Manifests are registered at module load via registerManifest() rather +// than imported directly, so the registry doesn't depend on providers/. +// Each provider's barrel (providers//index.ts) calls register at import. + +const PROVIDERS: Record = {}; + +export function registerManifest(manifest: ProviderManifest): void { + PROVIDERS[manifest.id] = manifest; +} + +export function getManifest(providerId: string): ProviderManifest | null { + return PROVIDERS[providerId] ?? null; +} + +export function getAllManifests(): ProviderManifest[] { + return Object.values(PROVIDERS); +} diff --git a/apps/journal/app/lib/connected-services/types.ts b/apps/journal/app/lib/connected-services/types.ts new file mode 100644 index 0000000..6dec18e --- /dev/null +++ b/apps/journal/app/lib/connected-services/types.ts @@ -0,0 +1,82 @@ +// Types for the Connected Services architecture. See docs/adr/0001-0003 and +// CONTEXT.md (Connected Services section). + +export type CredentialKind = "oauth" | "web-login" | "device" | "public"; + +export type ConnectionStatus = "active" | "needs_relink" | "revoked"; + +// OAuth credential blob (stored in connected_services.credentials when +// credential_kind = 'oauth'). expires_at is an ISO-8601 UTC string so the +// JSONB blob is round-trip safe; the manager parses it into a Date as needed. +export interface OAuthCredentials { + access_token: string; + refresh_token: string; + expires_at: string; +} + +// web-login (Komoot) and device (Apple Health) blob shapes will be defined +// alongside their respective consumer changes. The kinds are reserved here +// so the manager can switch on them without a follow-up enum migration. +export type Credentials = OAuthCredentials | Record; + +export interface ConnectedService { + id: string; + userId: string; + provider: string; + credentialKind: CredentialKind; + credentials: Credentials; + status: ConnectionStatus; + providerUserId: string | null; + grantedScopes: string[]; + createdAt: Date; +} + +// Returned by CredentialAdapter.refresh when the credential is permanently +// invalid (e.g. revoked refresh token). Manager flips the connection's +// status to 'needs_relink' and surfaces this to callers. +export class NeedsRelinkError extends Error { + reason: string; + constructor(reason: string) { + super(`Connection needs relinking: ${reason}`); + this.name = "NeedsRelinkError"; + this.reason = reason; + } +} + +// CredentialAdapter is implemented per credential_kind. The adapter owns +// the credential lifecycle for that kind — nothing else. +export interface CredentialAdapter { + // Returns refreshed credentials, or throws NeedsRelinkError on permanent + // failure. Implementations should be idempotent w.r.t. already-fresh + // credentials (callers may invoke even when not strictly expired). + refresh(credentials: C, providerConfig: ProviderOAuthConfig): Promise; + // Best-effort revocation at the provider's end on unlink. Failures + // should be swallowed by the caller — the local row is deleted regardless. + revoke?(credentials: C, providerConfig: ProviderOAuthConfig): Promise; + // Returns true if the credential is expired (or close enough that the + // caller should refresh before using it). Manager calls this from + // withFreshCredentials. + isExpired(credentials: C): boolean; +} + +// OAuth-specific config carried on the provider manifest. Used by the oauth +// CredentialAdapter to build refresh requests without hard-coding Wahoo URLs. +export interface ProviderOAuthConfig { + tokenUrl: string; + clientId: string; + clientSecret: string; + // Optional revoke endpoint (e.g. Wahoo's DELETE /v1/permissions). When + // absent, revoke is a no-op. + revokeUrl?: string; +} + +// Thrown when withFreshCredentials is called against a connection whose +// status is not 'active'. Caller should surface a re-link prompt. +export class ConnectionNotActiveError extends Error { + status: ConnectionStatus; + constructor(status: ConnectionStatus) { + super(`Connection status is ${status}; cannot use until re-linked`); + this.name = "ConnectionNotActiveError"; + this.status = status; + } +} diff --git a/apps/journal/app/lib/crypto.server.test.ts b/apps/journal/app/lib/crypto.server.test.ts new file mode 100644 index 0000000..ce849e2 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +// Set env before importing module +beforeEach(() => { + process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests"; +}); + +const { encrypt, decrypt } = await import("./crypto.server.ts"); + +describe("crypto.server", () => { + it("roundtrips ASCII plaintext", () => { + const plaintext = "hello world"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("roundtrips unicode plaintext", () => { + const plaintext = "Über dich 🏔️ trails.cool"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("produces different ciphertext for the same input (random IV)", () => { + const plaintext = "same input"; + const c1 = encrypt(plaintext); + const c2 = encrypt(plaintext); + expect(c1).not.toBe(c2); + // But both decrypt correctly + expect(decrypt(c1)).toBe(plaintext); + expect(decrypt(c2)).toBe(plaintext); + }); + + it("throws on tampered auth tag", () => { + const encoded = encrypt("sensitive"); + // Decode the base64 payload, split on ":", flip bytes in the auth tag + const payload = Buffer.from(encoded, "base64").toString("utf8"); + const parts = payload.split(":"); + // parts: [ivHex, encryptedHex, authTagHex] + const authTagBuf = Buffer.from(parts[2]!, "hex"); + authTagBuf[0] = authTagBuf[0]! ^ 0xff; + parts[2] = authTagBuf.toString("hex"); + const tampered = Buffer.from(parts.join(":")).toString("base64"); + expect(() => decrypt(tampered)).toThrow(); + }); + + it("throws on invalid format", () => { + expect(() => decrypt(Buffer.from("notvalidformat").toString("base64"))).toThrow( + "Invalid encrypted payload format", + ); + }); +}); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts new file mode 100644 index 0000000..8ede16d --- /dev/null +++ b/apps/journal/app/lib/crypto.server.ts @@ -0,0 +1,61 @@ +// AES-256-GCM encrypt/decrypt for storing sensitive credentials at rest. +// Keys are derived from a secret env var using scrypt. +// +// Encoded format: base64(iv_hex:ciphertext_hex:authtag_hex) +// All fields are hex-encoded before joining so the separator is unambiguous. + +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const KEY_LEN = 32; // AES-256 +const IV_LEN = 12; // GCM standard + +export interface AesCipher { + encrypt(plaintext: string): string; + decrypt(encoded: string): string; +} + +/** + * Build an encrypt/decrypt pair keyed from `secretEnvVar`. The key is + * derived lazily on first use so importing this module never throws — + * only actually encrypting/decrypting requires the env var to be set. + * Distinct `salt` per use-case keeps derived keys independent even if + * two env vars were ever set to the same value. + */ +export function createAesCipher(secretEnvVar: string, salt: string): AesCipher { + function getKey(): Buffer { + const secret = process.env[secretEnvVar]; + if (!secret) throw new Error(`${secretEnvVar} env var is not set`); + return scryptSync(secret, salt, KEY_LEN) as Buffer; + } + + return { + encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":"); + return Buffer.from(payload).toString("base64"); + }, + decrypt(encoded: string): string { + const key = getKey(); + const payload = Buffer.from(encoded, "base64").toString("utf8"); + const parts = payload.split(":"); + if (parts.length !== 3) throw new Error("Invalid encrypted payload format"); + const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string]; + const iv = Buffer.from(ivHex, "hex"); + const encrypted = Buffer.from(encryptedHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final("utf8"); + }, + }; +} + +// Integration-credential cipher (Komoot web-login, etc). Pre-existing +// callers import { encrypt, decrypt } directly; keep that surface. +const integrations = createAesCipher("INTEGRATION_SECRET", "trails-cool-integrations"); +export const encrypt = integrations.encrypt; +export const decrypt = integrations.decrypt; diff --git a/apps/journal/app/lib/db.ts b/apps/journal/app/lib/db.ts index 8fafd8c..7546fe0 100644 --- a/apps/journal/app/lib/db.ts +++ b/apps/journal/app/lib/db.ts @@ -8,3 +8,7 @@ export function getDb(): Database { } return _db; } + +export function setDb(db: Database | null): void { + _db = db; +} diff --git a/apps/journal/app/lib/demo-bot.integration.test.ts b/apps/journal/app/lib/demo-bot.integration.test.ts new file mode 100644 index 0000000..9556fb3 --- /dev/null +++ b/apps/journal/app/lib/demo-bot.integration.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { activities, routes, users } from "@trails-cool/db/schema/journal"; +import { + DEFAULT_PERSONA, + DemoPersonaUsernameClashError, + __resetPersonaCacheForTests, + ensureDemoUser, + generateOneWalk, + pruneSynthetic, +} from "./demo-bot.server.ts"; +import { randomUUID } from "node:crypto"; + +// Opt-in: these tests talk to a real Postgres and are skipped unless +// `DEMO_BOT_INTEGRATION=1` is set. CI gates them behind the same flag +// so we don't break laptop runs that have no Postgres up. +const runIntegration = process.env.DEMO_BOT_INTEGRATION === "1"; + +// A tiny valid GPX that parseGpxAsync can chew on. Two trkpts in Berlin, +// ~2 km apart — enough to pass the 500 m min-distance gate in +// generateOneWalk. +const STUB_GPX = ` + + + 34 + 38 + 40 + +`; + +async function wipeSynthetic() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.activities WHERE synthetic = true`); + await db.execute(sql`DELETE FROM journal.routes WHERE synthetic = true`); +} + +describe.skipIf(!runIntegration)("demo-bot integration", () => { + beforeAll(async () => { + // Sanity: make sure we can reach the DB and synthetic column exists. + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.routes WHERE synthetic IS NOT NULL LIMIT 0`); + }); + + afterEach(async () => { + await wipeSynthetic(); + // Scrub any test-inserted Bruno so the next test starts clean. + const db = getDb(); + await db.execute(sql`DELETE FROM journal.users WHERE username = ${DEFAULT_PERSONA.username}`); + __resetPersonaCacheForTests(); + vi.restoreAllMocks(); + }); + + it("ensureDemoUser is idempotent", async () => { + const id1 = await ensureDemoUser(); + const id2 = await ensureDemoUser(); + expect(id1).toBe(id2); + + const db = getDb(); + const rows = await db + .select() + .from(users) + .where(sql`${users.username} = ${DEFAULT_PERSONA.username}`); + expect(rows.length).toBe(1); + }); + + it("ensureDemoUser throws username-clash when a real user owns the persona name", async () => { + const db = getDb(); + const domain = process.env.DOMAIN ?? "localhost"; + const realUserId = randomUUID(); + // Create a real user at the persona username with a NON-sentinel email. + await db.execute(sql` + INSERT INTO journal.users (id, email, username, domain, terms_accepted_at, terms_version) + VALUES (${realUserId}, ${`real-${Date.now()}@${domain}`}, ${DEFAULT_PERSONA.username}, ${domain}, now(), '2026-04-19') + `); + + try { + await expect(ensureDemoUser()).rejects.toBeInstanceOf(DemoPersonaUsernameClashError); + } finally { + await db.execute(sql`DELETE FROM journal.users WHERE id = ${realUserId}`); + } + }); + + it("generateOneWalk inserts a public synthetic route + activity", async () => { + // createPlannerSession() calls resp.json() (returns { sessionId }); + // requestBrouterGpx() then calls resp.text() (returns the GPX body). + // Stub both so the chain completes without a real Planner running. + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.endsWith("/api/sessions")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ sessionId: "test-session" }), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + text: async () => STUB_GPX, + }); + }), + ); + + const ownerId = await ensureDemoUser(); + const routeId = await generateOneWalk(ownerId); + expect(routeId).toBeTruthy(); + + const db = getDb(); + const [route] = await db + .select() + .from(routes) + .where(sql`${routes.id} = ${routeId!}`); + expect(route?.synthetic).toBe(true); + expect(route?.visibility).toBe("public"); + expect(route?.routingProfile).toBe("trekking"); + expect(route?.ownerId).toBe(ownerId); + + const acts = await db + .select() + .from(activities) + .where(sql`${activities.routeId} = ${routeId!}`); + expect(acts.length).toBe(1); + expect(acts[0]?.synthetic).toBe(true); + expect(acts[0]?.visibility).toBe("public"); + expect(acts[0]?.duration).toBeGreaterThan(0); + }); + + it("pruneSynthetic only removes synthetic rows past the window", async () => { + const ownerId = await ensureDemoUser(); + const db = getDb(); + + // One synthetic route that is 30 days old, one that is fresh. + await db.execute(sql` + INSERT INTO journal.routes + (id, owner_id, name, synthetic, visibility, created_at, updated_at) + VALUES + (gen_random_uuid()::text, ${ownerId}, 'old-synth', true, 'public', now() - interval '30 days', now() - interval '30 days'), + (gen_random_uuid()::text, ${ownerId}, 'fresh-synth', true, 'public', now(), now()) + `); + + // A non-synthetic route that is also 30 days old — must be kept. + await db.execute(sql` + INSERT INTO journal.routes + (id, owner_id, name, synthetic, visibility, created_at, updated_at) + VALUES + (gen_random_uuid()::text, ${ownerId}, 'old-real', false, 'private', now() - interval '30 days', now() - interval '30 days') + `); + + const counts = await pruneSynthetic(14); + expect(counts.routes).toBe(1); + + const remaining = await db + .select({ name: routes.name, synthetic: routes.synthetic }) + .from(routes) + .where(sql`${routes.ownerId} = ${ownerId} AND ${routes.name} IN ('old-synth','fresh-synth','old-real')`); + + const names = remaining.map((r) => r.name).sort(); + expect(names).toEqual(["fresh-synth", "old-real"]); + + // Clean up the non-synthetic test row + await db.execute(sql`DELETE FROM journal.routes WHERE owner_id = ${ownerId} AND name = 'old-real'`); + }); +}); diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts new file mode 100644 index 0000000..7917524 --- /dev/null +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -0,0 +1,781 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { z } from "zod"; +import { and, eq, lt, sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { activities, routes, users } from "@trails-cool/db/schema/journal"; +import { createRoute } from "./routes.server.ts"; +import { createActivity } from "./activities.server.ts"; +import { processGpx } from "./gpx-save.server.ts"; +import { TERMS_VERSION } from "./legal.ts"; +import { logger } from "./logger.server.ts"; +import { + demoBotSyntheticActivitiesTotal, + demoBotSyntheticRoutesTotal, +} from "./metrics.server.ts"; + +// --- Persona configuration ----------------------------------------------- + +export type DemoLocale = "en" | "de"; + +export interface DemoPersonaContent { + names: { en?: string[]; de?: string[] }; + descriptions: { en?: string[]; de?: string[] }; +} + +export interface DemoPersona { + username: string; + displayName: string; + bio: string; + locales: DemoLocale[]; + content: DemoPersonaContent; +} + +const LocaleSchema = z.enum(["en", "de"]); +const PoolSchema = z.array(z.string().min(1)).min(3).max(50); + +const PersonaSchema = z + .object({ + username: z.string().regex(/^[a-z0-9][a-z0-9_-]{1,30}$/), + displayName: z.string().min(1).max(200), + bio: z.string().max(200), + locales: z.array(LocaleSchema).min(1), + content: z.object({ + names: z.object({ en: PoolSchema.optional(), de: PoolSchema.optional() }), + descriptions: z.object({ + en: PoolSchema.optional(), + de: PoolSchema.optional(), + }), + }), + }) + .superRefine((p, ctx) => { + for (const loc of p.locales) { + if (!p.content.names[loc]) { + ctx.addIssue({ + code: "custom", + path: ["content", "names", loc], + message: `missing name pool for declared locale '${loc}'`, + }); + } + if (!p.content.descriptions[loc]) { + ctx.addIssue({ + code: "custom", + path: ["content", "descriptions", loc], + message: `missing description pool for declared locale '${loc}'`, + }); + } + } + }); + +/** + * Built-in Bruno persona. Used verbatim when no `DEMO_BOT_PERSONA` is + * supplied or the supplied override fails validation. Moving these + * strings behind the persona abstraction means every reference to the + * bot's identity flows through one place. + */ +export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ + username: "bruno", + displayName: "Bruno", + bio: "Professional park inspector. Currently accepting tennis balls.", + locales: ["en", "de"], + content: { + names: { + en: [ + // Original Bruno-the-park-inspector roster + "Grunewald north-loop patrol", + "Tiergarten perimeter audit", + "Tempelhof runway inspection", + "Spree embankment survey", + "Bruno's morning rounds", + "Sniff-check: Kreuzberg edition", + "Bruno found three sticks today", + "Dog-audit report: all squirrels present", + "Volkspark compliance walk", + "Tennis ball reconnaissance", + "Pretzel-crumb cleanup detail", + "Bruno vs. the pigeons", + // More Berlin neighborhoods + "Mauerpark Sunday compliance walk", + "Görlitzer Park: known incidents follow-up", + "Treptower Park waterfront inquiry", + "Hasenheide irregularities review", + "Charlottenburg statue patrol", + "Viktoriapark summit assessment", + "Friedrichshain east-end sweep", + "Boxhagener flea-market reconnaissance", + "Wedding canal-side audit", + "Prenzlauer Berg playground inspection", + // Time-of-day variants + "Pre-breakfast investigative loop", + "Post-lunch dignity walk", + "Late-night ambient sniff", + "Twilight pigeon survey", + // Bureaucratic-deadpan + "Quarterly bush audit, Schöneberg branch", + "Cross-functional squirrel summit", + "Annual review: leaf situation", + "Briefing: tennis ball protocol updates", + "Field test: new harness", + // Specific events / one-offs + "Special operation: lost glove", + "Investigation: who left this baguette", + "Inter-park diplomatic mission", + "Currywurst stand perimeter check", + "Bruno briefly considered the lake", + ], + de: [ + // Original + "Grunewald-Nordschleife-Patrouille", + "Tiergarten-Umfangsprüfung", + "Tempelhof-Startbahn-Inspektion", + "Spreeufer-Rundgang", + "Brunos Morgenrunde", + "Schnüffelkontrolle: Kreuzberg", + "Bruno hat heute drei Stöcke gefunden", + "Hundeprüfbericht: alle Eichhörnchen anwesend", + "Volkspark-Konformitätsgang", + "Tennisball-Aufklärung", + "Brezel-Krümel-Aufräumdienst", + "Bruno gegen die Tauben", + // More Berlin neighborhoods + "Mauerpark-Sonntagskontrolle", + "Görlitzer Park: bekannte Vorfälle, Folgetermin", + "Treptower-Park-Uferprüfung", + "Hasenheide-Unregelmäßigkeiten", + "Charlottenburg-Statuenrunde", + "Viktoriapark-Gipfelinspektion", + "Friedrichshain-Ostseite-Durchgang", + "Boxhagener Flohmarkt-Aufklärung", + "Wedding-Kanalrunde", + "Prenzlauer Berg-Spielplatz-Inspektion", + // Time-of-day variants + "Vor-Frühstücks-Ermittlungsschleife", + "Würderunde nach dem Mittag", + "Spätabendliches Ambient-Schnüffeln", + "Dämmerungs-Taubenkontrolle", + // Bureaucratic-deadpan + "Quartalsbuschprüfung, Filiale Schöneberg", + "Funktionsübergreifender Eichhörnchen-Gipfel", + "Jahresbericht: Blätterlage", + "Briefing: aktualisiertes Tennisballprotokoll", + "Feldtest: neues Geschirr", + // Specific events / one-offs + "Sondereinsatz: verlorener Handschuh", + "Ermittlung: wer hat das Baguette liegen gelassen", + "Interparkische diplomatische Mission", + "Currywurst-Bude-Umkreissuche", + "Bruno hat kurz den See in Erwägung gezogen", + ], + }, + descriptions: { + en: [ + // Original + "Inspected several bushes. All present and accounted for.", + "Three squirrels successfully monitored. Two got away.", + "Good sticks: 2. Great sticks: 1. Excellent sticks: 0.", + "Pace was brisk. Sniffs were thorough.", + "Encountered another dog. Diplomacy established.", + "Weather: windy. Ears: flopping.", + "Pigeons stood their ground. A follow-up visit is required.", + "Finished the route ahead of schedule. Extra treats expected.", + "Investigated one suspicious paper bag. False alarm.", + "Logged one (1) successful puddle inspection.", + // Field-report flavor + "Perimeter secure. Mood: tactical.", + "Briefly mistaken for a smaller dog. Reputation restored within 30 seconds.", + "Two leaves of interest. One required additional sniffing.", + "Successfully ignored one bicycle bell.", + "Greeted seven (7) humans. Three returned the gesture.", + "Discovered a new puddle. Filed under: assets.", + // Wildlife encounters + "Squirrel made eye contact. Negotiations ongoing.", + "Encountered a leashed cat. Status: confused but respectful.", + "Heard distant fireworks. Posture: brave.", + "One duck observed at safe distance. Coexistence achieved.", + // Stick + ball lore + "Stick acquired. Stick relinquished. Stick re-acquired.", + "Located the same tennis ball as yesterday. Filing under: recurring asset.", + "Considered carrying a larger stick. Settled for two medium ones.", + // Self-aware moments + "Decided against the third loop. Dignity intact.", + "Confirmed: the bench is still there.", + "Park bench occupancy: nominal.", + "Pawprints on the kitchen floor: regrettable.", + "Got distracted by a leaf around minute 14. No further comment.", + // Berlin weather + "Rain commenced at minute 12. Coat: damp but professional.", + "Berlin drizzle. Standard operating posture.", + ], + de: [ + // Original + "Mehrere Büsche inspiziert. Alle vorhanden.", + "Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.", + "Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.", + "Tempo zügig. Schnüffeln gründlich.", + "Einen anderen Hund getroffen. Diplomatie hergestellt.", + "Wetter: windig. Ohren: flatternd.", + "Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.", + "Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.", + "Eine verdächtige Papiertüte untersucht. Fehlalarm.", + "Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.", + // Field-report flavor + "Perimeter gesichert. Stimmung: taktisch.", + "Kurz mit einem kleineren Hund verwechselt. Ruf binnen 30 Sekunden wiederhergestellt.", + "Zwei interessante Blätter. Eines erforderte zusätzliches Schnüffeln.", + "Erfolgreich eine Fahrradklingel ignoriert.", + "Sieben (7) Menschen begrüßt. Drei haben die Geste erwidert.", + "Eine neue Pfütze entdeckt. Vermerkt als: Vermögenswert.", + // Wildlife encounters + "Eichhörnchen hat Blickkontakt aufgenommen. Verhandlungen laufen.", + "Eine angeleinte Katze getroffen. Status: verwirrt aber respektvoll.", + "Entferntes Feuerwerk vernommen. Haltung: tapfer.", + "Eine Ente in sicherer Entfernung beobachtet. Koexistenz erreicht.", + // Stick + ball lore + "Stock erworben. Stock abgegeben. Stock erneut erworben.", + "Denselben Tennisball wie gestern lokalisiert. Vermerkt als: wiederkehrender Vermögenswert.", + "Einen größeren Stock erwogen. Auf zwei mittelgroße geeinigt.", + // Self-aware moments + "Gegen die dritte Runde entschieden. Würde unangetastet.", + "Bestätigt: die Bank steht noch.", + "Bank-Belegung: regulär.", + "Pfotenabdrücke auf dem Küchenboden: bedauerlich.", + "Bei Minute 14 von einem Blatt abgelenkt. Kein weiterer Kommentar.", + // Berlin weather + "Regen ab Minute 12. Mantel: feucht aber professionell.", + "Berliner Schmuddelwetter. Standardhaltung im Einsatz.", + ], + }, + }, +}); + +// Sanity: the default persona itself must parse under the schema. Catch +// accidental drift at module load rather than first invocation. +PersonaSchema.parse(DEFAULT_PERSONA); + +let _cachedPersona: DemoPersona | null = null; + +/** + * Read the persona from `DEMO_BOT_PERSONA`. Accepts either inline JSON + * or `file:`. Falls back to `DEFAULT_PERSONA` on any + * failure (unset, bad JSON, schema violation, unreadable file) with a + * single warn-level log line per process. + */ +export function loadPersona(): DemoPersona { + if (_cachedPersona) return _cachedPersona; + _cachedPersona = Object.freeze(loadPersonaUncached()); + return _cachedPersona; +} + +function loadPersonaUncached(): DemoPersona { + const raw = process.env.DEMO_BOT_PERSONA; + if (!raw) return DEFAULT_PERSONA; + + let json: string; + if (raw.startsWith("file:")) { + const path = raw.slice("file:".length); + try { + json = readFileSync(path, "utf8"); + } catch (err) { + logger.warn( + { path, err: (err as Error).message }, + "DEMO_BOT_PERSONA file unreadable, using default", + ); + return DEFAULT_PERSONA; + } + } else { + json = raw; + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (err) { + logger.warn( + { err: (err as Error).message }, + "DEMO_BOT_PERSONA not valid JSON, using default", + ); + return DEFAULT_PERSONA; + } + + const result = PersonaSchema.safeParse(parsed); + if (!result.success) { + const first = result.error.issues[0]; + logger.warn( + { path: first?.path.join("."), message: first?.message }, + "DEMO_BOT_PERSONA schema violation, using default", + ); + return DEFAULT_PERSONA; + } + return result.data; +} + +/** + * Test-only hook to reset the cached persona between tests. Never call + * from application code. + */ +export function __resetPersonaCacheForTests() { + _cachedPersona = null; +} + +/** + * Inner Berlin (Tiergarten → Grunewald corridor). Keeps Bruno's walks + * somewhere plausible for an urban dog and makes templated copy work. + * Override with `DEMO_BOT_REGION='{"bbox":[w,s,e,n]}'`. + */ +const DEFAULT_BBOX: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60]; + +export function isDemoBotEnabled(): boolean { + return process.env.DEMO_BOT_ENABLED === "true"; +} + +export function demoRetentionDays(): number { + const raw = process.env.DEMO_BOT_RETENTION_DAYS; + if (!raw) return 14; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 14; +} + +/** + * Surfaced when the persona username already belongs to a real user. + * Callers (worker boot) treat this as a signal to skip scheduling the + * bot for this process instead of silently attaching to a human's + * account. + */ +export class DemoPersonaUsernameClashError extends Error { + readonly kind = "username-clash"; + readonly username: string; + constructor(username: string) { + super(`demo persona username '${username}' is already taken by a non-demo user`); + this.username = username; + } +} + +function expectedSentinelEmail(persona: DemoPersona, domain: string): string { + return `${persona.username}@${domain}`; +} + +/** + * Idempotent insert of the demo user described by `persona`. Safe to + * call every worker boot; relies on `users.username` uniqueness to + * short-circuit duplicates. Throws `DemoPersonaUsernameClashError` if + * the row exists but looks like a real account (email doesn't match + * the sentinel pattern) — callers should catch this and keep the bot + * disabled for the process. + */ +export async function ensureDemoUser( + persona: DemoPersona = loadPersona(), +): Promise { + const db = getDb(); + const domain = process.env.DOMAIN ?? "localhost"; + const sentinelEmail = expectedSentinelEmail(persona, domain); + + const [existing] = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.username, persona.username)); + if (existing) { + if (existing.email !== sentinelEmail) { + throw new DemoPersonaUsernameClashError(persona.username); + } + return existing.id; + } + + const id = randomUUID(); + await db + .insert(users) + .values({ + id, + username: persona.username, + email: sentinelEmail, + displayName: persona.displayName, + bio: persona.bio, + domain, + // The demo persona is meant to be discoverable to anyone — its + // entire purpose is to populate empty instances with public + // content. Force `public` even though new accounts default to + // `private` (locked-account model). + profileVisibility: "public", + termsAcceptedAt: new Date(), + termsVersion: TERMS_VERSION, + }) + .onConflictDoNothing({ target: users.username }); + + // Re-read in case a concurrent insert won the race + const [row] = await db + .select({ id: users.id, email: users.email }) + .from(users) + .where(eq(users.username, persona.username)); + if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found"); + if (row.email !== sentinelEmail) { + throw new DemoPersonaUsernameClashError(persona.username); + } + return row.id; +} + +export interface Region { + bbox: [number, number, number, number]; +} + +export function loadRegion(): Region { + const raw = process.env.DEMO_BOT_REGION; + if (!raw) return { bbox: DEFAULT_BBOX }; + try { + const parsed = JSON.parse(raw) as { bbox?: unknown }; + if ( + Array.isArray(parsed.bbox) && + parsed.bbox.length === 4 && + parsed.bbox.every((n) => typeof n === "number") + ) { + return { bbox: parsed.bbox as [number, number, number, number] }; + } + } catch { + // fall through + } + logger.warn({ raw }, "DEMO_BOT_REGION invalid, using default"); + return { bbox: DEFAULT_BBOX }; +} + +/** + * Great-circle distance in metres between two (lon, lat) points. + */ +function haversineMeters(a: [number, number], b: [number, number]): number { + const R = 6_371_000; + const [lon1, lat1] = a; + const [lon2, lat2] = b; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(s)); +} + +function randInBbox(bbox: [number, number, number, number]): [number, number] { + const [w, s, e, n] = bbox; + return [w + Math.random() * (e - w), s + Math.random() * (n - s)]; +} + +export interface Endpoints { + start: [number, number]; + end: [number, number]; +} + +/** + * Random start + end inside the bbox such that the crow-distance is in + * the dog-walk band (2–12 km). Gives up after N tries; on exhaustion + * returns whatever it has so the caller can decide (BRouter will likely + * succeed anyway, just with shorter/longer routes). + */ +export function pickEndpoints(bbox: [number, number, number, number]): Endpoints { + const MIN = 2_000; + const MAX = 12_000; + for (let i = 0; i < 50; i++) { + const start = randInBbox(bbox); + const end = randInBbox(bbox); + const d = haversineMeters(start, end); + if (d >= MIN && d <= MAX) return { start, end }; + } + const start = randInBbox(bbox); + const end = randInBbox(bbox); + return { start, end }; +} + +// --- Persona-driven copy ------------------------------------------------- + +function pickFrom(pool: readonly T[], seed: number): T { + const idx = Math.abs(Math.floor(seed)) % pool.length; + return pool[idx]!; +} + +/** + * Pull a route name from the persona's name pool for the given locale. + * Seeded by the start-time so same-day walks usually don't collide. + * Falls back to the first persona locale if the caller asked for a + * locale the persona doesn't support (defensive — the caller is + * expected to pick from `persona.locales`). + */ +export function templateName( + startedAt: Date, + locale: DemoLocale, + persona: DemoPersona = loadPersona(), +): string { + const pool = persona.content.names[locale] ?? persona.content.names[persona.locales[0]!]!; + const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes(); + return pickFrom(pool, seed); +} + +export function templateDescription( + startedAt: Date, + locale: DemoLocale, + persona: DemoPersona = loadPersona(), +): string { + const pool = + persona.content.descriptions[locale] ?? + persona.content.descriptions[persona.locales[0]!]!; + const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7; + return pickFrom(pool, seed); +} + +/** + * Random locale from the persona's supported set. Used by the + * generation job so instances with `locales: ["en"]` never emit + * German-voiced walks. + */ +export function pickLocale( + persona: DemoPersona = loadPersona(), + rand: () => number = Math.random, +): DemoLocale { + return persona.locales[Math.floor(rand() * persona.locales.length)]!; +} + +// --- Route compute via the planner --------------------------------------- + +/** + * The journal goes through the planner (not BRouter directly) because + * the planner is the BRouter client in this architecture — route + * compute, rate limiting, and the eventual move of BRouter off-box + * all live there. Same `PLANNER_URL` the interactive handoff routes + * use; falls back to the dev port in local work. + */ +const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001"; + +export interface BrouterResult { + gpx: string; +} + +export type BrouterError = + | { kind: "no-route" } + | { kind: "timeout" } + | { kind: "upstream-error"; status?: number }; + +/** + * Create a throwaway planner session we can cite as the auth on the + * subsequent `/api/route` call. The planner session-binds its proxies + * so anonymous traffic doesn't pile up behind our Origin; bot traffic + * needs to look the same as browser traffic. Returned ids are cleaned + * up by the planner's `expire-sessions` cron (7d window) — we don't + * close them ourselves. + */ +async function createPlannerSession(signal?: AbortSignal): Promise { + try { + const resp = await fetch(`${PLANNER_URL}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + signal, + }); + if (!resp.ok) return null; + const payload = (await resp.json()) as { sessionId?: string }; + return payload.sessionId ?? null; + } catch { + return null; + } +} + +export async function requestBrouterGpx( + endpoints: Endpoints, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const sessionId = await createPlannerSession(signal); + if (!sessionId) return { kind: "upstream-error" }; + + const url = `${PLANNER_URL}/api/route`; + const body = { + waypoints: [ + { lon: endpoints.start[0], lat: endpoints.start[1] }, + { lon: endpoints.end[0], lat: endpoints.end[1] }, + ], + profile: "trekking", + format: "gpx" as const, + sessionId, + }; + try { + const resp = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!resp.ok) { + // Planner returns 422 for BRouter client errors (unroutable + // waypoints) and 5xx for upstream/planner failures. + if (resp.status === 422) return { kind: "no-route" }; + return { kind: "upstream-error", status: resp.status }; + } + const gpx = await resp.text(); + if (!gpx.includes(" { + const region = loadRegion(); + const endpoints = pickEndpoints(region.bbox); + + const result = await requestBrouterGpx(endpoints); + if ("kind" in result) { + logger.info({ endpoints, err: result.kind }, "demo-bot brouter miss"); + return null; + } + + const name = templateName(now, locale, persona); + const description = templateDescription(now, locale, persona); + + const { stats } = await processGpx(result.gpx); + const { distance, elevationGain, elevationLoss } = stats; + + if (!distance || distance < 500) return null; + + const walkingMetersPerSecond = 4.5 * 1000 / 3600; // ~4.5 km/h + const jitter = 0.85 + Math.random() * 0.3; // ±15 % + const durationSeconds = Math.round((distance / walkingMetersPerSecond) * jitter); + + const routeId = await createRoute(ownerId, { + name, + description, + gpx: result.gpx, + routingProfile: "trekking", + visibility: "public", + synthetic: true, + distance, + elevationGain, + elevationLoss, + }); + + await createActivity(ownerId, { + name, + description, + gpx: result.gpx, + routeId, + startedAt: now, + duration: durationSeconds, + distance, + visibility: "public", + synthetic: true, + }); + + return routeId; +} + +export async function countSyntheticRoutesRecent(days: number): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.routes + WHERE synthetic = true AND created_at > now() - make_interval(days => ${days})`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +export async function countSyntheticRoutesTotal(): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.routes WHERE synthetic = true`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +async function countSyntheticActivitiesTotal(): Promise { + const db = getDb(); + const result = await db.execute( + sql`SELECT count(*)::int AS count FROM journal.activities WHERE synthetic = true`, + ); + const rows = result as unknown as Array<{ count: number }>; + return rows[0]?.count ?? 0; +} + +/** + * Refresh the prometheus gauges. Called by the job handlers so the + * worker process exposes fresh counts at scrape time without needing a + * periodic poller. + */ +export async function refreshDemoBotGauges(): Promise { + const [routesCount, activitiesCount] = await Promise.all([ + countSyntheticRoutesTotal(), + countSyntheticActivitiesTotal(), + ]); + demoBotSyntheticRoutesTotal.set(routesCount); + demoBotSyntheticActivitiesTotal.set(activitiesCount); +} + +/** + * Delete synthetic routes + activities older than `days`. Never touches + * non-synthetic rows. + */ +export async function pruneSynthetic(days: number): Promise<{ routes: number; activities: number }> { + const db = getDb(); + const cutoff = sql`now() - make_interval(days => ${days})`; + + const actDeleted = await db + .delete(activities) + .where(and(eq(activities.synthetic, true), lt(activities.createdAt, cutoff))) + .returning({ id: activities.id }); + + const routeDeleted = await db + .delete(routes) + .where(and(eq(routes.synthetic, true), lt(routes.createdAt, cutoff))) + .returning({ id: routes.id }); + + return { routes: routeDeleted.length, activities: actDeleted.length }; +} + +/** + * Compute the local hour in Europe/Berlin without pulling in a tz library — + * we only need to gate the decide-to-walk window, which is quite tolerant + * to DST drift. + */ +export function berlinHour(now: Date): number { + const s = now.toLocaleString("en-US", { + timeZone: "Europe/Berlin", + hour12: false, + hour: "2-digit", + }); + return Number.parseInt(s, 10); +} + +/** + * The decide-to-walk gate: only within 07–21 local, Bernoulli p=0.09. + * Factored out so tests can replace `Math.random` / `now`. + * + * Cadence math: the job ticks every 30 min, so the 14-hour daytime + * window (07:00–21:00 Berlin) covers 28 ticks/day. With p=0.09 the + * expected walks/day ≈ 28 * 0.09 ≈ 2.52, which lands in the target + * 2–3 walks/day band. + */ +export function shouldWalkNow(now: Date, rand: () => number = Math.random): boolean { + const hour = berlinHour(now); + if (hour < 7 || hour >= 21) return false; + return rand() < 0.09; +} + +export const DEMO_DAILY_CAP = 40; +export const DEMO_BACKFILL_TARGET = 4; diff --git a/apps/journal/app/lib/demo-bot.test.ts b/apps/journal/app/lib/demo-bot.test.ts new file mode 100644 index 0000000..1607a27 --- /dev/null +++ b/apps/journal/app/lib/demo-bot.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + DEFAULT_PERSONA, + __resetPersonaCacheForTests, + berlinHour, + loadPersona, + loadRegion, + pickEndpoints, + pickLocale, + shouldWalkNow, + templateDescription, + templateName, + type DemoPersona, +} from "./demo-bot.server.ts"; + +describe("pickEndpoints", () => { + const bbox: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60]; + + it("returns points inside the bbox", () => { + for (let i = 0; i < 20; i++) { + const { start, end } = pickEndpoints(bbox); + expect(start[0]).toBeGreaterThanOrEqual(bbox[0]); + expect(start[0]).toBeLessThanOrEqual(bbox[2]); + expect(start[1]).toBeGreaterThanOrEqual(bbox[1]); + expect(start[1]).toBeLessThanOrEqual(bbox[3]); + expect(end[0]).toBeGreaterThanOrEqual(bbox[0]); + expect(end[0]).toBeLessThanOrEqual(bbox[2]); + expect(end[1]).toBeGreaterThanOrEqual(bbox[1]); + expect(end[1]).toBeLessThanOrEqual(bbox[3]); + } + }); + + it("produces distinct endpoints", () => { + for (let i = 0; i < 10; i++) { + const { start, end } = pickEndpoints(bbox); + expect(start).not.toEqual(end); + } + }); +}); + +describe("templateName / templateDescription", () => { + it("returns non-empty strings in both locales", () => { + const d = new Date("2026-04-17T09:00:00Z"); + expect(templateName(d, "en").length).toBeGreaterThan(0); + expect(templateName(d, "de").length).toBeGreaterThan(0); + expect(templateDescription(d, "en").length).toBeGreaterThan(0); + expect(templateDescription(d, "de").length).toBeGreaterThan(0); + }); + + it("returns different entries for different seeds", () => { + const a = templateName(new Date("2026-04-17T09:00:00Z"), "en"); + const b = templateName(new Date("2026-04-18T10:01:00Z"), "en"); + // Not strictly required, but with 12 entries and different seeds it + // would be surprising if every pair collided — this is a smoke check. + expect(typeof a).toBe("string"); + expect(typeof b).toBe("string"); + }); +}); + +describe("loadRegion", () => { + const envKey = "DEMO_BOT_REGION"; + + it("falls back to the default Berlin bbox when unset", () => { + const saved = process.env[envKey]; + delete process.env[envKey]; + try { + const r = loadRegion(); + expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]); + } finally { + if (saved !== undefined) process.env[envKey] = saved; + } + }); + + it("parses a valid JSON override", () => { + const saved = process.env[envKey]; + process.env[envKey] = JSON.stringify({ bbox: [0, 0, 1, 1] }); + try { + const r = loadRegion(); + expect(r.bbox).toEqual([0, 0, 1, 1]); + } finally { + if (saved === undefined) delete process.env[envKey]; + else process.env[envKey] = saved; + } + }); + + it("falls back on invalid JSON", () => { + const saved = process.env[envKey]; + process.env[envKey] = "{not json"; + try { + const r = loadRegion(); + expect(r.bbox).toEqual([13.25, 52.45, 13.55, 52.60]); + } finally { + if (saved === undefined) delete process.env[envKey]; + else process.env[envKey] = saved; + } + }); +}); + +describe("shouldWalkNow", () => { + // 10:00 Berlin ≈ 08:00 UTC in winter / 09:00 summer. Both are inside 07-21. + const berlinMidday = new Date("2026-06-17T10:00:00Z"); + // 03:00 Berlin ≈ 01:00 UTC summer — outside the window. + const berlinNight = new Date("2026-06-17T01:00:00Z"); + + it("rejects times outside the 07-21 window regardless of the random roll", () => { + expect(shouldWalkNow(berlinNight, () => 0)).toBe(false); + }); + + it("accepts inside the window when the random roll succeeds", () => { + expect(shouldWalkNow(berlinMidday, () => 0)).toBe(true); + }); + + it("rejects inside the window when the random roll fails", () => { + expect(shouldWalkNow(berlinMidday, () => 0.99)).toBe(false); + }); +}); + +describe("berlinHour", () => { + it("reports a plausible hour", () => { + const h = berlinHour(new Date("2026-06-17T10:00:00Z")); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThanOrEqual(23); + }); +}); + +// --- Persona configuration ------------------------------------------------- + +function minimalPersona(overrides: Partial = {}): DemoPersona { + return { + username: "test-demo", + displayName: "Test", + bio: "test bio", + locales: ["en"], + content: { + names: { + en: ["alpha walk", "bravo walk", "charlie walk"], + }, + descriptions: { + en: ["alpha desc", "bravo desc", "charlie desc"], + }, + }, + ...overrides, + }; +} + +describe("loadPersona", () => { + const envKey = "DEMO_BOT_PERSONA"; + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env[envKey]; + delete process.env[envKey]; + __resetPersonaCacheForTests(); + }); + + afterEach(() => { + if (savedEnv === undefined) delete process.env[envKey]; + else process.env[envKey] = savedEnv; + __resetPersonaCacheForTests(); + }); + + it("returns the default persona when unset", () => { + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("caches the result across calls", () => { + const a = loadPersona(); + const b = loadPersona(); + expect(a).toBe(b); + }); + + it("parses a valid inline JSON override", () => { + process.env[envKey] = JSON.stringify(minimalPersona({ username: "hamish" })); + const p = loadPersona(); + expect(p.username).toBe("hamish"); + expect(p.locales).toEqual(["en"]); + }); + + it("falls back to the default on invalid JSON", () => { + process.env[envKey] = "{not json"; + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when the username violates the pattern", () => { + process.env[envKey] = JSON.stringify(minimalPersona({ username: "Has Spaces" })); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when a declared locale has no content pool", () => { + process.env[envKey] = JSON.stringify( + minimalPersona({ + locales: ["en", "de"], + // intentionally omit de pools + }), + ); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("falls back when a pool has fewer than 3 entries", () => { + process.env[envKey] = JSON.stringify( + minimalPersona({ + content: { + names: { en: ["only", "two"] }, + descriptions: { en: ["d1", "d2", "d3"] }, + }, + }), + ); + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); + + it("reads a persona from a file: path", () => { + const dir = mkdtempSync(join(tmpdir(), "demo-persona-")); + const path = join(dir, "persona.json"); + writeFileSync(path, JSON.stringify(minimalPersona({ username: "from-file" }))); + try { + process.env[envKey] = `file:${path}`; + const p = loadPersona(); + expect(p.username).toBe("from-file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back when a file: path is unreadable", () => { + process.env[envKey] = "file:/nonexistent/does-not-exist.json"; + const p = loadPersona(); + expect(p.username).toBe(DEFAULT_PERSONA.username); + }); +}); + +describe("templateName / templateDescription with custom persona", () => { + it("draws from the supplied persona's pool and never leaks entries from the default", () => { + const persona = minimalPersona(); + const bannedSubstring = "Grunewald"; // present in DEFAULT_PERSONA but not in `persona` + for (let i = 0; i < 30; i++) { + const d = new Date(2026, 3, 1 + (i % 28), i % 24, i % 60, i); + const name = templateName(d, "en", persona); + const desc = templateDescription(d, "en", persona); + expect(persona.content.names.en).toContain(name); + expect(persona.content.descriptions.en).toContain(desc); + expect(name).not.toContain(bannedSubstring); + expect(desc).not.toContain(bannedSubstring); + } + }); +}); + +describe("pickLocale", () => { + it("only returns locales declared by the persona", () => { + const persona = minimalPersona({ locales: ["en"] }); + for (let i = 0; i < 10; i++) { + expect(pickLocale(persona)).toBe("en"); + } + }); + + it("distributes across declared locales", () => { + const persona = minimalPersona({ + locales: ["en", "de"], + content: { + names: { + en: ["en1", "en2", "en3"], + de: ["de1", "de2", "de3"], + }, + descriptions: { + en: ["d1", "d2", "d3"], + de: ["ds1", "ds2", "ds3"], + }, + }, + }); + expect(pickLocale(persona, () => 0)).toBe("en"); + expect(pickLocale(persona, () => 0.99)).toBe("de"); + }); +}); diff --git a/apps/journal/app/lib/email.server.ts b/apps/journal/app/lib/email.server.ts index 50b2209..3142685 100644 --- a/apps/journal/app/lib/email.server.ts +++ b/apps/journal/app/lib/email.server.ts @@ -1,5 +1,5 @@ import { createTransport, type Transporter } from "nodemailer"; -import { logger } from "./logger.server"; +import { logger } from "./logger.server.ts"; const FROM = process.env.SMTP_FROM ?? "trails.cool "; @@ -30,23 +30,33 @@ export async function sendEmail( // --- Templates --- -export function magicLinkTemplate(link: string): { html: string; text: string } { +export function magicLinkTemplate(link: string, code?: string): { html: string; text: string } { + const codeSection = code + ? `

Or enter this code on the login page:

+
+ ${code} +
` + : ""; + const html = `

Sign in to trails.cool

Click the button below to sign in to your account. This link expires in 15 minutes.

Sign In + ${codeSection}

If the button doesn't work, copy and paste this link:
${link}


If you didn't request this link, you can safely ignore this email.

`.trim(); + const codeText = code ? `\nOr enter this code: ${code}\n` : ""; + const text = [ "Sign in to trails.cool", "", `Click here to sign in: ${link}`, - "", + codeText, "This link expires in 15 minutes.", "", "If you didn't request this link, you can safely ignore this email.", @@ -89,8 +99,8 @@ export function welcomeTemplate(username: string): { html: string; text: string // --- Convenience wrappers --- -export async function sendMagicLink(email: string, link: string): Promise { - const { html, text } = magicLinkTemplate(link); +export async function sendMagicLink(email: string, link: string, code?: string): Promise { + const { html, text } = magicLinkTemplate(link, code); await sendEmail(email, "Sign in to trails.cool", html, text); } diff --git a/apps/journal/app/lib/events.server.ts b/apps/journal/app/lib/events.server.ts new file mode 100644 index 0000000..4f649da --- /dev/null +++ b/apps/journal/app/lib/events.server.ts @@ -0,0 +1,48 @@ +// In-process Server-Sent Events broker. Generation hooks call +// `emitTo(userId, event, data)` after they commit; any open SSE +// connection for that user gets the event written to its stream. +// +// Single-process today. When the Journal goes multi-process, swap the +// in-memory `Map` for a Redis pub/sub adapter behind the same +// emitTo/register interface — no caller changes needed. + +interface Connection { + send: (event: string, data: unknown) => void; + close: () => void; +} + +const connections = new Map>(); + +export function register(userId: string, conn: Connection): () => void { + let set = connections.get(userId); + if (!set) { + set = new Set(); + connections.set(userId, set); + } + set.add(conn); + return () => { + const s = connections.get(userId); + if (!s) return; + s.delete(conn); + if (s.size === 0) connections.delete(userId); + }; +} + +export function emitTo(userId: string, event: string, data: unknown): void { + const set = connections.get(userId); + if (!set) return; + for (const conn of set) { + try { + conn.send(event, data); + } catch { + // Broken pipe — connection will get cleaned up on its own + // teardown path; defensively close here too. + try { conn.close(); } catch { /* ignore */ } + } + } +} + +/** Test/diagnostic helper: how many connections does `userId` have? */ +export function connectionCount(userId: string): number { + return connections.get(userId)?.size ?? 0; +} diff --git a/apps/journal/app/lib/explore.integration.test.ts b/apps/journal/app/lib/explore.integration.test.ts new file mode 100644 index 0000000..93f916f --- /dev/null +++ b/apps/journal/app/lib/explore.integration.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { activities, users } from "@trails-cool/db/schema/journal"; +import { + listDirectory, + countDirectory, + listActiveRecently, + countFollowersBatch, +} from "./explore.server.ts"; +import { loadPersona } from "./demo-bot.server.ts"; + +// Opt-in: these talk to real Postgres. Gated by EXPLORE_INTEGRATION=1. +const runIntegration = process.env.EXPLORE_INTEGRATION === "1"; + +async function makeUser(opts: { + username: string; + profileVisibility?: "public" | "private"; + bio?: string | null; +}) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + bio: opts.bio ?? null, + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function makeActivity(opts: { + ownerId: string; + visibility?: "public" | "unlisted" | "private"; + createdAt?: Date; + name?: string; +}) { + const db = getDb(); + const id = randomUUID(); + await db.insert(activities).values({ + id, + ownerId: opts.ownerId, + name: opts.name ?? `act-${id.slice(0, 8)}`, + visibility: opts.visibility ?? "public", + createdAt: opts.createdAt ?? new Date(), + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("explore.server integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1`); + }); + afterEach(wipe); + + it("public user appears in the directory", async () => { + const id = await makeUser({ username: `ex_pub_${Date.now()}` }); + const { rows, totalCount } = await listDirectory({ page: 1, perPage: 50 }); + expect(rows.find((r) => r.id === id)).toBeDefined(); + expect(totalCount).toBeGreaterThanOrEqual(1); + }); + + it("private user is excluded from the directory", async () => { + const id = await makeUser({ + username: `ex_priv_${Date.now()}`, + profileVisibility: "private", + }); + const { rows } = await listDirectory({ page: 1, perPage: 50 }); + expect(rows.find((r) => r.id === id)).toBeUndefined(); + }); + + it("demo persona is INCLUDED in the directory", async () => { + const persona = loadPersona(); + // Insert a user with the persona's username — should appear like any + // other public user. The /explore loader is responsible for the + // demo-badge tagging at render time, not the directory query. + const id = await makeUser({ username: persona.username }); + const { rows } = await listDirectory({ page: 1, perPage: 50 }); + expect(rows.find((r) => r.id === id)).toBeDefined(); + }); + + it("orders by most-recent public activity, NULLS LAST", async () => { + const stamp = Date.now(); + const aId = await makeUser({ username: `ex_a_${stamp}` }); + const bId = await makeUser({ username: `ex_b_${stamp}` }); + // A has yesterday, B has today. + await makeActivity({ ownerId: aId, createdAt: new Date(Date.now() - 86_400_000) }); + await makeActivity({ ownerId: bId, createdAt: new Date() }); + const cId = await makeUser({ username: `ex_c_${stamp}` }); // no activities + + const { rows } = await listDirectory({ page: 1, perPage: 50 }); + const indexOf = (id: string) => rows.findIndex((r) => r.id === id); + expect(indexOf(bId)).toBeGreaterThanOrEqual(0); + expect(indexOf(aId)).toBeGreaterThan(indexOf(bId)); // B before A + expect(indexOf(cId)).toBeGreaterThan(indexOf(aId)); // A before C (C is NULL) + }); + + it("private activities don't count for ordering", async () => { + const stamp = Date.now(); + const aId = await makeUser({ username: `ex_priv_act_a_${stamp}` }); + const bId = await makeUser({ username: `ex_priv_act_b_${stamp}` }); + // A has a private activity from today; B has a public activity from yesterday. + await makeActivity({ ownerId: aId, visibility: "private", createdAt: new Date() }); + await makeActivity({ + ownerId: bId, + visibility: "public", + createdAt: new Date(Date.now() - 86_400_000), + }); + const { rows } = await listDirectory({ page: 1, perPage: 50 }); + const indexOf = (id: string) => rows.findIndex((r) => r.id === id); + // B (public yesterday) should come before A (private today is invisible). + expect(indexOf(bId)).toBeGreaterThanOrEqual(0); + expect(indexOf(aId)).toBeGreaterThan(indexOf(bId)); + }); + + it("'Active recently' includes public users with public activity in last 30 days", async () => { + const stamp = Date.now(); + const aId = await makeUser({ username: `ex_ar_a_${stamp}` }); + const bId = await makeUser({ username: `ex_ar_b_${stamp}` }); + // A has a public activity from 5 days ago. + await makeActivity({ + ownerId: aId, + visibility: "public", + createdAt: new Date(Date.now() - 5 * 86_400_000), + }); + // B has a public activity from 60 days ago. + await makeActivity({ + ownerId: bId, + visibility: "public", + createdAt: new Date(Date.now() - 60 * 86_400_000), + }); + const rows = await listActiveRecently(10); + expect(rows.find((r) => r.id === aId)).toBeDefined(); + expect(rows.find((r) => r.id === bId)).toBeUndefined(); + }); + + it("'Active recently' caps at the requested limit", async () => { + const stamp = Date.now(); + const ids: string[] = []; + for (let i = 0; i < 7; i++) { + const id = await makeUser({ username: `ex_cap_${i}_${stamp}` }); + await makeActivity({ + ownerId: id, + visibility: "public", + createdAt: new Date(Date.now() - i * 60_000), + }); + ids.push(id); + } + const rows = await listActiveRecently(3); + expect(rows.length).toBeLessThanOrEqual(3); + }); + + it("countDirectory matches listDirectory's totalCount", async () => { + await makeUser({ username: `ex_count_a_${Date.now()}` }); + await makeUser({ username: `ex_count_b_${Date.now()}` }); + const total = await countDirectory(); + const { totalCount } = await listDirectory({ page: 1, perPage: 1 }); + expect(total).toBe(totalCount); + }); + + it("countFollowersBatch handles empty input", async () => { + const result = await countFollowersBatch([]); + expect(result.size).toBe(0); + }); + + it("countFollowersBatch fills zeros for users with no followers", async () => { + const id = await makeUser({ username: `ex_fb_${Date.now()}` }); + const result = await countFollowersBatch([id]); + expect(result.get(id)).toBe(0); + }); +}); diff --git a/apps/journal/app/lib/explore.server.ts b/apps/journal/app/lib/explore.server.ts new file mode 100644 index 0000000..a604075 --- /dev/null +++ b/apps/journal/app/lib/explore.server.ts @@ -0,0 +1,212 @@ +import { and, count, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { activities, follows, users } from "@trails-cool/db/schema/journal"; +import { localActorIri } from "./actor-iri.ts"; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const ACTIVE_RECENTLY_DAYS = 30; +const ACTIVE_RECENTLY_DEFAULT_LIMIT = 5; + +export interface DirectoryRow { + id: string; + username: string; + displayName: string | null; + bio: string | null; + latestActivityAt: Date | null; +} + +export interface DirectoryListing { + rows: DirectoryRow[]; + totalCount: number; +} + +export interface ListDirectoryOptions { + page?: number; + perPage?: number; +} + +/** + * Clamps `?perPage=` into [1, MAX_PAGE_SIZE]. Out-of-range values + * (NaN, negative, > MAX) snap to bounds rather than 400. + */ +function clampPerPage(raw: number | undefined): number { + if (!raw || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE; + return Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(raw))); +} + +function clampPage(raw: number | undefined): number { + if (!raw || !Number.isFinite(raw)) return 1; + return Math.max(1, Math.floor(raw)); +} + +function exclusionFilters() { + // Public-only. The demo persona IS included on /explore — its whole + // purpose is to give new users a follow target, and the per-row demo + // badge in the UI signals what it is. Banned/suspended users would + // be filtered here too once such a status column exists — see design.md. + return eq(users.profileVisibility, "public"); +} + +/** + * Paginated directory of public local users. Order: most-recent public + * activity DESC NULLS LAST, tiebreaker `users.id DESC` for stable + * pagination. + */ +export async function listDirectory(opts: ListDirectoryOptions = {}): Promise { + const db = getDb(); + const perPage = clampPerPage(opts.perPage); + const page = clampPage(opts.page); + const offset = (page - 1) * perPage; + + const latestActivity = sql`MAX(CASE WHEN ${activities.visibility} = 'public' THEN ${activities.createdAt} ELSE NULL END)`; + + const rows = await db + .select({ + id: users.id, + username: users.username, + displayName: users.displayName, + bio: users.bio, + latestActivityAt: latestActivity, + }) + .from(users) + .leftJoin(activities, eq(activities.ownerId, users.id)) + .where(exclusionFilters()) + .groupBy(users.id) + .orderBy(sql`${latestActivity} DESC NULLS LAST`, desc(users.id)) + .limit(perPage) + .offset(offset); + + const [countRow] = await db + .select({ n: count() }) + .from(users) + .where(exclusionFilters()); + + return { rows, totalCount: countRow?.n ?? 0 }; +} + +/** + * Cheap count helper if a caller only wants the size — used by the + * pagination math path that doesn't need the page rows. + */ +export async function countDirectory(): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(users) + .where(exclusionFilters()); + return row?.n ?? 0; +} + +/** + * Top-N public users with at least one public activity in the last + * 30 days. Same exclusion rules as `listDirectory`. Returns at most + * `limit` rows; an empty array signals "no qualifying users — hide + * the strip." + */ +export async function listActiveRecently(limit: number = ACTIVE_RECENTLY_DEFAULT_LIMIT): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - ACTIVE_RECENTLY_DAYS * 24 * 60 * 60 * 1000); + + const latestActivity = sql`MAX(${activities.createdAt})`; + + const rows = await db + .select({ + id: users.id, + username: users.username, + displayName: users.displayName, + bio: users.bio, + latestActivityAt: latestActivity, + }) + .from(users) + .innerJoin(activities, eq(activities.ownerId, users.id)) + .where( + and( + exclusionFilters(), + eq(activities.visibility, "public"), + gte(activities.createdAt, cutoff), + ), + ) + .groupBy(users.id) + .orderBy(sql`${latestActivity} DESC`, desc(users.id)) + .limit(Math.max(1, Math.min(50, Math.floor(limit)))); + + return rows; +} + +/** + * Batched accepted-follower count for a set of users. One query + * regardless of how many users are on the page — avoids N+1 against + * `countFollowers` per row. + */ +export async function countFollowersBatch(userIds: string[]): Promise> { + const result = new Map(); + if (userIds.length === 0) return result; + const db = getDb(); + const rows = await db + .select({ + userId: follows.followedUserId, + n: count(), + }) + .from(follows) + .where( + and( + inArray(follows.followedUserId, userIds), + isNotNull(follows.acceptedAt), + ), + ) + .groupBy(follows.followedUserId); + for (const r of rows) { + if (r.userId) result.set(r.userId, r.n); + } + // Fill missing user ids with 0 so callers don't need to coalesce. + for (const id of userIds) { + if (!result.has(id)) result.set(id, 0); + } + return result; +} + +/** + * Batched follow-state lookup for a viewer against a set of target + * users. Returns Map. Used by /explore so + * each row's FollowButton has its `initialState` without N round-trips. + */ +export interface FollowStateRow { + following: boolean; + pending: boolean; +} + +export async function getFollowStateBatch( + followerId: string, + targets: { id: string; username: string }[], +): Promise> { + const result = new Map(); + if (targets.length === 0) return result; + const db = getDb(); + const iris = targets.map((t) => localActorIri(t.username)); + const irisToId = new Map(targets.map((t) => [localActorIri(t.username), t.id])); + const rows = await db + .select({ + iri: follows.followedActorIri, + acceptedAt: follows.acceptedAt, + }) + .from(follows) + .where( + and( + eq(follows.followerId, followerId), + inArray(follows.followedActorIri, iris), + ), + ); + for (const r of rows) { + const id = irisToId.get(r.iri); + if (!id) continue; + result.set(id, { following: r.acceptedAt !== null, pending: r.acceptedAt === null }); + } + return result; +} + +/** + * Page-size constants exposed for callers (route loader, tests). + */ +export const EXPLORE_DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE; +export const EXPLORE_MAX_PAGE_SIZE = MAX_PAGE_SIZE; diff --git a/apps/journal/app/lib/federation-blocklist.integration.test.ts b/apps/journal/app/lib/federation-blocklist.integration.test.ts new file mode 100644 index 0000000..b45c7c0 --- /dev/null +++ b/apps/journal/app/lib/federation-blocklist.integration.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { inArray } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, federationBlockedInstances } from "@trails-cool/db/schema/journal"; +import { + isBlockedDomain, + isBlockedIri, + filterBlockedDomains, +} from "./federation-blocklist.server.ts"; +import { enqueueActivityDeliveries } from "./federation-delivery.server.ts"; +import { pollRemoteActor, type PollDeps } from "./federation-ingest.server.ts"; +import { setBoss } from "./boss.server.ts"; + +// Opt-in: real Postgres; no instance is contacted (poll deps injected). +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const BLOCKED = `blocked-${Date.now()}.example`; +const ALLOWED = `allowed-${Date.now()}.example`; +const userIds: string[] = []; + +describe.runIf(runIntegration)("federation instance blocklist (integration)", () => { + beforeAll(async () => { + process.env.FEDERATION_ENABLED = "true"; + process.env.ORIGIN ??= "https://test.local"; + const db = getDb(); + await db.insert(federationBlockedInstances).values({ domain: BLOCKED, reason: "test" }); + }); + + afterAll(async () => { + const db = getDb(); + await db.delete(federationBlockedInstances).where(inArray(federationBlockedInstances.domain, [BLOCKED, ALLOWED])); + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)); + setBoss(null); + }); + + it("matches blocked domains exactly, by host", async () => { + expect(await isBlockedDomain(BLOCKED)).toBe(true); + expect(await isBlockedDomain(ALLOWED)).toBe(false); + expect(await isBlockedDomain(`sub.${BLOCKED}`)).toBe(false); // exact-host, no subdomain wildcard + }); + + it("isBlockedIri checks the IRI host; unparseable IRIs are treated as blocked", async () => { + expect(await isBlockedIri(`https://${BLOCKED}/users/x`)).toBe(true); + expect(await isBlockedIri(`https://${ALLOWED}/users/x`)).toBe(false); + expect(await isBlockedIri("garbage")).toBe(true); + }); + + it("filterBlockedDomains resolves a batch in one pass", async () => { + const set = await filterBlockedDomains([BLOCKED, ALLOWED, "another.example"]); + expect([...set]).toEqual([BLOCKED]); + }); + + it("delivery enqueue: recipients on a blocked domain are filtered out", async () => { + const db = getDb(); + const ownerId = randomUUID(); + userIds.push(ownerId); + await db.insert(users).values({ + id: ownerId, + email: `owner-${ownerId}@example.test`, + username: `owner_${Date.now()}`, + domain: "test.local", + profileVisibility: "public", + }); + const now = new Date(); + const followedActorIri = `https://test.local/users/owner_${ownerId}`; + await db.insert(follows).values([ + { id: randomUUID(), followedUserId: ownerId, followedActorIri, followerActorIri: `https://${BLOCKED}/users/a`, acceptedAt: now }, + { id: randomUUID(), followedUserId: ownerId, followedActorIri, followerActorIri: `https://${ALLOWED}/users/b`, acceptedAt: now }, + ]); + + const sent: Array<{ recipientActorIri: string }> = []; + setBoss({ + async send(_q: string, data: unknown) { + sent.push(data as { recipientActorIri: string }); + return "id"; + }, + } as unknown as Parameters[0]); + + await enqueueActivityDeliveries(ownerId, randomUUID(), "create"); + + // Only the allowed follower gets a delivery job. + expect(sent).toHaveLength(1); + expect(sent[0]?.recipientActorIri).toBe(`https://${ALLOWED}/users/b`); + }); + + it("outbox poll refuses a blocked instance without fetching", async () => { + const deps: PollDeps = { + async fetchJson() { + throw new Error("must not fetch a blocked instance"); + }, + async pace() { + throw new Error("must not pace a blocked instance"); + }, + }; + const result = await pollRemoteActor(`https://${BLOCKED}/users/x`, deps); + expect(result).toEqual({ skipped: "blocked instance" }); + }); +}); diff --git a/apps/journal/app/lib/federation-blocklist.server.test.ts b/apps/journal/app/lib/federation-blocklist.server.test.ts new file mode 100644 index 0000000..8dbfa26 --- /dev/null +++ b/apps/journal/app/lib/federation-blocklist.server.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { hostOfIri } from "./federation-blocklist.server.ts"; + +describe("hostOfIri", () => { + it("extracts the host from an actor/object IRI", () => { + expect(hostOfIri("https://mastodon.social/users/alice")).toBe("mastodon.social"); + expect(hostOfIri("https://sub.example.com:8443/x")).toBe("sub.example.com:8443"); + }); + + it("returns null for an unparseable IRI", () => { + expect(hostOfIri("not a url")).toBeNull(); + expect(hostOfIri("")).toBeNull(); + }); +}); diff --git a/apps/journal/app/lib/federation-blocklist.server.ts b/apps/journal/app/lib/federation-blocklist.server.ts new file mode 100644 index 0000000..21178c6 --- /dev/null +++ b/apps/journal/app/lib/federation-blocklist.server.ts @@ -0,0 +1,54 @@ +// Instance blocklist (spec: federation-operations "Instance blocklist"). +// A blocked domain is inert in both directions: its inbound activities are +// silently dropped, we send it no deliveries, and we don't fetch its +// actors/outboxes. Matching is exact-host (subdomain wildcarding is +// deferred until a real need). Management is a documented operator SQL +// procedure — see FEDERATION.md — with this table as the seam a future +// admin UI can sit on. + +import { inArray, eq } from "drizzle-orm"; +import { federationBlockedInstances } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; + +/** The host of an actor/object IRI, or null if it doesn't parse. */ +export function hostOfIri(iri: string): string | null { + try { + return new URL(iri).host; + } catch { + return null; + } +} + +/** Whether an exact host is on the blocklist. */ +export async function isBlockedDomain(domain: string): Promise { + const db = getDb(); + const [row] = await db + .select({ domain: federationBlockedInstances.domain }) + .from(federationBlockedInstances) + .where(eq(federationBlockedInstances.domain, domain)) + .limit(1); + return row !== undefined; +} + +/** Whether an actor/object IRI's host is blocked. Unparseable IRIs are + * treated as blocked — we won't process something we can't attribute. */ +export async function isBlockedIri(iri: string): Promise { + const host = hostOfIri(iri); + if (host === null) return true; + return isBlockedDomain(host); +} + +/** + * The subset of `domains` that are blocked, resolved in one query. Used + * by delivery fan-out to filter a batch of recipients without a query per + * recipient. + */ +export async function filterBlockedDomains(domains: string[]): Promise> { + if (domains.length === 0) return new Set(); + const db = getDb(); + const rows = await db + .select({ domain: federationBlockedInstances.domain }) + .from(federationBlockedInstances) + .where(inArray(federationBlockedInstances.domain, domains)); + return new Set(rows.map((r) => r.domain)); +} diff --git a/apps/journal/app/lib/federation-delivery.server.test.ts b/apps/journal/app/lib/federation-delivery.server.test.ts new file mode 100644 index 0000000..2d000ae --- /dev/null +++ b/apps/journal/app/lib/federation-delivery.server.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from "vitest"; + +// The db/boss imports in the module under test are irrelevant for the +// pure transition helper; stub them so importing the module is cheap. +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); +vi.mock("./boss.server.ts", () => ({ enqueueOptional: vi.fn() })); + +const { visibilityTransitionAction } = await import("./federation-delivery.server.ts"); + +describe("visibilityTransitionAction", () => { + it("publishes on any transition to public (including re-publish)", () => { + expect(visibilityTransitionAction("private", "public")).toBe("create"); + expect(visibilityTransitionAction("unlisted", "public")).toBe("create"); + expect(visibilityTransitionAction("public", "public")).toBe("create"); + }); + + it("retracts only when leaving public", () => { + expect(visibilityTransitionAction("public", "private")).toBe("delete"); + expect(visibilityTransitionAction("public", "unlisted")).toBe("delete"); + }); + + it("stays silent for non-public to non-public — a Delete would tombstone the URI", () => { + expect(visibilityTransitionAction("private", "unlisted")).toBeNull(); + expect(visibilityTransitionAction("unlisted", "private")).toBeNull(); + expect(visibilityTransitionAction("private", "private")).toBeNull(); + expect(visibilityTransitionAction("unlisted", "unlisted")).toBeNull(); + }); +}); diff --git a/apps/journal/app/lib/federation-delivery.server.ts b/apps/journal/app/lib/federation-delivery.server.ts new file mode 100644 index 0000000..820242b --- /dev/null +++ b/apps/journal/app/lib/federation-delivery.server.ts @@ -0,0 +1,155 @@ +// Push delivery of local public activities to accepted remote +// followers (spec: social-federation, "Push delivery on local activity +// create"). Enqueue side lives here; the actual signed POST happens in +// jobs/deliver-activity.ts. + +import { and, eq, isNotNull } from "drizzle-orm"; +import { follows, remoteActors, users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { federationEnabled } from "./federation.server.ts"; +import { enqueueOptional } from "./boss.server.ts"; +import { activityObjectIri } from "./federation-objects.server.ts"; +import { filterBlockedDomains, hostOfIri } from "./federation-blocklist.server.ts"; + +export type DeliveryAction = "create" | "delete"; + +/** + * Which federation action (if any) a visibility change warrants. + * + * The asymmetry matters: Mastodon records a `Delete` as a permanent + * tombstone — a later `Create` for the same URI is silently refused + * forever. So a retraction must only go out when remotes could + * actually have the object (it was public), never "just in case" + * (learned on the 2026-06-07 staging soak, where an unlisted + * activity's no-op save tombstoned its URI before its first real + * publish). + * + * - → public: push Create. Also for public→public — re-pushing is + * harmless (remotes dedupe by id) and doubles as back-delivery. + * - public → non-public: push Delete(Tombstone) — remotes saw it. + * - non-public → non-public: nothing — remotes never had it, and a + * Delete would poison the URI for any future publish. + */ +export function visibilityTransitionAction( + previous: string, + next: string, +): DeliveryAction | null { + if (next === "public") return "create"; + if (previous === "public") return "delete"; + return null; +} + +export interface DeliveryPayload { + action: DeliveryAction; + /** Present for `create` — the job re-reads the row at delivery time. */ + activityId?: string; + /** Object IRI; for `delete` this is all that's left of the activity. */ + objectIri: string; + ownerUsername: string; + recipientActorIri: string; +} + +/** Accepted remote followers of a local user (the delivery audience). */ +export async function listAcceptedRemoteFollowers(ownerId: string): Promise { + const db = getDb(); + const rows = await db + .select({ actorIri: follows.followerActorIri }) + .from(follows) + .where( + and( + eq(follows.followedUserId, ownerId), + isNotNull(follows.followerActorIri), + isNotNull(follows.acceptedAt), + ), + ); + return rows.map((r) => r.actorIri).filter((iri): iri is string => iri !== null); +} + +/** + * Fan out one delivery job per accepted remote follower (spec 5.3: a + * job per follower, each with its own retry/backoff lifecycle). No-op + * when federation is off or the user has no remote followers — the + * common case stays a single SELECT. + */ +export async function enqueueActivityDeliveries( + ownerId: string, + activityId: string, + action: DeliveryAction, +): Promise { + if (!federationEnabled()) return; + const allRecipients = await listAcceptedRemoteFollowers(ownerId); + if (allRecipients.length === 0) return; + + // Blocklist boundary: never enqueue a delivery to a blocked instance + // (spec: federation-operations "Instance blocklist"). Resolve the + // blocked set once, then drop recipients whose host is on it. + const blocked = await filterBlockedDomains( + allRecipients.map(hostOfIri).filter((h): h is string => h !== null), + ); + const recipients = allRecipients.filter((iri) => { + const host = hostOfIri(iri); + return host !== null && !blocked.has(host); + }); + if (recipients.length === 0) return; + + const db = getDb(); + const [owner] = await db + .select({ username: users.username, profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.id, ownerId)) + .limit(1); + // Private profiles don't federate — suppress push delivery entirely + // (spec 9.3: flipping to private stops federation). + if (!owner || owner.profileVisibility !== "public") return; + + for (const recipientActorIri of recipients) { + const payload: DeliveryPayload = { + action, + activityId: action === "create" ? activityId : undefined, + objectIri: activityObjectIri(activityId), + ownerUsername: owner.username, + recipientActorIri, + }; + await enqueueOptional( + "deliver-activity", + payload, + { source: "enqueueActivityDeliveries", action }, + // Spec 5.4: exponential backoff on failure, bounded retry budget. + // 8 retries with backoff spans roughly a day before permanent fail. + { retryLimit: 8, retryBackoff: true }, + ); + } +} + +export interface CachedRemoteActor { + actorIri: string; + inboxUrl: string | null; +} + +/** Read the remote actor cache; null when we've never seen the actor. */ +export async function getCachedRemoteActor(actorIri: string): Promise { + const db = getDb(); + const [row] = await db + .select({ actorIri: remoteActors.actorIri, inboxUrl: remoteActors.inboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + return row ?? null; +} + +/** Upsert the fields delivery/polling learn about a remote actor. */ +export async function upsertRemoteActor(fields: { + actorIri: string; + inboxUrl?: string | null; + outboxUrl?: string | null; + displayName?: string | null; + username?: string | null; + domain?: string | null; +}): Promise { + const db = getDb(); + const { actorIri, ...rest } = fields; + await db + .insert(remoteActors) + .values({ actorIri, ...rest }) + .onConflictDoUpdate({ target: remoteActors.actorIri, set: rest }); +} diff --git a/apps/journal/app/lib/federation-inbox.integration.test.ts b/apps/journal/app/lib/federation-inbox.integration.test.ts new file mode 100644 index 0000000..9adcbee --- /dev/null +++ b/apps/journal/app/lib/federation-inbox.integration.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows } from "@trails-cool/db/schema/journal"; +import { localActorIri } from "./actor-iri.ts"; +import { + recordRemoteFollow, + removeRemoteFollow, + settleOutgoingFollow, + rejectOutgoingFollow, +} from "./federation-inbox.server.ts"; + +// Opt-in: these talk to real Postgres. Gated by an env flag so laptop +// runs without Postgres aren't blocked. Same convention as +// follow.integration.test.ts. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const REMOTE_ACTOR = "https://other-trails.example/users/alice"; + +const createdUserIds: string[] = []; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + createdUserIds.push(id); + return id; +} + +describe.runIf(runIntegration)("federation inbox (integration)", () => { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + const db = getDb(); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followedUserId, id)); + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("inbound Follow on a public user records an accepted remote follow", async () => { + const username = `fed-pub-${Date.now()}`; + const userId = await makeUser({ username }); + + const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username); + expect(outcome).toBe("accepted"); + + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); + expect(rows).toHaveLength(1); + expect(rows[0]!.followerActorIri).toBe(REMOTE_ACTOR); + expect(rows[0]!.followerId).toBeNull(); + expect(rows[0]!.followedActorIri).toBe(localActorIri(username)); + expect(rows[0]!.acceptedAt).not.toBeNull(); + }); + + it("remote followers appear in the followers list (count/list consistency)", async () => { + const username = `fed-list-${Date.now()}`; + const userId = await makeUser({ username }); + await recordRemoteFollow(REMOTE_ACTOR, username); + + const { listFollowers, countFollowers } = await import("./follow.server.ts"); + const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]); + expect(total).toBe(1); + expect(entries).toHaveLength(1); + const entry = entries[0]!; + expect(entry.remote).toBe(true); + expect(entry.profileUrl).toBe(REMOTE_ACTOR); + // No remote_actors cache row in this test → identity parsed from the IRI. + expect(entry.username).toBe("alice"); + expect(entry.domain).toBe("other-trails.example"); + }); + + it("inbound Follow is idempotent — replays don't double-insert", async () => { + const username = `fed-replay-${Date.now()}`; + const userId = await makeUser({ username }); + + await recordRemoteFollow(REMOTE_ACTOR, username); + await recordRemoteFollow(REMOTE_ACTOR, username); + + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); + expect(rows).toHaveLength(1); + }); + + it("inbound Follow on a private user is refused with no row", async () => { + const username = `fed-priv-${Date.now()}`; + const userId = await makeUser({ username, profileVisibility: "private" }); + + const { outcome } = await recordRemoteFollow(REMOTE_ACTOR, username); + expect(outcome).toBe("refused"); + + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); + expect(rows).toHaveLength(0); + }); + + it("Undo(Follow) removes the remote follow row", async () => { + const username = `fed-undo-${Date.now()}`; + const userId = await makeUser({ username }); + await recordRemoteFollow(REMOTE_ACTOR, username); + + await removeRemoteFollow(REMOTE_ACTOR, username); + + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followedUserId, userId)); + expect(rows).toHaveLength(0); + }); + + it("Accept(Follow) settles a Pending outgoing follow exactly once", async () => { + const username = `fed-accept-${Date.now()}`; + const userId = await makeUser({ username }); + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId: userId, + followedActorIri: REMOTE_ACTOR, + acceptedAt: null, + }); + + const first = await settleOutgoingFollow(userId, REMOTE_ACTOR); + expect(first.settled).toBe(true); + // A replayed Accept is a no-op (row no longer Pending). + const second = await settleOutgoingFollow(userId, REMOTE_ACTOR); + expect(second.settled).toBe(false); + + const rows = await db.select().from(follows).where(eq(follows.followerId, userId)); + expect(rows[0]!.acceptedAt).not.toBeNull(); + }); + + it("Reject(Follow) deletes the Pending outgoing follow", async () => { + const username = `fed-reject-${Date.now()}`; + const userId = await makeUser({ username }); + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId: userId, + followedActorIri: REMOTE_ACTOR, + acceptedAt: null, + }); + + await rejectOutgoingFollow(userId, REMOTE_ACTOR); + + const rows = await db.select().from(follows).where(eq(follows.followerId, userId)); + expect(rows).toHaveLength(0); + }); + + it("database enforces exactly-one-follower invariant", async () => { + const username = `fed-check-${Date.now()}`; + const userId = await makeUser({ username }); + const db = getDb(); + await expect( + db.insert(follows).values({ + id: randomUUID(), + // Neither followerId nor followerActorIri — must violate the + // follows_has_follower_check constraint. + followedActorIri: localActorIri(username), + followedUserId: userId, + }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/journal/app/lib/federation-inbox.server.ts b/apps/journal/app/lib/federation-inbox.server.ts new file mode 100644 index 0000000..270314a --- /dev/null +++ b/apps/journal/app/lib/federation-inbox.server.ts @@ -0,0 +1,119 @@ +// Domain logic behind the federation inbox (spec: social-federation, +// "Narrow inbox — follow-graph activities only"). Pure DB operations, +// kept separate from the Fedify listener wiring in federation.server.ts +// so they're directly unit/integration-testable without HTTP signatures. + +import { randomUUID } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { users, follows } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { localActorIri } from "./actor-iri.ts"; +import { logger } from "./logger.server.ts"; + +/** + * Inbound `Follow` from a remote actor targeting a local user. + * Auto-accepts for public profiles (records the follow row); private + * profiles refuse without leaking existence — the caller maps `refused` + * to the same 404 the actor endpoint serves. + * + * Idempotent: a duplicate Follow from the same actor upserts onto the + * `(follower_actor_iri, followed_user_id)` partial unique index, so + * replays and Mastodon's retry storms cannot double-insert. + */ +export async function recordRemoteFollow( + remoteActorIri: string, + localUsername: string, +): Promise<{ outcome: "accepted" | "refused" }> { + const db = getDb(); + const [user] = await db + .select({ id: users.id, profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.username, localUsername)) + .limit(1); + if (!user || user.profileVisibility !== "public") { + return { outcome: "refused" }; + } + await db + .insert(follows) + .values({ + id: randomUUID(), + followerActorIri: remoteActorIri, + followedActorIri: localActorIri(localUsername), + followedUserId: user.id, + // Auto-accept: public profiles accept every follow (locked + // accounts are a later change). + acceptedAt: new Date(), + }) + .onConflictDoNothing(); + logger.info({ remoteActorIri, localUsername }, "federation: inbound follow accepted"); + return { outcome: "accepted" }; +} + +/** Inbound `Undo(Follow)`: remove the remote actor's follow row. */ +export async function removeRemoteFollow( + remoteActorIri: string, + localUsername: string, +): Promise { + const db = getDb(); + const deleted = await db + .delete(follows) + .where( + and( + eq(follows.followerActorIri, remoteActorIri), + eq(follows.followedActorIri, localActorIri(localUsername)), + ), + ) + .returning({ id: follows.id }); + if (deleted.length > 0) { + logger.info({ remoteActorIri, localUsername }, "federation: inbound follow undone"); + } +} + +/** + * Inbound `Accept(Follow)`: a remote instance accepted our local user's + * outgoing follow — settle the Pending row. Returns the settled row's + * remote actor IRI so the caller can enqueue the first outbox poll + * (spec: "First poll triggered immediately on accepted follow"). + */ +export async function settleOutgoingFollow( + localUserId: string, + remoteActorIri: string, +): Promise<{ settled: boolean }> { + const db = getDb(); + const updated = await db + .update(follows) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(follows.followerId, localUserId), + eq(follows.followedActorIri, remoteActorIri), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + if (updated.length > 0) { + logger.info({ localUserId, remoteActorIri }, "federation: outgoing follow accepted"); + } + return { settled: updated.length > 0 }; +} + +/** Inbound `Reject(Follow)`: the remote refused — drop our Pending row. */ +export async function rejectOutgoingFollow( + localUserId: string, + remoteActorIri: string, +): Promise { + const db = getDb(); + const deleted = await db + .delete(follows) + .where( + and( + eq(follows.followerId, localUserId), + eq(follows.followedActorIri, remoteActorIri), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + if (deleted.length > 0) { + logger.info({ localUserId, remoteActorIri }, "federation: outgoing follow rejected by remote"); + } +} diff --git a/apps/journal/app/lib/federation-ingest.integration.test.ts b/apps/journal/app/lib/federation-ingest.integration.test.ts new file mode 100644 index 0000000..d5fcd98 --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.integration.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, like } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal"; +import { FetchError } from "@fedify/fedify"; +import { + ingestRemoteActivities, + pollRemoteActor, + listActorsDuePolling, + resetHostBackoff, + type ParsedRemoteActivity, +} from "./federation-ingest.server.ts"; + +// Opt-in: real Postgres; network injected. Same convention as the +// other *.integration.test.ts files. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const ACTOR = `https://poll-target.example/users/alice-${Date.now()}`; +const createdUserIds: string[] = []; + +async function makeFollowerOf(actorIri: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `poll-${id}@example.test`, + username: `poll-${id.slice(0, 8)}`, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + await db.insert(follows).values({ + id: randomUUID(), + followerId: id, + followedActorIri: actorIri, + followedUserId: null, + acceptedAt: new Date(), + }); + return id; +} + +function parsed(n: number, audience: "public" | "followers-only" = "public"): ParsedRemoteActivity { + return { + originIri: `${ACTOR}/activities/${n}`, + name: `Remote activity ${n}`, + distance: 1000 * n, + elevationGain: null, + duration: null, + publishedAt: new Date(Date.now() - n * 60_000), + audience, + }; +} + +describe.runIf(runIntegration)("outbox-poll ingestion (integration)", () => { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + resetHostBackoff(); + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${ACTOR}%`)); + await db.delete(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("inserts remote rows with provenance and audience-mirrored visibility", async () => { + const inserted = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2, "followers-only")]); + expect(inserted).toBe(2); + + const db = getDb(); + const rows = await db + .select() + .from(activities) + .where(like(activities.remoteOriginIri, `${ACTOR}%`)); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.ownerId).toBeNull(); + expect(row.remoteActorIri).toBe(ACTOR); + expect(row.remotePublishedAt).not.toBeNull(); + } + const byIri = new Map(rows.map((r) => [r.remoteOriginIri, r])); + expect(byIri.get(`${ACTOR}/activities/1`)?.visibility).toBe("public"); + expect(byIri.get(`${ACTOR}/activities/2`)?.visibility).toBe("private"); + expect(byIri.get(`${ACTOR}/activities/2`)?.audience).toBe("followers-only"); + }); + + it("is replay-safe: re-ingesting inserts nothing", async () => { + await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + const again = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + expect(again).toBe(0); + }); + + it("database enforces exactly-one-author invariant", async () => { + const db = getDb(); + await expect( + db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + remoteActorIri: null, + name: "orphan", + }), + ).rejects.toThrow(); + }); + + it("pollRemoteActor resolves the outbox via the actor doc, ingests the first page, stamps last_polled_at", async () => { + await makeFollowerOf(ACTOR); + const fetched: string[] = []; + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson(url) { + fetched.push(url); + if (url === ACTOR) { + return { outbox: `${ACTOR}/outbox`, inbox: `${ACTOR}/inbox`, preferredUsername: "alice", name: "Alice" }; + } + if (url === `${ACTOR}/outbox`) { + return { type: "OrderedCollection", totalItems: 1, first: `${ACTOR}/outbox?cursor=0` }; + } + return { + type: "OrderedCollectionPage", + orderedItems: [ + { + type: "Create", + object: { + type: "Note", + id: `${ACTOR}/activities/77`, + content: "

Polled ride

", + published: "2026-06-05T10:00:00Z", + to: "as:Public", + }, + }, + ], + }; + }, + }); + expect(result).toEqual({ inserted: 1 }); + expect(fetched).toEqual([ACTOR, `${ACTOR}/outbox`, `${ACTOR}/outbox?cursor=0`]); + + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.outboxUrl).toBe(`${ACTOR}/outbox`); + expect(cached?.lastPolledAt).not.toBeNull(); + }); + + it("skips actors without an accepted local follower", async () => { + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson() { + throw new Error("must not fetch"); + }, + }); + expect(result).toEqual({ skipped: "no accepted local follower" }); + }); + + it("a 429 with Retry-After backs off the host; the next poll skips without fetching (7.4)", async () => { + await makeFollowerOf(ACTOR); + let fetches = 0; + const deps = { + async pace() {}, + async fetchJson(url: string): Promise { + fetches++; + throw new FetchError( + new URL(url), + "rate limited", + new Response(null, { status: 429, headers: { "Retry-After": "120" } }), + ); + }, + }; + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ skipped: "rate limited" }); + expect(fetches).toBe(1); + + // Host is now backing off — no network attempt at all. + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ + skipped: "host rate-limited (backing off)", + }); + expect(fetches).toBe(1); + + // last_polled_at was NOT stamped, so the sweep will retry later. + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.lastPolledAt ?? null).toBeNull(); + }); + + it("listActorsDuePolling returns followed actors not polled within the hour", async () => { + await makeFollowerOf(ACTOR); + expect(await listActorsDuePolling()).toContain(ACTOR); + + const db = getDb(); + await db.insert(remoteActors).values({ actorIri: ACTOR, lastPolledAt: new Date() }).onConflictDoUpdate({ + target: remoteActors.actorIri, + set: { lastPolledAt: new Date() }, + }); + expect(await listActorsDuePolling()).not.toContain(ACTOR); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.test.ts b/apps/journal/app/lib/federation-ingest.server.test.ts new file mode 100644 index 0000000..f4a77c2 --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { + parseOutboxItem, + parseRetryAfter, + noteHostRateLimited, + isHostBackingOff, + resetHostBackoff, + pollRemoteActor, + assertRemoteDocSize, +} = await import("./federation-ingest.server.ts"); + +function trailsCreate(overrides: Record = {}, noteOverrides: Record = {}) { + return { + type: "Create", + id: "https://remote.example/activities/a1#create", + published: "2026-06-01T08:00:00Z", + object: { + type: "Note", + id: "https://remote.example/activities/a1", + content: "

Morning ride

\n

42.2 km · ↗ 512 m

\n

link

", + published: "2026-06-01T08:00:00Z", + to: "as:Public", + attachment: [ + { type: "PropertyValue", name: "distance-m", value: "42195" }, + { type: "PropertyValue", name: "elevation-gain-m", value: "512" }, + { type: "PropertyValue", name: "duration-s", value: "9000" }, + ], + ...noteOverrides, + }, + ...overrides, + }; +} + +describe("parseOutboxItem", () => { + it("parses our own outgoing shape", () => { + const parsed = parseOutboxItem(trailsCreate()); + expect(parsed).toEqual({ + originIri: "https://remote.example/activities/a1", + name: "Morning ride", + distance: 42195, + elevationGain: 512, + duration: 9000, + publishedAt: new Date("2026-06-01T08:00:00Z"), + audience: "public", + }); + }); + + it("detects all spellings of the public collection", () => { + for (const to of [ + "https://www.w3.org/ns/activitystreams#Public", + "as:Public", + ["as:Public", "https://remote.example/followers"], + ]) { + expect(parseOutboxItem(trailsCreate({}, { to }))?.audience).toBe("public"); + } + expect(parseOutboxItem(trailsCreate({}, { to: "https://remote.example/followers" }))?.audience).toBe( + "followers-only", + ); + // cc counts too + expect( + parseOutboxItem(trailsCreate({}, { to: undefined, cc: "as:Public" }))?.audience, + ).toBe("public"); + }); + + it("tolerates missing stats and published", () => { + const parsed = parseOutboxItem(trailsCreate({ published: undefined }, { attachment: undefined, published: undefined })); + expect(parsed).toMatchObject({ distance: null, elevationGain: null, duration: null, publishedAt: null }); + }); + + it("skips anything that isn't Create(Note) with an id and a name", () => { + expect(parseOutboxItem(null)).toBeNull(); + expect(parseOutboxItem("string")).toBeNull(); + expect(parseOutboxItem({ type: "Announce" })).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { type: "Article" }))).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { id: undefined }))).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { content: "" }))).toBeNull(); + }); + + it("strips markup from the name and caps its length", () => { + const longName = "x".repeat(400); + const parsed = parseOutboxItem(trailsCreate({}, { content: `

${longName}

` })); + expect(parsed?.name).toHaveLength(300); + expect(parsed?.name).not.toContain(""); + }); +}); + +describe("429 backoff (7.4)", () => { + afterEach(() => resetHostBackoff()); + + const NOW = Date.parse("2026-06-07T12:00:00Z"); + + it("parses Retry-After as delta-seconds and HTTP-date", () => { + expect(parseRetryAfter("120", NOW)).toBe(120_000); + expect(parseRetryAfter(" 5 ", NOW)).toBe(5_000); + expect(parseRetryAfter("Sun, 07 Jun 2026 12:01:00 GMT", NOW)).toBe(60_000); + // past HTTP-date clamps to zero + expect(parseRetryAfter("Sun, 07 Jun 2026 11:00:00 GMT", NOW)).toBe(0); + expect(parseRetryAfter(null, NOW)).toBeNull(); + expect(parseRetryAfter("soon", NOW)).toBeNull(); + expect(parseRetryAfter("-5", NOW)).toBeNull(); + }); + + it("falls back to the default and caps absurd Retry-After values", () => { + // default (no header): 15 min + expect(noteHostRateLimited("a.example", null, NOW)).toBe(15 * 60 * 1000); + // honored when reasonable + expect(noteHostRateLimited("b.example", "120", NOW)).toBe(120_000); + // capped at the poll interval (1 h) + expect(noteHostRateLimited("c.example", String(7 * 24 * 3600), NOW)).toBe(60 * 60 * 1000); + }); + + it("backs off the host until the window expires", () => { + noteHostRateLimited("d.example", "60", NOW); + expect(isHostBackingOff("d.example", NOW + 59_000)).toBe(true); + expect(isHostBackingOff("d.example", NOW + 61_000)).toBe(false); + // expiry clears the entry; a later check stays false + expect(isHostBackingOff("d.example", NOW)).toBe(false); + expect(isHostBackingOff("other.example", NOW)).toBe(false); + }); + + it("pollRemoteActor skips a backing-off host before touching the database", async () => { + // getDb is mocked to {} — any query would throw, so reaching the + // skip proves the backoff check runs first. + noteHostRateLimited("limited.example", "3600"); + await expect(pollRemoteActor("https://limited.example/users/alice")).resolves.toEqual({ + skipped: "host rate-limited (backing off)", + }); + }); +}); + +describe("assertRemoteDocSize", () => { + it("accepts a normal-sized document", () => { + expect(() => assertRemoteDocSize({ type: "Person", name: "Alice" })).not.toThrow(); + expect(() => assertRemoteDocSize(null)).not.toThrow(); + }); + + it("rejects a document over the 4 MB cap", () => { + const huge = { type: "OrderedCollection", orderedItems: ["x".repeat(5 * 1024 * 1024)] }; + expect(() => assertRemoteDocSize(huge)).toThrow(/too large/); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.ts b/apps/journal/app/lib/federation-ingest.server.ts new file mode 100644 index 0000000..51d2d54 --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.ts @@ -0,0 +1,372 @@ +// Outbox-poll ingestion of remote trails activities (spec: +// social-federation §7). We are the poller: signed GETs (Authorized +// Fetch) against the outboxes of remote trails actors that local users +// follow, storing new activities for feed display. The wire shape is +// our own outbox format (trails-to-trails only), so parsing targets +// exactly what federation-objects.server.ts emits: Create activities +// wrapping Notes with PropertyValue stat attachments. +// +// Network steps are injectable for offline integration tests. + +import { FetchError } from "@fedify/fedify"; +import { and, eq, isNull, isNotNull, lt, or, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { activities, follows, remoteActors, users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { getFederation } from "./federation.server.ts"; +import { upsertRemoteActor } from "./federation-delivery.server.ts"; +import { isBlockedDomain } from "./federation-blocklist.server.ts"; +import { logger } from "./logger.server.ts"; + +/** Spec: fetch at most the 50 most recent items per remote actor. */ +const MAX_ITEMS_PER_POLL = 50; +/** Spec: skip actors polled within the last hour (cron sweep). */ +const POLL_INTERVAL_MS = 60 * 60 * 1000; +/** Spec: per-remote-host pacing of 1 request / 5 seconds. */ +const HOST_PACE_MS = 5_000; +/** Stop early after this many consecutive already-seen items (7.2). */ +const CONFLICT_STREAK_LIMIT = 5; +/** Backoff after a 429 without a usable Retry-After (7.4). */ +const DEFAULT_BACKOFF_MS = 15 * 60 * 1000; +/** + * Reject a fetched actor/outbox document larger than this once serialized. + * Fedify owns the transfer (and applies its own SSRF + redirect limits), + * so this is a downstream guard: it stops us iterating/persisting an + * absurdly large document from a hostile remote, on top of the existing + * per-poll item cap. A legitimate actor doc or 50-item page is a few KB. + */ +const MAX_REMOTE_DOC_BYTES = 4 * 1024 * 1024; // 4 MB + +/** Throws if a remote document's serialized size exceeds the cap. */ +export function assertRemoteDocSize(document: unknown): void { + const size = JSON.stringify(document ?? null).length; + if (size > MAX_REMOTE_DOC_BYTES) { + throw new Error(`remote document too large: ${size} bytes (cap ${MAX_REMOTE_DOC_BYTES})`); + } +} +/** + * Cap an honored Retry-After at the poll interval — anything longer is + * equivalent to "skip until a later sweep", and an absurd header from + * a misbehaving remote shouldn't park a host for days. + */ +const MAX_BACKOFF_MS = POLL_INTERVAL_MS; + +export interface ParsedRemoteActivity { + originIri: string; + name: string; + distance: number | null; + elevationGain: number | null; + duration: number | null; + publishedAt: Date | null; + audience: "public" | "followers-only"; +} + +function isPublicAudience(value: unknown): boolean { + const targets = Array.isArray(value) ? value : value == null ? [] : [value]; + return targets.some( + (t) => + t === "https://www.w3.org/ns/activitystreams#Public" || + t === "as:Public" || + t === "Public", + ); +} + +function textOfFirstParagraph(html: string): string { + const match = /

([\s\S]*?)<\/p>/.exec(html); + const raw = match?.[1] ?? html; + return raw.replace(/<[^>]+>/g, "").trim(); +} + +function statFromAttachments(attachments: unknown, name: string): number | null { + const list = Array.isArray(attachments) ? attachments : attachments == null ? [] : [attachments]; + for (const a of list) { + if ( + typeof a === "object" && a !== null && + (a as Record).type === "PropertyValue" && + (a as Record).name === name + ) { + const v = Number.parseFloat(String((a as Record).value)); + return Number.isFinite(v) ? v : null; + } + } + return null; +} + +/** + * Parse one outbox item (a `Create` wrapping a `Note` in our own + * outgoing shape) into an ingestable activity. Returns null for + * anything that doesn't match — unknown items are skipped, never + * fatal (forward compatibility with future trails versions). + */ +export function parseOutboxItem(item: unknown): ParsedRemoteActivity | null { + if (typeof item !== "object" || item === null) return null; + const create = item as Record; + if (create.type !== "Create") return null; + const note = create.object; + if (typeof note !== "object" || note === null) return null; + const n = note as Record; + if (n.type !== "Note" || typeof n.id !== "string") return null; + + const content = typeof n.content === "string" ? n.content : ""; + const name = textOfFirstParagraph(content); + if (!name) return null; + + const publishedRaw = typeof n.published === "string" ? n.published : typeof create.published === "string" ? create.published : null; + const published = publishedRaw ? new Date(publishedRaw) : null; + + return { + originIri: n.id, + name: name.slice(0, 300), + distance: statFromAttachments(n.attachment, "distance-m"), + elevationGain: statFromAttachments(n.attachment, "elevation-gain-m"), + duration: statFromAttachments(n.attachment, "duration-s"), + publishedAt: published && !Number.isNaN(published.getTime()) ? published : null, + audience: isPublicAudience(n.to) || isPublicAudience(n.cc) ? "public" : "followers-only", + }; +} + +/** + * Insert parsed activities for a remote actor. Replay-safe via the + * unique remote_origin_iri (`ON CONFLICT DO NOTHING`); stops early on + * a streak of already-seen items since outboxes are newest-first. + * Returns the number of newly inserted rows. + */ +export async function ingestRemoteActivities( + actorIri: string, + items: ParsedRemoteActivity[], +): Promise { + const db = getDb(); + let inserted = 0; + let conflictStreak = 0; + for (const item of items) { + const rows = await db + .insert(activities) + .values({ + id: randomUUID(), + ownerId: null, + name: item.name, + description: "", + distance: item.distance, + elevationGain: item.elevationGain, + duration: item.duration, + // Mirror the audience into visibility for coherence with + // local rows; the §8 feed query gates remote rows on audience + // + follow, and every other surface joins users on owner_id, + // which excludes remote rows structurally. + visibility: item.audience === "public" ? "public" : "private", + remoteOriginIri: item.originIri, + remoteActorIri: actorIri, + remotePublishedAt: item.publishedAt, + audience: item.audience, + }) + .onConflictDoNothing({ target: activities.remoteOriginIri }) + .returning({ id: activities.id }); + if (rows.length === 0) { + conflictStreak++; + if (conflictStreak >= CONFLICT_STREAK_LIMIT) break; + } else { + conflictStreak = 0; + inserted++; + } + } + return inserted; +} + +export interface PollDeps { + /** + * Fetch a JSON document with an HTTP Signature from `signerUsername` + * (Authorized Fetch — spec: "Polls are signed"). + */ + fetchJson(url: string, signerUsername: string): Promise; + /** Per-host pacing (spec 7.4: 1 req / 5 s). No-op in tests. */ + pace(host: string): Promise; +} + +const lastFetchPerHost = new Map(); + +// 7.4: hosts that answered 429 are skipped until their backoff expires. +// In-process state, like the pacing map above — a restart forgets it, +// which is fine: the worst case is one extra request that gets another +// 429 and re-arms the backoff. +const hostBackoffUntil = new Map(); + +/** + * Parse a Retry-After header value (RFC 9110: delta-seconds or an + * HTTP-date) into a millisecond delay from `now`. Null if absent or + * unparseable. + */ +export function parseRetryAfter(value: string | null, now: number): number | null { + if (value == null) return null; + const trimmed = value.trim(); + // Bare integers are delta-seconds; negative ones are invalid (and + // must not fall through to Date.parse, which reads them as years). + if (/^-?\d+$/.test(trimmed)) { + return /^\d+$/.test(trimmed) ? Number(trimmed) * 1000 : null; + } + const date = Date.parse(trimmed); + if (!Number.isNaN(date)) return Math.max(0, date - now); + return null; +} + +/** + * Record a 429 from `host`: back off for the remote's Retry-After + * (capped) or a default. Returns the applied delay in ms. + */ +export function noteHostRateLimited( + host: string, + retryAfter: string | null, + now = Date.now(), +): number { + const delay = Math.min(parseRetryAfter(retryAfter, now) ?? DEFAULT_BACKOFF_MS, MAX_BACKOFF_MS); + hostBackoffUntil.set(host, now + delay); + return delay; +} + +/** Whether `host` is inside a 429 backoff window. */ +export function isHostBackingOff(host: string, now = Date.now()): boolean { + const until = hostBackoffUntil.get(host) ?? 0; + if (until <= now) { + hostBackoffUntil.delete(host); + return false; + } + return true; +} + +/** Test hook: forget all 429 backoff state. */ +export function resetHostBackoff(): void { + hostBackoffUntil.clear(); +} + +function defaultDeps(): PollDeps { + return { + async fetchJson(url, signerUsername) { + const federation = getFederation(); + const ctx = federation.createContext(new URL(getOrigin()), undefined); + const loader = await ctx.getDocumentLoader({ identifier: signerUsername }); + const { document } = await loader(url); + assertRemoteDocSize(document); + return document; + }, + async pace(host) { + const last = lastFetchPerHost.get(host) ?? 0; + const wait = last + HOST_PACE_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastFetchPerHost.set(host, Date.now()); + }, + }; +} + +/** + * Poll one remote actor's outbox and ingest new activities (7.2/7.3). + * Returns counts for logging. Honors per-host pacing; a 429/Retry-After + * from the remote aborts this poll quietly (the hourly sweep retries). + */ +export async function pollRemoteActor( + actorIri: string, + deps: PollDeps = defaultDeps(), +): Promise<{ inserted: number } | { skipped: string }> { + // 7.4: respect an active 429 backoff before doing any work. + const host = new URL(actorIri).host; + if (isHostBackingOff(host)) return { skipped: "host rate-limited (backing off)" }; + // Blocklist boundary: never fetch a blocked instance's actor/outbox + // (spec: federation-operations "Instance blocklist"). + if (await isBlockedDomain(host)) return { skipped: "blocked instance" }; + + const db = getDb(); + + // Signer: any local user with an accepted follow against this actor. + const [signer] = await db + .select({ username: users.username }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where( + and( + eq(follows.followedActorIri, actorIri), + isNotNull(follows.acceptedAt), + isNull(follows.followedUserId), + ), + ) + .limit(1); + if (!signer) return { skipped: "no accepted local follower" }; + + try { + await deps.pace(host); + + // Resolve the outbox URL: cache first, actor document as fallback + // (which also refreshes the cache — 7.3). + const [cached] = await db + .select({ outboxUrl: remoteActors.outboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + let outboxUrl = cached?.outboxUrl ?? null; + if (!outboxUrl) { + const actorDoc = (await deps.fetchJson(actorIri, signer.username)) as Record | null; + outboxUrl = typeof actorDoc?.outbox === "string" ? actorDoc.outbox : null; + if (!outboxUrl) return { skipped: "actor has no outbox" }; + await upsertRemoteActor({ + actorIri, + outboxUrl, + displayName: typeof actorDoc?.name === "string" ? actorDoc.name : null, + username: typeof actorDoc?.preferredUsername === "string" ? actorDoc.preferredUsername : null, + inboxUrl: typeof actorDoc?.inbox === "string" ? actorDoc.inbox : undefined, + domain: host, + }); + } + + // Collection → first page (our outbox serves first=...?cursor=0). + await deps.pace(host); + const collection = (await deps.fetchJson(outboxUrl, signer.username)) as Record; + let itemsRaw = collection.orderedItems ?? collection.items; + if (!itemsRaw && typeof collection.first === "string") { + await deps.pace(host); + const page = (await deps.fetchJson(collection.first, signer.username)) as Record; + itemsRaw = page.orderedItems ?? page.items; + } + const items = (Array.isArray(itemsRaw) ? itemsRaw : itemsRaw == null ? [] : [itemsRaw]) + .slice(0, MAX_ITEMS_PER_POLL) + .map(parseOutboxItem) + .filter((p): p is ParsedRemoteActivity => p !== null); + + const inserted = await ingestRemoteActivities(actorIri, items); + await db + .update(remoteActors) + .set({ lastPolledAt: new Date() }) + .where(eq(remoteActors.actorIri, actorIri)); + logger.info({ actorIri, fetched: items.length, inserted }, "federation: outbox poll"); + return { inserted }; + } catch (err) { + // 429: honor Retry-After (or default) and skip the host until the + // backoff expires (7.4). + if (err instanceof FetchError && err.response?.status === 429) { + const delayMs = noteHostRateLimited(host, err.response.headers.get("retry-after")); + logger.warn({ actorIri, host, delayMs }, "federation: remote rate-limited poll; backing off host"); + return { skipped: "rate limited" }; + } + // Other network failures: log and let the hourly sweep retry. + logger.warn({ err, actorIri }, "federation: outbox poll failed; will retry on next sweep"); + return { skipped: "fetch failed" }; + } +} + +/** + * Remote actor IRIs due for polling (7.1): followed-and-accepted by at + * least one local user, never polled or polled more than an hour ago. + */ +export async function listActorsDuePolling(): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - POLL_INTERVAL_MS); + const rows = await db + .selectDistinct({ actorIri: follows.followedActorIri }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + isNull(follows.followedUserId), + isNotNull(follows.acceptedAt), + or(sql`${remoteActors.lastPolledAt} IS NULL`, lt(remoteActors.lastPolledAt, cutoff)), + ), + ); + return rows.map((r) => r.actorIri); +} diff --git a/apps/journal/app/lib/federation-keys.server.test.ts b/apps/journal/app/lib/federation-keys.server.test.ts new file mode 100644 index 0000000..095e4c3 --- /dev/null +++ b/apps/journal/app/lib/federation-keys.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +beforeEach(() => { + process.env.FEDERATION_KEY_ENCRYPTION_KEY = "test-federation-key-secret"; +}); + +const { generateUserKeypair, loadUserKeypair } = await import( + "./federation-keys.server.ts" +); + +describe("federation keypairs", () => { + it("generates a serializable RSA keypair with encrypted private key", async () => { + const pair = await generateUserKeypair(); + + const publicJwk = JSON.parse(pair.publicKey); + expect(publicJwk.kty).toBe("RSA"); + // 2048-bit modulus → 256 bytes → 342 base64url chars (spec wants RSA 2048) + expect(publicJwk.n.length).toBeGreaterThanOrEqual(342); + + // Private key must not be stored as plaintext JWK + expect(pair.privateKeyEncrypted).not.toContain('"kty"'); + expect(() => JSON.parse(pair.privateKeyEncrypted)).toThrow(); + }); + + it("roundtrips through load into usable CryptoKeys that sign and verify", async () => { + const stored = await generateUserKeypair(); + const loaded = await loadUserKeypair(stored); + expect(loaded).not.toBeNull(); + + const data = new TextEncoder().encode("signed federation payload"); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + loaded!.privateKey, + data, + ); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + loaded!.publicKey, + signature, + data, + ); + expect(valid).toBe(true); + }); + + it("returns null for users without stored keys", async () => { + expect(await loadUserKeypair({ publicKey: null, privateKeyEncrypted: null })).toBeNull(); + }); + + it("fails to load when the encryption key is wrong", async () => { + const stored = await generateUserKeypair(); + process.env.FEDERATION_KEY_ENCRYPTION_KEY = "a-different-secret"; + await expect(loadUserKeypair(stored)).rejects.toThrow(); + }); +}); diff --git a/apps/journal/app/lib/federation-keys.server.ts b/apps/journal/app/lib/federation-keys.server.ts new file mode 100644 index 0000000..3cd9c95 --- /dev/null +++ b/apps/journal/app/lib/federation-keys.server.ts @@ -0,0 +1,98 @@ +// Per-user federation signing keypairs (spec: social-federation, +// "Per-user signing keypairs"). +// +// Every federating user has an RSA keypair: the public key is embedded +// in their actor object for inbound signature verification by remotes; +// the private key signs our outbound requests (HTTP Signatures). Keys +// are stored as JWK JSON on journal.users — public in plaintext, +// private encrypted at rest with FEDERATION_KEY_ENCRYPTION_KEY. +// +// RSASSA-PKCS1-v1_5 (RSA 2048) is the algorithm Mastodon and the wider +// fediverse interoperate on for HTTP Signatures; Ed25519 support is +// still patchy across implementations. +// +// Key-rotation runbook: rotating FEDERATION_KEY_ENCRYPTION_KEY requires +// decrypt-with-old + encrypt-with-new over journal.users — documented in +// docs/deployment.md (task 10.2). + +import { generateCryptoKeyPair, exportJwk, importJwk } from "@fedify/fedify"; +import { and, eq, isNull } from "drizzle-orm"; +import { users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { createAesCipher } from "./crypto.server.ts"; +import { requireSecret } from "./config.server.ts"; + +const cipher = createAesCipher("FEDERATION_KEY_ENCRYPTION_KEY", "trails-cool-federation-keys"); + +/** + * Fail fast in production when the encryption key is missing or the + * known-public dev fallback. Called before any key material is written. + */ +function assertEncryptionKeyConfigured(): void { + requireSecret("FEDERATION_KEY_ENCRYPTION_KEY", "dev-only-federation-key"); + // requireSecret throws in prod for unset/fallback values; in dev it + // returns the fallback, which createAesCipher's env lookup won't see — + // so mirror it into the env for the cipher when unset. + process.env.FEDERATION_KEY_ENCRYPTION_KEY ??= "dev-only-federation-key"; +} + +export interface SerializedKeypair { + publicKey: string; + privateKeyEncrypted: string; +} + +/** Generate a fresh RSA 2048 keypair, serialized for journal.users. */ +export async function generateUserKeypair(): Promise { + assertEncryptionKeyConfigured(); + const { publicKey, privateKey } = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + return { + publicKey: JSON.stringify(await exportJwk(publicKey)), + privateKeyEncrypted: cipher.encrypt(JSON.stringify(await exportJwk(privateKey))), + }; +} + +/** + * Generate and store a keypair for `userId` if it doesn't have one. + * Concurrency-safe: the UPDATE is guarded on `public_key IS NULL`, so + * a racing generation loses and the first written pair stays canonical. + * Returns true when this call generated the pair. + */ +export async function ensureUserKeypair(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ publicKey: users.publicKey }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!row || row.publicKey !== null) return false; + const pair = await generateUserKeypair(); + const updated = await db + .update(users) + .set({ publicKey: pair.publicKey, privateKeyEncrypted: pair.privateKeyEncrypted }) + .where(and(eq(users.id, userId), isNull(users.publicKey))) + .returning({ id: users.id }); + return updated.length > 0; +} + +/** Deserialize a user's stored keypair into CryptoKeys for Fedify. */ +export async function loadUserKeypair(row: { + publicKey: string | null; + privateKeyEncrypted: string | null; +}): Promise { + if (!row.publicKey || !row.privateKeyEncrypted) return null; + assertEncryptionKeyConfigured(); + return { + publicKey: await importJwk(JSON.parse(row.publicKey), "public"), + privateKey: await importJwk(JSON.parse(cipher.decrypt(row.privateKeyEncrypted)), "private"), + }; +} + +/** All user ids still lacking a keypair (backfill workload). */ +export async function listUsersWithoutKeypair(): Promise { + const db = getDb(); + const rows = await db + .select({ id: users.id }) + .from(users) + .where(isNull(users.publicKey)); + return rows.map((r) => r.id); +} diff --git a/apps/journal/app/lib/federation-kv.integration.test.ts b/apps/journal/app/lib/federation-kv.integration.test.ts new file mode 100644 index 0000000..69807f1 --- /dev/null +++ b/apps/journal/app/lib/federation-kv.integration.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { like } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { federationKv } from "@trails-cool/db/schema/journal"; +import { PostgresKvStore } from "./federation-kv.server.ts"; + +// Opt-in: talks to real Postgres (same convention as the other +// *.integration.test.ts files). +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +// Namespace keys per test run so concurrent runs can't collide. +const NS = `test-${Date.now()}`; + +describe.runIf(runIntegration)("PostgresKvStore (integration)", () => { + const store = new PostgresKvStore(); + + afterAll(async () => { + const db = getDb(); + await db.delete(federationKv).where(like(federationKv.key, `["${NS}"%`)); + }); + + it("roundtrips JSON values", async () => { + await store.set([NS, "a"], { hello: "world", n: 42 }); + expect(await store.get([NS, "a"])).toEqual({ hello: "world", n: 42 }); + }); + + it("returns undefined for missing keys", async () => { + expect(await store.get([NS, "missing"])).toBeUndefined(); + }); + + it("overwrites on set (upsert)", async () => { + await store.set([NS, "b"], 1); + await store.set([NS, "b"], 2); + expect(await store.get([NS, "b"])).toBe(2); + }); + + it("expired values read as missing and are swept", async () => { + // Duck-typed Temporal.Duration — the store only calls + // .total("milliseconds"), and pulling in the polyfill just for the + // test isn't worth a dependency. + const oneMs = { total: () => 1 } as unknown as Temporal.Duration; + await store.set([NS, "ttl"], "ephemeral", { ttl: oneMs }); + await new Promise((r) => setTimeout(r, 10)); + expect(await store.get([NS, "ttl"])).toBeUndefined(); + const purged = await store.sweepExpired(); + expect(purged).toBeGreaterThanOrEqual(1); + }); + + it("deletes keys", async () => { + await store.set([NS, "c"], "x"); + await store.delete([NS, "c"]); + expect(await store.get([NS, "c"])).toBeUndefined(); + }); + + it("lists by prefix without matching sibling prefixes", async () => { + await store.set([NS, "list", "one"], 1); + await store.set([NS, "list", "two"], 2); + await store.set([NS, "listish"], 3); // shares textual prefix, different key path + + const entries = []; + for await (const e of store.list([NS, "list"])) entries.push(e); + const keys = entries.map((e) => e.key.join("/")).sort(); + expect(keys).toEqual([`${NS}/list/one`, `${NS}/list/two`]); + }); +}); diff --git a/apps/journal/app/lib/federation-kv.server.ts b/apps/journal/app/lib/federation-kv.server.ts new file mode 100644 index 0000000..fdc9b87 --- /dev/null +++ b/apps/journal/app/lib/federation-kv.server.ts @@ -0,0 +1,81 @@ +// Postgres-backed Fedify KvStore. Fedify uses its KvStore for inbox +// replay protection (activity-id idempotence), remote document caching, +// and key caching — all state that must survive process restarts and be +// shared across instances of the journal server, which rules out +// MemoryKvStore outside the dev loop (spec: social-federation 4.7). +// +// Keys are KvKey (readonly string[]) serialized as their JSON encoding; +// prefix listing relies on JSON arrays sharing a stable textual prefix +// up to the closing bracket. + +import type { KvKey, KvStore, KvStoreListEntry, KvStoreSetOptions } from "@fedify/fedify"; +import { eq, sql, like, and, or, isNull, gt, lt } from "drizzle-orm"; +import { federationKv } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; + +function serializeKey(key: KvKey): string { + return JSON.stringify(key); +} + +/** Predicate: row is not expired (expires_at NULL or in the future). */ +function notExpired() { + return or(isNull(federationKv.expiresAt), gt(federationKv.expiresAt, new Date())); +} + +export class PostgresKvStore implements KvStore { + async get(key: KvKey): Promise { + const db = getDb(); + const [row] = await db + .select({ value: federationKv.value }) + .from(federationKv) + .where(and(eq(federationKv.key, serializeKey(key)), notExpired())) + .limit(1); + return row === undefined ? undefined : (row.value as T); + } + + async set(key: KvKey, value: unknown, options?: KvStoreSetOptions): Promise { + const db = getDb(); + const expiresAt = options?.ttl + ? new Date(Date.now() + options.ttl.total("milliseconds")) + : null; + await db + .insert(federationKv) + .values({ key: serializeKey(key), value, expiresAt }) + .onConflictDoUpdate({ + target: federationKv.key, + set: { value, expiresAt }, + }); + } + + async delete(key: KvKey): Promise { + const db = getDb(); + await db.delete(federationKv).where(eq(federationKv.key, serializeKey(key))); + } + + async *list(prefix?: KvKey): AsyncIterable { + const db = getDb(); + const wherePrefix = prefix + ? // '["a","b"]' minus the trailing ']' is a textual prefix of every + // key extending ["a","b"] — and of nothing else, since the next + // character is either ']' (exact) or ',' (extension). + like(federationKv.key, `${serializeKey(prefix).slice(0, -1)}%`) + : undefined; + const rows = await db + .select({ key: federationKv.key, value: federationKv.value }) + .from(federationKv) + .where(wherePrefix ? and(wherePrefix, notExpired()) : notExpired()); + for (const row of rows) { + yield { key: JSON.parse(row.key) as KvKey, value: row.value }; + } + } + + /** Remove expired rows. Called by the federation-kv-sweep job. */ + async sweepExpired(): Promise { + const db = getDb(); + const deleted = await db + .delete(federationKv) + .where(and(sql`${federationKv.expiresAt} IS NOT NULL`, lt(federationKv.expiresAt, new Date()))) + .returning({ key: federationKv.key }); + return deleted.length; + } +} diff --git a/apps/journal/app/lib/federation-objects.server.test.ts b/apps/journal/app/lib/federation-objects.server.test.ts new file mode 100644 index 0000000..7b30df7 --- /dev/null +++ b/apps/journal/app/lib/federation-objects.server.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + activityToNote, + activityToCreate, + activityToDelete, + activityObjectIri, + type FederatableActivity, +} from "./federation-objects.server.ts"; + +const ACTIVITY: FederatableActivity = { + id: "act-1", + name: "Morning ride <3", + description: 'Through the "forest" & hills', + sportType: "ride", + distance: 42_195, + elevationGain: 512.4, + duration: 2 * 3600 + 30 * 60, + startedAt: new Date("2026-06-01T08:00:00Z"), + createdAt: new Date("2026-06-01T12:00:00Z"), +}; + +describe("activityToNote", () => { + it("builds a public Note addressed at the activity page", async () => { + const note = activityToNote(ACTIVITY, "bruno"); + expect(note.id?.href).toBe("http://localhost:3000/activities/act-1"); + expect(note.attributionId?.href).toBe("http://localhost:3000/users/bruno"); + expect(note.toIds.map((u) => u.href)).toContain( + "https://www.w3.org/ns/activitystreams#Public", + ); + expect(note.published?.toString()).toBe("2026-06-01T08:00:00Z"); + }); + + it("escapes HTML in user content and includes stats + link", () => { + const note = activityToNote(ACTIVITY, "bruno"); + const content = String(note.content); + expect(content).toContain("Morning ride <3"); + expect(content).toContain(""forest" & hills"); + expect(content).toContain("42.2 km"); + expect(content).toContain("↗ 512 m"); + expect(content).toContain("2h 30m"); + expect(content).toContain('href="http://localhost:3000/activities/act-1"'); + }); + + it("carries structured metadata as PropertyValue attachments", async () => { + const note = activityToNote(ACTIVITY, "bruno"); + const attachments = []; + for await (const a of note.getAttachments()) attachments.push(a); + const byName = new Map( + attachments.map((a) => [String((a as { name: unknown }).name), String((a as { value: unknown }).value)]), + ); + expect(byName.get("distance-m")).toBe("42195"); + expect(byName.get("elevation-gain-m")).toBe("512"); + expect(byName.get("duration-s")).toBe("9000"); + expect(byName.get("sport")).toBe("ride"); + }); + + it("omits the sport attachment when sport is unset", async () => { + const note = activityToNote({ ...ACTIVITY, sportType: null }, "bruno"); + const names = []; + for await (const a of note.getAttachments()) names.push(String((a as { name: unknown }).name)); + expect(names).not.toContain("sport"); + }); + + it("omits stats it doesn't have", () => { + const bare = activityToNote( + { ...ACTIVITY, distance: null, elevationGain: null, duration: null, description: null }, + "bruno", + ); + const content = String(bare.content); + expect(content).not.toContain("km"); + expect(content).not.toContain("↗"); + }); + + it("falls back to createdAt when startedAt is missing", () => { + const note = activityToNote({ ...ACTIVITY, startedAt: null }, "bruno"); + expect(note.published?.toString()).toBe("2026-06-01T12:00:00Z"); + }); +}); + +describe("activityToCreate", () => { + it("derives a stable id from the object and attributes the actor", () => { + const create = activityToCreate(ACTIVITY, "bruno"); + expect(create.id?.href).toBe("http://localhost:3000/activities/act-1#create"); + expect(create.actorId?.href).toBe("http://localhost:3000/users/bruno"); + }); +}); + +describe("activityToDelete", () => { + it("wraps a Tombstone for the deleted object", async () => { + const del = activityToDelete(activityObjectIri("act-1"), "bruno"); + expect(del.actorId?.href).toBe("http://localhost:3000/users/bruno"); + const tombstone = await del.getObject(); + expect(tombstone?.id?.href).toBe("http://localhost:3000/activities/act-1"); + }); +}); diff --git a/apps/journal/app/lib/federation-objects.server.ts b/apps/journal/app/lib/federation-objects.server.ts new file mode 100644 index 0000000..6981c10 --- /dev/null +++ b/apps/journal/app/lib/federation-objects.server.ts @@ -0,0 +1,126 @@ +// Mapping from journal activities to ActivityStreams objects (spec: +// social-federation, "Outbox publishes user's public activities" + +// push delivery). One mapping, used by both the outbox dispatcher and +// the deliver-activity job so remotes see identical shapes either way. +// +// Shape decision (design.md open question, resolved toward Mastodon +// compat): plain `Create(Note)`. The Note's HTML content carries the +// human-readable text + a link back to the activity page; structured +// trails metadata (distance, elevation, duration) rides along as +// PropertyValue attachments, which Mastodon ignores gracefully and +// trails consumers can read without parsing the HTML. + +import { Temporal as TemporalPolyfill } from "@js-temporal/polyfill"; +import { Create, Delete, Note, PropertyValue, Tombstone, PUBLIC_COLLECTION } from "@fedify/fedify/vocab"; +import { getOrigin } from "./config.server.ts"; +import { localActorIri } from "./actor-iri.ts"; +import type { SportType } from "@trails-cool/db/schema/journal"; + +export interface FederatableActivity { + id: string; + name: string; + description: string | null; + sportType: SportType | null; + distance: number | null; + elevationGain: number | null; + duration: number | null; + startedAt: Date | null; + createdAt: Date; +} + +/** Public IRI of an activity — its journal detail page. */ +export function activityObjectIri(activityId: string): string { + return `${getOrigin()}/activities/${activityId}`; +} + +function escapeHtml(s: string): string { + return s + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function formatStats(a: FederatableActivity): string { + const parts: string[] = []; + if (a.distance != null) parts.push(`${(a.distance / 1000).toFixed(1)} km`); + if (a.elevationGain != null) parts.push(`↗ ${Math.round(a.elevationGain)} m`); + if (a.duration != null) { + const h = Math.floor(a.duration / 3600); + const m = Math.round((a.duration % 3600) / 60); + parts.push(h > 0 ? `${h}h ${m}m` : `${m}m`); + } + return parts.join(" · "); +} + +// Fedify's types reference the *global* Temporal namespace +// (esnext.temporal lib); Node 22 doesn't ship Temporal at runtime, so +// we construct via the polyfill and bridge the nominally-distinct (but +// structurally identical) types with a cast. +function toInstant(d: Date): Temporal.Instant { + return TemporalPolyfill.Instant.fromEpochMilliseconds( + d.getTime(), + ) as unknown as Temporal.Instant; +} + +export function activityToNote(a: FederatableActivity, ownerUsername: string): Note { + const objectIri = activityObjectIri(a.id); + const stats = formatStats(a); + const paragraphs = [ + `

${escapeHtml(a.name)}

`, + a.description ? `

${escapeHtml(a.description)}

` : "", + stats ? `

${escapeHtml(stats)}

` : "", + `

${objectIri}

`, + ].filter(Boolean); + + const attachments: PropertyValue[] = []; + if (a.distance != null) { + attachments.push(new PropertyValue({ name: "distance-m", value: String(Math.round(a.distance)) })); + } + if (a.elevationGain != null) { + attachments.push(new PropertyValue({ name: "elevation-gain-m", value: String(Math.round(a.elevationGain)) })); + } + if (a.duration != null) { + attachments.push(new PropertyValue({ name: "duration-s", value: String(a.duration) })); + } + if (a.sportType != null) { + attachments.push(new PropertyValue({ name: "sport", value: a.sportType })); + } + + return new Note({ + id: new URL(objectIri), + attribution: new URL(localActorIri(ownerUsername)), + content: paragraphs.join("\n"), + mediaType: "text/html", + url: new URL(objectIri), + published: toInstant(a.startedAt ?? a.createdAt), + to: PUBLIC_COLLECTION, + attachments, + }); +} + +export function activityToCreate(a: FederatableActivity, ownerUsername: string): Create { + return new Create({ + // Stable id derived from the object so replays/dedupe work across + // outbox pages and push deliveries alike. + id: new URL(`${activityObjectIri(a.id)}#create`), + actor: new URL(localActorIri(ownerUsername)), + object: activityToNote(a, ownerUsername), + published: toInstant(a.startedAt ?? a.createdAt), + to: PUBLIC_COLLECTION, + }); +} + +/** + * Retraction for a deleted (or un-publicized) activity. Carries a + * Tombstone so remotes drop their copy (Mastodon honors Delete + + * Tombstone). + */ +export function activityToDelete(objectIri: string, ownerUsername: string): Delete { + return new Delete({ + id: new URL(`${objectIri}#delete-${Date.now()}`), + actor: new URL(localActorIri(ownerUsername)), + object: new Tombstone({ id: new URL(objectIri) }), + to: PUBLIC_COLLECTION, + }); +} diff --git a/apps/journal/app/lib/federation-outbound.integration.test.ts b/apps/journal/app/lib/federation-outbound.integration.test.ts new file mode 100644 index 0000000..dc65e55 --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.integration.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { + followRemoteActor, + cancelRemoteFollow, + listOutgoingRemoteFollows, + type OutboundFollowDeps, + type ResolvedRemoteActor, +} from "./federation-outbound.server.ts"; +import { FollowError } from "./follow.server.ts"; + +// Opt-in: real Postgres; network steps injected so no instance is +// contacted. Same convention as the other *.integration.test.ts files. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const REMOTE = "https://other-trails.example/users/alice"; +const createdUserIds: string[] = []; + +async function makeUser(username: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${username}@example.test`, + username, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +function fakeDeps(opts?: { + software?: string; + resolveTo?: ResolvedRemoteActor | null; +}): OutboundFollowDeps & { delivered: string[]; undone: string[] } { + const delivered: string[] = []; + const undone: string[] = []; + return { + delivered, + undone, + async resolveActor() { + return opts?.resolveTo !== undefined + ? opts.resolveTo + : { iri: REMOTE, inboxUrl: `${REMOTE}/inbox`, username: "alice", displayName: "Alice" }; + }, + async fetchSoftwareName() { + return opts?.software ?? "trails-cool"; + }, + async deliverFollow(_u, followId) { + delivered.push(followId); + }, + async deliverUndoFollow(_u, followId) { + undone.push(followId); + }, + }; +} + +describe.runIf(runIntegration)("outbound trails-to-trails follows (integration)", () => { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + const db = getDb(); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + await db.delete(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + }); + + it("creates a Pending row, caches the actor, and delivers Follow", async () => { + const userId = await makeUser(`out-${Date.now()}`); + const deps = fakeDeps(); + + const state = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(state).toEqual({ pending: true, actorIri: REMOTE }); + expect(deps.delivered).toHaveLength(1); + + const db = getDb(); + const [row] = await db.select().from(follows).where(eq(follows.followerId, userId)); + expect(row!.followedActorIri).toBe(REMOTE); + expect(row!.followedUserId).toBeNull(); + expect(row!.acceptedAt).toBeNull(); + + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + expect(cached!.inboxUrl).toBe(`${REMOTE}/inbox`); + expect(cached!.displayName).toBe("Alice"); + }); + + it("is idempotent — re-following returns the existing state without re-delivering", async () => { + const userId = await makeUser(`out-idem-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + const second = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(second.pending).toBe(true); + expect(deps.delivered).toHaveLength(1); + }); + + it("refuses non-trails instances with a clear code and writes nothing", async () => { + const userId = await makeUser(`out-mast-${Date.now()}`); + const deps = fakeDeps({ software: "mastodon" }); + await expect(followRemoteActor(userId, "@alice@other-trails.example", deps)).rejects.toMatchObject({ + code: "not_trails", + }); + const db = getDb(); + expect(await db.select().from(follows).where(eq(follows.followerId, userId))).toHaveLength(0); + }); + + it("refuses unresolvable handles and local handles", async () => { + const userId = await makeUser(`out-bad-${Date.now()}`); + await expect( + followRemoteActor(userId, "@ghost@other-trails.example", fakeDeps({ resolveTo: null })), + ).rejects.toMatchObject({ code: "remote_resolve_failed" }); + await expect(followRemoteActor(userId, "not a handle", fakeDeps())).rejects.toMatchObject({ + code: "invalid_handle", + }); + // Own-host check needs a parseable host (the default localhost:3000 + // fails the TLD shape first) — point ORIGIN at a real-looking domain. + const prevOrigin = process.env.ORIGIN; + process.env.ORIGIN = "https://own-instance.example"; + try { + await expect( + followRemoteActor(userId, "@someone@own-instance.example", fakeDeps()), + ).rejects.toMatchObject({ code: "local_handle" }); + } finally { + process.env.ORIGIN = prevOrigin; + } + }); + + it("lists outgoing follows and cancel removes the row + delivers Undo", async () => { + const userId = await makeUser(`out-cancel-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + + const entries = await listOutgoingRemoteFollows(userId); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ actorIri: REMOTE, pending: true, username: "alice" }); + + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + expect(await listOutgoingRemoteFollows(userId)).toHaveLength(0); + // Idempotent second cancel + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + }); + + it("FollowError codes are exported for the route layer", () => { + expect(new FollowError("not_trails", "x").code).toBe("not_trails"); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.test.ts b/apps/journal/app/lib/federation-outbound.server.test.ts new file mode 100644 index 0000000..63201bd --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { parseRemoteHandle, isTrailsSoftware } = await import("./federation-outbound.server.ts"); + +describe("parseRemoteHandle", () => { + it("accepts the common handle spellings", () => { + expect(parseRemoteHandle("@alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("acct:alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle(" @alice@other.example ")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("@a_l-i.ce@sub.other.example")).toEqual({ username: "a_l-i.ce", host: "sub.other.example" }); + }); + + it("lowercases hosts but preserves username case", () => { + expect(parseRemoteHandle("@Alice@Other.Example")).toEqual({ username: "Alice", host: "other.example" }); + }); + + it("accepts a port (dev instances)", () => { + expect(parseRemoteHandle("@bob@localhost:3000")).toBeNull(); // bare hostname without TLD is rejected + expect(parseRemoteHandle("@bob@staging.trails.cool:3000")).toEqual({ username: "bob", host: "staging.trails.cool:3000" }); + }); + + it("rejects everything else", () => { + expect(parseRemoteHandle("alice")).toBeNull(); + expect(parseRemoteHandle("https://other.example/users/alice")).toBeNull(); + expect(parseRemoteHandle("@@broken@host.example")).toBeNull(); + expect(parseRemoteHandle("")).toBeNull(); + }); +}); + +describe("isTrailsSoftware", () => { + it("accepts trails-cool and nothing else", () => { + expect(isTrailsSoftware("trails-cool")).toBe(true); + expect(isTrailsSoftware("mastodon")).toBe(false); + expect(isTrailsSoftware("hollo")).toBe(false); + expect(isTrailsSoftware(undefined)).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.ts b/apps/journal/app/lib/federation-outbound.server.ts new file mode 100644 index 0000000..983129b --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.ts @@ -0,0 +1,300 @@ +// Outbound trails-to-trails follows (spec: social-federation §6). +// +// A local user follows a user on another *trails* instance: resolve the +// handle via WebFinger, verify the target instance runs trails.cool via +// NodeInfo (checked against the ACTOR IRI's host, never the handle's +// domain — remote instances may run split domains, see +// docs/ideas/split-domain-handles.md), record a Pending follow row, and +// deliver a signed Follow. The remote's Accept(Follow) settles the row +// (inbox listener from §4); Reject deletes it. +// +// Network-touching steps (actor lookup, NodeInfo fetch, delivery) are +// injectable so the row lifecycle is integration-testable offline. + +import { randomUUID } from "node:crypto"; +import { getNodeInfo } from "@fedify/fedify"; +import { Follow, Undo } from "@fedify/fedify/vocab"; +import { and, eq, isNull, isNotNull, desc } from "drizzle-orm"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { localActorIri } from "./actor-iri.ts"; +import { getFederation } from "./federation.server.ts"; +import { upsertRemoteActor } from "./federation-delivery.server.ts"; +import { FollowError } from "./follow.server.ts"; +import { logger } from "./logger.server.ts"; + +/** + * Software names accepted by the trails-to-trails check. Forks that + * keep the NodeInfo name federate out of the box; rebrands need a PR + * here (see the open question in social-federation design.md; the + * Wanderer-interop idea would extend this list). + */ +const TRAILS_SOFTWARE = new Set(["trails-cool"]); + +export interface RemoteHandle { + username: string; + host: string; +} + +/** + * Parse `@user@host`, `user@host`, or an `acct:` URI into its parts. + * Returns null for anything else (including bare usernames and URLs — + * follows by URL can come later; handles are the user-facing shape). + */ +export function parseRemoteHandle(input: string): RemoteHandle | null { + const trimmed = input.trim().replace(/^acct:/, "").replace(/^@/, ""); + const match = /^([a-z0-9_.-]+)@([a-z0-9.-]+\.[a-z]{2,}(?::\d+)?)$/i.exec(trimmed); + if (!match) return null; + return { username: match[1]!, host: match[2]!.toLowerCase() }; +} + +export function isTrailsSoftware(name: string | undefined): boolean { + return name !== undefined && TRAILS_SOFTWARE.has(name); +} + +export interface ResolvedRemoteActor { + iri: string; + inboxUrl: string; + username: string | null; + displayName: string | null; +} + +export interface OutboundFollowDeps { + resolveActor(handle: RemoteHandle): Promise; + fetchSoftwareName(actorIri: string): Promise; + deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise; + deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise; +} + +function followActivityId(followerUsername: string, followId: string): URL { + return new URL(`${localActorIri(followerUsername)}#follows/${followId}`); +} + +function defaultDeps(): OutboundFollowDeps { + const federation = getFederation(); + const ctx = () => federation.createContext(new URL(getOrigin()), undefined); + return { + async resolveActor(handle) { + const actor = await ctx().lookupObject(`@${handle.username}@${handle.host}`); + if (actor == null || !("inboxId" in actor) || actor.id == null) return null; + const inbox = actor.inboxId as URL | null; + if (inbox == null) return null; + return { + iri: actor.id.href, + inboxUrl: inbox.href, + username: ("preferredUsername" in actor ? (actor.preferredUsername as unknown as string | null) : null) ?? handle.username, + displayName: ("name" in actor ? (actor.name as unknown as string | null) : null), + }; + }, + async fetchSoftwareName(actorIri) { + // Split-domain constraint: NodeInfo is looked up on the ACTOR + // IRI's origin (the server domain), never the handle's domain. + const info = await getNodeInfo(new URL(actorIri).origin); + return info?.software?.name; + }, + async deliverFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + ); + }, + async deliverUndoFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Undo({ + id: new URL(`${localActorIri(followerUsername)}#follows/${followId}/undo`), + actor: new URL(localActorIri(followerUsername)), + object: new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + }), + ); + }, + }; +} + +export interface OutgoingFollowState { + pending: boolean; + actorIri: string; +} + +/** + * Follow a user on another trails instance (spec 6.1/6.4). Pending + * until their Accept(Follow) lands in our inbox. Idempotent: an + * existing row (pending or accepted) is returned unchanged. + */ +export async function followRemoteActor( + followerId: string, + handleInput: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const handle = parseRemoteHandle(handleInput); + if (!handle) { + throw new FollowError("invalid_handle", "Enter a handle like @user@instance.example"); + } + const ownHost = new URL(getOrigin()).host; + if (handle.host === ownHost) { + throw new FollowError("local_handle", "That user is on this instance — follow them from their profile"); + } + + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const actor = await deps.resolveActor(handle); + if (!actor) { + throw new FollowError("remote_resolve_failed", "Couldn't find that user — check the handle"); + } + + // Trails-to-trails gate (spec 6.1/6.3). + let software: string | undefined; + try { + software = await deps.fetchSoftwareName(actor.iri); + } catch { + software = undefined; + } + if (!isTrailsSoftware(software)) { + throw new FollowError( + "not_trails", + "Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool", + ); + } + + // Idempotent re-follow. + const [existing] = await db + .select({ id: follows.id, acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, actor.iri))); + if (existing) { + return { pending: existing.acceptedAt === null, actorIri: actor.iri }; + } + + const followId = randomUUID(); + await db.insert(follows).values({ + id: followId, + followerId, + followedActorIri: actor.iri, + followedUserId: null, + acceptedAt: null, + }); + await upsertRemoteActor({ + actorIri: actor.iri, + inboxUrl: actor.inboxUrl, + username: actor.username, + displayName: actor.displayName, + domain: new URL(actor.iri).host, + }); + + try { + await deps.deliverFollow(follower.username, followId, actor); + } catch (err) { + // Keep the Pending row — Fedify's queue retries delivery; the row + // is also the user's visible record that a request is in flight. + logger.warn({ err, actorIri: actor.iri }, "outbound Follow delivery failed (queued retries may still succeed)"); + } + + logger.info({ followerId, actorIri: actor.iri }, "federation: outbound follow pending"); + return { pending: true, actorIri: actor.iri }; +} + +/** + * Cancel an outgoing remote follow (pending or accepted): delete the + * row and deliver Undo(Follow) (spec 6.6 / social-follows "Cancel a + * Pending follow"). + */ +export async function cancelRemoteFollow( + followerId: string, + actorIri: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const deleted = await db + .delete(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.followedActorIri, actorIri), + isNull(follows.followedUserId), // remote rows only + ), + ) + .returning({ id: follows.id }); + if (deleted.length === 0) return; // idempotent + + const [cached] = await db + .select({ inboxUrl: remoteActors.inboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + if (cached?.inboxUrl) { + try { + await deps.deliverUndoFollow(follower.username, deleted[0]!.id, { + iri: actorIri, + inboxUrl: cached.inboxUrl, + }); + } catch (err) { + logger.warn({ err, actorIri }, "Undo(Follow) delivery failed; row already removed locally"); + } + } + logger.info({ followerId, actorIri }, "federation: outbound follow cancelled"); +} + +export interface OutgoingFollowEntry { + actorIri: string; + pending: boolean; + createdAt: Date; + displayName: string | null; + username: string | null; + domain: string | null; +} + +/** All outgoing remote follows for the user, newest first (spec 6.6). */ +export async function listOutgoingRemoteFollows(followerId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + actorIri: follows.followedActorIri, + acceptedAt: follows.acceptedAt, + createdAt: follows.createdAt, + displayName: remoteActors.displayName, + username: remoteActors.username, + domain: remoteActors.domain, + }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNull(follows.followedUserId), + isNotNull(follows.followedActorIri), + ), + ) + .orderBy(desc(follows.createdAt)); + return rows.map((r) => ({ + actorIri: r.actorIri, + pending: r.acceptedAt === null, + createdAt: r.createdAt, + displayName: r.displayName, + username: r.username, + domain: r.domain, + })); +} diff --git a/apps/journal/app/lib/federation-outbox.integration.test.ts b/apps/journal/app/lib/federation-outbox.integration.test.ts new file mode 100644 index 0000000..da52de4 --- /dev/null +++ b/apps/journal/app/lib/federation-outbox.integration.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, activities, follows } from "@trails-cool/db/schema/journal"; + +// Opt-in: talks to real Postgres (and exercises the real Fedify +// dispatcher stack via handleFederationRequest). Same convention as the +// other *.integration.test.ts files. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const USERNAME = `outbox-user-${Date.now()}`; +let userId: string; + +async function seed() { + const db = getDb(); + userId = randomUUID(); + await db.insert(users).values({ + id: userId, + email: `${USERNAME}@example.test`, + username: USERNAME, + domain: "test.local", + profileVisibility: "public", + }); + // 3 public, 1 unlisted, 1 private — only the 3 public ones federate. + const mk = (n: number, visibility: "public" | "unlisted" | "private") => ({ + id: randomUUID(), + ownerId: userId, + name: `Activity ${n}`, + visibility, + distance: 1000 * n, + createdAt: new Date(Date.now() - n * 60_000), + }); + await db + .insert(activities) + .values([mk(1, "public"), mk(2, "public"), mk(3, "public"), mk(4, "unlisted"), mk(5, "private")]); +} + +describe.runIf(runIntegration)("federation outbox (integration)", () => { + beforeAll(async () => { + process.env.FEDERATION_ENABLED = "true"; + process.env.ORIGIN ??= "http://localhost:3000"; + await seed(); + }); + + afterAll(async () => { + const db = getDb(); + await db.delete(activities).where(eq(activities.ownerId, userId)); + await db.delete(follows).where(eq(follows.followedUserId, userId)); + await db.delete(users).where(eq(users.id, userId)); + }); + + // Mirrors routes/users.$username.outbox.ts: route-level visibility + // gate first (Fedify's collection-level response doesn't consult the + // page dispatcher), then delegate to Fedify. + async function fetchOutbox(path: string): Promise { + const { handleFederationRequest, isFederatableUser } = await import("./federation.server.ts"); + if (!(await isFederatableUser(USERNAME))) { + return new Response("Not Found", { status: 404 }); + } + return handleFederationRequest( + new Request(`http://localhost:3000${path}`, { + headers: { accept: "application/activity+json" }, + }), + ); + } + + it("serves an OrderedCollection counting only public activities", async () => { + const res = await fetchOutbox(`/users/${USERNAME}/outbox`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.type).toBe("OrderedCollection"); + expect(body.totalItems).toBe(3); + expect(body.first).toBeDefined(); + }); + + it("pages contain Create(Note) items, public only, newest first", async () => { + const res = await fetchOutbox(`/users/${USERNAME}/outbox?cursor=0`); + expect(res.status).toBe(200); + const body = await res.json(); + const items = Array.isArray(body.orderedItems) ? body.orderedItems : [body.orderedItems]; + expect(items).toHaveLength(3); + for (const item of items) { + expect(item.type).toBe("Create"); + expect(item.object.type).toBe("Note"); + expect(item.object.attributedTo).toContain(`/users/${USERNAME}`); + } + const names = items.map((i: { object: { content: string } }) => i.object.content); + expect(names[0]).toContain("Activity 1"); // newest first + expect(names.join()).not.toContain("Activity 4"); // unlisted excluded + expect(names.join()).not.toContain("Activity 5"); // private excluded + }); + + it("404s the outbox of a private profile", async () => { + const db = getDb(); + await db.update(users).set({ profileVisibility: "private" }).where(eq(users.id, userId)); + const res = await fetchOutbox(`/users/${USERNAME}/outbox`); + expect(res.status).toBe(404); + await db.update(users).set({ profileVisibility: "public" }).where(eq(users.id, userId)); + }); + + it("serves a public activity's Note at its IRI (dereferenceable)", async () => { + const { handleFederationRequest } = await import("./federation.server.ts"); + const db = getDb(); + const [row] = await db + .select({ id: activities.id }) + .from(activities) + .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "public"))) + .limit(1); + const res = await handleFederationRequest( + new Request(`http://localhost:3000/activities/${row!.id}`, { + headers: { accept: "application/activity+json" }, + }), + ); + expect(res.status).toBe(200); + const note = await res.json(); + expect(note.type).toBe("Note"); + expect(note.id).toBe(`http://localhost:3000/activities/${row!.id}`); + expect(note.attributedTo).toContain(`/users/${USERNAME}`); + }); + + it("404s the Note of a private activity", async () => { + const { handleFederationRequest } = await import("./federation.server.ts"); + const db = getDb(); + const [priv] = await db + .select({ id: activities.id }) + .from(activities) + .where(and(eq(activities.ownerId, userId), eq(activities.visibility, "private"))) + .limit(1); + const res = await handleFederationRequest( + new Request(`http://localhost:3000/activities/${priv!.id}`, { + headers: { accept: "application/activity+json" }, + }), + ); + expect(res.status).toBe(404); + }); + + it("lists accepted remote followers as the delivery audience", async () => { + const db = getDb(); + const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts"); + await db.insert(follows).values([ + { + id: randomUUID(), + followerActorIri: "https://other.example/users/accepted", + followedActorIri: `http://localhost:3000/users/${USERNAME}`, + followedUserId: userId, + acceptedAt: new Date(), + }, + { + id: randomUUID(), + followerActorIri: "https://other.example/users/pending", + followedActorIri: `http://localhost:3000/users/${USERNAME}`, + followedUserId: userId, + acceptedAt: null, + }, + ]); + const audience = await listAcceptedRemoteFollowers(userId); + expect(audience).toEqual(["https://other.example/users/accepted"]); + }); +}); diff --git a/apps/journal/app/lib/federation-outbox.server.ts b/apps/journal/app/lib/federation-outbox.server.ts new file mode 100644 index 0000000..6f9dee0 --- /dev/null +++ b/apps/journal/app/lib/federation-outbox.server.ts @@ -0,0 +1,45 @@ +// Outbox listing queries (spec 5.1). Offset-paged, public-only — +// `unlisted` and `private` never federate. Separate from +// activities.server.ts because the outbox needs raw rows (no geojson +// batching) in a stable reverse-chronological order keyed on createdAt. + +import { and, count, desc, eq } from "drizzle-orm"; +import { activities } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import type { FederatableActivity } from "./federation-objects.server.ts"; + +export const OUTBOX_PAGE_SIZE = 20; + +export async function listPublicActivitiesPage( + ownerId: string, + offset: number, + limit: number, +): Promise { + const db = getDb(); + return db + .select({ + id: activities.id, + name: activities.name, + description: activities.description, + sportType: activities.sportType, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + }) + .from(activities) + .where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public"))) + .orderBy(desc(activities.createdAt)) + .offset(offset) + .limit(limit); +} + +export async function countPublicActivities(ownerId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(activities) + .where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public"))); + return row?.n ?? 0; +} diff --git a/apps/journal/app/lib/federation-queue.server.test.ts b/apps/journal/app/lib/federation-queue.server.test.ts new file mode 100644 index 0000000..dc9600f --- /dev/null +++ b/apps/journal/app/lib/federation-queue.server.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { setBoss } from "./boss.server.ts"; +import { PgBossMessageQueue, FEDERATION_QUEUE_NAME } from "./federation-queue.server.ts"; + +// A durable in-memory stand-in for pg-boss: `sent` survives across adapter +// instances (like rows in Postgres), which is what lets us model a restart +// — a fresh PgBossMessageQueue.listen() draining messages a previous +// instance enqueued. Only the methods the adapter uses are implemented. +interface SentJob { + data: unknown; + startAfter?: number; + retryLimit?: number; + consumed: boolean; +} + +class FakeBoss { + sent: SentJob[] = []; + offWorkCalls: string[] = []; + + async send(name: string, data: unknown, options?: { startAfter?: number; retryLimit?: number }) { + expect(name).toBe(FEDERATION_QUEUE_NAME); + this.sent.push({ data, startAfter: options?.startAfter, retryLimit: options?.retryLimit, consumed: false }); + return "job-id"; + } + + // Drain every ready (non-delayed, unconsumed) job through the handler, + // mirroring a worker that picks up the durable backlog on startup. + async work(name: string, handler: (jobs: Array<{ id: string; name: string; data: unknown }>) => Promise) { + expect(name).toBe(FEDERATION_QUEUE_NAME); + for (const job of this.sent) { + if (job.consumed || (job.startAfter ?? 0) > 0) continue; + job.consumed = true; + await handler([{ id: "j", name, data: job.data }]); + } + return "worker-id"; + } + + async offWork(name: string) { + this.offWorkCalls.push(name); + } + + async createQueue() {} + + async getQueue(name: string) { + expect(name).toBe(FEDERATION_QUEUE_NAME); + const pending = this.sent.filter((j) => !j.consumed); + const delayed = pending.filter((j) => (j.startAfter ?? 0) > 0).length; + return { queuedCount: pending.length, readyCount: pending.length - delayed, deferredCount: delayed }; + } +} + +let boss: FakeBoss; + +beforeEach(() => { + boss = new FakeBoss(); + setBoss(boss as unknown as Parameters[0]); +}); + +afterEach(() => { + setBoss(null); +}); + +describe("PgBossMessageQueue", () => { + it("enqueues with no retry (Fedify owns retries) and no delay by default", async () => { + const q = new PgBossMessageQueue(); + await q.enqueue({ hello: "world" }); + expect(boss.sent).toHaveLength(1); + const [job] = boss.sent; + expect(job).toMatchObject({ data: { hello: "world" }, retryLimit: 0 }); + expect(job?.startAfter).toBeUndefined(); + }); + + it("maps a Temporal delay to whole-second startAfter (rounded up)", async () => { + const q = new PgBossMessageQueue(); + const delay = { total: () => 1.2 } as unknown as Temporal.Duration; + await q.enqueue({ x: 1 }, { delay }); + expect(boss.sent[0]?.startAfter).toBe(2); + }); + + it("declares nativeRetrial=false so Fedify keeps ownership of retries", () => { + expect(new PgBossMessageQueue().nativeRetrial).toBe(false); + }); + + it("consume roundtrip: a listener receives the enqueued message payload", async () => { + const q = new PgBossMessageQueue(); + await q.enqueue({ type: "Create", id: "a" }); + const received: unknown[] = []; + const ac = new AbortController(); + // listen() resolves only on abort; register the worker (which drains + // the backlog), then stop. + const listening = q.listen((m) => void received.push(m), { signal: ac.signal }); + ac.abort(); + await listening; + expect(received).toEqual([{ type: "Create", id: "a" }]); + }); + + it("survives a restart: a fresh listener drains messages a prior instance enqueued", async () => { + // Prior process: enqueue two, never consumed (crash before delivery). + const before = new PgBossMessageQueue(); + await before.enqueue({ n: 1 }); + await before.enqueue({ n: 2 }); + + // New process: same durable store, brand-new adapter instance. + const after = new PgBossMessageQueue(); + const received: unknown[] = []; + const ac = new AbortController(); + const listening = after.listen((m) => void received.push(m), { signal: ac.signal }); + ac.abort(); + await listening; + + expect(received).toEqual([{ n: 1 }, { n: 2 }]); + }); + + it("listen resolves on abort and stops the worker", async () => { + const q = new PgBossMessageQueue(); + const ac = new AbortController(); + const listening = q.listen(() => {}, { signal: ac.signal }); + ac.abort(); + await expect(listening).resolves.toBeUndefined(); + expect(boss.offWorkCalls).toContain(FEDERATION_QUEUE_NAME); + }); + + it("reports queue depth split into ready and delayed", async () => { + const q = new PgBossMessageQueue(); + await q.enqueue({ n: 1 }); + await q.enqueue({ n: 2 }, { delay: { total: () => 30 } as unknown as Temporal.Duration }); + expect(await q.getDepth()).toEqual({ queued: 2, ready: 1, delayed: 1 }); + }); +}); diff --git a/apps/journal/app/lib/federation-queue.server.ts b/apps/journal/app/lib/federation-queue.server.ts new file mode 100644 index 0000000..d35a681 --- /dev/null +++ b/apps/journal/app/lib/federation-queue.server.ts @@ -0,0 +1,77 @@ +// Postgres-backed Fedify MessageQueue, implemented over the pg-boss +// instance the journal already runs (packages/jobs). Fedify uses this +// queue for outbound delivery (with its own retry scheduling) and inbox +// processing; the previous InProcessMessageQueue lost every queued +// message on restart (spec: federation-operations "Durable federation +// queue"). pg-boss gives us durable storage + delayed jobs; Fedify stays +// the retry-policy owner (see `nativeRetrial` below). +// +// This mirrors the PostgresKvStore adapter pattern (federation-kv.server.ts): +// a thin Fedify-interface shim over infrastructure we already operate, +// resolved lazily via getBoss() so it works in both the server-bootstrap +// module and the request-path bundle (see boss.server.ts on the two-copy +// module situation). +// +// Fedify fans a single MessageQueue instance out to three logical roles +// (inbox / outbox / fanout) and calls listen() once per role with an +// equivalent type-dispatching handler. Backing all three with one pg-boss +// queue is correct: any worker can process any message because Fedify +// routes by message type internally. + +import type { + MessageQueue, + MessageQueueEnqueueOptions, + MessageQueueListenOptions, + MessageQueueDepth, +} from "@fedify/fedify"; +import { getBoss } from "./boss.server.ts"; + +/** pg-boss queue name backing Fedify's inbox/outbox/fanout message queue. */ +export const FEDERATION_QUEUE_NAME = "federation.fedify"; + +export class PgBossMessageQueue implements MessageQueue { + // Fedify owns retry scheduling: on a failed delivery it re-enqueues with + // its own backoff policy, so pg-boss must NOT also retry (retryLimit 0 + // on enqueue) or every failure would be attempted twice. + readonly nativeRetrial = false; + + async enqueue(message: unknown, options?: MessageQueueEnqueueOptions): Promise { + // Fedify passes delay as a Temporal.Duration; pg-boss startAfter is + // whole seconds. Round up so a sub-second backoff still defers. + const seconds = options?.delay ? Math.ceil(options.delay.total("seconds")) : 0; + await getBoss().send(FEDERATION_QUEUE_NAME, message as object, { + retryLimit: 0, + ...(seconds > 0 ? { startAfter: seconds } : {}), + }); + } + + async listen( + handler: (message: unknown) => Promise | void, + options?: MessageQueueListenOptions, + ): Promise { + const boss = getBoss(); + await boss.work(FEDERATION_QUEUE_NAME, async (jobs) => { + for (const job of jobs) await handler(job.data); + }); + // Fedify's contract: listen() resolves when the signal aborts and never + // rejects; with no signal it never resolves (runs for the process + // lifetime, consuming the queue). + return new Promise((resolve) => { + const signal = options?.signal; + if (signal == null) return; + const stop = () => void boss.offWork(FEDERATION_QUEUE_NAME).finally(() => resolve()); + if (signal.aborted) stop(); + else signal.addEventListener("abort", stop, { once: true }); + }); + } + + async getDepth(): Promise { + const queue = await getBoss().getQueue(FEDERATION_QUEUE_NAME); + if (queue == null) return { queued: 0, ready: 0, delayed: 0 }; + return { + queued: queue.queuedCount, + ready: queue.readyCount, + delayed: queue.deferredCount, + }; + } +} diff --git a/apps/journal/app/lib/federation-replay.integration.test.ts b/apps/journal/app/lib/federation-replay.integration.test.ts new file mode 100644 index 0000000..d55bd87 --- /dev/null +++ b/apps/journal/app/lib/federation-replay.integration.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { like, eq } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { federationProcessedActivities } from "@trails-cool/db/schema/journal"; +import { markInboundActivityProcessed, sweepProcessedActivities } from "./federation-replay.server.ts"; + +// Opt-in: talks to real Postgres (same convention as the other +// *.integration.test.ts files, e.g. federation-kv). +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +// Namespace IRIs per run so concurrent runs can't collide. +const NS = `https://replay.test/${Date.now()}`; + +describe.runIf(runIntegration)("federation replay defense (integration)", () => { + afterAll(async () => { + const db = getDb(); + await db.delete(federationProcessedActivities).where(like(federationProcessedActivities.activityIri, `${NS}%`)); + }); + + it("first delivery is fresh, redelivery is dropped as a duplicate", async () => { + const iri = `${NS}/like/1`; + expect((await markInboundActivityProcessed(iri)).fresh).toBe(true); + // The same signed activity delivered again: no-op, reported not fresh. + expect((await markInboundActivityProcessed(iri)).fresh).toBe(false); + expect((await markInboundActivityProcessed(iri)).fresh).toBe(false); + }); + + it("distinct activity IRIs are each fresh", async () => { + expect((await markInboundActivityProcessed(`${NS}/delete/1`)).fresh).toBe(true); + expect((await markInboundActivityProcessed(`${NS}/update/1`)).fresh).toBe(true); + }); + + it("sweeps rows older than 30 days, keeps recent ones", async () => { + const db = getDb(); + const oldIri = `${NS}/old`; + const freshIri = `${NS}/recent`; + // 31 days ago vs now. + await db.insert(federationProcessedActivities).values({ + activityIri: oldIri, + receivedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000), + }); + await markInboundActivityProcessed(freshIri); + + const purged = await sweepProcessedActivities(); + expect(purged).toBeGreaterThanOrEqual(1); + + const remaining = await db + .select({ iri: federationProcessedActivities.activityIri }) + .from(federationProcessedActivities) + .where(eq(federationProcessedActivities.activityIri, oldIri)); + expect(remaining).toHaveLength(0); + + const kept = await db + .select({ iri: federationProcessedActivities.activityIri }) + .from(federationProcessedActivities) + .where(eq(federationProcessedActivities.activityIri, freshIri)); + expect(kept).toHaveLength(1); + }); +}); diff --git a/apps/journal/app/lib/federation-replay.server.ts b/apps/journal/app/lib/federation-replay.server.ts new file mode 100644 index 0000000..f4e4958 --- /dev/null +++ b/apps/journal/app/lib/federation-replay.server.ts @@ -0,0 +1,50 @@ +// Inbound-activity replay defense (spec: federation-operations "Inbound +// replay defense"). The narrow inbox processes Follow/Undo/Accept/Reject; +// none of those carried replay protection before (only Create(Note) did, +// via the activities.remote_origin_iri unique constraint). A hostile or +// buggy remote redelivering a signed activity would re-run its side +// effects. This records each processed activity IRI and lets the inbox +// handlers drop a duplicate before doing anything. + +import { lt } from "drizzle-orm"; +import { federationProcessedActivities } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; + +/** + * Record an inbound activity IRI, returning whether this is the first + * time we've seen it. Insert-or-drop: on primary-key conflict no row is + * inserted and `fresh` is false, so the caller drops the activity as a + * replay before running side effects. + * + * Callers must be idempotent regardless: if a handler fails after this + * records the IRI, Fedify's retry would be dropped here as a duplicate, + * so the follow-graph handlers (recordRemoteFollow/removeRemoteFollow/ + * settle/reject) are all safe to under-run. + */ +export async function markInboundActivityProcessed( + activityIri: string, +): Promise<{ fresh: boolean }> { + const db = getDb(); + const inserted = await db + .insert(federationProcessedActivities) + .values({ activityIri }) + .onConflictDoNothing() + .returning({ iri: federationProcessedActivities.activityIri }); + return { fresh: inserted.length > 0 }; +} + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * Delete processed-activity rows older than 30 days. Called by the + * `federation-dedup-sweep` job. Returns the number of rows removed. + */ +export async function sweepProcessedActivities(now: Date = new Date()): Promise { + const db = getDb(); + const cutoff = new Date(now.getTime() - THIRTY_DAYS_MS); + const deleted = await db + .delete(federationProcessedActivities) + .where(lt(federationProcessedActivities.receivedAt, cutoff)) + .returning({ iri: federationProcessedActivities.activityIri }); + return deleted.length; +} diff --git a/apps/journal/app/lib/federation-two-instance.integration.test.ts b/apps/journal/app/lib/federation-two-instance.integration.test.ts new file mode 100644 index 0000000..d8ad1ee --- /dev/null +++ b/apps/journal/app/lib/federation-two-instance.integration.test.ts @@ -0,0 +1,203 @@ +// Two-instance federation drive-through (spec: social-federation 11.4). +// +// Talks to the docker-compose harness in e2e/federation/ — two complete +// journal instances (journal-a.test / journal-b.test) federating over a +// private Docker network with real HTTPS between them. This test is the +// driver: it seeds users straight into each instance's database, mints +// a session cookie for alice (cookie sessions are signed with the +// harness's known SESSION_SECRET), follows bob across instances through +// the real /follows/outgoing route, and then watches the entire +// Follow → Accept → first outbox poll → feed pipeline happen between +// two live servers. +// +// Run via e2e/federation/run.sh — it builds the images, pushes the +// schema into both databases, and sets FEDERATION_TWO_INSTANCE=1. + +import { describe, it, expect, afterAll } from "vitest"; +import postgres, { type Sql } from "postgres"; +import { randomUUID } from "node:crypto"; +import { createCookieSessionStorage } from "react-router"; + +const runTwoInstance = process.env.FEDERATION_TWO_INSTANCE === "1"; + +// Host-published harness endpoints (see e2e/federation/docker-compose.yml). +const A_URL = "http://127.0.0.1:3401"; +const B_URL = "http://127.0.0.1:3402"; +const DB_A = "postgres://trails:trails@127.0.0.1:5499/trails_a"; +const DB_B = "postgres://trails:trails@127.0.0.1:5499/trails_b"; +const B_DOMAIN = "journal-b.test"; +const BOB_ACTOR_IRI = `https://${B_DOMAIN}/users/bob`; +const ACTIVITY_NAME = "Sunrise ride over the ridge"; + +/** Poll `fn` until it returns something truthy. */ +async function until( + what: string, + fn: () => Promise, + timeoutMs = 120_000, +): Promise { + const start = Date.now(); + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() - start > timeoutMs) throw new Error(`Timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 1500)); + } +} + +async function seedUser(sql: Sql, username: string, domain: string): Promise { + const id = randomUUID(); + await sql` + INSERT INTO journal.users (id, email, username, domain, profile_visibility, terms_accepted_at, terms_version) + VALUES (${id}, ${`${username}@${domain}`}, ${username}, ${domain}, 'public', now(), '2026-04-19') + `; + return id; +} + +/** + * Mint a valid `__session` cookie for a user. The harness journals run + * with a known SESSION_SECRET, and sessions are signed cookies (see + * auth/session.server.ts) — so the driver can authenticate as any + * seeded user without driving the WebAuthn ceremony. + */ +async function mintSessionCookie(userId: string): Promise { + const storage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + sameSite: "lax", + path: "/", + secrets: ["two-instance-test-secret"], + }, + }); + const session = await storage.getSession(); + session.set("userId", userId); + const setCookie = await storage.commitSession(session); + return setCookie.split(";")[0]!; +} + +describe.runIf(runTwoInstance)("two-instance federation (11.4)", () => { + const sqlA = postgres(DB_A, { max: 2 }); + const sqlB = postgres(DB_B, { max: 2 }); + + afterAll(async () => { + await sqlA.end(); + await sqlB.end(); + }); + + it( + "alice@journal-a follows bob@journal-b: Follow → Accept → first poll → bob's public activity in alice's feed", + { timeout: 300_000 }, + async () => { + // Both instances are up (run.sh already gated on the compose + // healthchecks; this is a fail-fast sanity check). + for (const base of [A_URL, B_URL]) { + const health = await fetch(`${base}/api/health`); + expect(health.status).toBe(200); + } + + // ---- Seed: alice on A; bob + a public activity on B ---------- + const aliceId = await seedUser(sqlA, "alice", "journal-a.test"); + const bobId = await seedUser(sqlB, "bob", B_DOMAIN); + await sqlB` + INSERT INTO journal.activities (id, owner_id, name, description, visibility) + VALUES (${randomUUID()}, ${bobId}, ${ACTIVITY_NAME}, '', 'public') + `; + + // B serves bob over WebFinger before we point A at him. (Fedify's + // canonical-origin support means the host-port URL works here.) + const wf = await fetch(`${B_URL}/.well-known/webfinger?resource=acct:bob@${B_DOMAIN}`); + expect(wf.status).toBe(200); + + // ---- alice follows bob through the real route ----------------- + const cookie = await mintSessionCookie(aliceId); + const follow = await fetch(`${A_URL}/follows/outgoing`, { + method: "POST", + redirect: "manual", + headers: { cookie, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ intent: "follow", handle: `bob@${B_DOMAIN}` }), + }); + if (follow.status >= 400) { + // FollowError surfaces as a 400 with a code — bubble the body + // up so a harness failure says *why* resolution failed. + throw new Error(`follow action failed: ${follow.status} ${await follow.text()}`); + } + + // Outbound resolution (NodeInfo + WebFinger + actor fetch over the + // Caddy TLS hop) happens inside the action — a Pending row is the + // proof it all worked. + const pending = await until("pending follow row on A", async () => { + const rows = await sqlA` + SELECT id, accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }, 30_000); + expect(pending).toBeDefined(); + + // ---- B auto-accepts (public profile) and delivers Accept ------ + await until("Accept to settle the follow on A", async () => { + const rows = await sqlA` + SELECT accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]?.accepted_at != null; + }); + + // B's side sees a remote follower row for alice. + const bFollow = await sqlB` + SELECT follower_actor_iri FROM journal.follows WHERE followed_user_id = ${bobId} + `; + expect(bFollow[0]?.follower_actor_iri).toBe("https://journal-a.test/users/alice"); + + // ---- First poll ingests bob's outbox into A ------------------- + const ingested = await until("bob's activity to be ingested on A", async () => { + const rows = await sqlA` + SELECT name, audience, owner_id FROM journal.activities + WHERE remote_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }); + expect(ingested.name).toBe(ACTIVITY_NAME); + expect(ingested.audience).toBe("public"); + expect(ingested.owner_id).toBeNull(); + + // ---- And it shows up in alice's rendered feed ------------------ + const feed = await fetch(`${A_URL}/feed`, { headers: { cookie } }); + expect(feed.status).toBe(200); + // JSX puts `` separators between adjacent text expressions + // — strip them so the attribution reads as continuous text. + const html = (await feed.text()).replaceAll("", ""); + expect(html).toContain(ACTIVITY_NAME); + expect(html).toContain(`@bob@${B_DOMAIN}`); + }, + ); + + it("an unsigned Create(Note) posted to the inbox is rejected with no DB writes (11.3)", async () => { + const originIri = `https://intruder.example/notes/${randomUUID()}`; + const res = await fetch(`${A_URL}/users/alice/inbox`, { + method: "POST", + headers: { "content-type": "application/activity+json" }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${originIri}#create`, + type: "Create", + actor: "https://intruder.example/users/mallory", + to: "https://www.w3.org/ns/activitystreams#Public", + object: { + id: originIri, + type: "Note", + content: "

Injected note

", + }, + }), + }); + // Unauthenticated inbox delivery: Fedify refuses it outright + // (400 malformed / 401 unsigned — both fine, never 2xx). + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const rows = await sqlA` + SELECT id FROM journal.activities WHERE remote_origin_iri = ${originIri} + `; + expect(rows).toHaveLength(0); + }); +}); diff --git a/apps/journal/app/lib/federation.server.test.ts b/apps/journal/app/lib/federation.server.test.ts new file mode 100644 index 0000000..294ab08 --- /dev/null +++ b/apps/journal/app/lib/federation.server.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Spike validation (social-federation task 1.1): exercise Fedify's URL +// dispatcher the way a remote AP client would — WebFinger lookup, then +// actor fetch with an ActivityPub Accept header — without a running +// server or database. + +const dbUsers: Array> = []; + +vi.mock("./db.ts", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => dbUsers, + }), + }), + }), + }), +})); + +const { handleFederationRequest, wantsActivityJson, federationSourceHost } = await import( + "./federation.server.ts" +); + +const PUBLIC_USER = { + id: "u1", + username: "bruno", + displayName: "Bruno", + bio: "Riding bikes", + domain: "localhost", + profileVisibility: "public", +}; + +function webfingerRequest(handle: string): Request { + return new Request( + `http://localhost:3000/.well-known/webfinger?resource=${encodeURIComponent(handle)}`, + { headers: { accept: "application/jrd+json" } }, + ); +} + +function actorRequest(username: string): Request { + return new Request(`http://localhost:3000/users/${username}`, { + headers: { accept: "application/activity+json" }, + }); +} + +beforeEach(() => { + process.env.FEDERATION_ENABLED = "true"; + dbUsers.length = 0; +}); + +describe("federation flag", () => { + it("404s every federation request when FEDERATION_ENABLED is off", async () => { + process.env.FEDERATION_ENABLED = "false"; + dbUsers.push(PUBLIC_USER); + const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000")); + expect(res.status).toBe(404); + }); +}); + +describe("WebFinger", () => { + it("resolves a public user's handle to their actor IRI", async () => { + dbUsers.push(PUBLIC_USER); + const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000")); + expect(res.status).toBe(200); + const jrd = await res.json(); + expect(jrd.subject).toBe("acct:bruno@localhost:3000"); + const self = jrd.links.find((l: { rel: string }) => l.rel === "self"); + expect(self.href).toBe("http://localhost:3000/users/bruno"); + expect(self.type).toContain("application/activity+json"); + }); + + it("404s for a private user without leaking existence", async () => { + dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" }); + const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000")); + expect(res.status).toBe(404); + }); + + it("404s for an unknown user", async () => { + const res = await handleFederationRequest(webfingerRequest("acct:ghost@localhost:3000")); + expect(res.status).toBe(404); + }); +}); + +describe("actor object", () => { + it("serves a Person actor for a public user", async () => { + dbUsers.push(PUBLIC_USER); + const res = await handleFederationRequest(actorRequest("bruno")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/activity+json"); + const actor = await res.json(); + expect(actor.type).toBe("Person"); + expect(actor.id).toBe("http://localhost:3000/users/bruno"); + expect(actor.preferredUsername).toBe("bruno"); + expect(actor.name).toBe("Bruno"); + expect(actor.summary).toBe("Riding bikes"); + // Profile-metadata fields Mastodon renders ("this is a trails + // profile"). MUST serialize as a JSON array — Mastodon's parser + // silently ignores a bare attachment object, which is what Fedify + // compacts single-element arrays into (hence two fields). + expect(Array.isArray(actor.attachment)).toBe(true); + const fields = actor.attachment.filter((a: { type: string }) => a?.type === "PropertyValue"); + expect(fields.length).toBeGreaterThanOrEqual(2); + const trails = fields.find((f: { name: string }) => f.name === "🥾 trails.cool"); + expect(trails?.value).toContain('href="http://localhost:3000/users/bruno"'); + expect(trails?.value).toContain('rel="me"'); + const instance = fields.find((f: { name: string }) => f.name === "Instance"); + expect(instance?.value).toContain("localhost:3000"); + }); + + it("404s the actor for a private user", async () => { + dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" }); + const res = await handleFederationRequest(actorRequest("bruno")); + expect(res.status).toBe(404); + }); +}); + +describe("federationSourceHost", () => { + it("extracts the host from the HTTP Signature keyId", () => { + const req = new Request("http://localhost:3000/users/bruno/inbox", { + method: "POST", + headers: { + signature: + 'keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="abc=="', + }, + }); + expect(federationSourceHost(req)).toBe("mastodon.social"); + }); + + it("buckets unsigned or malformed requests as unknown", () => { + expect( + federationSourceHost(new Request("http://localhost:3000/users/bruno/inbox", { method: "POST" })), + ).toBe("unknown"); + expect( + federationSourceHost( + new Request("http://localhost:3000/users/bruno/inbox", { + method: "POST", + headers: { signature: 'keyId="not a url",signature="x"' }, + }), + ), + ).toBe("unknown"); + }); +}); + +describe("wantsActivityJson", () => { + it("matches AP client Accept headers, not browsers", () => { + expect(wantsActivityJson(actorRequest("bruno"))).toBe(true); + expect( + wantsActivityJson( + new Request("http://localhost:3000/users/bruno", { + // Mastodon's exact Accept header + headers: { + accept: + 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }), + ), + ).toBe(true); + expect( + wantsActivityJson( + new Request("http://localhost:3000/users/bruno", { + headers: { accept: "text/html,application/xhtml+xml" }, + }), + ), + ).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts new file mode 100644 index 0000000..b93b8ac --- /dev/null +++ b/apps/journal/app/lib/federation.server.ts @@ -0,0 +1,479 @@ +// ActivityPub federation via Fedify. See openspec/changes/social-federation. +// +// Version pinning (task 1.2): @fedify/fedify is pinned exactly (no caret). +// History: 2.2.x was briefly uninstallable from npm (its @fedify/webfinger +// dependency wasn't published) — fixed upstream; upgraded to 2.2.5 on +// 2026-06-07. The exact pin stays deliberate: a federation library defines +// the wire shapes remote instances see, so upgrades should be conscious. +// +// Mounting model: Fedify owns URL dispatch for its endpoints via +// `federation.fetch(request)`. We delegate to it from React Router routes: +// - /.well-known/webfinger → resource route (raw Response) +// - /.well-known/nodeinfo, +// /nodeinfo/2.1 → resource route (software discovery for +// the trails-to-trails outbound check — NodeInfo is the fediverse +// standard for this; the actor-level `software` AS extension the +// tasks file originally sketched isn't expressible with Fedify's +// typed vocab, and NodeInfo is what Mastodon et al. actually read) +// - /users/:username → route middleware short-circuits to +// Fedify when the request asks for an ActivityPub representation; +// browsers fall through to the HTML profile loader +// - /users/:username/inbox → resource route (POST), rate-limited +// per source instance before Fedify verifies the HTTP Signature +// +// Inbox scope (spec: "Narrow inbox — follow-graph activities only"): +// Follow, Undo(Follow), Accept(Follow), Reject(Follow). Fedify returns +// 202 for activity types without a registered listener, which is +// exactly the "acknowledge and drop" the spec wants; the same applies +// to wrapped types we inspect and ignore (e.g. Undo(Like)). +import { configure, getConsoleSink } from "@logtape/logtape"; +import { createFederation, type Federation } from "@fedify/fedify"; +import { Accept, Follow, Note, Person, PropertyValue, Reject, Undo } from "@fedify/fedify/vocab"; +import { and, eq } from "drizzle-orm"; +import { activities, users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { localActorIri } from "./actor-iri.ts"; +import { PostgresKvStore } from "./federation-kv.server.ts"; +import { PgBossMessageQueue } from "./federation-queue.server.ts"; +import { markInboundActivityProcessed } from "./federation-replay.server.ts"; +import { isBlockedIri } from "./federation-blocklist.server.ts"; +import { federationInboxDroppedTotal } from "./metrics.server.ts"; +import { ensureUserKeypair, loadUserKeypair } from "./federation-keys.server.ts"; +import { activityToCreate, activityToNote } from "./federation-objects.server.ts"; +import { + OUTBOX_PAGE_SIZE, + countPublicActivities, + listPublicActivitiesPage, +} from "./federation-outbox.server.ts"; +import { + recordRemoteFollow, + removeRemoteFollow, + settleOutgoingFollow, + rejectOutgoingFollow, +} from "./federation-inbox.server.ts"; +import { enqueueOptional } from "./boss.server.ts"; +import { logger } from "./logger.server.ts"; + +/** + * Feature flag (task 1.3). All federation surfaces — WebFinger, actor + * objects, inbox, outbox, NodeInfo — are disabled (404) unless + * FEDERATION_ENABLED is exactly "true". Default off so schema/code can + * deploy before any federation traffic is possible. + */ +export function federationEnabled(): boolean { + return process.env.FEDERATION_ENABLED === "true"; +} + +/** Does the request ask for an ActivityPub representation of a resource? */ +export function wantsActivityJson(request: Request): boolean { + const accept = request.headers.get("accept") ?? ""; + return ( + accept.includes("application/activity+json") || + accept.includes("application/ld+json") + ); +} + +type UserRow = typeof users.$inferSelect; + +async function findUserByUsername(username: string): Promise { + const db = getDb(); + const [user] = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user ?? null; +} + +/** + * Resolve a *local, public* user from an actor IRI; null otherwise. + * Matches against our canonical actor IRI shape (`{origin}/users/{name}`, + * the same shape localActorIri produces). + */ +async function findLocalPublicUserByIri(iri: URL | null): Promise { + if (iri === null) return null; + const prefix = `${getOrigin()}/users/`; + if (!iri.href.startsWith(prefix)) return null; + const username = iri.href.slice(prefix.length); + if (!username || username.includes("/")) return null; + const user = await findUserByUsername(username); + return user && user.profileVisibility === "public" ? user : null; +} + +/** + * Whether `id` is one of OUR outgoing Follow activity ids + * (`#follows/`, see federation-outbound.server.ts) and + * names the recipient of the personal inbox the activity arrived in. + * Used by the Accept/Reject listeners: another trails instance can't + * embed a trustworthy copy of our Follow (its id is cross-origin for + * them), so the id is the only recoverable reference. + */ +function isRecipientFollowUri(ctx: { recipient: string | null }, id: URL | null): boolean { + if (id == null || ctx.recipient == null) return false; + if (!id.hash.startsWith("#follows/")) return false; + return id.href.startsWith(`${localActorIri(ctx.recipient)}#`); +} + +let _federation: Federation | null = null; +let _logtapeConfigured = false; + +/** + * Fedify logs through LogTape, which is silent until configured — and + * an unconfigured LogTape means signature-verification failures (the + * single most debuggable federation problem) vanish without a trace. + * Routed to the console so docker logs / Loki pick them up alongside + * pino. Level via FEDERATION_LOG_LEVEL (default "info"; set "debug" + * to see per-request signature verification detail). + */ +function configureFederationLogging(): void { + if (_logtapeConfigured) return; + _logtapeConfigured = true; + const level = (process.env.FEDERATION_LOG_LEVEL ?? "info") as "debug" | "info" | "warning"; + configure({ + sinks: { console: getConsoleSink() }, + loggers: [ + { category: "fedify", lowestLevel: level, sinks: ["console"] }, + // LogTape's meta logger warns about itself; keep it quiet unless + // something is really wrong. + { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"] }, + ], + }).catch(() => { + // configure() throws if something else configured LogTape first — + // fine, their sinks win. + }); +} + +function buildFederation(): Federation { + configureFederationLogging(); + const federation = createFederation({ + kv: new PostgresKvStore(), + // Message queue so inbox listener execution and outbound deliveries + // (e.g. the Accept we push back after a Follow) happen asynchronously + // with Fedify's own retry policy, instead of inside the inbound HTTP + // request — Mastodon gives deliveries a 10s read timeout, and a slow + // remote must never make us blow it. + // + // Two queueing layers coexist by design: our fan-out jobs + // (federation-delivery.server.ts enqueues one deliverActivity pg-boss + // job per follower) feed Fedify, and Fedify's own send/retry + // scheduling now runs on THIS durable pg-boss-backed queue instead of + // in-process. Before, in-process meant a deploy or crash dropped every + // queued delivery and pending retry (spec: fan-out survives a deploy); + // PgBossMessageQueue closes that. Collapsing the two layers into one + // is a refactor with no user-visible payoff, so they stay separate. + queue: new PgBossMessageQueue(), + // Started explicitly below — keeps vitest runs (which build this + // instance via handleFederationRequest) free of dangling timers. + manuallyStartQueue: true, + // Canonical origin so generated IRIs are correct behind the Caddy + // proxy (the Node server itself only sees plain HTTP). + origin: getOrigin(), + // SSRF-guard opt-out for the two-instance federation harness + // (e2e/federation/), where both instances live on a private Docker + // network that Fedify's document loader rightly refuses to fetch + // from. Testing only — never set this in a real deployment: the + // private-address block is a genuine security boundary (a malicious + // actor IRI must not be able to point us at internal targets). + ...(process.env.FEDERATION_ALLOW_PRIVATE_ADDRESS === "true" + ? { allowPrivateAddress: true } + : {}), + }); + if (!process.env.VITEST) { + // Fire-and-forget: runs the queue consumer loop for the process + // lifetime. Dev and prod both reach this on first federation use. + void federation.startQueue(undefined); + } + + federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + const user = await findUserByUsername(identifier); + // Private profiles do not federate at all: returning null makes + // Fedify respond 404, and WebFinger lookups 404 with it — no leak + // of user existence (spec: "Private user is invisible to + // federation"). + if (!user || user.profileVisibility !== "public") return null; + const keys = await ctx.getActorKeyPairs(identifier); + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: user.displayName ?? user.username, + summary: user.bio || undefined, + // Human-facing profile URL — same as the actor IRI by design, + // declared explicitly so AP clients link browsers to HTML. + url: new URL(localActorIri(identifier)), + inbox: ctx.getInboxUri(identifier), + outbox: ctx.getOutboxUri(identifier), + // Public keys for inbound HTTP-Signature verification by + // remotes (Mastodon reads publicKey; newer stacks read the + // multikey assertionMethods). + publicKey: keys[0]?.cryptographicKey, + assertionMethods: keys.map((k) => k.multikey), + // Mastodon renders PropertyValue attachments as the profile + // metadata table — a human-visible "this is a trails profile" + // marker. (The machine-readable marker is NodeInfo; this is + // flair.) Mastodon strips most HTML in values but keeps links. + // NOTE: Mastodon's parser requires `attachment` to be a JSON + // *array* and silently ignores a bare object — and Fedify + // compacts single-element arrays to bare objects. Keep at least + // two fields here so the array survives serialization. + attachments: [ + new PropertyValue({ + name: "🥾 trails.cool", + value: `${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}`, + }), + new PropertyValue({ + name: "Instance", + value: `${getOrigin().replace(/^https?:\/\//, "")}`, + }), + ], + }); + }) + .setKeyPairsDispatcher(async (_ctxData, identifier) => { + let user = await findUserByUsername(identifier); + if (!user || user.profileVisibility !== "public") return []; + // The backfill job normally guarantees keys; generate lazily as a + // last line of defense (idempotent, guarded on NULL public_key). + if (!user.publicKey) { + await ensureUserKeypair(user.id); + user = await findUserByUsername(identifier); + if (!user) return []; + } + const pair = await loadUserKeypair(user); + return pair ? [pair] : []; + }); + + // Outbox (spec 5.1/5.2): paginated OrderedCollection of the user's + // public activities as Create(Note). Unsigned and signed fetches see + // the same content — the outbox only ever contains public items, so + // Authorized Fetch adds nothing until locked accounts exist (the + // spec's two scenarios deliberately collapse to one shape). + federation + .setOutboxDispatcher("/users/{identifier}/outbox", async (_ctx, identifier, cursor) => { + const user = await findUserByUsername(identifier); + if (!user || user.profileVisibility !== "public") return null; + const offset = cursor === null ? 0 : Number.parseInt(cursor, 10); + if (Number.isNaN(offset) || offset < 0) return null; + const page = await listPublicActivitiesPage(user.id, offset, OUTBOX_PAGE_SIZE); + return { + items: page.map((a) => activityToCreate(a, identifier)), + nextCursor: page.length === OUTBOX_PAGE_SIZE ? String(offset + OUTBOX_PAGE_SIZE) : null, + }; + }) + .setCounter(async (_ctx, identifier) => { + const user = await findUserByUsername(identifier); + if (!user || user.profileVisibility !== "public") return null; + return countPublicActivities(user.id); + }) + .setFirstCursor(() => "0"); + + federation + .setInboxListeners("/users/{identifier}/inbox") + .on(Follow, async (ctx, follow) => { + // Spec 4.2: Follow from any AP-compatible remote — auto-accept + // when the local target is public; otherwise drop (the actor + // already 404s for private users). + if (follow.id == null || follow.actorId == null || follow.objectId == null) return; + if (await isBlockedIri(follow.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop + if (!(await markInboundActivityProcessed(follow.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop + const parsed = ctx.parseUri(follow.objectId); + if (parsed?.type !== "actor") return; + const { outcome } = await recordRemoteFollow(follow.actorId.href, parsed.identifier); + if (outcome !== "accepted") return; + const follower = await follow.getActor(ctx); + if (follower == null) return; + await ctx.sendActivity( + { identifier: parsed.identifier }, + follower, + new Accept({ + id: new URL(`${localActorIri(parsed.identifier)}#accepts/${crypto.randomUUID()}`), + actor: ctx.getActorUri(parsed.identifier), + object: follow, + }), + ); + }) + .on(Undo, async (ctx, undo) => { + // Spec 4.3: Undo(Follow) removes the follow row. Other Undos are + // acknowledged and dropped. + if (undo.actorId == null) return; + if (await isBlockedIri(undo.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop + if (undo.id != null && !(await markInboundActivityProcessed(undo.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop + const undoObjectId = undo.objectId; // capture before dereference (see Accept) + const object = await undo.getObject(ctx); + if (object instanceof Follow && object.objectId != null) { + const parsed = ctx.parseUri(object.objectId); + if (parsed?.type !== "actor") return; + await removeRemoteFollow(undo.actorId.href, parsed.identifier); + return; + } + // trails→trails fallback: another trails instance's Undo embeds + // a Follow whose id lives on the SENDER's domain, but the embed + // is cross-origin relative to nothing we can verify, and + // dereferencing `…#follows/` yields the sender's actor + // document, not a Follow (fragments aren't separately fetchable). + // Our Follow ids are `#follows/` — when the Undo + // names one owned by the authenticated sender, the recipient of + // this personal inbox is the unfollowed user. + if ( + ctx.recipient != null && + undoObjectId?.hash.startsWith("#follows/") && + undoObjectId.origin === new URL(undo.actorId.href).origin + ) { + await removeRemoteFollow(undo.actorId.href, ctx.recipient); + } + }) + .on(Accept, async (ctx, accept) => { + // Spec 4.4: a remote accepted our outgoing Follow — settle the + // Pending row and trigger the first outbox poll for that actor. + if (accept.actorId == null) return; + if (await isBlockedIri(accept.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop + if (accept.id != null && !(await markInboundActivityProcessed(accept.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop + // Capture the raw object reference BEFORE dereferencing: + // getObject() memoizes the fetched document, after which objectId + // reports the fetched object's id (fragment stripped) instead of + // the id that was on the wire. + const objectId = accept.objectId; + const object = await accept.getObject(ctx); + logger.debug( + { + recipient: ctx.recipient, + objectId: objectId?.href ?? null, + objectType: object?.constructor?.name ?? null, + }, + "federation: Accept listener dispatch", + ); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // trails→trails: our own Follow id is cross-origin from the + // accepting instance's perspective, so Fedify rightly distrusts + // the embedded copy and a re-fetch of `…#follows/` returns + // our actor document instead of a Follow. The id itself names + // this inbox's recipient, which is all settling needs — and + // settleOutgoingFollow only matches a Pending row toward the + // authenticated sender, so a forged Accept can't settle + // anything that wasn't already directed at that actor. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } + if (!localUser) return; + const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href); + if (settled) { + await enqueueOptional("poll-remote-actor", { actorIri: accept.actorId.href }, { + reason: "first poll after accepted follow", + }); + } + }) + .on(Reject, async (ctx, reject) => { + // Spec 4.5: remote refused our Follow — drop the Pending row. + if (reject.actorId == null) return; + if (await isBlockedIri(reject.actorId.href)) { federationInboxDroppedTotal.inc({ reason: "blocked" }); return; } // silent 202 drop + if (reject.id != null && !(await markInboundActivityProcessed(reject.id.href)).fresh) { federationInboxDroppedTotal.inc({ reason: "duplicate" }); return; } // replay: drop + const objectId = reject.objectId; // capture before dereference (see Accept) + const object = await reject.getObject(ctx); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // Same trails→trails fallback as the Accept listener above. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } + if (!localUser) return; + await rejectOutgoingFollow(localUser.id, reject.actorId.href); + }) + .onError(async (_ctx, error) => { + logger.warn({ err: error }, "federation: inbox listener error"); + }); + + // Dereferenceable Note objects: the Notes we emit (outbox + push + // delivery) use /activities/{id} as their id, so that IRI must serve + // the Note as activity+json. Mastodon's search-fetch of an activity + // URL and strict instances that re-fetch pushed objects both rely on + // this. Browsers keep getting HTML — the activities.$id route + // middleware short-circuits AP requests here. + federation.setObjectDispatcher(Note, "/activities/{id}", async (_ctx, values) => { + const db = getDb(); + const [row] = await db + .select() + .from(activities) + .where(and(eq(activities.id, values.id), eq(activities.visibility, "public"))) + .limit(1); + // Remote-ingested activities are not our objects — only locally + // authored rows dereference here (their canonical IRI is on the + // origin instance). + if (!row || row.ownerId === null) return null; + const [owner] = await db + .select({ username: users.username, profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.id, row.ownerId)) + .limit(1); + // Private owners don't federate — their content 404s like their actor. + if (!owner || owner.profileVisibility !== "public") return null; + return activityToNote(row, owner.username); + }); + + // Software discovery (task 3.4, adapted): NodeInfo is the standard + // the fediverse uses to identify server software; the trails-to-trails + // outbound check (task 6.x) reads this from remote instances. + federation.setNodeInfoDispatcher("/nodeinfo/2.1", () => ({ + software: { + name: "trails-cool", + version: process.env.SENTRY_RELEASE ?? "0.0.0-dev", + homepage: new URL("https://trails.cool/"), + }, + protocols: ["activitypub"], + // Deliberately zeroed: publishing per-instance usage counts is a + // privacy decision we haven't made (privacy manifest, task 10.1). + usage: { users: {}, localPosts: 0, localComments: 0 }, + })); + + return federation; +} + +export function getFederation(): Federation { + _federation ??= buildFederation(); + return _federation; +} + +/** + * Is this username a local user that federates (exists + public)? + * Route-level gate for federation surfaces whose collection-level + * responses Fedify builds without consulting the page dispatcher + * (e.g. the outbox OrderedCollection) — private users must 404 + * everywhere, mirroring the actor object. + */ +export async function isFederatableUser(username: string): Promise { + const user = await findUserByUsername(username); + return user !== null && user.profileVisibility === "public"; +} + +/** + * Delegate a request to Fedify's URL dispatcher. 404s when the feature + * flag is off so disabled instances are indistinguishable from instances + * without federation. + */ +export function handleFederationRequest(request: Request): Promise { + if (!federationEnabled()) { + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + return getFederation().fetch(request, { contextData: undefined }); +} + +/** + * Extract the source instance host from an inbound federation request + * for per-instance rate limiting (spec 4.8). The HTTP Signature keyId + * is a URL on the sender's instance; its host identifies the instance + * before any verification work. Unsigned junk shares one tight bucket. + */ +export function federationSourceHost(request: Request): string { + const signature = request.headers.get("signature") ?? ""; + const match = /keyId="([^"]+)"/.exec(signature); + if (match?.[1]) { + try { + return new URL(match[1]).host; + } catch { + // unparseable keyId → shared bucket below + } + } + return "unknown"; +} diff --git a/apps/journal/app/lib/follow.integration.test.ts b/apps/journal/app/lib/follow.integration.test.ts new file mode 100644 index 0000000..2030eba --- /dev/null +++ b/apps/journal/app/lib/follow.integration.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows } from "@trails-cool/db/schema/journal"; +import { + followUser, + unfollowUser, + getFollowState, + countFollowers, + countFollowing, + countPendingFollowRequests, + listPendingFollowRequests, + approveFollowRequest, + rejectFollowRequest, +} from "./follow.server.ts"; + +// Opt-in: these talk to real Postgres. Gated by an env flag so laptop +// runs without Postgres aren't blocked. Same convention as +// demo-bot.integration.test.ts. +const runIntegration = process.env.FOLLOW_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("follow.server integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1`); + }); + afterEach(wipe); + + it("follow + unfollow cycle on a public profile", async () => { + const a = await makeUser({ username: `f_a_${Date.now()}` }); + const b = await makeUser({ username: `f_b_${Date.now()}` }); + const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!; + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + expect(await getFollowState(a, bRow.username)).toBeNull(); + + const s1 = await followUser(a, bRow.username); + expect(s1).toEqual({ following: true, pending: false }); + expect(await countFollowers(b)).toBe(1); + expect(await countFollowing(a)).toBe(1); + + // Idempotent + const s2 = await followUser(a, bRow.username); + expect(s2).toEqual({ following: true, pending: false }); + expect(await countFollowers(b)).toBe(1); + + const s3 = await unfollowUser(a, bRow.username); + expect(s3).toEqual({ following: false, pending: false }); + expect(await countFollowers(b)).toBe(0); + expect(await getFollowState(a, bRow.username)).toBeNull(); + + // Idempotent + const s4 = await unfollowUser(a, bRow.username); + expect(s4).toEqual({ following: false, pending: false }); + + // touch aRow so the variable is read (lint hygiene) + expect(aRow.username.startsWith("f_a_")).toBe(true); + }); + + it("creates a Pending follow against a private profile (not a refusal)", async () => { + const a = await makeUser({ username: `f_pa_${Date.now()}` }); + const b = await makeUser({ username: `f_pb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + const s = await followUser(a, bRow.username); + expect(s).toEqual({ following: false, pending: true }); + // Pending is excluded from accepted-only counts. + expect(await countFollowers(b)).toBe(0); + expect(await countFollowing(a)).toBe(0); + }); + + it("refuses self-follow", async () => { + const a = await makeUser({ username: `f_self_${Date.now()}` }); + const aRow = (await getDb().select().from(users).where(eq(users.id, a)))[0]!; + await expect(followUser(a, aRow.username)).rejects.toMatchObject({ code: "self_follow" }); + }); + + it("approve flips Pending → Accepted; reject deletes the request", async () => { + const a = await makeUser({ username: `f_apr_${Date.now()}` }); + const b = await makeUser({ username: `f_apb_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + await followUser(a, bRow.username); + expect(await countPendingFollowRequests(b)).toBe(1); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + const reqId = reqs[0]!.id; + + const approved = await approveFollowRequest(b, reqId); + expect(approved).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await countFollowers(b)).toBe(1); + expect(await getFollowState(a, bRow.username)).toEqual({ following: true, pending: false }); + + // Idempotent: approving again is a no-op. + expect(await approveFollowRequest(b, reqId)).toBe(false); + + // Reject path: a fresh request from a 3rd user, B rejects. + const c = await makeUser({ username: `f_apc_${Date.now()}` }); + await followUser(c, bRow.username); + const reqs2 = await listPendingFollowRequests(b); + expect(reqs2.length).toBe(1); + expect(await rejectFollowRequest(b, reqs2[0]!.id)).toBe(true); + expect(await countPendingFollowRequests(b)).toBe(0); + expect(await getFollowState(c, bRow.username)).toBeNull(); + }); + + it("approve/reject is owner-bound (other users can't approve someone else's request)", async () => { + const a = await makeUser({ username: `f_obA_${Date.now()}` }); + const b = await makeUser({ username: `f_obB_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + const c = await makeUser({ username: `f_obC_${Date.now()}` }); + await followUser(a, bRow.username); + const reqs = await listPendingFollowRequests(b); + // C tries to approve B's incoming request — should be a no-op. + expect(await approveFollowRequest(c, reqs[0]!.id)).toBe(false); + expect(await countPendingFollowRequests(b)).toBe(1); + }); + + it("404s on unknown username", async () => { + const a = await makeUser({ username: `f_404_${Date.now()}` }); + await expect(followUser(a, "no_such_user_xyz")).rejects.toMatchObject({ code: "user_not_found" }); + expect(await countFollowing(a)).toBe(0); + // suppress lint by using afterEach effect indirectly + expect(typeof a).toBe("string"); + // also touch follows table so the import isn't unused + const db = getDb(); + const rows = await db.select().from(follows).where(eq(follows.followerId, a)); + expect(rows.length).toBe(0); + }); +}); diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts new file mode 100644 index 0000000..1ac3250 --- /dev/null +++ b/apps/journal/app/lib/follow.server.ts @@ -0,0 +1,415 @@ +import { randomUUID } from "node:crypto"; +import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { localActorIri } from "./actor-iri.ts"; +import { createNotification } from "./notifications.server.ts"; +import { logger } from "./logger.server.ts"; + +export class FollowError extends Error { + readonly code: + | "self_follow" + | "user_not_found" + | "not_found" + | "forbidden" + // outbound federation codes (federation-outbound.server.ts) + | "invalid_handle" + | "local_handle" + | "remote_resolve_failed" + | "not_trails"; + constructor(code: FollowError["code"], message: string) { + super(message); + this.name = "FollowError"; + this.code = code; + } +} + +export interface FollowState { + following: boolean; + pending: boolean; +} + +async function loadFollowableTarget(targetUsername: string) { + const db = getDb(); + const [target] = await db + .select({ + id: users.id, + username: users.username, + profileVisibility: users.profileVisibility, + }) + .from(users) + .where(eq(users.username, targetUsername)); + if (!target) throw new FollowError("user_not_found", "User not found"); + return target; +} + +/** + * Create a follow row from `followerId` to the local user with username + * `targetUsername`. Public targets auto-accept (`accepted_at = now()`), + * private (locked) targets land Pending (`accepted_at = NULL`) and + * appear in the target's Requests tab on /notifications for manual approval. + * Idempotent: re-following keeps the existing row's state. + */ +export async function followUser(followerId: string, targetUsername: string): Promise { + const target = await loadFollowableTarget(targetUsername); + if (target.id === followerId) { + throw new FollowError("self_follow", "Users cannot follow themselves"); + } + + const db = getDb(); + const followedActorIri = localActorIri(target.username); + const [existing] = await db + .select({ id: follows.id, acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + if (existing) { + // Idempotent re-follow: do NOT emit a notification (the recipient + // was notified when the row was first created). + return { following: existing.acceptedAt !== null, pending: existing.acceptedAt === null }; + } + + const acceptedAt = target.profileVisibility === "public" ? new Date() : null; + const followId = randomUUID(); + await db.insert(follows).values({ + id: followId, + followerId, + followedActorIri, + followedUserId: target.id, + acceptedAt, + }); + + // Notify the target. follow_received for auto-accepted public follows; + // follow_request_received for Pending follows against private profiles. + // Errors are logged but don't fail the follow — the user-visible + // follow already succeeded. + try { + const [follower] = await db + .select({ username: users.username, displayName: users.displayName }) + .from(users) + .where(eq(users.id, followerId)); + if (follower) { + const payload = { + followerUsername: follower.username, + followerDisplayName: follower.displayName, + }; + await createNotification( + acceptedAt !== null + ? { + type: "follow_received", + recipientUserId: target.id, + actorUserId: followerId, + subjectId: followId, + payload, + } + : { + type: "follow_request_received", + recipientUserId: target.id, + actorUserId: followerId, + subjectId: followId, + payload, + }, + ); + } + } catch (err) { + logger.warn({ err, followId }, "followUser: notification emit failed"); + } + + return { + following: acceptedAt !== null, + pending: acceptedAt === null, + }; +} + +/** + * Delete the follow row from `followerId` against the local user with + * username `targetUsername`. Idempotent. + */ +export async function unfollowUser(followerId: string, targetUsername: string): Promise { + const target = await loadFollowableTarget(targetUsername); + const db = getDb(); + const followedActorIri = localActorIri(target.username); + await db + .delete(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + return { following: false, pending: false }; +} + +/** + * Read-side helper: is `followerId` currently following `targetUsername`? + * Returns `null` when no row exists (so callers can distinguish "no follow" + * from "row exists but unaccepted" once federation's Pending state lands). + */ +export async function getFollowState( + followerId: string, + targetUsername: string, +): Promise { + const db = getDb(); + const followedActorIri = localActorIri(targetUsername); + const [row] = await db + .select({ acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, followedActorIri))); + if (!row) return null; + return { following: row.acceptedAt !== null, pending: row.acceptedAt === null }; +} + +// Counts include only accepted relations — Pending requests don't count +// toward the public follower/following tallies (a request not yet +// approved isn't a real follow). + +export async function countFollowers(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +export async function countFollowing(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +/** + * Count of incoming Pending follow requests for `userId`. Drives the + * navbar badge. Distinct from countFollowers (which is accepted-only). + */ +export async function countPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(follows) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))); + return row?.n ?? 0; +} + +export interface FollowRequest { + id: string; + followerUsername: string; + followerDisplayName: string | null; + followerDomain: string; + createdAt: Date; +} + +/** + * Pending incoming follow requests for `userId`. Drives the Requests tab + * on /notifications. + * Reverse-chronological by request creation time. + */ +export async function listPendingFollowRequests(userId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + id: follows.id, + followerUsername: users.username, + followerDisplayName: users.displayName, + followerDomain: users.domain, + createdAt: follows.createdAt, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(and(eq(follows.followedUserId, userId), isNull(follows.acceptedAt))) + .orderBy(desc(follows.createdAt)); + return rows; +} + +/** + * Approve a Pending follow request. Owner-bound: `ownerId` must equal + * `follows.followedUserId` for the row, otherwise the call is a no-op. + */ +export async function approveFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .update(follows) + .set({ acceptedAt: new Date() }) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id, followerId: follows.followerId }); + + if (result.length === 0) return false; + + // Notify the requester that their request landed. Lookup target's + // display info for the payload. Errors logged but don't undo the + // approve — the follow row is already accepted. + try { + const followerId = result[0]!.followerId; + const [target] = await db + .select({ username: users.username, displayName: users.displayName }) + .from(users) + .where(eq(users.id, ownerId)); + // followerId is NULL for inbound federated follows — those are + // auto-accepted and never appear in the Requests tab, but guard + // anyway: remote followers can't receive local notifications. + if (target && followerId !== null) { + await createNotification({ + type: "follow_request_approved", + recipientUserId: followerId, + actorUserId: ownerId, + subjectId: followId, + payload: { + targetUsername: target.username, + targetDisplayName: target.displayName, + }, + }); + } + } catch (err) { + logger.warn({ err, followId }, "approveFollowRequest: notification emit failed"); + } + + return true; +} + +/** + * Reject a Pending follow request. Deletes the row entirely so the + * follower can re-request later if they want. + */ +export async function rejectFollowRequest(ownerId: string, followId: string): Promise { + const db = getDb(); + const result = await db + .delete(follows) + .where( + and( + eq(follows.id, followId), + eq(follows.followedUserId, ownerId), + isNull(follows.acceptedAt), + ), + ) + .returning({ id: follows.id }); + return result.length > 0; +} + +export interface CollectionEntry { + username: string; + displayName: string | null; + domain: string; + /** Where the entry's profile lives: local path or remote actor URL. */ + profileUrl: string; + remote: boolean; +} + +const COLLECTION_PAGE_SIZE = 50; + +/** + * Best-effort identity for a remote actor we may or may not have + * cached: prefer the remote_actors row, fall back to parsing the IRI + * (canonical fediverse shape `https://host/users/name`). + */ +function remoteEntry( + actorIri: string, + cached: { username: string | null; displayName: string | null; domain: string | null }, +): CollectionEntry { + let host = ""; + let lastSegment: string; + try { + const url = new URL(actorIri); + host = url.host; + lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? ""; + } catch { + // Malformed IRI in the DB — display it raw rather than hide the row. + lastSegment = actorIri; + } + return { + username: cached.username ?? lastSegment, + displayName: cached.displayName, + domain: cached.domain ?? host, + profileUrl: actorIri, + remote: true, + }; +} + +/** + * Paginated list of accepted followers of `userId`. Newest acceptance first. + * Includes remote (federated) followers — `follower_actor_iri` rows — so the + * list matches countFollowers; display data comes from the remote_actors + * cache when present, IRI parsing otherwise. Pending requests are excluded — + * they live in the Requests tab on /notifications. + */ +export async function listFollowers(userId: string, page: number = 1): Promise { + const db = getDb(); + const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE; + const rows = await db + .select({ + localUsername: users.username, + localDisplayName: users.displayName, + localDomain: users.domain, + followerActorIri: follows.followerActorIri, + remoteUsername: remoteActors.username, + remoteDisplayName: remoteActors.displayName, + remoteDomain: remoteActors.domain, + }) + .from(follows) + .leftJoin(users, eq(follows.followerId, users.id)) + .leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri)) + .where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt))) + .orderBy(desc(follows.acceptedAt)) + .limit(COLLECTION_PAGE_SIZE) + .offset(offset); + return rows.map((r) => + r.localUsername !== null + ? { + username: r.localUsername, + displayName: r.localDisplayName, + domain: r.localDomain ?? "", + profileUrl: `/users/${r.localUsername}`, + remote: false, + } + : remoteEntry(r.followerActorIri ?? "", { + username: r.remoteUsername, + displayName: r.remoteDisplayName, + domain: r.remoteDomain, + }), + ); +} + +/** + * Paginated list of accepted follows from `userId`. Newest acceptance first. + * Includes remote (trails-to-trails) targets — rows whose followed side is + * only an actor IRI — for the same count/list consistency as listFollowers. + * Pending outgoing follows (against private/locked targets) are excluded. + */ +export async function listFollowing(userId: string, page: number = 1): Promise { + const db = getDb(); + const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE; + const rows = await db + .select({ + localUsername: users.username, + localDisplayName: users.displayName, + localDomain: users.domain, + followedActorIri: follows.followedActorIri, + remoteUsername: remoteActors.username, + remoteDisplayName: remoteActors.displayName, + remoteDomain: remoteActors.domain, + }) + .from(follows) + .leftJoin(users, eq(follows.followedUserId, users.id)) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where(and(eq(follows.followerId, userId), isNotNull(follows.acceptedAt))) + .orderBy(desc(follows.acceptedAt)) + .limit(COLLECTION_PAGE_SIZE) + .offset(offset); + return rows.map((r) => + r.localUsername !== null + ? { + username: r.localUsername, + displayName: r.localDisplayName, + domain: r.localDomain ?? "", + profileUrl: `/users/${r.localUsername}`, + remote: false, + } + : remoteEntry(r.followedActorIri, { + username: r.remoteUsername, + displayName: r.remoteDisplayName, + domain: r.remoteDomain, + }), + ); +} diff --git a/apps/journal/app/lib/gpx-save.server.test.ts b/apps/journal/app/lib/gpx-save.server.test.ts new file mode 100644 index 0000000..3113406 --- /dev/null +++ b/apps/journal/app/lib/gpx-save.server.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { generateGpx } from "@trails-cool/gpx"; +import { processGpx, validateGpx, GpxValidationError } from "./gpx-save.server.ts"; + +const VALID_GPX = ` + + + + 500 + 520 + 510 + + +`; + +const ONE_POINT_GPX = ` + + + + 500 + + +`; + +const EMPTY_GPX = ` + + +`; + +const OUT_OF_RANGE_LAT_GPX = ` + + + + + + + +`; + +const OUT_OF_RANGE_LON_GPX = ` + + + + + + + +`; + +describe("validateGpx", () => { + it("returns parsed GpxData for valid GPX", async () => { + const result = await validateGpx(VALID_GPX); + expect(result.tracks.flat().length).toBe(3); + expect(result.tracks[0]![0]!.lat).toBeCloseTo(47.0); + }); + + it("throws GpxValidationError for GPX with fewer than 2 track points", async () => { + await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow(GpxValidationError); + await expect(validateGpx(ONE_POINT_GPX)).rejects.toThrow("at least 2 track points"); + }); + + it("throws GpxValidationError for GPX with zero track points", async () => { + await expect(validateGpx(EMPTY_GPX)).rejects.toThrow(GpxValidationError); + await expect(validateGpx(EMPTY_GPX)).rejects.toThrow("at least 2 track points"); + }); + + it("throws GpxValidationError for out-of-range latitude", async () => { + await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow(GpxValidationError); + await expect(validateGpx(OUT_OF_RANGE_LAT_GPX)).rejects.toThrow("out-of-range coordinates"); + }); + + it("throws GpxValidationError for out-of-range longitude", async () => { + await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow(GpxValidationError); + await expect(validateGpx(OUT_OF_RANGE_LON_GPX)).rejects.toThrow("out-of-range coordinates"); + }); + + it("throws GpxValidationError for unparseable XML", async () => { + await expect(validateGpx("not xml at all")).rejects.toThrow(GpxValidationError); + await expect(validateGpx(" { + const err = await validateGpx(EMPTY_GPX).catch((e) => e); + expect(err).toBeInstanceOf(GpxValidationError); + expect(err.name).toBe("GpxValidationError"); + }); +}); + +const TIMED_GPX = ` + + An alpine loop + + + 500 + 520 + + +`; + +describe("processGpx", () => { + it("extracts coords in [lon, lat] PostGIS axis order", async () => { + const { coords } = await processGpx(VALID_GPX); + expect(coords).toEqual([ + [8.0, 47.0], + [8.1, 47.1], + [8.2, 47.2], + ]); + }); + + it("derives distance, elevation, description, and start time", async () => { + const { stats } = await processGpx(TIMED_GPX); + expect(stats.distance).toBeGreaterThan(0); + expect(stats.elevationGain).not.toBeNull(); + expect(stats.elevationLoss).not.toBeNull(); + expect(stats.description).toBe("An alpine loop"); + expect(stats.startTime).toEqual(new Date("2026-06-01T08:00:00Z")); + }); + + it("returns null startTime and empty dayBreaks when absent", async () => { + const { stats } = await processGpx(VALID_GPX); + expect(stats.startTime).toBeNull(); + expect(stats.dayBreaks).toEqual([]); + }); + + it("derives dayBreaks from day-break waypoints (round-trip via generateGpx)", async () => { + const gpx = generateGpx({ + name: "Multi-day", + waypoints: [ + { lat: 47.0, lon: 8.0, name: "Start" }, + { lat: 47.1, lon: 8.1, name: "Hut", isDayBreak: true }, + { lat: 47.2, lon: 8.2, name: "End" }, + ], + tracks: [[ + { lat: 47.0, lon: 8.0 }, + { lat: 47.1, lon: 8.1 }, + { lat: 47.2, lon: 8.2 }, + ]], + }); + + const { stats } = await processGpx(gpx); + expect(stats.dayBreaks).toEqual([1]); + }); + + it("propagates GpxValidationError from validation", async () => { + await expect(processGpx(ONE_POINT_GPX)).rejects.toThrow(GpxValidationError); + }); + + it("returns the parsed GpxData so callers never re-parse", async () => { + const { parsed } = await processGpx(VALID_GPX); + expect(parsed.tracks.flat()).toHaveLength(3); + }); +}); diff --git a/apps/journal/app/lib/gpx-save.server.ts b/apps/journal/app/lib/gpx-save.server.ts new file mode 100644 index 0000000..7431a39 --- /dev/null +++ b/apps/journal/app/lib/gpx-save.server.ts @@ -0,0 +1,111 @@ +import { sql } from "drizzle-orm"; +import type { Database } from "@trails-cool/db"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import type { GpxData } from "@trails-cool/gpx"; + +// Re-export so callers only need one import. +export type { GpxData }; + +export class GpxValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "GpxValidationError"; + } +} + +/** + * Parse and validate a GPX string. Returns the parsed data so callers + * don't need to parse again for stat extraction. Throws GpxValidationError + * when the GPX is malformed, has fewer than 2 track points, or contains + * out-of-range coordinates. + */ +export async function validateGpx(gpx: string): Promise { + let parsed: GpxData; + try { + parsed = await parseGpxAsync(gpx); + } catch (e) { + throw new GpxValidationError(`GPX parse failed: ${(e as Error).message}`); + } + + const points = parsed.tracks.flat(); + if (points.length < 2) { + throw new GpxValidationError( + `GPX must contain at least 2 track points (got ${points.length})`, + ); + } + + for (const p of points) { + if (p.lat < -90 || p.lat > 90 || p.lon < -180 || p.lon > 180) { + throw new GpxValidationError( + `GPX contains out-of-range coordinates: lat=${p.lat}, lon=${p.lon}`, + ); + } + } + + return parsed; +} + +export interface GpxStats { + distance: number | null; + elevationGain: number | null; + elevationLoss: number | null; + /** Indices of waypoints flagged as day breaks. */ + dayBreaks: number[]; + /** GPX-level description, when present. */ + description?: string; + /** Timestamp of the first track point, when present. */ + startTime: Date | null; +} + +export interface ProcessedGpx { + parsed: GpxData; + /** [lon, lat] pairs in PostGIS axis order, ready for writeGeom. */ + coords: Array<[number, number]>; + stats: GpxStats; +} + +/** + * The validate-and-derive step every GPX save starts with: parse + + * validate (throws GpxValidationError), extract the geometry + * coordinates, and derive the stats rows store. Callers own the + * precedence between these derived stats and caller-supplied ones — + * routes let explicit input win wholesale, activities prefer the GPX + * distance unless it is zero. + */ +export async function processGpx(gpx: string): Promise { + const parsed = await validateGpx(gpx); + return { + parsed, + coords: parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]), + stats: { + distance: parsed.distance ?? null, + elevationGain: parsed.elevation.gain ?? null, + elevationLoss: parsed.elevation.loss ?? null, + dayBreaks: parsed.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0), + description: parsed.description, + startTime: parsed.tracks[0]?.[0]?.time ? new Date(parsed.tracks[0][0].time) : null, + }, + }; +} + +type Tx = Parameters[0]>[0]; + +/** + * Write a PostGIS LineString geometry from already-validated track points. + * Must be called inside a db.transaction() — the tx client is required so + * the geometry write participates in the enclosing transaction. + * Throws on failure; callers must NOT swallow the error. + */ +export async function writeGeom( + tx: Tx, + id: string, + table: "routes" | "activities", + coords: Array<[number, number]>, +): Promise { + const geojson = JSON.stringify({ type: "LineString", coordinates: coords }); + await tx.execute( + sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`, + ); +} diff --git a/apps/journal/app/lib/http.server.test.ts b/apps/journal/app/lib/http.server.test.ts new file mode 100644 index 0000000..3e66d32 --- /dev/null +++ b/apps/journal/app/lib/http.server.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { fetchWithTimeout } from "./http.server.ts"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("fetchWithTimeout", () => { + it("aborts when the request exceeds the timeout", async () => { + // Mock fetch to honor the AbortSignal but never resolve otherwise. + globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }) as typeof fetch; + + await expect(fetchWithTimeout("https://example.test", {}, 25)).rejects.toThrow(); + }); + + it("forwards the response when the call returns in time", async () => { + globalThis.fetch = vi.fn(async () => new Response("ok", { status: 200 })) as typeof fetch; + const resp = await fetchWithTimeout("https://example.test", {}, 1000); + expect(resp.status).toBe(200); + expect(await resp.text()).toBe("ok"); + }); + + it("respects a caller-supplied abort signal alongside the timeout", async () => { + const controller = new AbortController(); + globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }) as typeof fetch; + const p = fetchWithTimeout("https://example.test", { signal: controller.signal }, 60_000); + controller.abort(); + await expect(p).rejects.toThrow(); + }); +}); diff --git a/apps/journal/app/lib/http.server.ts b/apps/journal/app/lib/http.server.ts new file mode 100644 index 0000000..f22bd25 --- /dev/null +++ b/apps/journal/app/lib/http.server.ts @@ -0,0 +1,14 @@ +// Default timeout for outbound HTTP calls to third-party providers. +// Hung providers must not stall job workers or request handlers. +export const DEFAULT_EXTERNAL_FETCH_TIMEOUT_MS = 30_000; + +export function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_EXTERNAL_FETCH_TIMEOUT_MS, +): Promise { + const signal = init.signal + ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) + : AbortSignal.timeout(timeoutMs); + return fetch(input, { ...init, signal }); +} diff --git a/apps/journal/app/lib/jwt.server.ts b/apps/journal/app/lib/jwt.server.ts index 5bf62a4..b7e1637 100644 --- a/apps/journal/app/lib/jwt.server.ts +++ b/apps/journal/app/lib/jwt.server.ts @@ -1,10 +1,14 @@ import { SignJWT, jwtVerify } from "jose"; +import { randomUUID } from "node:crypto"; +import { getOrigin, requireSecret } from "./config.server.ts"; +import { getDb } from "./db.ts"; +import { consumedJwtJti } from "@trails-cool/db/schema/journal"; const JWT_SECRET = new TextEncoder().encode( - process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production", + requireSecret("JWT_SECRET", "dev-jwt-secret-change-in-production"), ); -const ISSUER = process.env.ORIGIN ?? "http://localhost:3000"; +const ISSUER = getOrigin(); export async function createRouteToken(routeId: string, permissions: string[] = ["read", "write"]): Promise { return new SignJWT({ route_id: routeId, permissions }) @@ -12,14 +16,57 @@ export async function createRouteToken(routeId: string, permissions: string[] = .setIssuer(ISSUER) .setExpirationTime("7d") .setIssuedAt() + .setJti(randomUUID()) .sign(JWT_SECRET); } +export class TokenAlreadyConsumedError extends Error { + constructor() { + super("Token already consumed"); + this.name = "TokenAlreadyConsumedError"; + } +} + +/** + * Verify a route token AND atomically consume it. Subsequent calls + * with the same token throw `TokenAlreadyConsumedError`. + * + * The consume step is `INSERT … ON CONFLICT DO NOTHING RETURNING jti`. + * Postgres serializes the insert against concurrent attempts, so + * exactly one caller observes the returned row — the rest see an + * empty result and reject. + * + * Tokens minted before this PR (no `jti` claim) are rejected outright, + * which is the right behavior: pre-existing tokens are already in + * planner-session DB rows and would be replayable if accepted. The + * user re-saves by going back to the journal for a fresh token. + */ export async function verifyRouteToken(token: string): Promise<{ routeId: string; permissions: string[] }> { const { payload } = await jwtVerify(token, JWT_SECRET, { issuer: ISSUER, }); + if (typeof payload.jti !== "string" || !payload.jti) { + throw new TokenAlreadyConsumedError(); + } + if (typeof payload.exp !== "number") { + throw new TokenAlreadyConsumedError(); + } + + const db = getDb(); + const inserted = await db + .insert(consumedJwtJti) + .values({ + jti: payload.jti, + expiresAt: new Date(payload.exp * 1000), + }) + .onConflictDoNothing() + .returning({ jti: consumedJwtJti.jti }); + + if (inserted.length === 0) { + throw new TokenAlreadyConsumedError(); + } + return { routeId: payload.route_id as string, permissions: payload.permissions as string[], diff --git a/apps/journal/app/lib/komoot-bulk-import.server.ts b/apps/journal/app/lib/komoot-bulk-import.server.ts new file mode 100644 index 0000000..defb7a4 --- /dev/null +++ b/apps/journal/app/lib/komoot-bulk-import.server.ts @@ -0,0 +1,135 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { importBatches } from "@trails-cool/db/schema/journal"; +import { fetchKomootTours, fetchKomootTourGpx } from "./komoot.server.ts"; +import { isAlreadyImported, recordImport } from "./sync/imports.server.ts"; +import { createActivity } from "./activities.server.ts"; +import { mapSportType } from "./sport-type.ts"; +import { decrypt } from "./crypto.server.ts"; +import { logger } from "./logger.server.ts"; + +export type KomootCreds = + | { mode: "public"; komootUserId: string } + | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; + +/** + * Marks a batch failed unless it already reached a terminal state. + * Used by the job handler when credential resolution fails before + * runKomootBulkImport (which owns failure-marking for its own errors) + * ever runs — otherwise the batch would sit "pending" forever. + */ +export async function markBatchFailed(batchId: string, message: string): Promise { + const db = getDb(); + await db + .update(importBatches) + .set({ status: "failed", errorMessage: message, completedAt: new Date() }) + .where( + and( + eq(importBatches.id, batchId), + notInArray(importBatches.status, ["completed", "failed"]), + ), + ); +} + +function getBasicAuthToken(creds: KomootCreds): string | undefined { + if (creds.mode !== "authenticated") return undefined; + const password = decrypt(creds.encryptedPassword); + return Buffer.from(`${creds.email}:${password}`).toString("base64"); +} + +export async function runKomootBulkImport( + batchId: string, + userId: string, + creds: KomootCreds, +): Promise { + const db = getDb(); + const basicAuthToken = getBasicAuthToken(creds); + const komootUserId = creds.komootUserId; + + await db + .update(importBatches) + .set({ status: "running" }) + .where(eq(importBatches.id, batchId)); + + try { + let page = 1; + let hasMore = true; + let totalFound = 0; + let importedCount = 0; + let duplicateCount = 0; + + while (hasMore) { + let result: Awaited>; + try { + result = await fetchKomootTours(komootUserId, page, basicAuthToken); + } catch (err) { + logger.warn({ batchId, page, err }, "komoot bulk import: fetchKomootTours failed, stopping"); + break; + } + + totalFound += result.tours.length; + hasMore = page < result.totalPages; + page++; + + await db + .update(importBatches) + .set({ totalFound }) + .where(eq(importBatches.id, batchId)); + + for (const tour of result.tours) { + if (await isAlreadyImported(userId, "komoot", tour.id)) { + duplicateCount++; + await db + .update(importBatches) + .set({ duplicateCount }) + .where(eq(importBatches.id, batchId)); + continue; + } + + let gpx: string | undefined; + try { + gpx = await fetchKomootTourGpx(tour.id, basicAuthToken); + } catch { + // No GPX — import metadata only + } + + try { + const activityId = await createActivity(userId, { + name: tour.name, + gpx, + sportType: mapSportType(tour.sport), + distance: tour.distance > 0 ? tour.distance : null, + duration: tour.duration > 0 ? tour.duration : null, + startedAt: tour.date ? new Date(tour.date) : null, + }); + await recordImport(userId, "komoot", tour.id, activityId); + importedCount++; + } catch (err) { + logger.warn({ batchId, tourId: tour.id, err }, "komoot bulk import: failed to import tour"); + } + + await db + .update(importBatches) + .set({ importedCount, duplicateCount }) + .where(eq(importBatches.id, batchId)); + } + } + + await db + .update(importBatches) + .set({ status: "completed", totalFound, importedCount, duplicateCount, completedAt: new Date() }) + .where(eq(importBatches.id, batchId)); + + logger.info({ batchId, totalFound, importedCount, duplicateCount }, "komoot bulk import completed"); + } catch (err) { + await db + .update(importBatches) + .set({ + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err), + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + throw err; + } +} diff --git a/apps/journal/app/lib/komoot.server.test.ts b/apps/journal/app/lib/komoot.server.test.ts new file mode 100644 index 0000000..9e5e3a9 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + parseKomootUserId, + verifyKomootOwnership, + fetchPublicProfile, + fetchKomootTours, +} from "./komoot.server.ts"; + +describe("parseKomootUserId", () => { + it("accepts a plain numeric ID", () => { + expect(parseKomootUserId("27595800585")).toBe("27595800585"); + }); + + it("accepts a full komoot.com URL", () => { + expect(parseKomootUserId("https://www.komoot.com/user/27595800585")).toBe("27595800585"); + }); + + it("accepts a locale-prefixed URL (de-de)", () => { + expect(parseKomootUserId("https://www.komoot.com/de-de/user/27595800585")).toBe("27595800585"); + }); + + it("accepts komoot.de URL", () => { + expect(parseKomootUserId("https://www.komoot.de/user/12345")).toBe("12345"); + }); + + it("returns null for an invalid input", () => { + expect(parseKomootUserId("not-a-user")).toBeNull(); + expect(parseKomootUserId("https://example.com/user/abc")).toBeNull(); + }); +}); + +describe("fetchPublicProfile", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("maps response fields correctly", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "27595800585", + display_name: "Test User", + content: { text: "Visit https://trails.cool/users/ullrich for my routes", link: null }, + }), + } as Response); + + const profile = await fetchPublicProfile("27595800585"); + expect(profile.displayName).toBe("Test User"); + expect(profile.contentText).toBe("Visit https://trails.cool/users/ullrich for my routes"); + expect(profile.contentLink).toBeNull(); + }); + + it("falls back to username when display_name missing", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ username: "99999", content: {} }), + } as Response); + + const profile = await fetchPublicProfile("99999"); + expect(profile.displayName).toBe("99999"); + }); + + it("throws on non-ok response", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response); + await expect(fetchPublicProfile("bad")).rejects.toThrow("404"); + }); +}); + +describe("verifyKomootOwnership", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("returns true when bio contains the trails URL (exact match)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "https://trails.cool/users/ullrich" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true); + }); + + it("returns true for case-insensitive match", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "HTTPS://TRAILS.COOL/USERS/ULLRICH" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true); + }); + + it("returns false when bio does not contain the trails URL", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "Just a normal bio with no trails link" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false); + }); + + it("returns false when content_text is null", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ username: "123", content: { text: null } }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false); + }); + + it("returns false when fetch fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response); + expect(await verifyKomootOwnership("bad", "https://trails.cool/users/x")).toBe(false); + }); +}); + +describe("fetchKomootTours", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("appends status=public when no auth token", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { tours: [] }, + page: { totalPages: 1, number: 0 }, + }), + } as Response); + await fetchKomootTours("123", 1); + const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toContain("status=public"); + }); + + it("omits status=public when auth token provided", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { tours: [] }, + page: { totalPages: 1, number: 0 }, + }), + } as Response); + await fetchKomootTours("123", 1, "sometoken"); + const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).not.toContain("status=public"); + }); + + it("maps tour fields correctly", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { + tours: [ + { + id: 987654321, + name: "Morning ride", + type: "touringbicycle", + date: "2024-06-01T08:00:00Z", + distance: 42000, + duration: 7200, + elevation_up: 300, + elevation_down: 290, + }, + ], + }, + page: { totalPages: 3, number: 0 }, + }), + } as Response); + const result = await fetchKomootTours("123", 1); + expect(result.tours).toHaveLength(1); + expect(result.tours[0]).toMatchObject({ + id: "987654321", + name: "Morning ride", + sport: "touringbicycle", + distance: 42000, + duration: 7200, + }); + expect(result.totalPages).toBe(3); + }); +}); diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts new file mode 100644 index 0000000..9bbc75d --- /dev/null +++ b/apps/journal/app/lib/komoot.server.ts @@ -0,0 +1,190 @@ +// Komoot API client. Covers unauthenticated public profile access and +// authenticated tour listing / GPX export. +// +// All network calls use plain fetch; no auth state is cached here. + +import { fetchWithTimeout } from "./http.server.ts"; + +const API_BASE = "https://api.komoot.de/v007"; +const AUTH_BASE = "https://api.komoot.de/v006"; + +export interface KomootTour { + id: string; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevationUp: number; + elevationDown: number; +} + +// --------------------------------------------------------------------------- +// URL parsing +// --------------------------------------------------------------------------- + +// Accepts a plain numeric ID or a Komoot profile URL in any locale variant. +// Returns the numeric user ID string, or null if it cannot be parsed. +export function parseKomootUserId(input: string): string | null { + const trimmed = input.trim(); + // Plain numeric ID + if (/^\d+$/.test(trimmed)) return trimmed; + // URL forms: https://www.komoot.com/user/12345 or .../de-de/user/12345 + const match = trimmed.match(/komoot\.(?:com|de)\/(?:[a-z]{2}-[a-z]{2}\/)?user\/(\d+)/i); + return match?.[1] ?? null; +} + +// --------------------------------------------------------------------------- +// Public profile +// --------------------------------------------------------------------------- + +interface KomootPublicProfileResponse { + username: string; + display_name?: string; + content?: { + text?: string | null; + link?: string | null; + }; +} + +export async function fetchPublicProfile(komootUserId: string): Promise<{ + displayName: string; + contentText: string | null; + contentLink: string | null; +}> { + const resp = await fetchWithTimeout(`${API_BASE}/users/${komootUserId}/`); + if (!resp.ok) { + throw new Error(`Komoot profile fetch failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootPublicProfileResponse; + return { + displayName: data.display_name ?? data.username, + contentText: data.content?.text ?? null, + contentLink: data.content?.link ?? null, + }; +} + +// Returns true if the profile's content text contains the trails profile URL +// (case-insensitive, trimmed). +export async function verifyKomootOwnership( + komootUserId: string, + trailsProfileUrl: string, +): Promise { + try { + const profile = await fetchPublicProfile(komootUserId); + if (!profile.contentText) return false; + return profile.contentText.toLowerCase().includes(trailsProfileUrl.toLowerCase().trim()); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +interface KomootAuthResponse { + username: string; + // The API returns user data nested under the email key in v006 + email?: string; +} + +// Login with email+password via Basic auth. Returns the Komoot username +// (numeric user ID) and the base64-encoded Basic auth token. +export async function loginKomoot( + email: string, + password: string, +): Promise<{ username: string; basicAuthToken: string }> { + const basicAuthToken = Buffer.from(`${email}:${password}`).toString("base64"); + const resp = await fetchWithTimeout(`${AUTH_BASE}/account/email/${encodeURIComponent(email)}/`, { + headers: { Authorization: `Basic ${basicAuthToken}` }, + }); + if (!resp.ok) { + throw new Error(`Komoot login failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootAuthResponse; + return { username: data.username, basicAuthToken }; +} + +// --------------------------------------------------------------------------- +// Tours +// --------------------------------------------------------------------------- + +interface KomootTourRaw { + id: number; + name: string; + type: string; + date: string; + distance?: number; + duration?: number; + elevation_up?: number; + elevation_down?: number; +} + +interface KomootToursResponse { + _embedded?: { + tours?: KomootTourRaw[]; + }; + page?: { + totalPages?: number; + number?: number; + }; +} + +function toTour(raw: KomootTourRaw): KomootTour { + return { + id: String(raw.id), + name: raw.name || `Tour ${raw.id}`, + sport: raw.type, + date: raw.date, + distance: raw.distance ?? 0, + duration: raw.duration ?? 0, + elevationUp: raw.elevation_up ?? 0, + elevationDown: raw.elevation_down ?? 0, + }; +} + +export async function fetchKomootTours( + komootUserId: string, + page: number, + basicAuthToken?: string, +): Promise<{ tours: KomootTour[]; totalPages: number }> { + const params = new URLSearchParams({ + page: String(page - 1), // Komoot uses 0-based pages + limit: "50", + }); + if (!basicAuthToken) { + params.set("status", "public"); + } + const headers: Record = {}; + if (basicAuthToken) { + headers["Authorization"] = `Basic ${basicAuthToken}`; + } + const resp = await fetchWithTimeout(`${API_BASE}/users/${komootUserId}/tours/?${params}`, { headers }); + if (!resp.ok) { + throw new Error(`Komoot tours fetch failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootToursResponse; + const tours = (data._embedded?.tours ?? []).map(toTour); + const totalPages = data.page?.totalPages ?? 1; + return { tours, totalPages }; +} + +// --------------------------------------------------------------------------- +// GPX export +// --------------------------------------------------------------------------- + +export async function fetchKomootTourGpx( + tourId: string, + basicAuthToken?: string, +): Promise { + const headers: Record = {}; + if (basicAuthToken) { + headers["Authorization"] = `Basic ${basicAuthToken}`; + } + const resp = await fetchWithTimeout(`${API_BASE}/tours/${tourId}.gpx`, { headers }); + if (!resp.ok) { + throw new Error(`Komoot GPX fetch failed: ${resp.status}`); + } + return resp.text(); +} diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts new file mode 100644 index 0000000..fc2a9be --- /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-06-07"; diff --git a/apps/journal/app/lib/logger.server.test.ts b/apps/journal/app/lib/logger.server.test.ts new file mode 100644 index 0000000..34b028b --- /dev/null +++ b/apps/journal/app/lib/logger.server.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { Writable } from "node:stream"; +import pino from "pino"; +import { AsyncLocalStorage } from "node:async_hooks"; + +// We test the *mixin contract* — that pino's `mixin` callback reads +// from the async-local store and tags every record. Constructing a +// fresh pino instance per test (vs. importing the module-level one) +// lets us capture stdout cleanly. + +describe("logger mixin attaches requestId from async context", () => { + function captureLogger(als: AsyncLocalStorage<{ requestId: string }>) { + const lines: string[] = []; + const sink = new Writable({ + write(chunk, _enc, cb) { + lines.push(chunk.toString()); + cb(); + }, + }); + const lg = pino( + { + level: "info", + mixin: () => { + const ctx = als.getStore(); + return ctx ? { requestId: ctx.requestId } : {}; + }, + }, + sink, + ); + return { lg, lines }; + } + + it("tags log records with requestId when inside als.run()", () => { + const als = new AsyncLocalStorage<{ requestId: string }>(); + const { lg, lines } = captureLogger(als); + als.run({ requestId: "abc-123" }, () => lg.info("hello")); + const record = JSON.parse(lines[0]!); + expect(record.requestId).toBe("abc-123"); + expect(record.msg).toBe("hello"); + }); + + it("omits requestId when no async context is active", () => { + const als = new AsyncLocalStorage<{ requestId: string }>(); + const { lg, lines } = captureLogger(als); + lg.info("bare"); + const record = JSON.parse(lines[0]!); + expect(record.requestId).toBeUndefined(); + }); +}); diff --git a/apps/journal/app/lib/logger.server.ts b/apps/journal/app/lib/logger.server.ts index a0454ef..7fafe99 100644 --- a/apps/journal/app/lib/logger.server.ts +++ b/apps/journal/app/lib/logger.server.ts @@ -1,7 +1,27 @@ import pino from "pino"; +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * Per-request context propagated through the async stack. The HTTP + * server wraps each request in `requestContext.run({ requestId }, ...)` + * so every downstream `logger.info(...)` automatically tags log lines + * with `requestId` — no need to thread it through every call site. + */ +export interface RequestContext { + requestId: string; +} + +export const requestContext = new AsyncLocalStorage(); export const logger = pino({ level: process.env.LOG_LEVEL ?? "info", + // Mixin runs on every log call and merges its return value into the + // emitted record. Reading from ALS here is what makes requestId + // automatic for every downstream logger.info/warn/error. + mixin: () => { + const ctx = requestContext.getStore(); + return ctx ? { requestId: ctx.requestId } : {}; + }, ...(process.env.NODE_ENV !== "production" ? { transport: { target: "pino-pretty" } } : {}), diff --git a/apps/journal/app/lib/metrics.server.test.ts b/apps/journal/app/lib/metrics.server.test.ts new file mode 100644 index 0000000..40681de --- /dev/null +++ b/apps/journal/app/lib/metrics.server.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRoute, ROUTE_TEMPLATES } from "./metrics.server.ts"; +import routesConfig from "../routes.ts"; + +describe("normalizeRoute", () => { + it("passes static routes through unchanged", () => { + expect(normalizeRoute("/")).toBe("/"); + expect(normalizeRoute("/feed")).toBe("/feed"); + expect(normalizeRoute("/explore")).toBe("/explore"); + expect(normalizeRoute("/settings/security")).toBe("/settings/security"); + expect(normalizeRoute("/api/v1/routes")).toBe("/api/v1/routes"); + expect(normalizeRoute("/legal/terms")).toBe("/legal/terms"); + }); + + it("maps dynamic id segments to the :id template", () => { + expect( + normalizeRoute("/activities/0257f241-d880-4ca6-b073-c8706f07a293"), + ).toBe("/activities/:id"); + expect( + normalizeRoute("/routes/0257f241-d880-4ca6-b073-c8706f07a293/edit"), + ).toBe("/routes/:id/edit"); + expect(normalizeRoute("/api/notifications/12345/read")).toBe( + "/api/notifications/:id/read", + ); + }); + + it("maps usernames to the :username template", () => { + expect(normalizeRoute("/users/alice")).toBe("/users/:username"); + expect(normalizeRoute("/users/bob/followers")).toBe( + "/users/:username/followers", + ); + expect(normalizeRoute("/api/users/carol/follow")).toBe( + "/api/users/:username/follow", + ); + }); + + it("maps sync providers to the :provider template", () => { + expect(normalizeRoute("/api/sync/connect/wahoo")).toBe( + "/api/sync/connect/:provider", + ); + expect( + normalizeRoute( + "/api/sync/push/komoot/0257f241-d880-4ca6-b073-c8706f07a293", + ), + ).toBe("/api/sync/push/:provider/:routeId"); + }); + + it("prefers a literal template over a parametric one", () => { + // /routes/new must NOT be swallowed by /routes/:id + expect(normalizeRoute("/routes/new")).toBe("/routes/new"); + expect(normalizeRoute("/activities/new")).toBe("/activities/new"); + // a known provider literal wins over /sync/import/:provider + expect(normalizeRoute("/sync/import/komoot")).toBe("/sync/import/komoot"); + expect(normalizeRoute("/sync/import/strava")).toBe("/sync/import/:provider"); + }); + + it("collapses anything matching no template to /other", () => { + expect(normalizeRoute("/.ssh/id_rsa")).toBe("/other"); + expect(normalizeRoute("/wp-login.php")).toBe("/other"); + expect(normalizeRoute("/.aws/credentials")).toBe("/other"); + // right prefix, wrong arity also falls through + expect(normalizeRoute("/routes/abc/def/ghi")).toBe("/other"); + // a username-shaped path under an unknown collection + expect(normalizeRoute("/profile/alice")).toBe("/other"); + }); + + it("strips query strings and fragments and trailing slashes", () => { + expect( + normalizeRoute("/activities/0257f241-d880-4ca6-b073-c8706f07a293?foo=1"), + ).toBe("/activities/:id"); + expect(normalizeRoute("/feed#section")).toBe("/feed"); + expect(normalizeRoute("/feed/")).toBe("/feed"); + }); + + it("bounds cardinality: distinct outputs are limited to templates + /other", () => { + const outputs = new Set(); + for (let i = 0; i < 500; i++) { + outputs.add(normalizeRoute(`/activities/id-${i}`)); + outputs.add(normalizeRoute(`/scanner-junk-${i}`)); + outputs.add(normalizeRoute(`/users/user${i}`)); + } + // All 1500 distinct paths collapse to just 3 labels. + expect([...outputs].sort()).toEqual([ + "/activities/:id", + "/other", + "/users/:username", + ]); + }); +}); + +describe("ROUTE_TEMPLATES drift guard", () => { + // Flatten the real route config to full path templates. If a route is + // added/removed/renamed in app/routes.ts without updating ROUTE_TEMPLATES, + // this fails — keeping the metric label set honest. + type Entry = { path?: string; index?: boolean; children?: Entry[] }; + function flatten(entries: Entry[], prefix: string): string[] { + const out: string[] = []; + for (const e of entries) { + if (e.index) { + out.push(prefix === "" ? "/" : `/${prefix}`); + continue; + } + const full = prefix === "" ? e.path ?? "" : `${prefix}/${e.path ?? ""}`; + if (e.children && e.children.length > 0) { + out.push(...flatten(e.children, full)); + } else { + out.push(`/${full}`); + } + } + return out; + } + + it("matches every full path declared in app/routes.ts", () => { + const fromConfig = flatten(routesConfig as unknown as Entry[], "").sort(); + expect(fromConfig).toEqual([...ROUTE_TEMPLATES].sort()); + }); +}); diff --git a/apps/journal/app/lib/metrics.server.ts b/apps/journal/app/lib/metrics.server.ts index d7dab88..9ea2181 100644 --- a/apps/journal/app/lib/metrics.server.ts +++ b/apps/journal/app/lib/metrics.server.ts @@ -1,13 +1,224 @@ import client from "prom-client"; -// Collect default Node.js metrics (event loop, heap, GC) -client.collectDefaultMetrics(); +// React Router's build sometimes produces two module instances of this +// file (server entry + route module graph). prom-client's registry is a +// process-wide singleton, so re-running the `new Gauge(...)` / `new +// Histogram(...)` / `collectDefaultMetrics()` calls on the second load +// throws "metric already registered". Guard everything via +// `getSingleMetric` so a second module load reuses the existing objects. -export const httpRequestDuration = new client.Histogram({ - name: "http_request_duration_seconds", - help: "Duration of HTTP requests in seconds", - labelNames: ["method", "route", "status"] as const, - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], -}); +function getOrCreate>( + name: string, + create: () => T, +): T { + const existing = client.register.getSingleMetric(name); + if (existing) return existing as T; + return create(); +} + +// Default process/Node metrics: registered exactly once. +if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) { + client.collectDefaultMetrics(); +} + +export const httpRequestDuration = getOrCreate("http_request_duration_seconds", () => + new client.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + }), +); + +export const demoBotSyntheticRoutesTotal = getOrCreate( + "demo_bot_synthetic_routes_total", + () => + new client.Gauge({ + name: "demo_bot_synthetic_routes_total", + help: "Total synthetic demo-bot routes currently stored", + }), +); + +export const demoBotSyntheticActivitiesTotal = getOrCreate( + "demo_bot_synthetic_activities_total", + () => + new client.Gauge({ + name: "demo_bot_synthetic_activities_total", + help: "Total synthetic demo-bot activities currently stored", + }), +); + +// --- Federation metrics (spec: federation-operations "Federation delivery +// observability") --------------------------------------------------------- + +/** Outbound delivery attempts by outcome (delivered | skipped | failed). */ +export const federationDeliveryTotal = getOrCreate( + "federation_delivery_total", + () => + new client.Counter({ + name: "federation_delivery_total", + help: "Outbound federation delivery attempts by outcome", + labelNames: ["outcome"] as const, + }), +); + +/** Inbound activities dropped, by reason (duplicate | blocked). */ +export const federationInboxDroppedTotal = getOrCreate( + "federation_inbox_dropped_total", + () => + new client.Counter({ + name: "federation_inbox_dropped_total", + help: "Inbound federation activities dropped before side effects, by reason", + labelNames: ["reason"] as const, + }), +); + +/** Messages waiting in the durable Fedify queue. Set at scrape time by the + * metrics route (the restart-loss regression detector). */ +export const federationQueueDepth = getOrCreate( + "federation_queue_depth", + () => + new client.Gauge({ + name: "federation_queue_depth", + help: "Messages waiting in the durable Fedify (pg-boss) message queue", + }), +); export const registry = client.register; + +// --- Route label normalization ------------------------------------------- +// +// The `route` label on httpRequestDuration MUST be bounded. prom-client +// stores one label-set per distinct combination and NEVER evicts, so +// feeding it raw request paths (`/activities/`, probed usernames, +// crawler junk) leaks memory linearly for the life of the process. +// +// We match each request path against the journal's known route templates +// (mirroring app/routes.ts) and label it with the matched template; a +// `:param` segment matches any single path segment. Anything matching no +// template — vulnerability scanners, typos, unmounted paths — collapses to +// a single `/other` bucket. Cardinality is therefore hard-bounded to +// (number of templates + 1), independent of traffic, and real routes can +// never be crowded out by scanner noise (the failure mode of the earlier +// first-come-first-served cap). +// +// IMPORTANT: ROUTE_TEMPLATES must stay in sync with app/routes.ts. The +// co-located test imports the route config, flattens it to full paths, and +// fails if the two ever drift — so adding a route there forces an update +// here. +const ROUTE_TEMPLATES: readonly string[] = [ + "/", + "/.well-known/trails-cool", + "/.well-known/webfinger", + "/.well-known/nodeinfo", + "/nodeinfo/2.1", + "/users/:username/inbox", + "/users/:username/outbox", + "/oauth/authorize", + "/oauth/token", + "/auth/register", + "/auth/login", + "/auth/verify", + "/auth/logout", + "/auth/accept-terms", + "/api/auth/register", + "/api/auth/login", + "/routes", + "/routes/new", + "/routes/:id", + "/routes/:id/edit", + "/api/e2e/seed", + "/api/e2e/route/:id", + "/api/e2e/komoot", + "/api/routes/:id/callback", + "/api/routes/:id/edit-in-planner", + "/api/routes/:id/gpx", + "/activities", + "/activities/new", + "/activities/:id", + "/users/:username", + "/users/:username/followers", + "/users/:username/following", + "/api/users/:username/follow", + "/api/users/:username/unfollow", + "/follows/requests", + "/follows/outgoing", + "/api/follows/:id/approve", + "/api/follows/:id/reject", + "/feed", + "/explore", + "/api/events", + "/notifications", + "/api/notifications/:id/read", + "/api/notifications/read-all", + "/settings", + "/settings/profile", + "/settings/account", + "/settings/security", + "/settings/connections", + "/settings/connections/komoot", + "/api/settings/profile", + "/api/settings/email", + "/api/settings/passkey/delete", + "/api/settings/delete-account", + "/sync/import/komoot", + "/sync/import/garmin", + "/sync/import/:provider", + "/api/sync/komoot/verify", + "/api/sync/komoot/connect", + "/api/sync/komoot/import", + "/api/sync/komoot/import-status", + "/api/sync/connect/:provider", + "/api/sync/callback/:provider", + "/api/sync/disconnect/:provider", + "/api/sync/webhook/:provider", + "/api/sync/push/:provider/:routeId", + "/privacy", + "/legal/imprint", + "/legal/terms", + "/legal/privacy", + "/api/v1/routes", + "/api/v1/routes/compute", + "/api/v1/routes/:id", + "/api/v1/activities", + "/api/v1/activities/:id", + "/api/v1/auth/devices", + "/api/v1/auth/devices/:id", + "/api/v1/uploads", +]; + +export { ROUTE_TEMPLATES }; + +const isParam = (seg: string): boolean => seg.startsWith(":"); + +// Pre-split templates into segments, ordered most-specific first (fewest +// `:param` segments) so a literal like `/routes/new` is matched before the +// parametric `/routes/:id`. +const TEMPLATE_SEGMENTS = ROUTE_TEMPLATES.map((template) => ({ + template, + segments: template === "/" ? [] : template.slice(1).split("/"), +})).sort( + (a, b) => + a.segments.filter(isParam).length - b.segments.filter(isParam).length, +); + +/** Map a request path to one of the known route templates, or `/other`. */ +export function normalizeRoute(pathname: string): string { + const path = (pathname.split("?")[0] ?? "/").split("#")[0] || "/"; + const segs = path.replace(/\/+$/, "").split("/").filter((s) => s !== ""); + if (segs.length === 0) return "/"; + + for (const { template, segments } of TEMPLATE_SEGMENTS) { + if (segments.length !== segs.length) continue; + let matched = true; + for (let i = 0; i < segments.length; i++) { + const t = segments[i]!; + if (!isParam(t) && t !== segs[i]) { + matched = false; + break; + } + } + if (matched) return template; + } + return "/other"; +} diff --git a/apps/journal/app/lib/notifications.integration.test.ts b/apps/journal/app/lib/notifications.integration.test.ts new file mode 100644 index 0000000..2d4d5d5 --- /dev/null +++ b/apps/journal/app/lib/notifications.integration.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, notifications } from "@trails-cool/db/schema/journal"; +import { + createNotification, + listForUser, + countUnread, + markRead, + markAllRead, + purgeReadOlderThan, +} from "./notifications.server.ts"; +import { + followUser, + approveFollowRequest, + listPendingFollowRequests, +} from "./follow.server.ts"; + +// Opt-in: hits real Postgres. Same convention as demo-bot / follow integration. +const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1"; + +async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${opts.username}@example.test`, + username: opts.username, + domain: "test.local", + profileVisibility: opts.profileVisibility ?? "public", + }); + return id; +} + +async function wipe() { + const db = getDb(); + await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`); + await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`); +} + +describe.skipIf(!runIntegration)("notifications.server integration", () => { + beforeAll(async () => { + const db = getDb(); + await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`); + }); + afterEach(wipe); + + it("createNotification persists payload + version and is idempotent on (recipient,type,subject)", async () => { + const a = await makeUser({ username: `n_a_${Date.now()}` }); + const b = await makeUser({ username: `n_b_${Date.now()}` }); + const subject = randomUUID(); + + const first = await createNotification({ + type: "activity_published", + recipientUserId: a, + actorUserId: b, + subjectId: subject, + payload: { activityId: subject, activityName: "Walk", ownerUsername: "b", ownerDisplayName: null }, + }); + expect(first).toBe(true); + + // Re-insert with same (recipient, type, subject_id) is a no-op. + const second = await createNotification({ + type: "activity_published", + recipientUserId: a, + actorUserId: b, + subjectId: subject, + payload: { activityId: subject, activityName: "Walk", ownerUsername: "b", ownerDisplayName: null }, + }); + expect(second).toBe(false); + + const { rows, nextCursor } = await listForUser(a); + expect(rows.length).toBe(1); + expect(nextCursor).toBeNull(); + expect(rows[0]?.payloadVersion).toBe(1); + expect(rows[0]?.payload).toMatchObject({ activityId: subject }); + }); + + it("countUnread excludes read; markRead is owner-bound and idempotent", async () => { + const a = await makeUser({ username: `n_mr_a_${Date.now()}` }); + const c = await makeUser({ username: `n_mr_c_${Date.now()}` }); + + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "x", followerDisplayName: null }, + }); + expect(await countUnread(a)).toBe(1); + + const all = (await listForUser(a)).rows; + const id = all[0]!.id; + + // Foreign user can't mark a's notification read. + expect(await markRead(c, id)).toBe(false); + expect(await countUnread(a)).toBe(1); + + // Owner can. + expect(await markRead(a, id)).toBe(true); + expect(await countUnread(a)).toBe(0); + + // Idempotent — already read, but row still belongs to a, so returns true. + expect(await markRead(a, id)).toBe(true); + }); + + it("markAllRead clears every unread row of the caller and only the caller", async () => { + const a = await makeUser({ username: `n_mar_a_${Date.now()}` }); + const b = await makeUser({ username: `n_mar_b_${Date.now()}` }); + + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "x", followerDisplayName: null }, + }); + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "y", followerDisplayName: null }, + }); + await createNotification({ + type: "follow_received", + recipientUserId: b, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "z", followerDisplayName: null }, + }); + + const updated = await markAllRead(a); + expect(updated).toBe(2); + expect(await countUnread(a)).toBe(0); + expect(await countUnread(b)).toBe(1); + }); + + it("purgeReadOlderThan removes only read rows past the window", async () => { + const a = await makeUser({ username: `n_pr_a_${Date.now()}` }); + + const db = getDb(); + // Old read (should be purged), old unread (kept), recent read (kept). + await db.insert(notifications).values([ + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "old-read" }, + payloadVersion: 1, + readAt: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + }, + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "old-unread" }, + payloadVersion: 1, + readAt: null, + createdAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), + }, + { + id: randomUUID(), + recipientUserId: a, + type: "follow_received", + actorUserId: null, + subjectId: null, + payload: { followerUsername: "recent-read" }, + payloadVersion: 1, + readAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), + }, + ]); + + const purged = await purgeReadOlderThan(90); + expect(purged).toBe(1); + + const survivors = await db + .select({ payload: notifications.payload }) + .from(notifications) + .where(eq(notifications.recipientUserId, a)); + const names = survivors + .map((s) => (s.payload as { followerUsername?: string } | null)?.followerUsername) + .sort(); + expect(names).toEqual(["old-unread", "recent-read"]); + }); + + it("followUser → follow_received emitted for public-target auto-accept; idempotent re-follow does NOT double-emit", async () => { + const a = await makeUser({ username: `n_fr_a_${Date.now()}` }); + const b = await makeUser({ username: `n_fr_b_${Date.now()}` }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + await followUser(a, bRow.username); + let bRows = (await listForUser(b)).rows; + expect(bRows.length).toBe(1); + expect(bRows[0]?.type).toBe("follow_received"); + + // Re-follow is idempotent at the follow layer — and must NOT emit again. + await followUser(a, bRow.username); + bRows = (await listForUser(b)).rows; + expect(bRows.length).toBe(1); + }); + + it("followUser private-target → follow_request_received; approveFollowRequest → follow_request_approved", async () => { + const a = await makeUser({ username: `n_fp_a_${Date.now()}` }); + const b = await makeUser({ username: `n_fp_b_${Date.now()}`, profileVisibility: "private" }); + const bRow = (await getDb().select().from(users).where(eq(users.id, b)))[0]!; + + await followUser(a, bRow.username); + const bRows = (await listForUser(b)).rows; + expect(bRows.length).toBe(1); + expect(bRows[0]?.type).toBe("follow_request_received"); + + const reqs = await listPendingFollowRequests(b); + expect(reqs.length).toBe(1); + + await approveFollowRequest(b, reqs[0]!.id); + const aRows = (await listForUser(a)).rows; + expect(aRows.length).toBe(1); + expect(aRows[0]?.type).toBe("follow_request_approved"); + + // Idempotent re-approval must not double-emit. + await approveFollowRequest(b, reqs[0]!.id); + const aRowsAfter = (await listForUser(a)).rows; + expect(aRowsAfter.length).toBe(1); + }); + + it("paginates with nextCursor; cursor is stable across pages and ends with null", async () => { + const a = await makeUser({ username: `n_pg_a_${Date.now()}` }); + // Insert 7 notifications, each older than the previous, so we can + // page through them with limit: 3. Each row has a unique subject so + // the partial-unique constraint doesn't deduplicate them. + for (let i = 0; i < 7; i++) { + await createNotification({ + type: "activity_published", + recipientUserId: a, + actorUserId: null, + subjectId: randomUUID(), + payload: { + activityId: randomUUID(), + activityName: `act-${i}`, + ownerUsername: "x", + ownerDisplayName: null, + }, + }); + } + + const p1 = await listForUser(a, { limit: 3 }); + expect(p1.rows.length).toBe(3); + expect(p1.nextCursor).not.toBeNull(); + const p1Names = p1.rows.map((r) => (r.payload as { activityName?: string })?.activityName); + + const p2 = await listForUser(a, { limit: 3, before: p1.nextCursor! }); + expect(p2.rows.length).toBe(3); + expect(p2.nextCursor).not.toBeNull(); + const p2Names = p2.rows.map((r) => (r.payload as { activityName?: string })?.activityName); + // Pages must not overlap. + expect(p1Names.some((n) => p2Names.includes(n))).toBe(false); + + const p3 = await listForUser(a, { limit: 3, before: p2.nextCursor! }); + expect(p3.rows.length).toBe(1); + // Only one row left → nextCursor is null (no more pages). + expect(p3.nextCursor).toBeNull(); + }); + + it("treats malformed cursor as 'start from top' rather than erroring", async () => { + const a = await makeUser({ username: `n_pgbad_a_${Date.now()}` }); + await createNotification({ + type: "follow_received", + recipientUserId: a, + actorUserId: null, + subjectId: null, + payload: { followerUsername: "x", followerDisplayName: null }, + }); + const r = await listForUser(a, { before: "not-a-real-cursor" }); + expect(r.rows.length).toBe(1); + }); +}); diff --git a/apps/journal/app/lib/notifications.server.ts b/apps/journal/app/lib/notifications.server.ts new file mode 100644 index 0000000..8991d1e --- /dev/null +++ b/apps/journal/app/lib/notifications.server.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { and, count, desc, eq, isNull, lt, or, sql } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { notifications } from "@trails-cool/db/schema/journal"; +import type { NotificationType } from "@trails-cool/db/schema/journal"; +import { emitTo } from "./events.server.ts"; +import { + PAYLOAD_VERSION, + type ActivityPayloadV1, + type ApprovalPayloadV1, + type FollowPayloadV1, +} from "./notifications/payload.ts"; + +/** + * Push the current unread count to all of `userId`'s open SSE + * connections. Called after any state change that could affect the + * count: createNotification (new row), markRead (one less unread), + * markAllRead (zero unread). + */ +async function emitUnreadCount(userId: string): Promise { + const c = await countUnread(userId); + emitTo(userId, "notifications.unread", { count: c }); +} + +// Discriminated union of (type, payload) so callers can't pass the +// wrong payload shape for a given event type. PAYLOAD_VERSION is set +// internally; callers don't pick a version. +type CreateInput = + | { type: "follow_request_received"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: FollowPayloadV1 } + | { type: "follow_received"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: FollowPayloadV1 } + | { type: "follow_request_approved"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: ApprovalPayloadV1 } + | { type: "activity_published"; recipientUserId: string; actorUserId: string | null; subjectId: string | null; payload: ActivityPayloadV1 }; + +/** + * Create a notification. When `subjectId` is set, idempotent against + * the partial unique index `(recipient_user_id, type, subject_id) WHERE + * subject_id IS NOT NULL` so fan-out job retries can't double-insert. + * When `subjectId` is null (1:1 follow events) idempotency is enforced + * upstream by the follow hooks (they only emit on a *new* row), so a + * plain insert is safe and avoids ON CONFLICT inference against a + * partial index, which Postgres rejects. + */ +export async function createNotification(input: CreateInput): Promise { + const db = getDb(); + const values = { + id: randomUUID(), + recipientUserId: input.recipientUserId, + type: input.type, + actorUserId: input.actorUserId, + subjectId: input.subjectId, + payload: input.payload as unknown as Record, + payloadVersion: PAYLOAD_VERSION, + }; + + const result = input.subjectId + ? await db + .insert(notifications) + .values(values) + .onConflictDoNothing({ + target: [notifications.recipientUserId, notifications.type, notifications.subjectId], + // Match the partial unique index's predicate so Postgres can + // infer the arbiter index (`WHERE subject_id IS NOT NULL`). + // `onConflictDoNothing` only exposes `where` (no targetWhere). + where: sql`${notifications.subjectId} IS NOT NULL`, + }) + .returning({ id: notifications.id }) + : await db.insert(notifications).values(values).returning({ id: notifications.id }); + + if (result.length > 0) { + // Push refreshed count to any open SSE connections for this + // recipient. Emit happens after the row commits so the count + // reflects reality. + await emitUnreadCount(input.recipientUserId); + return true; + } + return false; +} + +export interface NotificationRow { + id: string; + type: NotificationType; + actorUserId: string | null; + subjectId: string | null; + payload: Record | null; + payloadVersion: number; + readAt: Date | null; + createdAt: Date; +} + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; + +export interface ListOptions { + /** + * Opaque cursor from a previous response. When set, returns rows + * strictly older than the cursor's `(createdAt, id)` position, so + * pagination is stable even when two rows share `created_at`. + */ + before?: string; + /** Soft cap; clamped to `[1, MAX_PAGE_SIZE]`. Defaults to 50. */ + limit?: number; +} + +export interface ListResult { + rows: NotificationRow[]; + /** + * Opaque cursor for the next page, or `null` when the caller has + * reached the end. Pass back as `before` on the next request. + */ + nextCursor: string | null; +} + +interface CursorShape { + ts: string; + id: string; +} + +function encodeCursor(c: CursorShape): string { + return Buffer.from(JSON.stringify(c), "utf8").toString("base64url"); +} + +function decodeCursor(s: string): CursorShape | null { + try { + const obj = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as { + ts?: unknown; + id?: unknown; + }; + if (typeof obj.ts !== "string" || typeof obj.id !== "string") return null; + if (Number.isNaN(Date.parse(obj.ts))) return null; + return { ts: obj.ts, id: obj.id }; + } catch { + return null; + } +} + +/** + * Cursor-paginated list of `userId`'s notifications, newest first. + * Pass the previous response's `nextCursor` as `before` to fetch the + * next page; an unknown / malformed cursor is treated as "start from + * the top" rather than 400-ing, since the cursor is opaque to clients. + */ +export async function listForUser( + userId: string, + opts: ListOptions = {}, +): Promise { + const db = getDb(); + const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, opts.limit ?? DEFAULT_PAGE_SIZE)); + const cursor = opts.before ? decodeCursor(opts.before) : null; + + const baseWhere = eq(notifications.recipientUserId, userId); + const where = cursor + ? and( + baseWhere, + // (created_at, id) < (cursor.ts, cursor.id) + // Keyset comparison broken into the two-row form so Postgres + // uses the (recipient, created_at desc) index for the leading + // column. + or( + lt(notifications.createdAt, new Date(cursor.ts)), + and( + eq(notifications.createdAt, new Date(cursor.ts)), + lt(notifications.id, cursor.id), + ), + ), + ) + : baseWhere; + + // Fetch limit + 1 so we can decide whether a `nextCursor` exists + // without a separate count query. + const rows = await db + .select({ + id: notifications.id, + type: notifications.type, + actorUserId: notifications.actorUserId, + subjectId: notifications.subjectId, + payload: notifications.payload, + payloadVersion: notifications.payloadVersion, + readAt: notifications.readAt, + createdAt: notifications.createdAt, + }) + .from(notifications) + .where(where) + .orderBy(desc(notifications.createdAt), desc(notifications.id)) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const trimmed = hasMore ? rows.slice(0, limit) : rows; + const last = trimmed[trimmed.length - 1]; + const nextCursor = hasMore && last + ? encodeCursor({ ts: last.createdAt.toISOString(), id: last.id }) + : null; + + return { rows: trimmed, nextCursor }; +} + +export async function countUnread(userId: string): Promise { + const db = getDb(); + const [row] = await db + .select({ n: count() }) + .from(notifications) + .where( + and( + eq(notifications.recipientUserId, userId), + isNull(notifications.readAt), + ), + ); + return row?.n ?? 0; +} + +/** + * Mark a single notification read. Owner-bound: only the recipient can + * mark their own. Returns true on success, false if the row doesn't + * exist or doesn't belong to the caller. Idempotent for already-read. + */ +export async function markRead(ownerId: string, id: string): Promise { + const db = getDb(); + const result = await db + .update(notifications) + .set({ readAt: new Date() }) + .where( + and( + eq(notifications.id, id), + eq(notifications.recipientUserId, ownerId), + ), + ) + .returning({ id: notifications.id }); + if (result.length === 0) return false; + await emitUnreadCount(ownerId); + return true; +} + +/** + * Mark all of `ownerId`'s unread notifications read. Returns the + * number of rows touched. + */ +export async function markAllRead(ownerId: string): Promise { + const db = getDb(); + const result = await db + .update(notifications) + .set({ readAt: new Date() }) + .where( + and( + eq(notifications.recipientUserId, ownerId), + isNull(notifications.readAt), + ), + ) + .returning({ id: notifications.id }); + if (result.length > 0) await emitUnreadCount(ownerId); + return result.length; +} + +/** + * Retention: drop read notifications older than `days`. Unread rows + * survive indefinitely. Called by the daily pg-boss job. + */ +export async function purgeReadOlderThan(days: number): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + const result = await db + .delete(notifications) + .where( + and( + sql`${notifications.readAt} IS NOT NULL`, + lt(notifications.readAt, cutoff), + ), + ) + .returning({ id: notifications.id }); + return result.length; +} diff --git a/apps/journal/app/lib/notifications/link-for.test.ts b/apps/journal/app/lib/notifications/link-for.test.ts new file mode 100644 index 0000000..1c6a744 --- /dev/null +++ b/apps/journal/app/lib/notifications/link-for.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { linkFor } from "./link-for.ts"; + +describe("linkFor", () => { + it("follow_received uses payload.followerUsername", () => { + const link = linkFor({ + type: "follow_received", + subjectId: null, + payloadVersion: 1, + payload: { followerUsername: "alice", followerDisplayName: null }, + }); + expect(link.web).toBe("/users/alice"); + expect(link.mobile).toBe("trails:/users/alice"); + expect(link.email).toMatch(/^https?:\/\//); + expect(link.email).toMatch(/\/users\/alice$/); + }); + + it("follow_received falls back to / when payload is missing", () => { + const link = linkFor({ + type: "follow_received", + subjectId: null, + payloadVersion: 1, + payload: null, + }); + expect(link.web).toBe("/"); + }); + + it("follow_request_received always points at the Requests tab on /notifications", () => { + const link = linkFor({ + type: "follow_request_received", + subjectId: null, + payloadVersion: 1, + payload: { followerUsername: "bob", followerDisplayName: null }, + }); + expect(link.web).toBe("/notifications?tab=requests"); + }); + + it("follow_request_approved uses payload.targetUsername", () => { + const link = linkFor({ + type: "follow_request_approved", + subjectId: null, + payloadVersion: 1, + payload: { targetUsername: "carol", targetDisplayName: null }, + }); + expect(link.web).toBe("/users/carol"); + }); + + it("activity_published prefers subjectId, falls back to payload.activityId", () => { + const fromSubject = linkFor({ + type: "activity_published", + subjectId: "sub-id-1", + payloadVersion: 1, + payload: { activityId: "payload-id", activityName: "x", ownerUsername: "o", ownerDisplayName: null }, + }); + expect(fromSubject.web).toBe("/activities/sub-id-1"); + + const fromPayload = linkFor({ + type: "activity_published", + subjectId: null, + payloadVersion: 1, + payload: { activityId: "payload-id", activityName: "x", ownerUsername: "o", ownerDisplayName: null }, + }); + expect(fromPayload.web).toBe("/activities/payload-id"); + }); + + it("activity_published falls back to / when both subjectId and payload are missing", () => { + const link = linkFor({ + type: "activity_published", + subjectId: null, + payloadVersion: 1, + payload: null, + }); + expect(link.web).toBe("/"); + }); +}); diff --git a/apps/journal/app/lib/notifications/link-for.ts b/apps/journal/app/lib/notifications/link-for.ts new file mode 100644 index 0000000..d199673 --- /dev/null +++ b/apps/journal/app/lib/notifications/link-for.ts @@ -0,0 +1,66 @@ +// Single source of truth for "what URL does this notification link to?" +// Every renderer (web loader, future mobile push formatter, future email +// formatter) calls linkFor; the type→URL mapping lives here, in one +// place, in three flavors so the same logical destination renders +// correctly per platform. + +import type { NotificationType } from "@trails-cool/db/schema/journal"; +import { readPayload } from "./payload.ts"; + +export interface LinkBundle { + web: string; // app-relative path for in-app navigation + mobile?: string; // trails:// scheme for native deep linking + email?: string; // absolute https:// URL for email click-through +} + +export interface NotificationForLink { + type: NotificationType; + subjectId: string | null; + payload: Record | null; + payloadVersion: number; +} + +export function linkFor(n: NotificationForLink): LinkBundle { + const origin = process.env.ORIGIN ?? "https://trails.cool"; + const p = readPayload(n.type, n.payloadVersion, n.payload); + + switch (n.type) { + case "follow_received": + case "follow_request_received": { + // The actionable surface for a request lives in the Requests tab + // of /notifications (where Approve/Reject is); a received + // auto-accept notification links to the new follower's profile. + // Both fall back to the payload's followerUsername if the actor + // account is gone. + const username = + p && "followerUsername" in p ? p.followerUsername : null; + const path = + n.type === "follow_request_received" + ? "/notifications?tab=requests" + : username + ? `/users/${username}` + : "/"; + return buildBundle(origin, path); + } + case "follow_request_approved": { + const username = + p && "targetUsername" in p ? p.targetUsername : null; + const path = username ? `/users/${username}` : "/"; + return buildBundle(origin, path); + } + case "activity_published": { + const activityId = + n.subjectId ?? (p && "activityId" in p ? p.activityId : null); + const path = activityId ? `/activities/${activityId}` : "/"; + return buildBundle(origin, path); + } + } +} + +function buildBundle(origin: string, path: string): LinkBundle { + return { + web: path, + mobile: `trails:/${path.startsWith("/") ? "" : "/"}${path.replace(/^\//, "")}`, + email: `${origin.replace(/\/$/, "")}${path}`, + }; +} diff --git a/apps/journal/app/lib/notifications/payload.ts b/apps/journal/app/lib/notifications/payload.ts new file mode 100644 index 0000000..f7e4742 --- /dev/null +++ b/apps/journal/app/lib/notifications/payload.ts @@ -0,0 +1,70 @@ +// Per-type payload snapshots stored at notification creation time. +// Lets future mobile push / email / RSS renderers display the +// notification without a fresh DB lookup, and lets the web renderer +// fall back to the snapshot when the live record is gone (subject +// deleted, gone private, etc.). +// +// Versioning: every payload type carries an associated `payloadVersion` +// (stored in a separate column on the row, not embedded in the JSON). +// To evolve a payload schema, bump the version and update renderers to +// handle both shapes. Retention naturally ages out old versions. + +import type { NotificationType } from "@trails-cool/db/schema/journal"; + +export const PAYLOAD_VERSION = 1; + +export interface FollowPayloadV1 { + followerUsername: string; + followerDisplayName: string | null; +} + +export interface ApprovalPayloadV1 { + targetUsername: string; + targetDisplayName: string | null; +} + +export interface ActivityPayloadV1 { + activityId: string; + activityName: string; + ownerUsername: string; + ownerDisplayName: string | null; +} + +export type NotificationPayload = + | { type: "follow_request_received"; v: 1; payload: FollowPayloadV1 } + | { type: "follow_received"; v: 1; payload: FollowPayloadV1 } + | { type: "follow_request_approved"; v: 1; payload: ApprovalPayloadV1 } + | { type: "activity_published"; v: 1; payload: ActivityPayloadV1 }; + +// Narrow the `payload` JSONB to the matching shape based on `type` + `v`. +// Renderers should call this rather than indexing into the loose Record. +export function readPayload( + type: NotificationType, + version: number, + payload: Record | null, +): NotificationPayload["payload"] | null { + if (!payload || version !== 1) return null; + switch (type) { + case "follow_request_received": + case "follow_received": + return { + followerUsername: String(payload.followerUsername ?? ""), + followerDisplayName: + (payload.followerDisplayName as string | null | undefined) ?? null, + }; + case "follow_request_approved": + return { + targetUsername: String(payload.targetUsername ?? ""), + targetDisplayName: + (payload.targetDisplayName as string | null | undefined) ?? null, + }; + case "activity_published": + return { + activityId: String(payload.activityId ?? ""), + activityName: String(payload.activityName ?? ""), + ownerUsername: String(payload.ownerUsername ?? ""), + ownerDisplayName: + (payload.ownerDisplayName as string | null | undefined) ?? null, + }; + } +} diff --git a/apps/journal/app/lib/oauth.server.test.ts b/apps/journal/app/lib/oauth.server.test.ts new file mode 100644 index 0000000..d6baead --- /dev/null +++ b/apps/journal/app/lib/oauth.server.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from "vitest"; +import { createHash, randomBytes } from "node:crypto"; + +// Mock the database module before importing oauth +vi.mock("./db.ts", () => { + const store: Record = { + oauth_clients: [], + oauth_codes: [], + oauth_tokens: [], + }; + + return { + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => { + // Return empty array by default; tests override via __store + return []; + }, + }), + }), + insert: () => ({ + values: () => ({ + onConflictDoUpdate: () => Promise.resolve(), + }), + }), + update: () => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + }), + }), + __store: store, + }; +}); + +/** + * Test PKCE verification logic directly. + * The full OAuth flow requires a real database, so we test the crypto + * primitives and error handling here. + */ +describe("PKCE verification", () => { + it("S256: verifier matches challenge", () => { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + + // Re-derive the challenge from the verifier + const derived = createHash("sha256").update(verifier).digest("base64url"); + expect(derived).toBe(challenge); + }); + + it("S256: wrong verifier does not match", () => { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + + const wrongVerifier = randomBytes(32).toString("base64url"); + const wrongDerived = createHash("sha256").update(wrongVerifier).digest("base64url"); + expect(wrongDerived).not.toBe(challenge); + }); + + it("S256: challenge is base64url-encoded SHA-256", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + const challenge = createHash("sha256").update(verifier).digest("base64url"); + + // base64url has no padding, no +, no / + expect(challenge).not.toContain("="); + expect(challenge).not.toContain("+"); + expect(challenge).not.toContain("/"); + expect(challenge.length).toBeGreaterThan(0); + }); +}); + +describe("OAuthError", () => { + it("has code and message", async () => { + const { OAuthError } = await import("./oauth.server.ts"); + const err = new OAuthError("invalid_grant", "Bad code"); + expect(err.code).toBe("invalid_grant"); + expect(err.message).toBe("Bad code"); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("token generation", () => { + it("generates unique tokens", async () => { + const { randomBytes } = await import("node:crypto"); + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(randomBytes(32).toString("base64url")); + } + expect(tokens.size).toBe(100); + }); + + it("tokens are base64url encoded", async () => { + const token = randomBytes(32).toString("base64url"); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(token.length).toBe(43); // 32 bytes → 43 base64url chars + }); +}); + +describe("seedOAuthClient", () => { + it("can be imported and called", async () => { + const { seedOAuthClient } = await import("./oauth.server.ts"); + // Should not throw with mocked DB + await seedOAuthClient("test-client", "testapp://callback", true); + }); +}); diff --git a/apps/journal/app/lib/oauth.server.ts b/apps/journal/app/lib/oauth.server.ts new file mode 100644 index 0000000..d1cd525 --- /dev/null +++ b/apps/journal/app/lib/oauth.server.ts @@ -0,0 +1,272 @@ +import { randomUUID, randomBytes, createHash } from "node:crypto"; +import { eq, and, gt, isNull } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { + users, + oauthClients, + oauthCodes, + oauthTokens, +} from "@trails-cool/db/schema/journal"; +import { getSessionUser } from "./auth/session.server.ts"; + +const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes +const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour + +function generateToken(): string { + return randomBytes(32).toString("base64url"); +} + +// --- Client validation --- + +export async function getOAuthClient(clientId: string) { + const db = getDb(); + const [client] = await db + .select() + .from(oauthClients) + .where(eq(oauthClients.clientId, clientId)); + return client ?? null; +} + +export function validateRedirectUri( + client: { redirectUri: string }, + redirectUri: string, +): boolean { + return client.redirectUri === redirectUri; +} + +// --- Authorization code --- + +export async function createAuthorizationCode(params: { + userId: string; + clientId: string; + codeChallenge: string; + codeChallengeMethod: string; + redirectUri: string; +}): Promise { + const db = getDb(); + const code = generateToken(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS); + + await db.insert(oauthCodes).values({ + id: randomUUID(), + code, + userId: params.userId, + clientId: params.clientId, + codeChallenge: params.codeChallenge, + codeChallengeMethod: params.codeChallengeMethod, + redirectUri: params.redirectUri, + expiresAt, + }); + + return code; +} + +// --- PKCE verification --- + +function verifyCodeChallenge( + codeVerifier: string, + codeChallenge: string, + method: string, +): boolean { + if (method === "S256") { + const hash = createHash("sha256").update(codeVerifier).digest("base64url"); + return hash === codeChallenge; + } + if (method === "plain") { + return codeVerifier === codeChallenge; + } + return false; +} + +// --- Token exchange --- + +export async function exchangeCodeForTokens(params: { + code: string; + clientId: string; + redirectUri: string; + codeVerifier: string; + deviceName?: string; +}) { + const db = getDb(); + + const [record] = await db + .select() + .from(oauthCodes) + .where( + and( + eq(oauthCodes.code, params.code), + eq(oauthCodes.clientId, params.clientId), + gt(oauthCodes.expiresAt, new Date()), + isNull(oauthCodes.usedAt), + ), + ); + + if (!record) { + throw new OAuthError("invalid_grant", "Invalid or expired authorization code"); + } + + if (record.redirectUri !== params.redirectUri) { + throw new OAuthError("invalid_grant", "Redirect URI mismatch"); + } + + if (!verifyCodeChallenge(params.codeVerifier, record.codeChallenge, record.codeChallengeMethod)) { + throw new OAuthError("invalid_grant", "PKCE verification failed"); + } + + // Mark code as used + await db + .update(oauthCodes) + .set({ usedAt: new Date() }) + .where(eq(oauthCodes.id, record.id)); + + return issueTokens(record.userId, params.clientId, params.deviceName); +} + +// --- Refresh token --- + +export async function refreshAccessToken(params: { + refreshToken: string; + clientId: string; + deviceName?: string; +}) { + const db = getDb(); + + const [record] = await db + .select() + .from(oauthTokens) + .where( + and( + eq(oauthTokens.refreshToken, params.refreshToken), + eq(oauthTokens.clientId, params.clientId), + isNull(oauthTokens.revokedAt), + ), + ); + + if (!record) { + throw new OAuthError("invalid_grant", "Invalid refresh token"); + } + + // Revoke old token pair + await db + .update(oauthTokens) + .set({ revokedAt: new Date() }) + .where(eq(oauthTokens.id, record.id)); + + // Issue fresh tokens (token rotation) + return issueTokens(record.userId, params.clientId, params.deviceName ?? record.deviceName ?? undefined); +} + +// --- Token issuance --- + +async function issueTokens(userId: string, clientId: string, deviceName?: string) { + const db = getDb(); + const accessToken = generateToken(); + const refreshToken = generateToken(); + const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY_MS); + + await db.insert(oauthTokens).values({ + id: randomUUID(), + accessToken, + refreshToken, + userId, + clientId, + deviceName: deviceName ?? null, + expiresAt, + lastActiveAt: new Date(), + }); + + return { + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer" as const, + expires_in: Math.floor(ACCESS_TOKEN_EXPIRY_MS / 1000), + }; +} + +// --- Bearer token validation (middleware) --- + +export async function validateBearerToken(request: Request) { + const db = getDb(); + const authHeader = request.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) return null; + + const token = authHeader.slice(7); + const [record] = await db + .select() + .from(oauthTokens) + .where( + and( + eq(oauthTokens.accessToken, token), + isNull(oauthTokens.revokedAt), + ), + ); + + if (!record) return null; + + // Check if access token is expired — but don't reject (client should refresh) + // We still allow expired access tokens for a grace period; the client + // gets a 401 which triggers a refresh flow + if (record.expiresAt < new Date()) return null; + + // Update last active timestamp + await db + .update(oauthTokens) + .set({ lastActiveAt: new Date() }) + .where(eq(oauthTokens.id, record.id)); + + return { userId: record.userId, tokenId: record.id }; +} + +// --- Seed trusted client --- + +export async function seedOAuthClient( + clientId: string, + redirectUri: string, + trusted: boolean, +) { + const db = getDb(); + await db + .insert(oauthClients) + .values({ + clientId, + redirectUri, + trusted: trusted ? 1 : 0, + }) + .onConflictDoUpdate({ + target: oauthClients.clientId, + set: { redirectUri, trusted: trusted ? 1 : 0 }, + }); +} + +// --- Combined auth: cookie session OR bearer token --- + +/** + * Authenticate a request via cookie session or OAuth2 bearer token. + * Use this in API routes that both the web UI and mobile app call. + */ +export async function getAuthenticatedUser(request: Request) { + // Try cookie session first (web UI) + const sessionUser = await getSessionUser(request); + if (sessionUser) return sessionUser; + + // Try bearer token (mobile app) + const tokenResult = await validateBearerToken(request); + if (!tokenResult) return null; + + const db = getDb(); + const [user] = await db + .select() + .from(users) + .where(eq(users.id, tokenResult.userId)); + return user ?? null; +} + +// --- Error type --- + +export class OAuthError extends Error { + code: string; + constructor(code: string, message: string) { + super(message); + this.code = code; + } +} diff --git a/apps/journal/app/lib/operator.ts b/apps/journal/app/lib/operator.ts new file mode 100644 index 0000000..4ea49af --- /dev/null +++ b/apps/journal/app/lib/operator.ts @@ -0,0 +1,20 @@ +/** + * Operator details for the Impressum (§5 TMG / §18 MStV). + * + * Required by German law. Address must be a reachable physical address. + * Email must be a direct contact (no forms-only policy). + */ +export const operator = { + name: "Ullrich Schäfer", + address: { + street: "Mehringdamm 87", + postalCode: "10965", + city: "Berlin", + country: "Germany", + }, + email: "legal@trails.cool", + // Responsible for content per §18 Abs. 2 MStV + responsiblePerson: "Ullrich Schäfer", +} as const; + +export type Operator = typeof operator; diff --git a/apps/journal/app/lib/overpass-ways.server.ts b/apps/journal/app/lib/overpass-ways.server.ts new file mode 100644 index 0000000..5ef404c --- /dev/null +++ b/apps/journal/app/lib/overpass-ways.server.ts @@ -0,0 +1,84 @@ +// Server-side Overpass client for the surface backfill: fetches highway ways +// (with their `surface` tag + geometry) in a bbox so a route/activity can be +// map-matched. Runs only in the backfill job, never in a request path. +// Privacy: this sends the route's bounding box to the upstream Overpass — see +// route-surface-breakdown design §D5; `OVERPASS_URLS` can point at a +// self-hosted instance to keep it in-house. + +export interface OverpassWay { + highway?: string; + surface?: string; + /** Polyline nodes, in order. */ + geometry: Array<{ lat: number; lon: number }>; +} + +export interface Bbox { + south: number; + west: number; + north: number; + east: number; +} + +const DEFAULT_UPSTREAMS = [ + "https://lz4.overpass-api.de/api/interpreter", + "https://overpass-api.de/api/interpreter", +]; + +function upstreams(): string[] { + const env = process.env.OVERPASS_URLS ?? process.env.OVERPASS_URL; + return env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_UPSTREAMS; +} + +// Skip absurdly large bboxes — Overpass would time out / return too much, and a +// huge box means the route is so long that per-segment matching is unreliable +// anyway. ~0.25 deg² (roughly a 50 km square at mid latitudes). +const MAX_BBOX_DEG2 = 0.25; +const USER_AGENT = "trails.cool Journal (https://trails.cool; legal@trails.cool)"; + +/** + * Fetch highway ways within `bbox`. Returns `[]` for an empty/oversized bbox + * (caller treats that as "no data, don't store"); throws if every upstream + * fails so the job retries. + */ +export async function fetchWaysInBbox( + bbox: Bbox, + opts: { timeoutMs?: number } = {}, +): Promise { + const area = Math.abs(bbox.north - bbox.south) * Math.abs(bbox.east - bbox.west); + if (!(area > 0) || area > MAX_BBOX_DEG2) return []; + + const query = `[out:json][timeout:25];way["highway"](${bbox.south},${bbox.west},${bbox.north},${bbox.east});out tags geom;`; + const timeoutMs = opts.timeoutMs ?? 30_000; + + let lastError: unknown; + for (const url of upstreams()) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "text/plain", "User-Agent": USER_AGENT }, + body: query, + signal: controller.signal, + }); + if (!resp.ok) { + lastError = new Error(`overpass ${resp.status}`); + continue; + } + const json = (await resp.json()) as { + elements?: Array<{ type: string; tags?: Record; geometry?: Array<{ lat: number; lon: number }> }>; + }; + const ways: OverpassWay[] = []; + for (const el of json.elements ?? []) { + if (el.type !== "way" || !el.geometry || el.geometry.length < 2) continue; + ways.push({ highway: el.tags?.highway, surface: el.tags?.surface, geometry: el.geometry }); + } + return ways; + } catch (e) { + lastError = e; + } finally { + clearTimeout(timer); + } + } + throw new Error(`overpass: all upstreams failed (${(lastError as Error)?.message ?? "unknown"})`); +} diff --git a/apps/journal/app/lib/ownership.server.test.ts b/apps/journal/app/lib/ownership.server.test.ts new file mode 100644 index 0000000..3104d71 --- /dev/null +++ b/apps/journal/app/lib/ownership.server.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getRoute = vi.fn(); +vi.mock("./routes.server.ts", () => ({ + getRoute: (...args: unknown[]) => getRoute(...args), +})); + +const getActivity = vi.fn(); +vi.mock("./activities.server.ts", () => ({ + getActivity: (...args: unknown[]) => getActivity(...args), +})); + +import { + loadOwnedRoute, + loadOwnedActivity, + requireOwnedRoute, + requireOwnedActivity, + vouchOwnership, +} from "./ownership.server.ts"; + +const USER = "user-1"; + +describe("loadOwnedRoute / loadOwnedActivity", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns not_found for a missing route", async () => { + getRoute.mockResolvedValue(null); + expect(await loadOwnedRoute("r1", USER)).toEqual({ ok: false, reason: "not_found" }); + }); + + it("returns not_owner for someone else's route", async () => { + getRoute.mockResolvedValue({ id: "r1", ownerId: "someone-else" }); + expect(await loadOwnedRoute("r1", USER)).toEqual({ ok: false, reason: "not_owner" }); + }); + + it("returns the entity when owned", async () => { + const route = { id: "r1", ownerId: USER, name: "Tour" }; + getRoute.mockResolvedValue(route); + const result = await loadOwnedRoute("r1", USER); + expect(result.ok).toBe(true); + if (result.ok) expect(result.entity).toBe(route); + }); + + it("treats remote-ingested activities (ownerId null) as not owned", async () => { + getActivity.mockResolvedValue({ id: "a1", ownerId: null }); + expect(await loadOwnedActivity("a1", USER)).toEqual({ ok: false, reason: "not_owner" }); + }); +}); + +describe("requireOwnedRoute / requireOwnedActivity", () => { + beforeEach(() => vi.clearAllMocks()); + + async function statusOf(promise: Promise): Promise { + try { + await promise; + throw new Error("expected a throw"); + } catch (thrown) { + // react-router data() throws a Response-like with init status + return (thrown as { init?: { status: number }; status?: number }).init?.status + ?? (thrown as { status: number }).status; + } + } + + it("throws 404 for missing entities", async () => { + getRoute.mockResolvedValue(null); + expect(await statusOf(requireOwnedRoute("r1", USER))).toBe(404); + }); + + it("throws 404 for not-owner by default (no existence leak)", async () => { + getRoute.mockResolvedValue({ id: "r1", ownerId: "someone-else" }); + expect(await statusOf(requireOwnedRoute("r1", USER))).toBe(404); + }); + + it("throws 403 for not-owner when the surface opts in", async () => { + getActivity.mockResolvedValue({ id: "a1", ownerId: "someone-else" }); + expect(await statusOf(requireOwnedActivity("a1", USER, { notOwnerStatus: 403 }))).toBe(403); + }); + + it("returns the entity when owned", async () => { + const activity = { id: "a1", ownerId: USER }; + getActivity.mockResolvedValue(activity); + expect(await requireOwnedActivity("a1", USER)).toBe(activity); + }); +}); + +describe("vouchOwnership", () => { + it("passes the entity through unchanged (brand is type-level)", () => { + const route = { id: "r1", ownerId: "u9" }; + expect(vouchOwnership(route)).toBe(route); + }); +}); diff --git a/apps/journal/app/lib/ownership.server.ts b/apps/journal/app/lib/ownership.server.ts new file mode 100644 index 0000000..aa5f7ba --- /dev/null +++ b/apps/journal/app/lib/ownership.server.ts @@ -0,0 +1,88 @@ +// The single enforcement point for "does this user own this entity". +// +// Mutators in routes.server.ts / activities.server.ts take an `OwnedRef` +// — a branded {id, ownerId} that can only be produced here (or by the +// explicit `vouchOwnership` escape hatch). A handler that forgets the +// ownership check no longer compiles, instead of silently no-op'ing or +// leaking another user's data. + +import { data } from "react-router"; +import { getRoute } from "./routes.server.ts"; +import { getActivity } from "./activities.server.ts"; + +declare const ownedBrand: unique symbol; + +/** An entity verified to belong to the acting user (or an equivalent grant). */ +export type Owned = T & { readonly [ownedBrand]: true }; + +/** The minimal shape mutators need; any Owned entity satisfies it. */ +export type OwnedRef = Owned<{ id: string; ownerId: string }>; + +export type OwnershipFailure = "not_found" | "not_owner"; + +type LoadResult = { ok: true; entity: Owned } | { ok: false; reason: OwnershipFailure }; + +function check( + entity: T | null, + userId: string, +): LoadResult { + if (!entity) return { ok: false, reason: "not_found" }; + // ownerId === null covers remote-ingested content, which nobody owns locally. + if (entity.ownerId === null || entity.ownerId !== userId) { + return { ok: false, reason: "not_owner" }; + } + return { ok: true, entity: entity as Owned }; +} + +/** Non-throwing core — for callers with their own error vocabulary + * (discriminated results, apiError JSON shapes). */ +export async function loadOwnedRoute(routeId: string, userId: string) { + return check(await getRoute(routeId), userId); +} + +export async function loadOwnedActivity(activityId: string, userId: string) { + return check(await getActivity(activityId), userId); +} + +interface RequireOptions { + /** + * Status for the not-owner case. Defaults to 404 so a guessed id + * doesn't leak that the entity exists; pass 403 on surfaces where + * the entity is already known to the viewer (e.g. the edit page). + */ + notOwnerStatus?: 403 | 404; +} + +function throwFor(reason: OwnershipFailure, label: string, opts?: RequireOptions): never { + if (reason === "not_owner" && (opts?.notOwnerStatus ?? 404) === 403) { + throw data({ error: "Not authorized" }, { status: 403 }); + } + throw data({ error: `${label} not found` }, { status: 404 }); +} + +/** Throwing wrapper for web loaders/actions: 404 / 403 as `data()`. */ +export async function requireOwnedRoute(routeId: string, userId: string, opts?: RequireOptions) { + const result = await loadOwnedRoute(routeId, userId); + if (!result.ok) throwFor(result.reason, "Route", opts); + return result.entity; +} + +export async function requireOwnedActivity( + activityId: string, + userId: string, + opts?: RequireOptions, +) { + const result = await loadOwnedActivity(activityId, userId); + if (!result.ok) throwFor(result.reason, "Activity", opts); + return result.entity; +} + +/** + * Escape hatch for callers whose authorization does not come from a + * session — today only the Planner JWT callback, where a single-use + * token scoped to the route stands in for the owner. Every use should + * be greppable and justified by a comment at the call site. + */ +export function vouchOwnership(entity: T): Owned { + return entity as Owned; +} diff --git a/apps/journal/app/lib/rate-limit.server.test.ts b/apps/journal/app/lib/rate-limit.server.test.ts new file mode 100644 index 0000000..4c06fa9 --- /dev/null +++ b/apps/journal/app/lib/rate-limit.server.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { consumeRateLimit, clientIp, _resetRateLimitsForTest } from "./rate-limit.server.ts"; + +describe("consumeRateLimit", () => { + beforeEach(() => { + _resetRateLimitsForTest(); + }); + + it("allows up to `limit` calls in the window, blocks the (limit+1)th", () => { + for (let i = 0; i < 5; i++) { + const r = consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 }); + expect(r.allowed).toBe(true); + } + const blocked = consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 }); + expect(blocked.allowed).toBe(false); + expect(blocked.remaining).toBe(0); + }); + + it("counts buckets independently per (scope, key) pair", () => { + for (let i = 0; i < 5; i++) { + consumeRateLimit({ scope: "t", key: "a", limit: 5, windowMs: 60_000 }); + } + // Different key → unaffected + expect( + consumeRateLimit({ scope: "t", key: "b", limit: 5, windowMs: 60_000 }).allowed, + ).toBe(true); + // Different scope, same key → unaffected + expect( + consumeRateLimit({ scope: "u", key: "a", limit: 5, windowMs: 60_000 }).allowed, + ).toBe(true); + }); + + it("decrements `remaining` toward zero", () => { + const a = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 }); + const b = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 }); + const c = consumeRateLimit({ scope: "t", key: "k", limit: 3, windowMs: 60_000 }); + expect([a.remaining, b.remaining, c.remaining]).toEqual([2, 1, 0]); + }); + + it("reports a reset window roughly equal to the configured window on first call", () => { + const r = consumeRateLimit({ scope: "t", key: "k", limit: 1, windowMs: 60_000 }); + expect(r.resetMs).toBe(60_000); + }); +}); + +describe("clientIp", () => { + it("uses the first entry from X-Forwarded-For", () => { + const req = new Request("http://x/", { + headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" }, + }); + expect(clientIp(req)).toBe("203.0.113.5"); + }); + + it("falls back to a stable 'unknown' bucket when no header is set", () => { + const req = new Request("http://x/"); + expect(clientIp(req)).toBe("unknown"); + }); + + it("trims whitespace from the parsed IP", () => { + const req = new Request("http://x/", { + headers: { "x-forwarded-for": " 198.51.100.7 , 10.0.0.1" }, + }); + expect(clientIp(req)).toBe("198.51.100.7"); + }); +}); diff --git a/apps/journal/app/lib/rate-limit.server.ts b/apps/journal/app/lib/rate-limit.server.ts new file mode 100644 index 0000000..b818fe5 --- /dev/null +++ b/apps/journal/app/lib/rate-limit.server.ts @@ -0,0 +1,95 @@ +// Per-process in-memory rate limiter. Single-instance is fine for the +// flagship's current topology (one journal container); when we +// horizontally scale we revisit with a Postgres- or Redis-backed store. +// +// Algorithm: fixed-window counter. Each `(scope, key)` pair tracks the +// first-attempt timestamp and a counter. The next attempt after the +// window resets the counter. Simpler than token-bucket and good enough +// for "5 logins per minute" / "3 magic links per 5 minutes" semantics. + +interface Bucket { + windowStart: number; + count: number; +} + +const buckets = new Map(); + +// Periodic sweep so stale entries don't grow unbounded. 5-minute interval +// is fine — entries are tiny and expire on next check anyway. +let sweepTimer: ReturnType | undefined; +function ensureSweeper(): void { + if (sweepTimer || process.env.NODE_ENV === "test" || process.env.VITEST) return; + sweepTimer = setInterval(() => { + const now = Date.now(); + for (const [k, b] of buckets) { + // Conservative: drop entries older than 1 hour regardless of + // configured window. Active limiters re-create their entry. + if (now - b.windowStart > 60 * 60 * 1000) buckets.delete(k); + } + }, 5 * 60 * 1000); + sweepTimer.unref?.(); +} + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + resetMs: number; +} + +export interface RateLimitOptions { + scope: string; + key: string; + limit: number; + windowMs: number; +} + +/** + * Check (and consume) one slot from the given limiter. Returns whether + * the call is allowed plus how many slots remain in the current window. + * + * The limiter is keyed by `${scope}:${key}` — `scope` distinguishes the + * route family (e.g. "login-attempt", "magic-link-send") so a flood on + * one endpoint doesn't lock out the others. + */ +export function consumeRateLimit(opts: RateLimitOptions): RateLimitResult { + // E2E suite drives auth flows from one IP at high volume; production + // limits would trip and flake tests. Same opt-out pattern as + // requireSecret() / getDatabaseUrl(). + if (process.env.E2E === "true") { + return { allowed: true, remaining: opts.limit, resetMs: opts.windowMs }; + } + ensureSweeper(); + const now = Date.now(); + const id = `${opts.scope}:${opts.key}`; + const existing = buckets.get(id); + if (!existing || now - existing.windowStart >= opts.windowMs) { + buckets.set(id, { windowStart: now, count: 1 }); + return { allowed: true, remaining: opts.limit - 1, resetMs: opts.windowMs }; + } + existing.count += 1; + const resetMs = opts.windowMs - (now - existing.windowStart); + if (existing.count > opts.limit) { + return { allowed: false, remaining: 0, resetMs }; + } + return { allowed: true, remaining: opts.limit - existing.count, resetMs }; +} + +/** + * Extract the client IP from a Request. Honors `X-Forwarded-For` + * (Caddy in front of the journal sets it) and falls back to a stable + * "unknown" bucket so the limiter still meaningfully rate-limits when + * the header is missing. + */ +export function clientIp(request: Request): string { + const xff = request.headers.get("x-forwarded-for"); + if (xff) { + // First entry is the original client; everything after is proxies. + return xff.split(",")[0]!.trim(); + } + return "unknown"; +} + +/** Test-only: reset all buckets between tests. */ +export function _resetRateLimitsForTest(): void { + buckets.clear(); +} diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 1d36af3..4e2a31a 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,54 +1,75 @@ import { randomUUID } from "node:crypto"; -import { eq, desc, and } from "drizzle-orm"; +import { eq, desc, and, inArray, isNotNull } from "drizzle-orm"; import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; -import { parseGpx } from "@trails-cool/gpx"; +import type { Visibility, SurfaceBreakdown } from "@trails-cool/db/schema/journal"; +import { sql } from "drizzle-orm"; +import { processGpx, writeGeom } from "./gpx-save.server.ts"; +import type { ProcessedGpx } from "./gpx-save.server.ts"; +import type { OwnedRef } from "./ownership.server.ts"; export interface RouteInput { name: string; description?: string; gpx?: string; routingProfile?: string; + visibility?: Visibility; + // Pre-computed stats — when provided, skip re-parsing (used by demo-bot) + distance?: number | null; + elevationGain?: number | null; + elevationLoss?: number | null; + dayBreaks?: number[]; + synthetic?: boolean; + surfaceBreakdown?: SurfaceBreakdown; } export async function createRoute(ownerId: string, input: RouteInput) { const db = getDb(); const id = randomUUID(); - let distance: number | null = null; - let elevationGain: number | null = null; - let elevationLoss: number | null = null; + let processed: ProcessedGpx | null = null; + let distance: number | null = input.distance ?? null; + let elevationGain: number | null = input.elevationGain ?? null; + let elevationLoss: number | null = input.elevationLoss ?? null; + let dayBreaks: number[] = input.dayBreaks ?? []; if (input.gpx) { - const stats = computeRouteStats(input.gpx); - distance = stats.distance; - elevationGain = stats.elevationGain; - elevationLoss = stats.elevationLoss; + processed = await processGpx(input.gpx); + // Only use GPX-derived stats if not pre-supplied by caller + if (input.distance === undefined) { + ({ distance, elevationGain, elevationLoss, dayBreaks } = processed.stats); + } } - await db.insert(routes).values({ - id, - ownerId, - name: input.name, - description: input.description ?? "", - gpx: input.gpx, - routingProfile: input.routingProfile, - distance, - elevationGain, - elevationLoss, - }); - - // Create initial version if GPX provided - if (input.gpx) { - await db.insert(routeVersions).values({ - id: randomUUID(), - routeId: id, - version: 1, + await db.transaction(async (tx) => { + await tx.insert(routes).values({ + id, + ownerId, + name: input.name, + description: input.description ?? "", gpx: input.gpx, - createdBy: ownerId, - changeDescription: "Initial version", + routingProfile: input.routingProfile, + distance, + elevationGain, + elevationLoss, + dayBreaks, + ...(input.visibility ? { visibility: input.visibility } : {}), + ...(input.synthetic ? { synthetic: true } : {}), }); - } + + if (input.gpx && processed) { + await writeGeom(tx, id, "routes", processed.coords); + + await tx.insert(routeVersions).values({ + id: randomUUID(), + routeId: id, + version: 1, + gpx: input.gpx, + createdBy: ownerId, + changeDescription: "Initial version", + }); + } + }); return id; } @@ -56,7 +77,9 @@ export async function createRoute(ownerId: string, input: RouteInput) { export async function getRoute(id: string) { const db = getDb(); const [route] = await db.select().from(routes).where(eq(routes.id, id)); - return route ?? null; + if (!route) return null; + const geojson = await getGeojson("routes", id); + return { ...route, geojson }; } export async function getRouteWithVersions(id: string) { @@ -75,77 +98,134 @@ export async function getRouteWithVersions(id: string) { export async function listRoutes(ownerId: string) { const db = getDb(); - return db + const rows = await db .select() .from(routes) .where(eq(routes.ownerId, ownerId)) .orderBy(desc(routes.updatedAt)); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } -export async function updateRoute( - id: string, - ownerId: string, - input: Partial, -) { +/** + * List the *public* routes of a given owner. Used for cross-user listings + * (the public profile page); never includes `unlisted` or `private` content. + */ +export async function listPublicRoutesForOwner(ownerId: string, limit: number = 100) { const db = getDb(); + const rows = await db + .select() + .from(routes) + .where(and(eq(routes.ownerId, ownerId), eq(routes.visibility, "public"))) + .orderBy(desc(routes.updatedAt)) + .limit(limit); + + const ids = rows.map((r) => r.id); + const geojsonMap = ids.length > 0 ? await getSimplifiedGeojsonBatch(ids) : new Map(); + return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); +} + +export async function updateRoute(route: OwnedRef, input: Partial) { + const { id, ownerId } = route; + const db = getDb(); + + let processed: ProcessedGpx | null = null; + if (input.gpx) { + processed = await processGpx(input.gpx); + } const updateData: Record = { updatedAt: new Date() }; if (input.name !== undefined) updateData.name = input.name; if (input.description !== undefined) updateData.description = input.description; + if (input.visibility !== undefined) updateData.visibility = input.visibility; + if (input.surfaceBreakdown !== undefined) updateData.surfaceBreakdown = input.surfaceBreakdown; - if (input.gpx) { + if (input.gpx && processed) { + const { stats } = processed; updateData.gpx = input.gpx; - const stats = computeRouteStats(input.gpx); updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; - - // Get next version number - const existingVersions = await db - .select() - .from(routeVersions) - .where(eq(routeVersions.routeId, id)) - .orderBy(desc(routeVersions.version)); - - const nextVersion = (existingVersions[0]?.version ?? 0) + 1; - - await db.insert(routeVersions).values({ - id: randomUUID(), - routeId: id, - version: nextVersion, - gpx: input.gpx, - createdBy: ownerId, - }); + updateData.dayBreaks = stats.dayBreaks; + if (stats.description && input.description === undefined) { + updateData.description = stats.description; + } } - await db - .update(routes) - .set(updateData) - .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + await db.transaction(async (tx) => { + await tx + .update(routes) + .set(updateData) + .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + + if (input.gpx && processed) { + await writeGeom(tx, id, "routes", processed.coords); + + const existingVersions = await tx + .select() + .from(routeVersions) + .where(eq(routeVersions.routeId, id)) + .orderBy(desc(routeVersions.version)); + + const nextVersion = (existingVersions[0]?.version ?? 0) + 1; + + await tx.insert(routeVersions).values({ + id: randomUUID(), + routeId: id, + version: nextVersion, + gpx: input.gpx, + createdBy: ownerId, + }); + } + }); } -export async function deleteRoute(id: string, ownerId: string) { +export async function deleteRoute(route: OwnedRef) { const db = getDb(); + // The WHERE ownerId clause stays as defense in depth even though the + // OwnedRef brand already proves ownership. const result = await db .delete(routes) - .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))) + .where(and(eq(routes.id, route.id), eq(routes.ownerId, route.ownerId))) .returning({ id: routes.id }); return result.length > 0; } -function computeRouteStats(gpxString: string) { +async function getGeojson(table: "routes" | "activities", id: string): Promise { try { - const gpxData = parseGpx(gpxString); - return { - distance: Math.round( - gpxData.elevation.profile.length > 0 - ? gpxData.elevation.profile[gpxData.elevation.profile.length - 1]!.distance - : 0, - ), - elevationGain: gpxData.elevation.gain, - elevationLoss: gpxData.elevation.loss, - }; + const db = getDb(); + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(geom) as geojson FROM ${sql.identifier("journal")}.${sql.identifier(table)} WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + return row?.geojson ?? null; } catch { - return { distance: null, elevationGain: null, elevationLoss: null }; + return null; } } + +async function getSimplifiedGeojsonBatch(ids: string[]): Promise> { + const map = new Map(); + if (ids.length === 0) return map; + try { + const db = getDb(); + // Query-builder id list: a raw `ANY(${ids}::text[])` makes drizzle expand + // the array to `($1,$2,...)`, yielding the invalid `ANY((...)::text[])` + // — it throws and silently drops every route preview. + const rows = await db + .select({ + id: routes.id, + geojson: sql`ST_AsGeoJSON(ST_Simplify(${routes.geom}, 0.001))`, + }) + .from(routes) + .where(and(inArray(routes.id, ids), isNotNull(routes.geom))); + for (const row of rows) { + if (row.geojson) map.set(row.id, row.geojson); + } + } catch { + // Fallback: no geojson + } + return map; +} diff --git a/apps/journal/app/lib/sentry.client.ts b/apps/journal/app/lib/sentry.client.ts new file mode 100644 index 0000000..8ecaf65 --- /dev/null +++ b/apps/journal/app/lib/sentry.client.ts @@ -0,0 +1,46 @@ +import * as Sentry from "@sentry/react"; +import { useEffect } from "react"; +import { useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from "react-router"; +import { browserSentryConfig } from "@trails-cool/sentry-config"; + +let initialized = false; + +// Build-time DSN injection. `VITE_SENTRY_DSN` (set during `pnpm build`, +// see apps/journal/Dockerfile + cd-apps.yml) determines whether the +// client emits Sentry events at all. Self-hosted builds without the +// env var produce a Sentry-free client bundle. +const CLIENT_DSN = (import.meta.env as Record) + .VITE_SENTRY_DSN; + +export function initSentryClient() { + if (initialized) return; + initialized = true; + if (!CLIENT_DSN) return; + + Sentry.init({ + dsn: CLIENT_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ], + ...browserSentryConfig("journal client", import.meta.env), + }); +} + +/** + * Tear down the Sentry client on logout. After this call, `Sentry.captureException` + * and other hub methods become no-ops until `initSentryClient` is called again. + * + * Fire-and-forget: the close flush happens async, but we don't want to block the + * logout UI on it. + */ +export function stopSentryClient() { + if (!initialized) return; + initialized = false; + void Sentry.close(); +} diff --git a/apps/journal/app/lib/social-feed.integration.test.ts b/apps/journal/app/lib/social-feed.integration.test.ts new file mode 100644 index 0000000..f24774a --- /dev/null +++ b/apps/journal/app/lib/social-feed.integration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, like } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal"; +import { listSocialFeed } from "./activities.server.ts"; + +// Opt-in: real Postgres. Covers social-federation §8 — the audience +// leak guard is THE scenario that must never regress. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const RUN = Date.now(); +const REMOTE_ACTOR = `https://feed-origin.example/users/x-${RUN}`; +const createdUserIds: string[] = []; + +async function makeUser(suffix: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `feed-${suffix}-${RUN}@example.test`, + username: `feed-${suffix}-${RUN}`, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +async function followRemote(followerId: string, accepted: boolean) { + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri: REMOTE_ACTOR, + followedUserId: null, + acceptedAt: accepted ? new Date() : null, + }); +} + +async function remoteActivity(n: number, audience: "public" | "followers-only", publishedAt: Date) { + const db = getDb(); + await db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + name: `Remote ${audience} ${n}`, + visibility: audience === "public" ? "public" : "private", + remoteOriginIri: `${REMOTE_ACTOR}/activities/${n}`, + remoteActorIri: REMOTE_ACTOR, + remotePublishedAt: publishedAt, + audience, + }); +} + +describe.runIf(runIntegration)("social feed with remote rows (§8, integration)", () => { + beforeAll(async () => { + process.env.ORIGIN ??= "http://localhost:3000"; + const db = getDb(); + await db.insert(remoteActors).values({ + actorIri: REMOTE_ACTOR, + username: "x", + displayName: "Remote X", + domain: "feed-origin.example", + }).onConflictDoNothing(); + }); + + afterEach(async () => { + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${REMOTE_ACTOR}%`)); + for (const id of createdUserIds.splice(0)) { + await db.delete(activities).where(eq(activities.ownerId, id)); + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("followers-only remote content reaches only the viewer with the accepted follow", async () => { + const a = await makeUser("a"); + const b = await makeUser("b"); + await followRemote(a, true); // A follows X (accepted) + // B does NOT follow X at all — but the row exists in our DB. + await remoteActivity(1, "followers-only", new Date()); + + const feedA = await listSocialFeed(a); + const feedB = await listSocialFeed(b); + expect(feedA.map((r) => r.name)).toContain("Remote followers-only 1"); + expect(feedB.map((r) => r.name)).not.toContain("Remote followers-only 1"); + }); + + it("public remote content reaches accepted followers with remote attribution", async () => { + const a = await makeUser("pub"); + await followRemote(a, true); + await remoteActivity(2, "public", new Date()); + + const feed = await listSocialFeed(a); + const entry = feed.find((r) => r.name === "Remote public 2"); + expect(entry).toBeDefined(); + expect(entry!.remote).toBe(true); + expect(entry!.externalUrl).toBe(`${REMOTE_ACTOR}/activities/2`); + expect(entry!.ownerUsername).toBe("x"); + expect(entry!.ownerDomain).toBe("feed-origin.example"); + expect(entry!.geojson).toBeNull(); + }); + + it("pending follows contribute nothing", async () => { + const a = await makeUser("pend"); + await followRemote(a, false); // Pending + await remoteActivity(3, "public", new Date()); + expect(await listSocialFeed(a)).toHaveLength(0); + }); + + it("mixes local and remote rows sorted by origin publish time", async () => { + const viewer = await makeUser("viewer"); + const author = await makeUser("author"); + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId: viewer, + followedActorIri: `http://localhost:3000/users/feed-author-${RUN}`, + followedUserId: author, + acceptedAt: new Date(), + }); + await followRemote(viewer, true); + + const now = Date.now(); + // Local activity created "now" (createdAt defaults to now()). + await db.insert(activities).values({ + id: randomUUID(), + ownerId: author, + name: `Local newest ${RUN}`, + visibility: "public", + }); + // Remote activity published an hour ago. + await remoteActivity(4, "public", new Date(now - 3600_000)); + + const feed = await listSocialFeed(viewer); + const names = feed.map((r) => r.name); + expect(names.indexOf(`Local newest ${RUN}`)).toBeLessThan(names.indexOf("Remote public 4")); + }); +}); diff --git a/apps/journal/app/lib/sport-type.test.ts b/apps/journal/app/lib/sport-type.test.ts new file mode 100644 index 0000000..df35644 --- /dev/null +++ b/apps/journal/app/lib/sport-type.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { mapSportType } from "./sport-type.ts"; + +describe("mapSportType", () => { + it("maps known foot sports", () => { + expect(mapSportType("hike")).toBe("hike"); + expect(mapSportType("mountaineering")).toBe("hike"); + expect(mapSportType("jogging")).toBe("run"); + expect(mapSportType("running")).toBe("run"); + expect(mapSportType("walking")).toBe("walk"); + }); + + it("folds road/touring/city bikes into ride", () => { + expect(mapSportType("racebike")).toBe("ride"); + expect(mapSportType("touringbicycle")).toBe("ride"); + expect(mapSportType("citybike")).toBe("ride"); + expect(mapSportType("e_touringbicycle")).toBe("ride"); + }); + + it("maps gravel and mountain bikes", () => { + expect(mapSportType("gravelbike")).toBe("gravel"); + expect(mapSportType("mountainbike")).toBe("mtb"); + expect(mapSportType("e_mountainbike")).toBe("mtb"); + expect(mapSportType("mountainbikeadvanced")).toBe("mtb"); + }); + + it("maps snow sports to ski", () => { + expect(mapSportType("skitour")).toBe("ski"); + expect(mapSportType("skatingnordic")).toBe("ski"); + }); + + it("normalizes case, whitespace, and separators", () => { + expect(mapSportType("Mountain Bike")).toBe("mtb"); + expect(mapSportType(" HIKE ")).toBe("hike"); + expect(mapSportType("gravel-ride")).toBe("gravel"); + }); + + it("falls back to other for unrecognized non-empty input", () => { + expect(mapSportType("unicycle")).toBe("other"); + expect(mapSportType("kitesurf")).toBe("other"); + }); + + it("returns undefined when no descriptor is supplied", () => { + expect(mapSportType(null)).toBeUndefined(); + expect(mapSportType(undefined)).toBeUndefined(); + expect(mapSportType("")).toBeUndefined(); + expect(mapSportType(" ")).toBeUndefined(); + }); +}); diff --git a/apps/journal/app/lib/sport-type.ts b/apps/journal/app/lib/sport-type.ts new file mode 100644 index 0000000..5317801 --- /dev/null +++ b/apps/journal/app/lib/sport-type.ts @@ -0,0 +1,59 @@ +import type { SportType } from "@trails-cool/db/schema/journal"; + +/** + * Normalize a connected service's sport/activity descriptor into our + * `SportType` enum. Source taxonomies (Komoot's `tour.sport`, etc.) are + * messy and evolve, so this is the single place that maps them; anything + * without a confident match becomes `other`. This is the only spot to extend + * when a provider adds a new descriptor. + */ +const SPORT_ALIASES: Record = { + // foot + hike: "hike", + hiking: "hike", + mountaineering: "hike", + walk: "walk", + walking: "walk", + snowshoe: "walk", + jogging: "run", + running: "run", + run: "run", + trailrunning: "run", + // wheels — road / touring / city fold into `ride` + ride: "ride", + road: "ride", + racebike: "ride", + touringbicycle: "ride", + citybike: "ride", + e_racebike: "ride", + e_touringbicycle: "ride", + e_citybike: "ride", + // gravel + gravel: "gravel", + gravelbike: "gravel", + gravelride: "gravel", + // mountain bike (incl. e-MTB and difficulty variants) + mtb: "mtb", + mountainbike: "mtb", + mountainbikeeasy: "mtb", + mountainbikeadvanced: "mtb", + e_mountainbike: "mtb", + // snow + ski: "ski", + skitour: "ski", + nordic: "ski", + skatingnordic: "ski", + crosscountryski: "ski", +}; + +/** + * Map a raw provider sport string to a `SportType`, or `undefined` when the + * provider supplied nothing (so the activity is stored with no sport type + * rather than a guessed `other`). + */ +export function mapSportType(raw: string | null | undefined): SportType | undefined { + if (raw == null) return undefined; + const key = raw.trim().toLowerCase().replace(/[\s-]+/g, ""); + if (key === "") return undefined; + return SPORT_ALIASES[key] ?? "other"; +} diff --git a/apps/journal/app/lib/stats.test.ts b/apps/journal/app/lib/stats.test.ts new file mode 100644 index 0000000..a0ab68c --- /dev/null +++ b/apps/journal/app/lib/stats.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + formatDistanceKm, + formatElevationM, + formatDuration, + formatSpeedKmh, + formatPace, + deriveRate, + activityStatItems, +} from "./stats.ts"; + +// Identity translate: labels come back as their keys so we can assert order. +const t = (k: string) => k; + +describe("formatters", () => { + it("formats distance (1 decimal under 100 km, integer above)", () => { + expect(formatDistanceKm(12_340)).toBe("12.3 km"); + expect(formatDistanceKm(123_400)).toBe("123 km"); + }); + it("formats elevation as integer metres", () => { + expect(formatElevationM(512.6)).toBe("513 m"); + }); + it("formats duration h/m", () => { + expect(formatDuration(9000)).toBe("2h 30m"); + expect(formatDuration(2820)).toBe("47m"); + }); + it("formats speed and pace", () => { + expect(formatSpeedKmh(30.86)).toBe("30.9 km/h"); + expect(formatPace(318)).toBe("5:18 /km"); + }); +}); + +describe("deriveRate", () => { + it("returns pace for foot sports", () => { + expect(deriveRate(10_000, 3000, "run")).toEqual({ kind: "pace", value: 300 }); + expect(deriveRate(10_000, 3000, "hike")?.kind).toBe("pace"); + }); + it("returns speed for wheeled/ski sports", () => { + expect(deriveRate(30_000, 3600, "ride")).toEqual({ kind: "speed", value: 30 }); + expect(deriveRate(1000, 3600, "mtb")?.kind).toBe("speed"); + }); + it("defaults to speed when sport is unset", () => { + expect(deriveRate(30_000, 3600, null)?.kind).toBe("speed"); + }); + it("returns null without usable distance/time", () => { + expect(deriveRate(0, 3600, "run")).toBeNull(); + expect(deriveRate(1000, 0, "run")).toBeNull(); + expect(deriveRate(null, null, "run")).toBeNull(); + }); +}); + +describe("activityStatItems", () => { + it("builds the full canonical order", () => { + const items = activityStatItems( + { + distance: 30_000, + durationSec: 3600, + movingTimeSec: 3300, + elevationGain: 500, + elevationLoss: 480, + sportType: "ride", + }, + t, + ); + expect(items.map((i) => i.label)).toEqual([ + "stats.distance", + "stats.duration", + "stats.movingTime", + "stats.avgSpeed", + "stats.ascent", + "stats.descent", + ]); + }); + + it("compact drops moving time and ascent/descent, keeps order", () => { + const items = activityStatItems( + { distance: 10_000, durationSec: 3000, movingTimeSec: 2800, elevationGain: 100, sportType: "run" }, + t, + { compact: true }, + ); + expect(items.map((i) => i.label)).toEqual([ + "stats.distance", + "stats.duration", + "stats.avgPace", + ]); + }); + + it("omits items with no value", () => { + const items = activityStatItems({ distance: 5000 }, t); + expect(items.map((i) => i.label)).toEqual(["stats.distance"]); + }); + + it("rate prefers moving time over elapsed", () => { + const items = activityStatItems( + { distance: 10_000, durationSec: 4000, movingTimeSec: 3000, sportType: "run" }, + t, + ); + const pace = items.find((i) => i.label === "stats.avgPace"); + // 10 km in 3000s moving → 5:00 /km (not 6:40 from elapsed) + expect(pace?.value).toBe("5:00 /km"); + }); +}); diff --git a/apps/journal/app/lib/stats.ts b/apps/journal/app/lib/stats.ts new file mode 100644 index 0000000..b2d68fa --- /dev/null +++ b/apps/journal/app/lib/stats.ts @@ -0,0 +1,112 @@ +import type { SportType } from "@trails-cool/db/schema/journal"; + +// Minimal translate signature (the `t` returned by react-i18next satisfies it) +// so this stays a plain util with no react-i18next coupling. +type Translate = (key: string) => string; + +export interface StatItem { + label: string; + value: string; +} + +// --- pure formatters (metric baseline; an imperial toggle is future work) --- + +export function formatDistanceKm(meters: number): string { + const km = meters / 1000; + return km >= 100 ? `${Math.round(km)} km` : `${km.toFixed(1)} km`; +} + +export function formatElevationM(meters: number): string { + return `${Math.round(meters)} m`; +} + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} + +export function formatSpeedKmh(kmh: number): string { + return `${kmh.toFixed(1)} km/h`; +} + +export function formatPace(secondsPerKm: number): string { + const m = Math.floor(secondsPerKm / 60); + const s = Math.round(secondsPerKm % 60); + return `${m}:${String(s).padStart(2, "0")} /km`; +} + +// --- derived rate (pace vs speed, sport-aware) --- + +const PACE_SPORTS = new Set(["hike", "walk", "run"]); + +export function deriveRate( + distanceM: number | null | undefined, + timeSec: number | null | undefined, + sportType: SportType | null | undefined, +): { kind: "pace" | "speed"; value: number } | null { + if (!distanceM || !timeSec || distanceM <= 0 || timeSec <= 0) return null; + const km = distanceM / 1000; + if (sportType && PACE_SPORTS.has(sportType)) { + return { kind: "pace", value: timeSec / km }; + } + return { kind: "speed", value: km / (timeSec / 3600) }; +} + +export interface ActivityStatsInput { + distance?: number | null; + durationSec?: number | null; // elapsed + movingTimeSec?: number | null; + elevationGain?: number | null; + elevationLoss?: number | null; + sportType?: SportType | null; +} + +/** + * Build the canonical ordered stat items for an activity or route. Order is + * fixed (distance · time · avg pace/speed · ascent · descent); moving time, when + * present, is shown right after elapsed time. `compact` drops moving time and + * ascent/descent for tight surfaces (feed card, profile list) — the remaining + * items keep the canonical order. Items with no value are omitted. + */ +export function activityStatItems( + input: ActivityStatsInput, + t: Translate, + opts: { compact?: boolean } = {}, +): StatItem[] { + const { compact = false } = opts; + const items: StatItem[] = []; + + if (input.distance != null) { + items.push({ label: t("stats.distance"), value: formatDistanceKm(input.distance) }); + } + + const elapsed = input.durationSec; + if (elapsed != null) { + items.push({ label: t("stats.duration"), value: formatDuration(elapsed) }); + } + if (!compact && input.movingTimeSec != null) { + items.push({ label: t("stats.movingTime"), value: formatDuration(input.movingTimeSec) }); + } + + // Rate prefers moving time when available, else elapsed. + const rate = deriveRate(input.distance, input.movingTimeSec ?? elapsed, input.sportType); + if (rate) { + items.push( + rate.kind === "pace" + ? { label: t("stats.avgPace"), value: formatPace(rate.value) } + : { label: t("stats.avgSpeed"), value: formatSpeedKmh(rate.value) }, + ); + } + + if (!compact) { + if (input.elevationGain != null) { + items.push({ label: t("stats.ascent"), value: `↑ ${formatElevationM(input.elevationGain)}` }); + } + if (input.elevationLoss != null) { + items.push({ label: t("stats.descent"), value: `↓ ${formatElevationM(input.elevationLoss)}` }); + } + } + + return items; +} diff --git a/apps/journal/app/lib/surface-match.server.test.ts b/apps/journal/app/lib/surface-match.server.test.ts new file mode 100644 index 0000000..af0f195 --- /dev/null +++ b/apps/journal/app/lib/surface-match.server.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { matchSurfaces } from "./surface-match.server.ts"; +import type { OverpassWay } from "./overpass-ways.server.ts"; + +// Two parallel ways: one along lat 0 (asphalt road), one along lat 1 (gravel track). +const ways: OverpassWay[] = [ + { + highway: "residential", + surface: "asphalt", + geometry: [ + { lat: 0, lon: 0 }, + { lat: 0, lon: 10 }, + ], + }, + { + highway: "track", + surface: "gravel", + geometry: [ + { lat: 1, lon: 0 }, + { lat: 1, lon: 10 }, + ], + }, +]; + +describe("matchSurfaces", () => { + it("assigns a segment near the lat-0 way its asphalt/residential tags", () => { + const { surfaces, highways } = matchSurfaces([[1, 0.1], [2, 0.1]], ways); + expect(surfaces).toEqual(["asphalt"]); + expect(highways).toEqual(["residential"]); + }); + + it("assigns a segment near the lat-1 way its gravel/track tags", () => { + const { surfaces, highways } = matchSurfaces([[1, 0.9], [2, 0.9]], ways); + expect(surfaces).toEqual(["gravel"]); + expect(highways).toEqual(["track"]); + }); + + it("returns unknown when there are no candidate ways", () => { + const { surfaces, highways } = matchSurfaces([[0, 0], [1, 0]], []); + expect(surfaces).toEqual(["unknown"]); + expect(highways).toEqual(["unknown"]); + }); + + it("buckets a missing surface tag as unknown but keeps the highway", () => { + const untagged: OverpassWay[] = [{ highway: "path", geometry: [{ lat: 0, lon: 0 }, { lat: 0, lon: 10 }] }]; + const { surfaces, highways } = matchSurfaces([[1, 0], [2, 0]], untagged); + expect(surfaces).toEqual(["unknown"]); + expect(highways).toEqual(["path"]); + }); +}); diff --git a/apps/journal/app/lib/surface-match.server.ts b/apps/journal/app/lib/surface-match.server.ts new file mode 100644 index 0000000..6d17a8d --- /dev/null +++ b/apps/journal/app/lib/surface-match.server.ts @@ -0,0 +1,59 @@ +import type { OverpassWay } from "./overpass-ways.server.ts"; + +/** Squared distance from point P to segment AB, in (degree) units — only used + * for *comparing* candidates, so the planar approximation is fine at the scale + * of a route bbox. */ +function distToSegmentSq( + px: number, py: number, + ax: number, ay: number, + bx: number, by: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq > 0 ? ((px - ax) * dx + (py - ay) * dy) / lenSq : 0; + t = Math.max(0, Math.min(1, t)); + const cx = ax + t * dx; + const cy = ay + t * dy; + return (px - cx) ** 2 + (py - cy) ** 2; +} + +/** + * Map-match each route segment to the nearest OSM way and read its + * surface/highway. `coords` are `[lon, lat, ...]`; returns `surfaces[i]` / + * `highways[i]` for the segment from `coords[i]` to `coords[i+1]` + * (length `coords.length - 1`). Segments with no candidate way → `"unknown"`. + * Feed the result through `computeSurfaceBreakdown` to get the distribution. + */ +export function matchSurfaces( + coords: ReadonlyArray, + ways: readonly OverpassWay[], +): { surfaces: string[]; highways: string[] } { + const surfaces: string[] = []; + const highways: string[] = []; + + for (let i = 0; i < coords.length - 1; i++) { + const a = coords[i]!; + const b = coords[i + 1]!; + const mx = (a[0]! + b[0]!) / 2; // lon + const my = (a[1]! + b[1]!) / 2; // lat + + let best: OverpassWay | null = null; + let bestDist = Infinity; + for (const way of ways) { + const g = way.geometry; + for (let j = 0; j < g.length - 1; j++) { + const d = distToSegmentSq(mx, my, g[j]!.lon, g[j]!.lat, g[j + 1]!.lon, g[j + 1]!.lat); + if (d < bestDist) { + bestDist = d; + best = way; + } + } + } + + surfaces.push(best?.surface ?? "unknown"); + highways.push(best?.highway ?? "unknown"); + } + + return { surfaces, highways }; +} diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts new file mode 100644 index 0000000..18c4116 --- /dev/null +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -0,0 +1,98 @@ +import { randomUUID } from "node:crypto"; +import { eq, and, inArray } from "drizzle-orm"; +import { getDb } from "../db.ts"; +import { syncImports } from "@trails-cool/db/schema/journal"; +import { createActivity } from "../activities.server.ts"; +import type { SportType } from "@trails-cool/db/schema/journal"; + +export async function recordImport( + userId: string, + provider: string, + externalWorkoutId: string, + activityId: string, +) { + const db = getDb(); + await db.insert(syncImports).values({ + id: randomUUID(), + userId, + provider, + externalWorkoutId, + activityId, + }); +} + +export async function isAlreadyImported( + userId: string, + provider: string, + externalWorkoutId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ id: syncImports.id }) + .from(syncImports) + .where( + and( + eq(syncImports.userId, userId), + eq(syncImports.provider, provider), + eq(syncImports.externalWorkoutId, externalWorkoutId), + ), + ); + return !!row; +} + +export async function deleteImportByActivity(activityId: string) { + const db = getDb(); + await db.delete(syncImports).where(eq(syncImports.activityId, activityId)); +} + +// Single callsite for "create an activity from an imported workout and record +// the dedup entry". Providers call this instead of calling createActivity + +// recordImport separately, so the pair stays atomic from the provider's view. +export async function importActivity( + userId: string, + provider: string, + externalWorkoutId: string, + input: { + name: string; + gpx?: string; + // Stats-only imports (no GPS file — e.g. Garmin notifications for + // FIT-less activities) carry whatever the provider's summary had. + distance?: number | null; + duration?: number | null; + startedAt?: Date | null; + // Already normalized to our enum by the caller (see mapSportType); + // undefined when the provider supplied no sport. + sportType?: SportType; + }, +): Promise<{ activityId: string }> { + const activityId = await createActivity(userId, { + name: input.name, + gpx: input.gpx, + distance: input.distance, + duration: input.duration, + startedAt: input.startedAt, + sportType: input.sportType, + }); + await recordImport(userId, provider, externalWorkoutId, activityId); + return { activityId }; +} + +export async function getImportedIds( + userId: string, + provider: string, + externalWorkoutIds: string[], +): Promise> { + if (externalWorkoutIds.length === 0) return new Set(); + const db = getDb(); + const rows = await db + .select({ externalWorkoutId: syncImports.externalWorkoutId }) + .from(syncImports) + .where( + and( + eq(syncImports.userId, userId), + eq(syncImports.provider, provider), + inArray(syncImports.externalWorkoutId, externalWorkoutIds), + ), + ); + return new Set(rows.map((r) => r.externalWorkoutId)); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index cdd85c1..e75dabd 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -1,27 +1,47 @@ 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, Link, redirect } from "react-router"; import type { LinksFunction } from "react-router"; import type { Route } from "./+types/root"; import * as Sentry from "@sentry/react"; import { useTranslation } from "react-i18next"; -import { initI18n } from "@trails-cool/i18n"; -import { getSessionUser } from "~/lib/auth.server"; -import stylesheet from "@trails-cool/ui/styles.css?url"; +import { detectLocale } from "@trails-cool/i18n"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { LocaleProvider } from "~/components/LocaleContext"; +import { AlphaBanner } from "~/components/AlphaBanner"; +import { useUnreadNotifications } from "~/hooks/useUnreadNotifications"; +import { countUnread } from "~/lib/notifications.server"; +import { Footer } from "~/components/Footer"; +import { AccountDropdown } from "~/components/AccountDropdown"; +import { MobileNavMenu } from "~/components/MobileNavMenu"; +import { initSentryClient, stopSentryClient } from "~/lib/sentry.client"; +import { TERMS_VERSION } from "~/lib/legal"; +import stylesheet from "./styles.css?url"; -initI18n(); +// 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. +// `/legal/` matches as a prefix (covers `/legal/terms`, `/legal/privacy`, etc.). +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 }) { + const { i18n } = useTranslation(); return ( - + + + + - + {children} @@ -32,58 +52,138 @@ export function Layout({ children }: { children: React.ReactNode }) { export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); - return { user: user ? { id: user.id, username: user.username } : null }; + 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}`); + } + } + + // Unread-notification count for the navbar bell badge. Pending follow + // requests are not separately counted here — each pending request + // creates an unread `follow_request_received` notification, so the + // unread count already covers them. + let unreadNotifications = 0; + if (user) { + unreadNotifications = await countUnread(user.id); + } + + return { + user: user + ? { id: user.id, username: user.username, displayName: user.displayName } + : null, + locale, + unreadNotifications, + }; } -function NavBar({ user }: { user: { id: string; username: string } | null }) { +function BellLink({ unread, label }: { unread: number; label: string }) { + return ( + + + {unread > 0 && ( + + {unread} + + )} + + ); +} + +function NavBar({ + user, + unreadNotifications, +}: { + user: { id: string; username: string; displayName: string | null } | null; + unreadNotifications: number; +}) { const { t } = useTranslation("journal"); const location = useLocation(); + // Live-updating unread count for the navbar badge. Loader value is + // the SSR baseline; SSE pushes overrides after first event. + const liveUnread = useUnreadNotifications(unreadNotifications, user !== null); const isActive = (path: string) => location.pathname === path || location.pathname.startsWith(path + "/"); const linkClass = (path: string) => `text-sm font-medium ${ - isActive(path) - ? "text-blue-600" - : "text-gray-600 hover:text-gray-900" + isActive(path) ? "text-blue-600" : "text-gray-600 hover:text-gray-900" }`; return (