diff --git a/.agents/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md deleted file mode 100644 index 8e323f9..0000000 --- a/.agents/skills/ia-review/SKILL.md +++ /dev/null @@ -1,275 +0,0 @@ ---- -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/.agents/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md deleted file mode 100644 index bf1cae9..0000000 --- a/.agents/skills/spec-drift-review/SKILL.md +++ /dev/null @@ -1,216 +0,0 @@ ---- -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 c6cb9b6..bf23721 100644 --- a/.claude/commands/opsx/apply.md +++ b/.claude/commands/opsx/apply.md @@ -1,15 +1,12 @@ --- 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** @@ -29,7 +26,6 @@ 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 +35,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) + - Context file paths (varies by schema) - Progress (total, complete, remaining) - Task list with status - Dynamic instruction based on current state @@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `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 df8a2f2..5e91608 100644 --- a/.claude/commands/opsx/archive.md +++ b/.claude/commands/opsx/archive.md @@ -1,15 +1,12 @@ --- 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** @@ -29,7 +26,6 @@ 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 +48,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Check for delta specs at `openspec/changes//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 +63,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/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 `changeRoot` to the archive directory + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs All artifacts complete. All tasks complete. @@ -111,7 +107,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** No delta specs All artifacts complete. All tasks complete. @@ -124,7 +120,7 @@ All artifacts complete. All tasks complete. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** Sync skipped (user chose to skip) **Warnings:** @@ -141,7 +137,7 @@ Review the archive if this was not intentional. ## Archive Failed **Change:** -**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Target:** openspec/changes/archive/YYYY-MM-DD-/ Target archive directory already exists. diff --git a/.claude/commands/opsx/explore.md b/.claude/commands/opsx/explore.md index 7558a63..30d9c57 100644 --- a/.claude/commands/opsx/explore.md +++ b/.claude/commands/opsx/explore.md @@ -1,7 +1,6 @@ --- 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] --- @@ -12,8 +11,6 @@ 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" @@ -62,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, │ @@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -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`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -121,14 +119,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 8d99588..05276f4 100644 --- a/.claude/commands/opsx/propose.md +++ b/.claude/commands/opsx/propose.md @@ -1,7 +1,6 @@ --- 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] --- @@ -17,8 +16,6 @@ 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** @@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -45,7 +42,6 @@ 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 +59,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 - - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `outputPath`: Where 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 and write it to `resolvedOutputPath` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.claude/skills b/.claude/skills deleted file mode 120000 index 2b7a412..0000000 --- a/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../.agents/skills \ No newline at end of file diff --git a/.agents/skills/cmux-browser/SKILL.md b/.claude/skills/cmux-browser/SKILL.md similarity index 100% rename from .agents/skills/cmux-browser/SKILL.md rename to .claude/skills/cmux-browser/SKILL.md diff --git a/.agents/skills/cmux-browser/agents/openai.yaml b/.claude/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-browser/agents/openai.yaml rename to .claude/skills/cmux-browser/agents/openai.yaml diff --git a/.agents/skills/cmux-browser/references/authentication.md b/.claude/skills/cmux-browser/references/authentication.md similarity index 100% rename from .agents/skills/cmux-browser/references/authentication.md rename to .claude/skills/cmux-browser/references/authentication.md diff --git a/.agents/skills/cmux-browser/references/commands.md b/.claude/skills/cmux-browser/references/commands.md similarity index 100% rename from .agents/skills/cmux-browser/references/commands.md rename to .claude/skills/cmux-browser/references/commands.md diff --git a/.agents/skills/cmux-browser/references/proxy-support.md b/.claude/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .agents/skills/cmux-browser/references/proxy-support.md rename to .claude/skills/cmux-browser/references/proxy-support.md diff --git a/.agents/skills/cmux-browser/references/session-management.md b/.claude/skills/cmux-browser/references/session-management.md similarity index 100% rename from .agents/skills/cmux-browser/references/session-management.md rename to .claude/skills/cmux-browser/references/session-management.md diff --git a/.agents/skills/cmux-browser/references/snapshot-refs.md b/.claude/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .agents/skills/cmux-browser/references/snapshot-refs.md rename to .claude/skills/cmux-browser/references/snapshot-refs.md diff --git a/.agents/skills/cmux-browser/references/video-recording.md b/.claude/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .agents/skills/cmux-browser/references/video-recording.md rename to .claude/skills/cmux-browser/references/video-recording.md diff --git a/.agents/skills/cmux-browser/templates/authenticated-session.sh b/.claude/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/authenticated-session.sh rename to .claude/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.agents/skills/cmux-browser/templates/capture-workflow.sh b/.claude/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/capture-workflow.sh rename to .claude/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.agents/skills/cmux-browser/templates/form-automation.sh b/.claude/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .agents/skills/cmux-browser/templates/form-automation.sh rename to .claude/skills/cmux-browser/templates/form-automation.sh diff --git a/.agents/skills/cmux-debug-windows/SKILL.md b/.claude/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .agents/skills/cmux-debug-windows/SKILL.md rename to .claude/skills/cmux-debug-windows/SKILL.md diff --git a/.agents/skills/cmux-debug-windows/agents/openai.yaml b/.claude/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-debug-windows/agents/openai.yaml rename to .claude/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.agents/skills/cmux-markdown/SKILL.md b/.claude/skills/cmux-markdown/SKILL.md similarity index 100% rename from .agents/skills/cmux-markdown/SKILL.md rename to .claude/skills/cmux-markdown/SKILL.md diff --git a/.agents/skills/cmux-markdown/agents/openai.yaml b/.claude/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux-markdown/agents/openai.yaml rename to .claude/skills/cmux-markdown/agents/openai.yaml diff --git a/.agents/skills/cmux-markdown/references/commands.md b/.claude/skills/cmux-markdown/references/commands.md similarity index 100% rename from .agents/skills/cmux-markdown/references/commands.md rename to .claude/skills/cmux-markdown/references/commands.md diff --git a/.agents/skills/cmux-markdown/references/live-reload.md b/.claude/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .agents/skills/cmux-markdown/references/live-reload.md rename to .claude/skills/cmux-markdown/references/live-reload.md diff --git a/.agents/skills/cmux/SKILL.md b/.claude/skills/cmux/SKILL.md similarity index 100% rename from .agents/skills/cmux/SKILL.md rename to .claude/skills/cmux/SKILL.md diff --git a/.agents/skills/cmux/agents/openai.yaml b/.claude/skills/cmux/agents/openai.yaml similarity index 100% rename from .agents/skills/cmux/agents/openai.yaml rename to .claude/skills/cmux/agents/openai.yaml diff --git a/.agents/skills/cmux/references/handles-and-identify.md b/.claude/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .agents/skills/cmux/references/handles-and-identify.md rename to .claude/skills/cmux/references/handles-and-identify.md diff --git a/.agents/skills/cmux/references/panes-surfaces.md b/.claude/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .agents/skills/cmux/references/panes-surfaces.md rename to .claude/skills/cmux/references/panes-surfaces.md diff --git a/.agents/skills/cmux/references/trigger-flash-and-health.md b/.claude/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .agents/skills/cmux/references/trigger-flash-and-health.md rename to .claude/skills/cmux/references/trigger-flash-and-health.md diff --git a/.agents/skills/cmux/references/windows-workspaces.md b/.claude/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .agents/skills/cmux/references/windows-workspaces.md rename to .claude/skills/cmux/references/windows-workspaces.md diff --git a/.agents/skills/crit-cli/SKILL.md b/.claude/skills/crit-cli/SKILL.md similarity index 100% rename from .agents/skills/crit-cli/SKILL.md rename to .claude/skills/crit-cli/SKILL.md diff --git a/.agents/skills/openspec-apply-change/SKILL.md b/.claude/skills/openspec-apply-change/SKILL.md similarity index 82% rename from .agents/skills/openspec-apply-change/SKILL.md rename to .claude/skills/openspec-apply-change/SKILL.md index 1375861..d474dc1 100644 --- a/.agents/skills/openspec-apply-change/SKILL.md +++ b/.claude/skills/openspec-apply-change/SKILL.md @@ -1,19 +1,16 @@ --- 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.6.0" + generatedBy: "1.2.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** @@ -33,7 +30,6 @@ 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** @@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change. ``` This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs) + - Context 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 @@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change. 4. **Read context files** - Read every file path listed under `contextFiles` from the apply instructions output. + Read the files listed in `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/.agents/skills/openspec-archive-change/SKILL.md b/.claude/skills/openspec-archive-change/SKILL.md similarity index 75% rename from .agents/skills/openspec-archive-change/SKILL.md rename to .claude/skills/openspec-archive-change/SKILL.md index c0c169d..9b1f851 100644 --- a/.agents/skills/openspec-archive-change/SKILL.md +++ b/.claude/skills/openspec-archive-change/SKILL.md @@ -1,19 +1,16 @@ --- 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.6.0" + generatedBy: "1.2.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** @@ -33,7 +30,6 @@ 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`:** @@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow. 4. **Assess delta spec sync state** - Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt. + Check for delta specs at `openspec/changes//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` @@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow. 5. **Perform the archive** - Create an `archive` directory under `planningHome.changesDir` if it doesn't exist: + Create the archive directory if it doesn't exist: ```bash - mkdir -p "/archive" + mkdir -p openspec/changes/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 `changeRoot` to the archive directory + - If no: Move the change directory to archive ```bash - mv "" "/archive/YYYY-MM-DD-" + mv openspec/changes/ openspec/changes/archive/YYYY-MM-DD- ``` 6. **Display summary** @@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow. **Change:** **Schema:** -**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-/ +**Archived to:** openspec/changes/archive/YYYY-MM-DD-/ **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") All artifacts complete. All tasks complete. diff --git a/.agents/skills/openspec-explore/SKILL.md b/.claude/skills/openspec-explore/SKILL.md similarity index 84% rename from .agents/skills/openspec-explore/SKILL.md rename to .claude/skills/openspec-explore/SKILL.md index 771271a..ffa10ca 100644 --- a/.agents/skills/openspec-explore/SKILL.md +++ b/.claude/skills/openspec-explore/SKILL.md @@ -1,13 +1,12 @@ --- 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.6.0" + generatedBy: "1.2.0" --- Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. @@ -16,8 +15,6 @@ 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 @@ -59,10 +56,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, │ @@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer: If the user mentions a change or you detect one is relevant: -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`. +1. **Read existing artifacts for context** + - `openspec/changes//proposal.md` + - `openspec/changes//design.md` + - `openspec/changes//tasks.md` + - etc. 2. **Reference them naturally in conversation** - "Your design mentions using Redis, but we just realized SQLite fits better..." @@ -116,14 +114,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?" @@ -229,7 +227,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/.agents/skills/openspec-propose/SKILL.md b/.claude/skills/openspec-propose/SKILL.md similarity index 81% rename from .agents/skills/openspec-propose/SKILL.md rename to .claude/skills/openspec-propose/SKILL.md index 716d2d3..d27bc53 100644 --- a/.agents/skills/openspec-propose/SKILL.md +++ b/.claude/skills/openspec-propose/SKILL.md @@ -1,13 +1,12 @@ --- 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.6.0" + generatedBy: "1.2.0" --- Propose a new change - create the change and generate all artifacts in one step. @@ -21,8 +20,6 @@ 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** @@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply ```bash openspec new change "" ``` - This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. 3. **Get the artifact build order** ```bash @@ -49,7 +46,6 @@ 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** @@ -67,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 - - `resolvedOutputPath`: Resolved path or pattern to write the artifact + - `outputPath`: Where 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 and write it to `resolvedOutputPath` + - Create the artifact file using `template` as the structure - Apply `context` and `rules` as constraints - but do NOT copy them into the file - Show brief progress: "Created " diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 0d2af30..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -# 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 deleted file mode 100644 index 4b73c10..0000000 --- a/.env.development.example +++ /dev/null @@ -1,10 +0,0 @@ -# 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/dependabot.yml b/.github/dependabot.yml index f2e30a4..21f282c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,22 +20,10 @@ updates: 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. + # react-native is pinned by the Expo SDK — upgrade it via an Expo + # SDK bump, not on its own. Community react-native-* libraries are + # intentionally NOT ignored here; they can be bumped independently. - 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 a5a967e..24ab70f 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -13,15 +13,6 @@ 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 @@ -34,7 +25,7 @@ jobs: matrix: app: [journal, planner] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -57,12 +48,8 @@ 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 @@ -72,7 +59,7 @@ jobs: runs-on: ubuntu-latest environment: production steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -81,18 +68,6 @@ 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 @@ -100,7 +75,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/caddy" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" target: /opt/trails-cool strip_components: 1 @@ -121,10 +96,6 @@ 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 @@ -133,69 +104,15 @@ jobs: # Pull and deploy app containers docker compose --env-file app.env pull 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 + 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 - # 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 + # Clean up + docker image prune -af 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-) + # Annotate deploy in Grafana + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ diff --git a/.github/workflows/cd-brouter.yml b/.github/workflows/cd-brouter.yml index 5d28e86..bc2452f 100644 --- a/.github/workflows/cd-brouter.yml +++ b/.github/workflows/cd-brouter.yml @@ -5,7 +5,6 @@ on: branches: [main] paths: - "docker/brouter/**" - - "infrastructure/brouter-host/**" workflow_dispatch: {} concurrency: @@ -20,7 +19,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: docker/login-action@v4 with: @@ -37,87 +36,13 @@ jobs: ghcr.io/trails-cool/brouter:${{ github.sha }} deploy: - name: Deploy BRouter to dedicated host + name: Deploy BRouter needs: [build] runs-on: ubuntu-latest - environment: infra steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - - 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 + - name: Deploy via SSH uses: appleboy/ssh-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} @@ -125,7 +50,34 @@ jobs: key: ${{ secrets.DEPLOY_SSH_KEY }} script: | cd /opt/trails-cool - GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) + + # Download BRouter segments for Europe (W10-E40, N35-N70) + mkdir -p /opt/trails-cool/segments + EUROPE_TILES=" + W10_N50 W10_N55 W10_N60 W10_N65 + W5_N35 W5_N40 W5_N45 W5_N50 W5_N55 W5_N60 W5_N65 + E0_N35 E0_N40 E0_N45 E0_N50 E0_N55 E0_N60 E0_N65 E0_N70 + E5_N35 E5_N40 E5_N45 E5_N50 E5_N55 E5_N60 E5_N65 E5_N70 + E10_N35 E10_N40 E10_N45 E10_N50 E10_N55 E10_N60 E10_N65 E10_N70 + E15_N35 E15_N40 E15_N45 E15_N50 E15_N55 E15_N60 E15_N65 E15_N70 + E20_N35 E20_N40 E20_N45 E20_N50 E20_N55 E20_N60 E20_N65 E20_N70 + E25_N35 E25_N40 E25_N45 E25_N50 E25_N55 E25_N60 E25_N65 + E30_N35 E30_N40 E30_N45 E30_N50 E30_N55 E30_N60 E30_N65 + E35_N40 E35_N45 E35_N50 E35_N55 E35_N60 + E40_N40 E40_N45 E40_N50 E40_N55 + " + for tile in $EUROPE_TILES; do + [ -f "/opt/trails-cool/segments/${tile}.rd5" ] || \ + wget -q "https://brouter.de/brouter/segments4/${tile}.rd5" -O "/opt/trails-cool/segments/${tile}.rd5" 2>/dev/null || \ + rm -f "/opt/trails-cool/segments/${tile}.rd5" + done + + docker pull ghcr.io/trails-cool/brouter:latest + docker compose up -d brouter + docker image prune -af + + # Annotate deploy in Grafana + GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null) if [ -n "$GRAFANA_TOKEN" ]; then docker compose exec -T grafana curl -sf -X POST \ -H "Authorization: Bearer $GRAFANA_TOKEN" \ diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index df41b93..b1839c4 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@v7 + - uses: actions/checkout@v6 - name: Decrypt secrets run: | @@ -32,10 +32,6 @@ 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 @@ -43,7 +39,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - 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" + source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" target: /opt/trails-cool strip_components: 1 @@ -64,11 +60,6 @@ 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) @@ -84,77 +75,17 @@ jobs: 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 (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 + # Restart infra services (except Caddy — just reload its config) + docker compose --env-file .env up -d postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile 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 diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml deleted file mode 100644 index e69dec6..0000000 --- a/.github/workflows/cd-staging.yml +++ /dev/null @@ -1,520 +0,0 @@ -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 068ebc5..3240a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: contents: read pull-requests: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Gitleaks @@ -42,27 +42,14 @@ jobs: name: Dockerfile Package Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - 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@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -75,7 +62,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -88,7 +75,7 @@ jobs: name: Unit Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -101,7 +88,7 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -110,72 +97,6 @@ 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 @@ -183,7 +104,7 @@ jobs: env: DATABASE_URL: postgres://trails:trails@localhost:5432/trails steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: @@ -191,12 +112,64 @@ jobs: 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@v6 + uses: actions/cache@v5 with: path: /tmp/brouter-segments - key: brouter-segment-E10_N50-v1.7.9 + key: brouter-segment-E10_N50 - name: Download Berlin segment if: steps.segment-cache.outputs.cache-hit != 'true' @@ -204,54 +177,26 @@ jobs: mkdir -p /tmp/brouter-segments wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 - - name: Pre-seed BRouter segment volume + - name: Start BRouter run: | - 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 + 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 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@v6 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-${{ hashFiles('pnpm-lock.yaml') }} @@ -273,12 +218,6 @@ 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() }} @@ -316,87 +255,3 @@ 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 deleted file mode 100644 index 85e110f..0000000 --- a/.github/workflows/dependabot-auto-fix.yml +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index d8f6a09..0000000 --- a/.github/workflows/disk-maintenance.yml +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index bed2fd4..0000000 --- a/.github/workflows/staging-cleanup.yml +++ /dev/null @@ -1,112 +0,0 @@ -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 450df80..04b7ae3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,7 @@ 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 2109593..c525e98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with Full architecture: `docs/architecture.md` Philosophy: `docs/philosophy.md` -Roadmap: `docs/roadmap.md` -Ideas (pre-spec explorations): `docs/ideas/` -OpenSpec changes: `openspec/changes/` +OpenSpec change: `openspec/changes/phase-1-mvp/` ## Principles @@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/` - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Maps**: Leaflet + OpenStreetMap tiles - **CRDT**: Yjs + y-websocket (Planner only) -- **Federation**: Fedify (Journal only) +- **Federation**: Fedify (Journal only, Phase 2) - **Database**: PostgreSQL + PostGIS - **Media storage**: S3-compatible (Garage) - **Routing engine**: BRouter (Java, runs as separate Docker container) @@ -41,15 +39,11 @@ apps/ planner/ — Planner app (React Router 7) journal/ — Journal app (React Router 7 + Fedify) packages/ - 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 + types/ — Shared TypeScript interfaces (Route, Activity, Waypoint) + ui/ — Shared React components (Tailwind) + map/ — Leaflet map wrappers and tile layer configs 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 @@ -74,40 +68,6 @@ 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. @@ -123,8 +83,8 @@ local config symmetric. - **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) -- 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 +- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions +- Map components go in `@trails-cool/map`, not in individual apps - 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) @@ -183,89 +143,31 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a ## Deployment -Five CD workflows triggered by path or event: +Three separate CD workflows triggered by path: -| 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. +| Workflow | Triggers on | Deploys | +|----------|-------------|---------| +| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | +| `cd-infra.yml` | `infrastructure/` | caddy, postgres, prometheus, loki, grafana, exporters | +| `cd-brouter.yml` | `docker/brouter/` | brouter | ### 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). +All secrets are stored in SOPS-encrypted files (`infrastructure/secrets.app.env`, `infrastructure/secrets.infra.env`). Edit with `sops infrastructure/secrets.app.env`. Only `AGE_SECRET_KEY`, `DEPLOY_SSH_KEY`, and `DEPLOY_HOST` remain as GitHub secrets. ### Full restart -To restart **all** containers on the flagship (not just the ones a workflow normally touches): +To restart **all** containers (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 deleted file mode 100644 index e48200e..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,198 +0,0 @@ -# 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 deleted file mode 100644 index aa4b88f..0000000 --- a/FEDERATION.md +++ /dev/null @@ -1,198 +0,0 @@ -# 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 1bc4215..36f7493 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,6 @@ 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 deleted file mode 100644 index 8a51ed0..0000000 --- a/apps/journal/.env.example +++ /dev/null @@ -1,63 +0,0 @@ -# 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 306948b..d9f8e31 100644 --- a/apps/journal/Dockerfile +++ b/apps/journal/Dockerfile @@ -1,7 +1,5 @@ -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/* +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/* RUN npm install -g pnpm@10.6.5 WORKDIR /app @@ -9,29 +7,24 @@ 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 @@ -40,7 +33,6 @@ 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 diff --git a/apps/journal/app/components/AccountDropdown.tsx b/apps/journal/app/components/AccountDropdown.tsx deleted file mode 100644 index fd12a9e..0000000 --- a/apps/journal/app/components/AccountDropdown.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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/Avatar.tsx b/apps/journal/app/components/Avatar.tsx deleted file mode 100644 index cc74702..0000000 --- a/apps/journal/app/components/Avatar.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// 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 300b376..af210ae 100644 --- a/apps/journal/app/components/ClientDate.tsx +++ b/apps/journal/app/components/ClientDate.tsx @@ -3,14 +3,10 @@ import { useLocale } from "./LocaleContext"; /** * 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, withTime = false }: { iso: string; withTime?: boolean }) { +export function ClientDate({ iso }: { iso: string }) { const locale = useLocale(); - const d = new Date(iso); - const text = withTime - ? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" }) - : d.toLocaleDateString(locale); - return ; + return ( + + ); } diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx deleted file mode 100644 index 2632b44..0000000 --- a/apps/journal/app/components/CollectionPage.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index fbf02fc..0000000 --- a/apps/journal/app/components/ElevationProfile.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// @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 deleted file mode 100644 index 335a9b7..0000000 --- a/apps/journal/app/components/ElevationProfile.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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 deleted file mode 100644 index 78b40be..0000000 --- a/apps/journal/app/components/FollowButton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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/MobileNavMenu.tsx b/apps/journal/app/components/MobileNavMenu.tsx deleted file mode 100644 index cf81241..0000000 --- a/apps/journal/app/components/MobileNavMenu.tsx +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index f4077e0..0000000 --- a/apps/journal/app/components/ProfileStats.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// @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 deleted file mode 100644 index 349d18d..0000000 --- a/apps/journal/app/components/ProfileStats.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 index 1d03175..3ea476d 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -1,63 +1,9 @@ import { useEffect, useRef } from "react"; -import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet"; +import { MapContainer, TileLayer, GeoJSON, useMap } 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); @@ -134,35 +80,16 @@ interface RouteMapProps { 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) { +export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) { const data: GeoJsonObject = JSON.parse(geojson); return ( )} - - {hoverSeries && hoverSeries.length > 0 && onHoverIndex && ( - - )} - ); } @@ -220,7 +142,7 @@ function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObj type: "Feature", geometry: { type: "LineString", coordinates: seg.coords }, properties: {}, - } as GeoJsonObject; + } as unknown as GeoJsonObject; return ( = { - 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 deleted file mode 100644 index cdde2cf..0000000 --- a/apps/journal/app/components/StatRow.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// @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 deleted file mode 100644 index 8d8d33e..0000000 --- a/apps/journal/app/components/StatRow.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 521bbc0..0000000 --- a/apps/journal/app/components/SurfaceBreakdown.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// @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 deleted file mode 100644 index 868e713..0000000 --- a/apps/journal/app/components/SurfaceBreakdown.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 9ae069c..0000000 --- a/apps/journal/app/components/WeeklyDistanceChart.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// @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 deleted file mode 100644 index cfeec61..0000000 --- a/apps/journal/app/components/WeeklyDistanceChart.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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/hooks/useSurfaceBackfill.ts b/apps/journal/app/hooks/useSurfaceBackfill.ts deleted file mode 100644 index ea0aacc..0000000 --- a/apps/journal/app/hooks/useSurfaceBackfill.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 7286262..0000000 --- a/apps/journal/app/hooks/useUnreadNotifications.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 1f79e4b..0000000 --- a/apps/journal/app/jobs/backfill-user-keypairs.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 5be848c..0000000 --- a/apps/journal/app/jobs/consumed-jti-sweep.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 30eb001..0000000 --- a/apps/journal/app/jobs/deliver-activity.ts +++ /dev/null @@ -1,134 +0,0 @@ -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 index 7257a3d..9d6f67e 100644 --- a/apps/journal/app/jobs/demo-bot-generate.ts +++ b/apps/journal/app/jobs/demo-bot-generate.ts @@ -1,4 +1,4 @@ -import { defineJournalJob } from "./payloads.ts"; +import type { JobDefinition } from "@trails-cool/jobs"; import { DEMO_BACKFILL_TARGET, DEMO_DAILY_CAP, @@ -21,8 +21,8 @@ import { logger } from "../lib/logger.server.ts"; * 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", +export const demoBotGenerateJob: JobDefinition = { + name: "demo-bot:generate", cron: "0,30 * * * *", retryLimit: 1, expireInSeconds: 120, @@ -57,4 +57,4 @@ export const demoBotGenerateJob = defineJournalJob({ 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 index b065bf8..d3f4d31 100644 --- a/apps/journal/app/jobs/demo-bot-prune.ts +++ b/apps/journal/app/jobs/demo-bot-prune.ts @@ -1,4 +1,4 @@ -import { defineJournalJob } from "./payloads.ts"; +import type { JobDefinition } from "@trails-cool/jobs"; import { demoRetentionDays, isDemoBotEnabled, @@ -11,8 +11,8 @@ 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", +export const demoBotPruneJob: JobDefinition = { + name: "demo-bot:prune", cron: "15 3 * * *", retryLimit: 1, expireInSeconds: 60, @@ -24,4 +24,4 @@ export const demoBotPruneJob = defineJournalJob({ 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 deleted file mode 100644 index 8fe3980..0000000 --- a/apps/journal/app/jobs/federation-dedup-sweep.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 6bec691..0000000 --- a/apps/journal/app/jobs/federation-kv-sweep.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index b396150..0000000 --- a/apps/journal/app/jobs/garmin-import-activity.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 5bee88e..0000000 --- a/apps/journal/app/jobs/import-batches-sweep.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index bcf3d42..0000000 --- a/apps/journal/app/jobs/komoot-bulk-import.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index fa0c9a2..0000000 --- a/apps/journal/app/jobs/komoot-bulk-import.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index e25d011..0000000 --- a/apps/journal/app/jobs/notifications-fanout.integration.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 70caae9..0000000 --- a/apps/journal/app/jobs/notifications-fanout.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 625689c..0000000 --- a/apps/journal/app/jobs/notifications-purge.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 00e4844..0000000 --- a/apps/journal/app/jobs/payloads.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index e3ba254..0000000 --- a/apps/journal/app/jobs/payloads.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 18dc1d7..0000000 --- a/apps/journal/app/jobs/poll-remote-actor.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 1847ad2..0000000 --- a/apps/journal/app/jobs/poll-remote-outboxes.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 69551d3..0000000 --- a/apps/journal/app/jobs/send-welcome-email.ts +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 41caec3..0000000 --- a/apps/journal/app/jobs/surface-backfill.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 4087045..0000000 --- a/apps/journal/app/jobs/surface-backfill.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 6a97b80..f2ea007 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,134 +1,76 @@ import { randomUUID } from "node:crypto"; -import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm"; -import { unionAll } from "drizzle-orm/pg-core"; +import { eq, desc, and, sql } from "drizzle-orm"; import { getDb } from "./db.ts"; -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"; +import { activities, routes, syncImports } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { setGeomFromGpx } from "./routes.server.ts"; 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, + id: string, + ownerId: string, 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 + const result = 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; + .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))) + .returning({ id: activities.id }); + return result.length > 0; } export async function createActivity(ownerId: string, input: ActivityInput) { const db = getDb(); const id = randomUUID(); - 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 = input.startedAt ?? null; const duration: number | null = input.duration ?? null; - if (input.gpx) { - 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; + try { + const gpxData = await parseGpxAsync(input.gpx); + distance = gpxData.distance || distance; + elevationGain = gpxData.elevation.gain; + elevationLoss = gpxData.elevation.loss; + + if (!startedAt && gpxData.tracks[0]?.[0]?.time) { + startedAt = new Date(gpxData.tracks[0][0].time); + } + } catch { + // Continue without stats if GPX parsing fails + } } - 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); - } + await db.insert(activities).values({ + id, + ownerId, + routeId: input.routeId ?? null, + name: input.name, + description: input.description ?? "", + gpx: input.gpx, + distance, + duration, + elevationGain, + elevationLoss, + startedAt, }); - // 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}` }, - ); + if (input.gpx) { + await setGeomFromGpx(id, "activities", input.gpx); } return id; @@ -143,20 +85,11 @@ export async function getActivity(id: string) { return { ...activity, geojson, importSource }; } -export async function deleteActivity(ownedActivity: OwnedRef): Promise { - const { id, ownerId } = ownedActivity; +export async function deleteActivity(id: string, ownerId: string): Promise { const db = getDb(); - // 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) + const [activity] = await db.select({ id: activities.id }).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; } @@ -170,109 +103,13 @@ async function getImportSource(activityId: string): Promise<{ provider: string; 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 { +export async function listActivities(ownerId: string) { 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(order); + .orderBy(desc(activities.createdAt)); const ids = rows.map((r) => r.id); const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); @@ -284,181 +121,52 @@ export async function listActivities( * listings (the public profile page); never includes `unlisted` or * `private` content. */ -export async function listPublicActivitiesForOwner( - ownerId: string, - sort: "startedAt" | "addedAt" = "startedAt", - limit: number = 100, -) { +export async function listPublicActivitiesForOwner(ownerId: string) { 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); + .orderBy(desc(activities.createdAt)); const ids = rows.map((r) => r.id); const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } -/** - * 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) { +export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) { const db = getDb(); await db .update(activities) - .set({ routeId: ownedRoute.id }) - .where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId))); + .set({ routeId }) + .where(eq(activities.id, activityId)); } -export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise { - const { id: activityId, ownerId } = ownedActivity; +export async function createRouteFromActivity(activityId: string, ownerId: string): Promise { const db = getDb(); - const [activity] = await db - .select() - .from(activities) - .where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId))); + const [activity] = await db.select().from(activities).where(eq(activities.id, activityId)); if (!activity?.gpx) return null; - const { coords } = await processGpx(activity.gpx); const routeId = randomUUID(); - - 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)); + 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, }); + await setGeomFromGpx(routeId, "routes", activity.gpx); + + // Link the activity to the new route + await db + .update(activities) + .set({ routeId }) + .where(eq(activities.id, activityId)); + return routeId; } @@ -480,19 +188,13 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise`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); - } + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); } catch { // Fallback: no geojson } diff --git a/apps/journal/app/lib/actor-iri.ts b/apps/journal/app/lib/actor-iri.ts deleted file mode 100644 index 06da49d..0000000 --- a/apps/journal/app/lib/actor-iri.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 695086b..0000000 --- a/apps/journal/app/lib/api-guard.server.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 index 7ac8466..bf480b2 100644 --- a/apps/journal/app/lib/api-guard.server.ts +++ b/apps/journal/app/lib/api-guard.server.ts @@ -1,14 +1,8 @@ -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. + * Require authentication for an API route. Returns the user or throws a 401 Response. */ export async function requireApiUser(request: Request) { const user = await getAuthenticatedUser(request); @@ -18,16 +12,6 @@ export async function requireApiUser(request: Request) { { 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; } @@ -37,19 +21,3 @@ export async function requireApiUser(request: Request) { 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 a68aab9..cc006bc 100644 --- a/apps/journal/app/lib/auth.server.ts +++ b/apps/journal/app/lib/auth.server.ts @@ -12,16 +12,12 @@ 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 = getOrigin(); +const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`; // --- Registration --- @@ -95,27 +91,9 @@ export async function finishRegistration( 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) { @@ -176,7 +154,7 @@ export async function registerWithMagicLink( email: string, username: string, termsVersion: string, -): Promise<{ token: string; code: string }> { +): Promise { const db = getDb(); const [existingEmail] = await db.select().from(users).where(eq(users.email, email)); @@ -197,23 +175,18 @@ export async function registerWithMagicLink( 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). + // Create magic token for verification 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 }; + return token; } // --- Passkey Login --- @@ -277,16 +250,12 @@ function generateLoginCode(): string { return String(num).padStart(6, "0"); } -export async function createMagicToken( - email: string, -): Promise<{ token: string; code: string } | null> { +export async function createMagicToken(email: string): Promise<{ token: string; code: string }> { const db = getDb(); - // 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. + // Check user exists const [user] = await db.select().from(users).where(eq(users.email, email)); - if (!user) return null; + if (!user) throw new Error("No account found for this email"); const token = randomBytes(32).toString("base64url"); const code = generateLoginCode(); @@ -306,13 +275,9 @@ export async function createMagicToken( 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() }) + .select() + .from(magicTokens) .where( and( eq(magicTokens.email, email), @@ -321,11 +286,15 @@ export async function verifyLoginCode(email: string, code: string): Promise { const db = getDb(); - // Atomic consume — see verifyLoginCode for rationale. const [record] = await db - .update(magicTokens) - .set({ usedAt: new Date() }) + .select() + .from(magicTokens) .where( and( eq(magicTokens.token, token), @@ -370,11 +338,15 @@ export async function verifyMagicToken(token: string): Promise { gt(magicTokens.expiresAt, new Date()), isNull(magicTokens.usedAt), ), - ) - .returning(); + ); if (!record) throw new Error("Invalid or expired magic link"); + await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where(eq(magicTokens.id, record.id)); + const [user] = await db.select().from(users).where(eq(users.email, record.email)); if (!user) throw new Error("User not found"); @@ -384,14 +356,9 @@ export async function verifyMagicToken(token: string): Promise { export async function verifyEmailChange(token: string, userId: string): Promise { const db = getDb(); - // 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() }) + .select() + .from(magicTokens) .where( and( eq(magicTokens.token, token), @@ -399,20 +366,25 @@ export async function verifyEmailChange(token: string, userId: string): Promise< 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. + // registered with this email between initiation and verification const [existing] = await db.select().from(users).where(eq(users.email, newEmail)); if (existing) { + await db.update(magicTokens).set({ usedAt: new Date() }).where(eq(magicTokens.id, record.id)); throw new Error("This email is now in use by another account"); } + await db + .update(magicTokens) + .set({ usedAt: new Date() }) + .where(eq(magicTokens.id, record.id)); + await db .update(users) .set({ email: newEmail }) @@ -421,10 +393,23 @@ export async function verifyEmailChange(token: string, userId: string): Promise< return newEmail; } -// 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. +// --- 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], + }, +}); /** * A row that carries the minimum a visibility check needs. @@ -478,3 +463,23 @@ export async function recordTermsAcceptance(userId: string, termsVersion: string .where(eq(users.id, userId)); } +export async function createSession(userId: string, request: Request) { + const session = await sessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", userId); + 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; +} + +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/auth/completion.server.test.ts b/apps/journal/app/lib/auth/completion.server.test.ts deleted file mode 100644 index f5400b9..0000000 --- a/apps/journal/app/lib/auth/completion.server.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// 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 deleted file mode 100644 index cbf7dcf..0000000 --- a/apps/journal/app/lib/auth/completion.server.ts +++ /dev/null @@ -1,76 +0,0 @@ -// 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 deleted file mode 100644 index ff387a9..0000000 --- a/apps/journal/app/lib/auth/session.server.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 5c6edbd..0000000 --- a/apps/journal/app/lib/auth/session.server.ts +++ /dev/null @@ -1,75 +0,0 @@ -// 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 deleted file mode 100644 index 61c0182..0000000 --- a/apps/journal/app/lib/boss.server.ts +++ /dev/null @@ -1,109 +0,0 @@ -// 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/config.server.test.ts b/apps/journal/app/lib/config.server.test.ts deleted file mode 100644 index 37b9d11..0000000 --- a/apps/journal/app/lib/config.server.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index c4222bc..0000000 --- a/apps/journal/app/lib/config.server.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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 deleted file mode 100644 index f68bd26..0000000 --- a/apps/journal/app/lib/connected-services/credential-adapters/noop.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index ae0b707..0000000 --- a/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index 5e92dc3..0000000 --- a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts +++ /dev/null @@ -1,82 +0,0 @@ -// 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 deleted file mode 100644 index 317ceac..0000000 --- a/apps/journal/app/lib/connected-services/fit.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 0a684a2..0000000 --- a/apps/journal/app/lib/connected-services/fit.ts +++ /dev/null @@ -1,249 +0,0 @@ -// 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 deleted file mode 100644 index a3bb78c..0000000 --- a/apps/journal/app/lib/connected-services/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 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 deleted file mode 100644 index e8e8d28..0000000 --- a/apps/journal/app/lib/connected-services/manager.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -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 deleted file mode 100644 index ae95b42..0000000 --- a/apps/journal/app/lib/connected-services/manager.ts +++ /dev/null @@ -1,256 +0,0 @@ -// 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 deleted file mode 100644 index f627ebb..0000000 --- a/apps/journal/app/lib/connected-services/oauth-flow.server.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -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 deleted file mode 100644 index df67786..0000000 --- a/apps/journal/app/lib/connected-services/oauth-flow.server.ts +++ /dev/null @@ -1,128 +0,0 @@ -// 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 deleted file mode 100644 index ae0fb8a..0000000 --- a/apps/journal/app/lib/connected-services/oauth-state.server.ts +++ /dev/null @@ -1,59 +0,0 @@ -// 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 deleted file mode 100644 index 36d9f90..0000000 --- a/apps/journal/app/lib/connected-services/oauth-state.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// 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 deleted file mode 100644 index 9c1be9e..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// 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 deleted file mode 100644 index 1055a78..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts +++ /dev/null @@ -1,111 +0,0 @@ -// 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 deleted file mode 100644 index 955a191..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -// 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 deleted file mode 100644 index 0eff891..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts +++ /dev/null @@ -1,87 +0,0 @@ -// 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 deleted file mode 100644 index 531f181..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// 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 deleted file mode 100644 index c9a9398..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts +++ /dev/null @@ -1,120 +0,0 @@ -// 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 deleted file mode 100644 index c360717..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// 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 deleted file mode 100644 index aded436..0000000 --- a/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts +++ /dev/null @@ -1,154 +0,0 @@ -// 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 deleted file mode 100644 index ba54534..0000000 --- a/apps/journal/app/lib/connected-services/providers/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 deleted file mode 100644 index 5292962..0000000 --- a/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index 0d89f42..0000000 --- a/apps/journal/app/lib/connected-services/providers/komoot/importer.ts +++ /dev/null @@ -1,97 +0,0 @@ -// 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 deleted file mode 100644 index dd142e8..0000000 --- a/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 deleted file mode 100644 index d1980c0..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// 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 deleted file mode 100644 index 46c59bc..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts +++ /dev/null @@ -1,160 +0,0 @@ -// 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 deleted file mode 100644 index 8f5eaf2..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts +++ /dev/null @@ -1,130 +0,0 @@ -// 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 deleted file mode 100644 index 4bb2a30..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -// 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 deleted file mode 100644 index 365347b..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/pusher.ts +++ /dev/null @@ -1,217 +0,0 @@ -// 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 deleted file mode 100644 index 6d03966..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -// 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 deleted file mode 100644 index db05f14..0000000 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index eb17cd0..0000000 --- a/apps/journal/app/lib/connected-services/push-action.server.ts +++ /dev/null @@ -1,117 +0,0 @@ -// 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 deleted file mode 100644 index 3bac1c7..0000000 --- a/apps/journal/app/lib/connected-services/registry.ts +++ /dev/null @@ -1,170 +0,0 @@ -// 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 deleted file mode 100644 index 6dec18e..0000000 --- a/apps/journal/app/lib/connected-services/types.ts +++ /dev/null @@ -1,82 +0,0 @@ -// 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 deleted file mode 100644 index ce849e2..0000000 --- a/apps/journal/app/lib/crypto.server.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 8ede16d..0000000 --- a/apps/journal/app/lib/crypto.server.ts +++ /dev/null @@ -1,61 +0,0 @@ -// 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 7546fe0..8fafd8c 100644 --- a/apps/journal/app/lib/db.ts +++ b/apps/journal/app/lib/db.ts @@ -8,7 +8,3 @@ 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 index 9556fb3..7ed763a 100644 --- a/apps/journal/app/lib/demo-bot.integration.test.ts +++ b/apps/journal/app/lib/demo-bot.integration.test.ts @@ -82,26 +82,9 @@ describe.skipIf(!runIntegration)("demo-bot integration", () => { }); 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, - }); - }), + vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => STUB_GPX }), ); const ownerId = await ensureDemoUser(); diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 7917524..9b22296 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -4,10 +4,9 @@ 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 { setGeomFromGpx } from "./routes.server.ts"; import { TERMS_VERSION } from "./legal.ts"; +import { parseGpxAsync } from "@trails-cool/gpx"; import { logger } from "./logger.server.ts"; import { demoBotSyntheticActivitiesTotal, @@ -52,14 +51,14 @@ const PersonaSchema = z for (const loc of p.locales) { if (!p.content.names[loc]) { ctx.addIssue({ - code: "custom", + code: z.ZodIssueCode.custom, path: ["content", "names", loc], message: `missing name pool for declared locale '${loc}'`, }); } if (!p.content.descriptions[loc]) { ctx.addIssue({ - code: "custom", + code: z.ZodIssueCode.custom, path: ["content", "descriptions", loc], message: `missing description pool for declared locale '${loc}'`, }); @@ -81,7 +80,6 @@ export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ content: { names: { en: [ - // Original Bruno-the-park-inspector roster "Grunewald north-loop patrol", "Tiergarten perimeter audit", "Tempelhof runway inspection", @@ -94,37 +92,8 @@ export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ "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", @@ -137,39 +106,10 @@ export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ "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.", @@ -180,34 +120,8 @@ export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ "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.", @@ -218,31 +132,6 @@ export const DEFAULT_PERSONA: DemoPersona = Object.freeze({ "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.", ], }, }, @@ -390,11 +279,6 @@ export async function ensureDemoUser( 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, }) @@ -549,37 +433,10 @@ export type BrouterError = | { 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: [ @@ -588,7 +445,6 @@ export async function requestBrouterGpx( ], profile: "trekking", format: "gpx" as const, - sessionId, }; try { const resp = await fetch(url, { @@ -649,38 +505,59 @@ export async function generateOneWalk( const name = templateName(now, locale, persona); const description = templateDescription(now, locale, persona); - const { stats } = await processGpx(result.gpx); - const { distance, elevationGain, elevationLoss } = stats; + let distance: number; + let elevationGain: number; + let elevationLoss: number; + try { + const parsed = await parseGpxAsync(result.gpx); + distance = parsed.distance; + elevationGain = parsed.elevation.gain; + elevationLoss = parsed.elevation.loss; + } catch { + return null; + } if (!distance || distance < 500) return null; + const db = getDb(); + const routeId = randomUUID(); + const activityId = randomUUID(); + + await db.insert(routes).values({ + id: routeId, + ownerId, + name, + description, + gpx: result.gpx, + routingProfile: "trekking", + distance, + elevationGain, + elevationLoss, + visibility: "public", + synthetic: true, + }); + await setGeomFromGpx(routeId, "routes", result.gpx); + 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, + await db.insert(activities).values({ + id: activityId, + ownerId, routeId, + name, + description, + gpx: result.gpx, startedAt: now, duration: durationSeconds, distance, + elevationGain, + elevationLoss, visibility: "public", synthetic: true, }); + await setGeomFromGpx(activityId, "activities", result.gpx); return routeId; } diff --git a/apps/journal/app/lib/email.server.ts b/apps/journal/app/lib/email.server.ts index 3142685..3b5a856 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.ts"; +import { logger } from "./logger.server"; const FROM = process.env.SMTP_FROM ?? "trails.cool "; diff --git a/apps/journal/app/lib/events.server.ts b/apps/journal/app/lib/events.server.ts deleted file mode 100644 index 4f649da..0000000 --- a/apps/journal/app/lib/events.server.ts +++ /dev/null @@ -1,48 +0,0 @@ -// 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 deleted file mode 100644 index 93f916f..0000000 --- a/apps/journal/app/lib/explore.integration.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -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 deleted file mode 100644 index a604075..0000000 --- a/apps/journal/app/lib/explore.server.ts +++ /dev/null @@ -1,212 +0,0 @@ -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 deleted file mode 100644 index b45c7c0..0000000 --- a/apps/journal/app/lib/federation-blocklist.integration.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 8dbfa26..0000000 --- a/apps/journal/app/lib/federation-blocklist.server.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 21178c6..0000000 --- a/apps/journal/app/lib/federation-blocklist.server.ts +++ /dev/null @@ -1,54 +0,0 @@ -// 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 deleted file mode 100644 index 2d000ae..0000000 --- a/apps/journal/app/lib/federation-delivery.server.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 820242b..0000000 --- a/apps/journal/app/lib/federation-delivery.server.ts +++ /dev/null @@ -1,155 +0,0 @@ -// 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 deleted file mode 100644 index 9adcbee..0000000 --- a/apps/journal/app/lib/federation-inbox.integration.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 270314a..0000000 --- a/apps/journal/app/lib/federation-inbox.server.ts +++ /dev/null @@ -1,119 +0,0 @@ -// 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 deleted file mode 100644 index d5fcd98..0000000 --- a/apps/journal/app/lib/federation-ingest.integration.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index f4a77c2..0000000 --- a/apps/journal/app/lib/federation-ingest.server.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -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 deleted file mode 100644 index 51d2d54..0000000 --- a/apps/journal/app/lib/federation-ingest.server.ts +++ /dev/null @@ -1,372 +0,0 @@ -// 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 deleted file mode 100644 index 095e4c3..0000000 --- a/apps/journal/app/lib/federation-keys.server.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 3cd9c95..0000000 --- a/apps/journal/app/lib/federation-keys.server.ts +++ /dev/null @@ -1,98 +0,0 @@ -// 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 deleted file mode 100644 index 69807f1..0000000 --- a/apps/journal/app/lib/federation-kv.integration.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index fdc9b87..0000000 --- a/apps/journal/app/lib/federation-kv.server.ts +++ /dev/null @@ -1,81 +0,0 @@ -// 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 deleted file mode 100644 index 7b30df7..0000000 --- a/apps/journal/app/lib/federation-objects.server.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 deleted file mode 100644 index 6981c10..0000000 --- a/apps/journal/app/lib/federation-objects.server.ts +++ /dev/null @@ -1,126 +0,0 @@ -// 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 deleted file mode 100644 index dc65e55..0000000 --- a/apps/journal/app/lib/federation-outbound.integration.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index 63201bd..0000000 --- a/apps/journal/app/lib/federation-outbound.server.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 983129b..0000000 --- a/apps/journal/app/lib/federation-outbound.server.ts +++ /dev/null @@ -1,300 +0,0 @@ -// 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 deleted file mode 100644 index da52de4..0000000 --- a/apps/journal/app/lib/federation-outbox.integration.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -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 deleted file mode 100644 index 6f9dee0..0000000 --- a/apps/journal/app/lib/federation-outbox.server.ts +++ /dev/null @@ -1,45 +0,0 @@ -// 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 deleted file mode 100644 index dc9600f..0000000 --- a/apps/journal/app/lib/federation-queue.server.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index d35a681..0000000 --- a/apps/journal/app/lib/federation-queue.server.ts +++ /dev/null @@ -1,77 +0,0 @@ -// 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 deleted file mode 100644 index d55bd87..0000000 --- a/apps/journal/app/lib/federation-replay.integration.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index f4e4958..0000000 --- a/apps/journal/app/lib/federation-replay.server.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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 deleted file mode 100644 index d8ad1ee..0000000 --- a/apps/journal/app/lib/federation-two-instance.integration.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -// 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 deleted file mode 100644 index 294ab08..0000000 --- a/apps/journal/app/lib/federation.server.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index b93b8ac..0000000 --- a/apps/journal/app/lib/federation.server.ts +++ /dev/null @@ -1,479 +0,0 @@ -// 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 deleted file mode 100644 index 2030eba..0000000 --- a/apps/journal/app/lib/follow.integration.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 1ac3250..0000000 --- a/apps/journal/app/lib/follow.server.ts +++ /dev/null @@ -1,415 +0,0 @@ -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 deleted file mode 100644 index 3113406..0000000 --- a/apps/journal/app/lib/gpx-save.server.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index 7431a39..0000000 --- a/apps/journal/app/lib/gpx-save.server.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 3e66d32..0000000 --- a/apps/journal/app/lib/http.server.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index f22bd25..0000000 --- a/apps/journal/app/lib/http.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -// 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 b7e1637..5bf62a4 100644 --- a/apps/journal/app/lib/jwt.server.ts +++ b/apps/journal/app/lib/jwt.server.ts @@ -1,14 +1,10 @@ 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( - requireSecret("JWT_SECRET", "dev-jwt-secret-change-in-production"), + process.env.JWT_SECRET ?? "dev-jwt-secret-change-in-production", ); -const ISSUER = getOrigin(); +const ISSUER = process.env.ORIGIN ?? "http://localhost:3000"; export async function createRouteToken(routeId: string, permissions: string[] = ["read", "write"]): Promise { return new SignJWT({ route_id: routeId, permissions }) @@ -16,57 +12,14 @@ 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 deleted file mode 100644 index defb7a4..0000000 --- a/apps/journal/app/lib/komoot-bulk-import.server.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 9e5e3a9..0000000 --- a/apps/journal/app/lib/komoot.server.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index 9bbc75d..0000000 --- a/apps/journal/app/lib/komoot.server.ts +++ /dev/null @@ -1,190 +0,0 @@ -// 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 index fc2a9be..9105096 100644 --- a/apps/journal/app/lib/legal.ts +++ b/apps/journal/app/lib/legal.ts @@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19"; * require re-acceptance (the policy is informational, not contract), so this * is display-only — not persisted. */ -export const PRIVACY_LAST_UPDATED = "2026-06-07"; +export const PRIVACY_LAST_UPDATED = "2026-04-20"; diff --git a/apps/journal/app/lib/logger.server.test.ts b/apps/journal/app/lib/logger.server.test.ts deleted file mode 100644 index 34b028b..0000000 --- a/apps/journal/app/lib/logger.server.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 7fafe99..a0454ef 100644 --- a/apps/journal/app/lib/logger.server.ts +++ b/apps/journal/app/lib/logger.server.ts @@ -1,27 +1,7 @@ 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 deleted file mode 100644 index 40681de..0000000 --- a/apps/journal/app/lib/metrics.server.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -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 9ea2181..300bfab 100644 --- a/apps/journal/app/lib/metrics.server.ts +++ b/apps/journal/app/lib/metrics.server.ts @@ -48,177 +48,4 @@ export const demoBotSyntheticActivitiesTotal = getOrCreate( }), ); -// --- 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 deleted file mode 100644 index 2d4d5d5..0000000 --- a/apps/journal/app/lib/notifications.integration.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index 8991d1e..0000000 --- a/apps/journal/app/lib/notifications.server.ts +++ /dev/null @@ -1,269 +0,0 @@ -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 deleted file mode 100644 index 1c6a744..0000000 --- a/apps/journal/app/lib/notifications/link-for.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index d199673..0000000 --- a/apps/journal/app/lib/notifications/link-for.ts +++ /dev/null @@ -1,66 +0,0 @@ -// 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 deleted file mode 100644 index f7e4742..0000000 --- a/apps/journal/app/lib/notifications/payload.ts +++ /dev/null @@ -1,70 +0,0 @@ -// 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.ts b/apps/journal/app/lib/oauth.server.ts index d1cd525..a4c8c30 100644 --- a/apps/journal/app/lib/oauth.server.ts +++ b/apps/journal/app/lib/oauth.server.ts @@ -7,7 +7,7 @@ import { oauthCodes, oauthTokens, } from "@trails-cool/db/schema/journal"; -import { getSessionUser } from "./auth/session.server.ts"; +import { getSessionUser } from "./auth.server.ts"; const CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes const ACCESS_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour diff --git a/apps/journal/app/lib/overpass-ways.server.ts b/apps/journal/app/lib/overpass-ways.server.ts deleted file mode 100644 index 5ef404c..0000000 --- a/apps/journal/app/lib/overpass-ways.server.ts +++ /dev/null @@ -1,84 +0,0 @@ -// 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 deleted file mode 100644 index 3104d71..0000000 --- a/apps/journal/app/lib/ownership.server.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index aa5f7ba..0000000 --- a/apps/journal/app/lib/ownership.server.ts +++ /dev/null @@ -1,88 +0,0 @@ -// 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 deleted file mode 100644 index 4c06fa9..0000000 --- a/apps/journal/app/lib/rate-limit.server.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index b818fe5..0000000 --- a/apps/journal/app/lib/rate-limit.server.ts +++ /dev/null @@ -1,95 +0,0 @@ -// 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 4e2a31a..1d6f13c 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,12 +1,10 @@ import { randomUUID } from "node:crypto"; -import { eq, desc, and, inArray, isNotNull } from "drizzle-orm"; +import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; -import type { Visibility, SurfaceBreakdown } from "@trails-cool/db/schema/journal"; +import type { Visibility } from "@trails-cool/db/schema/journal"; +import { parseGpxAsync } from "@trails-cool/gpx"; import { sql } from "drizzle-orm"; -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; @@ -14,63 +12,53 @@ export interface RouteInput { 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 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 ?? []; - + let distance: number | null = null; + let elevationGain: number | null = null; + let elevationLoss: number | null = null; + let dayBreaks: number[] = []; if (input.gpx) { - 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); - } + const stats = await computeRouteStats(input.gpx); + distance = stats.distance; + elevationGain = stats.elevationGain; + elevationLoss = stats.elevationLoss; + dayBreaks = stats.dayBreaks; } - await db.transaction(async (tx) => { - await tx.insert(routes).values({ - id, - ownerId, - name: input.name, - description: input.description ?? "", - gpx: input.gpx, - 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", - }); - } + await db.insert(routes).values({ + id, + ownerId, + name: input.name, + description: input.description ?? "", + gpx: input.gpx, + routingProfile: input.routingProfile, + distance, + elevationGain, + elevationLoss, + dayBreaks, }); + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } + + // Create initial version if GPX provided + if (input.gpx) { + await db.insert(routeVersions).values({ + id: randomUUID(), + routeId: id, + version: 1, + gpx: input.gpx, + createdBy: ownerId, + changeDescription: "Initial version", + }); + } + return id; } @@ -104,6 +92,7 @@ export async function listRoutes(ownerId: string) { .where(eq(routes.ownerId, ownerId)) .orderBy(desc(routes.updatedAt)); + // Batch-fetch simplified GeoJSON for list thumbnails 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 })); @@ -113,38 +102,34 @@ export async function listRoutes(ownerId: string) { * 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) { +export async function listPublicRoutesForOwner(ownerId: string) { const db = getDb(); const rows = await db .select() .from(routes) .where(and(eq(routes.ownerId, ownerId), eq(routes.visibility, "public"))) - .orderBy(desc(routes.updatedAt)) - .limit(limit); + .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(route: OwnedRef, input: Partial) { - const { id, ownerId } = route; +export async function updateRoute( + id: string, + ownerId: string, + input: Partial, +) { 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 && processed) { - const { stats } = processed; + if (input.gpx) { updateData.gpx = input.gpx; + const stats = await computeRouteStats(input.gpx); updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; @@ -152,47 +137,79 @@ export async function updateRoute(route: OwnedRef, input: Partial) { if (stats.description && input.description === undefined) { updateData.description = stats.description; } + + // 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, + }); } - await db.transaction(async (tx) => { - await tx - .update(routes) - .set(updateData) - .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + await db + .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, - }); - } - }); + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } } -export async function deleteRoute(route: OwnedRef) { +export async function deleteRoute(id: string, ownerId: string) { 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, route.id), eq(routes.ownerId, route.ownerId))) + .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))) .returning({ id: routes.id }); return result.length > 0; } +async function computeRouteStats(gpxString: string) { + try { + const gpxData = await parseGpxAsync(gpxString); + const dayBreaks = gpxData.waypoints + .map((w, i) => (w.isDayBreak ? i : -1)) + .filter((i) => i >= 0); + return { + distance: gpxData.distance, + elevationGain: gpxData.elevation.gain, + elevationLoss: gpxData.elevation.loss, + dayBreaks, + description: gpxData.description, + }; + } catch { + return { distance: null, elevationGain: null, elevationLoss: null, dayBreaks: [] as number[], description: undefined }; + } +} + +async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxString: string) { + try { + const gpxData = await parseGpxAsync(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length < 2) return; + const geojson = JSON.stringify({ type: "LineString", coordinates: coords }); + const db = getDb(); + await db.execute( + sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`, + ); + } catch (e) { + console.error(`Failed to set geom for ${table}/${id}:`, e); + } +} + +export { setGeomFromGpx }; + async function getGeojson(table: "routes" | "activities", id: string): Promise { try { const db = getDb(); @@ -211,19 +228,14 @@ async function getSimplifiedGeojsonBatch(ids: string[]): Promise`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); - } + // Fetch individually — Drizzle's sql template doesn't handle array params well with ANY() + await Promise.all(ids.map(async (id) => { + const result = await db.execute( + sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.routes WHERE id = ${id} AND geom IS NOT NULL`, + ); + const row = (result as unknown as Array<{ geojson: string }>)[0]; + if (row?.geojson) map.set(id, row.geojson); + })); } catch { // Fallback: no geojson } diff --git a/apps/journal/app/lib/sentry.client.ts b/apps/journal/app/lib/sentry.client.ts index 8ecaf65..f4ea454 100644 --- a/apps/journal/app/lib/sentry.client.ts +++ b/apps/journal/app/lib/sentry.client.ts @@ -5,20 +5,12 @@ 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, + dsn: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728", integrations: [ Sentry.reactRouterV7BrowserTracingIntegration({ useEffect, diff --git a/apps/journal/app/lib/social-feed.integration.test.ts b/apps/journal/app/lib/social-feed.integration.test.ts deleted file mode 100644 index f24774a..0000000 --- a/apps/journal/app/lib/social-feed.integration.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index df35644..0000000 --- a/apps/journal/app/lib/sport-type.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 5317801..0000000 --- a/apps/journal/app/lib/sport-type.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index a0ab68c..0000000 --- a/apps/journal/app/lib/stats.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index b2d68fa..0000000 --- a/apps/journal/app/lib/stats.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index af0f195..0000000 --- a/apps/journal/app/lib/surface-match.server.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 6d17a8d..0000000 --- a/apps/journal/app/lib/surface-match.server.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts new file mode 100644 index 0000000..4e47594 --- /dev/null +++ b/apps/journal/app/lib/sync/connections.server.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { eq, and } from "drizzle-orm"; +import { getDb } from "../db.ts"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import type { TokenSet } from "./types.ts"; + +export async function saveConnection( + userId: string, + provider: string, + tokens: TokenSet, +) { + const db = getDb(); + // Upsert: delete existing connection for this user+provider, then insert + await db + .delete(syncConnections) + .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); + await db.insert(syncConnections).values({ + id: randomUUID(), + userId, + provider, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + providerUserId: tokens.providerUserId ?? null, + }); +} + +export async function getConnection(userId: string, provider: string) { + const db = getDb(); + const [conn] = await db + .select() + .from(syncConnections) + .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); + return conn ?? null; +} + +export async function getConnectionByProviderUser(provider: string, providerUserId: string) { + const db = getDb(); + const [conn] = await db + .select() + .from(syncConnections) + .where( + and(eq(syncConnections.provider, provider), eq(syncConnections.providerUserId, providerUserId)), + ); + return conn ?? null; +} + +export async function updateTokens( + connectionId: string, + tokens: TokenSet, +) { + const db = getDb(); + await db + .update(syncConnections) + .set({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, + }) + .where(eq(syncConnections.id, connectionId)); +} + +export async function deleteConnection(userId: string, provider: string) { + const db = getDb(); + await db + .delete(syncConnections) + .where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider))); +} diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts index 18c4116..2be87c1 100644 --- a/apps/journal/app/lib/sync/imports.server.ts +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -2,8 +2,6 @@ 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, @@ -45,38 +43,6 @@ export async function deleteImportByActivity(activityId: string) { 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, diff --git a/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit b/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit new file mode 100644 index 0000000..94e96ef Binary files /dev/null and b/apps/journal/app/lib/sync/providers/__fixtures__/wahoo-ride.fit differ diff --git a/apps/journal/app/lib/sync/providers/wahoo.test.ts b/apps/journal/app/lib/sync/providers/wahoo.test.ts new file mode 100644 index 0000000..412ff05 --- /dev/null +++ b/apps/journal/app/lib/sync/providers/wahoo.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, it, expect } from "vitest"; +import { wahooProvider } from "./wahoo"; + +const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit"); +const fitBuffer = readFileSync(fixturePath); + +describe("wahooProvider.convertToGpx", () => { + it("converts a FIT file to valid GPX with correct coordinates", async () => { + const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); + + expect(gpx).not.toBeNull(); + expect(gpx).toContain(' { + const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); + expect(gpx).not.toBeNull(); + + const latMatches = gpx!.matchAll(/lat="([^"]+)"/g); + const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g); + + const lats = [...latMatches].map((m) => parseFloat(m[1]!)); + const lons = [...lonMatches].map((m) => parseFloat(m[1]!)); + + expect(lats.length).toBeGreaterThan(10); + expect(lons.length).toBeGreaterThan(10); + + for (const lat of lats) { + expect(lat).toBeGreaterThan(-90); + expect(lat).toBeLessThan(90); + // Should be real-world coords, not near-zero from double-conversion + expect(Math.abs(lat)).toBeGreaterThan(1); + } + for (const lon of lons) { + expect(lon).toBeGreaterThan(-180); + expect(lon).toBeLessThan(180); + } + }); + + it("includes ISO 8601 timestamps (not Date objects)", async () => { + const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer)); + expect(gpx).not.toBeNull(); + + const timeMatches = gpx!.matchAll(/