Compare commits

..

1 commit

Author SHA1 Message Date
Ullrich Schäfer
888c706af1
experiment: let Playwright run with default parallel workers on CI
Previously `workers: process.env.CI ? 1 : undefined` pinned CI to a
single worker. PR #365 added a hydration helper that was supposed to
make workers=1 unnecessary; this PR validates that on real CI by
letting Playwright default to logical CPU count (~2 on standard
GitHub Actions runners).

If CI stays green here across the auth + journal + planner +
public-content projects, the speedup is essentially free. If flaky,
revert and we know we have more hydration work to do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 02:42:21 +02:00
811 changed files with 12132 additions and 42909 deletions

View file

@ -1,15 +1,12 @@
--- ---
name: "OPSX: Apply" name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental) description: Implement tasks from an OpenSpec change (Experimental)
allowed-tools: Bash(openspec:*)
category: Workflow category: Workflow
tags: [workflow, artifacts, experimental] tags: [workflow, artifacts, experimental]
--- ---
Implement tasks from an OpenSpec change. 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 <id>` 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. **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** **Steps**
@ -29,7 +26,6 @@ Implement tasks from an OpenSpec change.
``` ```
Parse the JSON to understand: Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven") - `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) - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions** 3. **Get apply instructions**
@ -39,7 +35,7 @@ Implement tasks from an OpenSpec change.
``` ```
This returns: This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) - Context file paths (varies by schema)
- Progress (total, complete, remaining) - Progress (total, complete, remaining)
- Task list with status - Task list with status
- Dynamic instruction based on current state - Dynamic instruction based on current state
@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files** 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: The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks - **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output - Other schemas: follow the contextFiles from CLI output

View file

@ -1,15 +1,12 @@
--- ---
name: "OPSX: Archive" name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow description: Archive a completed change in the experimental workflow
allowed-tools: Bash(openspec:*)
category: Workflow category: Workflow
tags: [workflow, archive, experimental] tags: [workflow, archive, experimental]
--- ---
Archive a completed change in the experimental workflow. 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 <id>` 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. **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** **Steps**
@ -29,7 +26,6 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand: Parse the JSON to understand:
- `schemaName`: The workflow being used - `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other) - `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:** **If any artifacts are not `done`:**
@ -52,7 +48,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state** 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/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:** **If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md` - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -67,19 +63,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive** 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 ```bash
mkdir -p "<planningHome.changesDir>/archive" mkdir -p openspec/changes/archive
``` ```
Generate target name using current date: `YYYY-MM-DD-<change-name>` Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:** **Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date - 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 ```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>" mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
``` ```
6. **Display summary** 6. **Display summary**
@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name> **Change:** <change-name>
**Schema:** <schema-name> **Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/ **Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs **Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete. All artifacts complete. All tasks complete.
@ -111,7 +107,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name> **Change:** <change-name>
**Schema:** <schema-name> **Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/ **Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs **Specs:** No delta specs
All artifacts complete. All tasks complete. All artifacts complete. All tasks complete.
@ -124,7 +120,7 @@ All artifacts complete. All tasks complete.
**Change:** <change-name> **Change:** <change-name>
**Schema:** <schema-name> **Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/ **Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip) **Specs:** Sync skipped (user chose to skip)
**Warnings:** **Warnings:**
@ -141,7 +137,7 @@ Review the archive if this was not intentional.
## Archive Failed ## Archive Failed
**Change:** <change-name> **Change:** <change-name>
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/ **Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists. Target archive directory already exists.

View file

@ -1,7 +1,6 @@
--- ---
name: "OPSX: Explore" name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements" description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
allowed-tools: Bash(openspec:*)
category: Workflow category: Workflow
tags: [workflow, explore, experimental, thinking] 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. **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 <id>` 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: **Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration" - A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy" - A specific problem: "the auth system is getting unwieldy"
@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant: If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context** 1. **Read existing artifacts for context**
- Run `openspec status --change "<name>" --json`. - `openspec/changes/<name>/proposal.md`
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - `openspec/changes/<name>/design.md`
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`. - `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation** 2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..." - "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -122,7 +120,7 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made** 3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture | | Insight Type | Where to Capture |
|----------------------------|--------------------------------| |--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` | | New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` | | Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` | | Design decision made | `design.md` |

View file

@ -1,7 +1,6 @@
--- ---
name: "OPSX: Propose" name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step description: Propose a new change - create it and generate all artifacts in one step
allowed-tools: Bash(openspec:*)
category: Workflow category: Workflow
tags: [workflow, artifacts, experimental] 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 <id>` 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. **Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps** **Steps**
@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply
```bash ```bash
openspec new change "<name>" openspec new change "<name>"
``` ```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order** 3. **Get the artifact build order**
```bash ```bash
@ -45,7 +42,6 @@ When ready to implement, run /opsx:apply
Parse the JSON to get: Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies - `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** 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) - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file - `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type - `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 - `dependencies`: Completed artifacts to read for context
- Read any completed dependency files 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 - Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>" - Show brief progress: "Created <artifact-id>"

View file

@ -1 +0,0 @@
../.agents/skills

View file

@ -1,19 +1,16 @@
--- ---
name: openspec-apply-change 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. 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 license: MIT
compatibility: Requires openspec CLI. compatibility: Requires openspec CLI.
metadata: metadata:
author: openspec author: openspec
version: "1.0" version: "1.0"
generatedBy: "1.6.0" generatedBy: "1.2.0"
--- ---
Implement tasks from an OpenSpec change. 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 <id>` 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. **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** **Steps**
@ -33,7 +30,6 @@ Implement tasks from an OpenSpec change.
``` ```
Parse the JSON to understand: Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven") - `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) - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions** 3. **Get apply instructions**
@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change.
``` ```
This returns: 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) - Progress (total, complete, remaining)
- Task list with status - Task list with status
- Dynamic instruction based on current state - Dynamic instruction based on current state
@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change.
4. **Read context files** 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: The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks - **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output - Other schemas: follow the contextFiles from CLI output

View file

@ -1,19 +1,16 @@
--- ---
name: openspec-archive-change 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. 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 license: MIT
compatibility: Requires openspec CLI. compatibility: Requires openspec CLI.
metadata: metadata:
author: openspec author: openspec
version: "1.0" version: "1.0"
generatedBy: "1.6.0" generatedBy: "1.2.0"
--- ---
Archive a completed change in the experimental workflow. 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 <id>` 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. **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** **Steps**
@ -33,7 +30,6 @@ Archive a completed change in the experimental workflow.
Parse the JSON to understand: Parse the JSON to understand:
- `schemaName`: The workflow being used - `schemaName`: The workflow being used
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
- `artifacts`: List of artifacts with their status (`done` or other) - `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:** **If any artifacts are not `done`:**
@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow.
4. **Assess delta spec sync state** 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/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:** **If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md` - Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow.
5. **Perform the archive** 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 ```bash
mkdir -p "<planningHome.changesDir>/archive" mkdir -p openspec/changes/archive
``` ```
Generate target name using current date: `YYYY-MM-DD-<change-name>` Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:** **Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date - 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 ```bash
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>" mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
``` ```
6. **Display summary** 6. **Display summary**
@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow.
**Change:** <change-name> **Change:** <change-name>
**Schema:** <schema-name> **Schema:** <schema-name>
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/ **Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped") **Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete. All artifacts complete. All tasks complete.

View file

@ -1,13 +1,12 @@
--- ---
name: openspec-explore 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. 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 license: MIT
compatibility: Requires openspec CLI. compatibility: Requires openspec CLI.
metadata: metadata:
author: openspec author: openspec
version: "1.0" version: "1.0"
generatedBy: "1.6.0" generatedBy: "1.2.0"
--- ---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. 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. **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 <id>` 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 ## The Stance
@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer:
If the user mentions a change or you detect one is relevant: If the user mentions a change or you detect one is relevant:
1. **Resolve and read existing artifacts for context** 1. **Read existing artifacts for context**
- Run `openspec status --change "<name>" --json`. - `openspec/changes/<name>/proposal.md`
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON. - `openspec/changes/<name>/design.md`
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`. - `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation** 2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..." - "Your design mentions using Redis, but we just realized SQLite fits better..."
@ -117,7 +115,7 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made** 3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture | | Insight Type | Where to Capture |
|----------------------------|--------------------------------| |--------------|------------------|
| New requirement discovered | `specs/<capability>/spec.md` | | New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` | | Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` | | Design decision made | `design.md` |

View file

@ -1,13 +1,12 @@
--- ---
name: openspec-propose 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. 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 license: MIT
compatibility: Requires openspec CLI. compatibility: Requires openspec CLI.
metadata: metadata:
author: openspec author: openspec
version: "1.0" 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. 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 <id>` 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. **Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps** **Steps**
@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply
```bash ```bash
openspec new change "<name>" openspec new change "<name>"
``` ```
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`. This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order** 3. **Get the artifact build order**
```bash ```bash
@ -49,7 +46,6 @@ When ready to implement, run /opsx:apply
Parse the JSON to get: Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies - `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** 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) - `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file - `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type - `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 - `dependencies`: Completed artifacts to read for context
- Read any completed dependency files 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 - Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>" - Show brief progress: "Created <artifact-id>"

View file

@ -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

View file

@ -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

View file

@ -20,22 +20,10 @@ updates:
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
- dependency-name: "@types/node" - dependency-name: "@types/node"
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
# These packages are version-pinned by the Expo SDK (see # react-native is pinned by the Expo SDK — upgrade it via an Expo
# expo/bundledNativeModules.json) — upgrade them via an Expo SDK # SDK bump, not on its own. Community react-native-* libraries are
# bump / `npx expo install --fix`, never on their own. Dependabot # intentionally NOT ignored here; they can be bumped independently.
# bumping them past the SDK's expected version broke the native
# build (expo-modules-core macro mismatch, June 2026) because CI
# never compiles native code. `react` stays unignored: the web
# apps own its version via the workspace catalog, and apps/mobile
# excludes it from expo version checks.
- dependency-name: "react-native" - dependency-name: "react-native"
- 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" - package-ecosystem: "github-actions"
directory: "/" directory: "/"

View file

@ -13,15 +13,6 @@ concurrency:
group: deploy-apps group: deploy-apps
cancel-in-progress: true 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: jobs:
build-images: build-images:
name: Build & Push Docker Images name: Build & Push Docker Images
@ -34,7 +25,7 @@ jobs:
matrix: matrix:
app: [journal, planner] app: [journal, planner]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: docker/login-action@v4 - uses: docker/login-action@v4
with: with:
@ -57,12 +48,8 @@ jobs:
tags: | tags: |
ghcr.io/trails-cool/${{ matrix.app }}:latest ghcr.io/trails-cool/${{ matrix.app }}:latest
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }} 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: | build-args: |
SENTRY_RELEASE=${{ github.sha }} SENTRY_RELEASE=${{ github.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: | secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -72,7 +59,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Decrypt secrets - name: Decrypt secrets
run: | run: |
@ -83,16 +70,6 @@ jobs:
echo "DOMAIN=trails.cool" >> infrastructure/app.env echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates. # Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env 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 - name: Copy files to server
uses: appleboy/scp-action@v1 uses: appleboy/scp-action@v1
@ -100,7 +77,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.yml,infrastructure/caddy" source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
target: /opt/trails-cool target: /opt/trails-cool
strip_components: 1 strip_components: 1
@ -121,10 +98,6 @@ jobs:
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | 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 cd /opt/trails-cool
# Login to ghcr.io # Login to ghcr.io
@ -136,34 +109,11 @@ jobs:
# Hand-written data migrations (idempotent) run BEFORE drizzle-kit # Hand-written data migrations (idempotent) run BEFORE drizzle-kit
# push so unique-key reshapes can collapse duplicate rows first. # 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 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 docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# 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 # --remove-orphans cleans up containers whose service was deleted
# from the compose file, matching cd-infra's behaviour. # 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 up -d --remove-orphans 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 # Reload Caddy with the Caddyfile we just scp'd. cd-apps
# ships infrastructure/Caddyfile alongside docker-compose.yml # ships infrastructure/Caddyfile alongside docker-compose.yml
# (see scp step above), but containers don't auto-pick-up # (see scp step above), but containers don't auto-pick-up
@ -177,13 +127,8 @@ jobs:
# deploy from failing if Caddy itself is unhealthy. # deploy from failing if Caddy itself is unhealthy.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
# Clean up (best-effort). This runs AFTER the containers are # Clean up
# swapped + Caddy reloaded, so the deploy already succeeded — docker image prune -af
# a transient "a prune operation is already running" collision
# with a concurrent deploy/disk-maintenance prune must NOT fail
# an otherwise-green deploy. disk-maintenance.yml is the real
# image-prune safety net.
docker image prune -af || true
docker compose ps docker compose ps
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives # Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives

View file

@ -20,7 +20,7 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: docker/login-action@v4 - uses: docker/login-action@v4
with: with:
@ -42,7 +42,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: infra environment: infra
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Decrypt shared secret - name: Decrypt shared secret
id: decrypt id: decrypt
@ -87,7 +87,6 @@ jobs:
TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \ TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \
docker-compose.yml Caddyfile promtail-config.yml \ docker-compose.yml Caddyfile promtail-config.yml \
download-segments.sh .env \ download-segments.sh .env \
poi-extract \
| base64 -w0) | base64 -w0)
# One SSH session: untar, (re)start containers, report status # One SSH session: untar, (re)start containers, report status
@ -100,7 +99,7 @@ jobs:
set -euo pipefail set -euo pipefail
mkdir -p ~/brouter && cd ~/brouter mkdir -p ~/brouter && cd ~/brouter
echo "$TARBALL_B64" | base64 -d | tar -xzf - echo "$TARBALL_B64" | base64 -d | tar -xzf -
chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py chmod +x download-segments.sh
# Segment seeding is a one-shot operator task (~10 GB, a few # Segment seeding is a one-shot operator task (~10 GB, a few
# minutes). CD must not rerun it on every deploy. # minutes). CD must not rerun it on every deploy.

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: infra environment: infra
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Decrypt secrets - name: Decrypt secrets
run: | run: |
@ -43,7 +43,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} 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/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
target: /opt/trails-cool target: /opt/trails-cool
strip_components: 1 strip_components: 1
@ -64,11 +64,6 @@ jobs:
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
script: | 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 cd /opt/trails-cool
# .env was placed by the SCP step (decrypted app + infra secrets) # .env was placed by the SCP step (decrypted app + infra secrets)
@ -84,77 +79,20 @@ jobs:
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
fi 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 # Full restart: gh workflow run cd-infra.yml -f restart_all=true
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
docker compose --env-file .env up -d --remove-orphans docker compose --env-file .env up -d --remove-orphans
else else
# Restart infra services (config reloads handled below). # Restart infra services (except Caddy — just reload its config).
# --remove-orphans cleans up containers whose service was deleted # --remove-orphans cleans up containers whose service was deleted
# from the compose file (e.g., the flagship `brouter` removal in # from the compose file (e.g., the flagship `brouter` removal in
# PR #297 left an orphan that had to be removed by hand). # 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 docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
fi
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
# Apply config-only changes that `up -d` skips — it recreates a
# container only when its compose *definition* changes, not when a
# mounted config file's content changes. The configs are mounted as
# directories (not single files), so a reload/restart re-reads the
# freshly scp'd file; a single-file mount would have pinned the old
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
# the same container stayed up; a recreated container already loaded
# the new config on startup. Loki and Promtail reload their main
# config only on restart; Caddy reloads gracefully (validates, swaps
# live, no downtime).
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
docker compose --env-file .env kill -s SIGHUP prometheus
fi
docker compose --env-file .env restart loki promtail
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
fi
docker compose ps 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 # Annotate deploy in Grafana
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
if [ -n "$GRAFANA_TOKEN" ]; then if [ -n "$GRAFANA_TOKEN" ]; then

View file

@ -17,9 +17,7 @@ on:
- "infrastructure/docker-compose.staging.yml" - "infrastructure/docker-compose.staging.yml"
- ".github/workflows/cd-staging.yml" - ".github/workflows/cd-staging.yml"
pull_request: pull_request:
# `labeled`/`unlabeled` so toggling the `preview` label on an existing PR types: [opened, synchronize, reopened, closed]
# starts / tears down its preview (see the opt-in gate on the jobs below).
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
paths: paths:
- "apps/**" - "apps/**"
- "packages/**" - "packages/**"
@ -32,12 +30,6 @@ concurrency:
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }} group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
cancel-in-progress: true 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: jobs:
# ── Build ───────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────
# Tags: # Tags:
@ -46,15 +38,7 @@ jobs:
# Skipped entirely on PR close (teardown doesn't need new images). # Skipped entirely on PR close (teardown doesn't need new images).
build-images: build-images:
name: Build & Push Docker Images name: Build & Push Docker Images
# PR previews are opt-in to keep flagship disk in check (each preview is a if: github.event_name != 'pull_request' || github.event.action != 'closed'
# journal container + database): build a PR's images only when it carries
# the `preview` label or a `<!-- preview -->` 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, '<!-- preview -->')))
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
permissions: permissions:
@ -67,7 +51,7 @@ jobs:
tag_primary: ${{ steps.tags.outputs.primary }} tag_primary: ${{ steps.tags.outputs.primary }}
tag_sha: ${{ steps.tags.outputs.sha }} tag_sha: ${{ steps.tags.outputs.sha }}
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- id: tags - id: tags
name: Compute image tags name: Compute image tags
@ -105,7 +89,6 @@ jobs:
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }} ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
build-args: | build-args: |
SENTRY_RELEASE=${{ steps.tags.outputs.sha }} SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
secrets: | secrets: |
SENTRY_AUTH_TOKEN=/tmp/sentry_token SENTRY_AUTH_TOKEN=/tmp/sentry_token
@ -117,7 +100,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Decrypt secrets - name: Decrypt secrets
run: | run: |
@ -135,15 +118,6 @@ jobs:
echo "JOURNAL_IMAGE_TAG=staging" echo "JOURNAL_IMAGE_TAG=staging"
echo "PLANNER_IMAGE_TAG=staging" echo "PLANNER_IMAGE_TAG=staging"
echo "SENTRY_RELEASE=${{ github.sha }}" 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 } >> infrastructure/staging.env
- name: Copy compose file + env to server - name: Copy compose file + env to server
@ -191,48 +165,26 @@ jobs:
# Pull and deploy staging containers (journal + planner via "persistent" profile) # 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 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 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
# 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 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 # Reload Caddy so new staging routes (or Caddyfile changes shipped
# via cd-infra) are live. Idempotent. # via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true 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 docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ──────────────────────────────────────────────── # ── PR preview deploy ────────────────────────────────────────────────
deploy-preview: deploy-preview:
name: Deploy PR Preview name: Deploy PR Preview
# Opt-in only (see build-images): the `preview` label or a `<!-- preview -->` if: github.event_name == 'pull_request' && github.event.action != 'closed'
# 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, '<!-- preview -->'))
needs: [build-images] needs: [build-images]
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
permissions: permissions:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- id: ports - id: ports
name: Compute preview ports + project name name: Compute preview ports + project name
@ -252,8 +204,7 @@ jobs:
run: | run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 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 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.env
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 "DOMAIN=${{ steps.ports.outputs.host }}"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
@ -264,18 +215,7 @@ jobs:
# PR-preview journals all share the persistent staging planner. # PR-preview journals all share the persistent staging planner.
echo "PLANNER_URL=https://planner.staging.trails.cool" echo "PLANNER_URL=https://planner.staging.trails.cool"
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" } >> infrastructure/staging.env
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 - name: Generate per-PR Caddyfile snippet
run: | run: |
@ -302,7 +242,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile" source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
target: /opt/trails-cool target: /opt/trails-cool
strip_components: 1 strip_components: 1
@ -312,25 +252,18 @@ jobs:
PR: ${{ steps.ports.outputs.pr }} PR: ${{ steps.ports.outputs.pr }}
PROJECT: ${{ steps.ports.outputs.project }} PROJECT: ${{ steps.ports.outputs.project }}
DB: ${{ steps.ports.outputs.database }} DB: ${{ steps.ports.outputs.database }}
JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }}
with: with:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: PR,PROJECT,DB,JOURNAL_PORT envs: PR,PROJECT,DB
script: | script: |
set -euo pipefail set -euo pipefail
cd /opt/trails-cool cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-) GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
# Serialize all preview deploys with a server-side lock so concurrent
# CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state.
exec 9>/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. # Same network bootstrap as deploy-staging — see comment there.
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared 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) PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
@ -353,12 +286,9 @@ jobs:
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
echo "At cap; evicting oldest preview: $OLDEST" echo "At cap; evicting oldest preview: $OLDEST"
OLD_PR=${OLDEST#trails-pr-} OLD_PR=${OLDEST#trails-pr-}
OLD_ENV="staging-pr-${OLD_PR}.env" docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file staging.env down --remove-orphans || true
# 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 docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV" rm -f "sites/pr-$OLD_PR.caddyfile"
fi fi
fi fi
fi fi
@ -373,30 +303,20 @@ jobs:
"CREATE EXTENSION IF NOT EXISTS postgis" "CREATE EXTENSION IF NOT EXISTS postgis"
# Pull, migrate, deploy (journal-only — no --profile means planner skipped) # 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 docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env pull journal
# Same drizzle-kit exit-code-0-on-error guard as the persistent docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
# staging deploy above. docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env up -d --remove-orphans journal
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) # 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 docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
# Same disk-hygiene prune as the persistent staging deploy — docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env ps
# 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 # 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 # than spamming a new one each push. The marker line at the bottom of
# the body is what `body-includes` matches on. # the body is what `body-includes` matches on.
- name: Find existing preview comment - name: Find existing preview comment
uses: peter-evans/find-comment@v4 uses: peter-evans/find-comment@v3
id: find-comment id: find-comment
with: with:
issue-number: ${{ github.event.number }} issue-number: ${{ github.event.number }}
@ -404,7 +324,7 @@ jobs:
body-includes: "<!-- cd-staging:preview -->" body-includes: "<!-- cd-staging:preview -->"
- name: Upsert preview comment on PR - name: Upsert preview comment on PR
uses: peter-evans/create-or-update-comment@v5 uses: peter-evans/create-or-update-comment@v4
with: with:
comment-id: ${{ steps.find-comment.outputs.comment-id }} comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.number }} issue-number: ${{ github.event.number }}
@ -424,15 +344,7 @@ jobs:
# ── PR preview teardown ────────────────────────────────────────────── # ── PR preview teardown ──────────────────────────────────────────────
teardown-preview: teardown-preview:
name: Tear Down PR Preview name: Tear Down PR Preview
# Tear down on close, or when the `preview` opt-in is removed (label pulled if: github.event_name == 'pull_request' && github.event.action == 'closed'
# and no `<!-- preview -->` 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, '<!-- preview -->'))))
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
permissions: permissions:
@ -446,19 +358,19 @@ jobs:
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT" echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT" echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Decrypt secrets (needed to satisfy compose env vars during down) - name: Decrypt secrets (needed to satisfy compose env vars during down)
run: | run: |
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 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 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 SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
{ {
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool" echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}" echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))" echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))" echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env } >> infrastructure/staging.env
- name: Copy compose + env (teardown still needs the file) - name: Copy compose + env (teardown still needs the file)
uses: appleboy/scp-action@v1 uses: appleboy/scp-action@v1
@ -466,7 +378,7 @@ jobs:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: root username: root
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env" source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
target: /opt/trails-cool target: /opt/trails-cool
strip_components: 1 strip_components: 1
@ -484,20 +396,19 @@ jobs:
script: | script: |
set -euo pipefail set -euo pipefail
cd /opt/trails-cool cd /opt/trails-cool
ENV_FILE="staging-pr-${PR}.env"
# Stop and remove containers + volumes for this PR # 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 docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file staging.env down --remove-orphans || true
# Drop the per-PR database (idempotent) # Drop the per-PR database (idempotent)
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
# Remove the per-PR Caddy snippet, env file, and reload # Remove the per-PR Caddy snippet and reload
rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE" rm -f "sites/pr-$PR.caddyfile"
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
- name: Find existing preview comment - name: Find existing preview comment
uses: peter-evans/find-comment@v4 uses: peter-evans/find-comment@v3
id: find-comment id: find-comment
with: with:
issue-number: ${{ github.event.number }} issue-number: ${{ github.event.number }}
@ -506,7 +417,7 @@ jobs:
- name: Update preview comment on close - name: Update preview comment on close
if: steps.find-comment.outputs.comment-id if: steps.find-comment.outputs.comment-id
uses: peter-evans/create-or-update-comment@v5 uses: peter-evans/create-or-update-comment@v4
with: with:
comment-id: ${{ steps.find-comment.outputs.comment-id }} comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace edit-mode: replace

View file

@ -19,7 +19,7 @@ jobs:
contents: read contents: read
pull-requests: read pull-requests: read
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Gitleaks - name: Gitleaks
@ -42,14 +42,14 @@ jobs:
name: Dockerfile Package Check name: Dockerfile Package Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- run: bash scripts/check-dockerfiles.sh - run: bash scripts/check-dockerfiles.sh
openspec: openspec:
name: OpenSpec Validate name: OpenSpec Validate
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -62,7 +62,7 @@ jobs:
name: Typecheck name: Typecheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -75,7 +75,7 @@ jobs:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -88,7 +88,7 @@ jobs:
name: Unit Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -101,7 +101,7 @@ jobs:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -110,72 +110,6 @@ jobs:
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm build - 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: e2e:
name: E2E Tests name: E2E Tests
needs: build needs: build
@ -183,7 +117,7 @@ jobs:
env: env:
DATABASE_URL: postgres://trails:trails@localhost:5432/trails DATABASE_URL: postgres://trails:trails@localhost:5432/trails
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
@ -191,12 +125,64 @@ jobs:
cache: pnpm cache: pnpm
- run: pnpm install --frozen-lockfile - 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 - name: Cache BRouter segment
id: segment-cache id: segment-cache
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: /tmp/brouter-segments path: /tmp/brouter-segments
key: brouter-segment-E10_N50-v1.7.9 key: brouter-segment-E10_N50
- name: Download Berlin segment - name: Download Berlin segment
if: steps.segment-cache.outputs.cache-hit != 'true' if: steps.segment-cache.outputs.cache-hit != 'true'
@ -204,54 +190,26 @@ jobs:
mkdir -p /tmp/brouter-segments mkdir -p /tmp/brouter-segments
wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5 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: | run: |
docker volume create trails_brouter_segments cd /tmp/brouter
docker run --rm \ java -Xmx256M -Xms64M \
-v /tmp/brouter-segments:/src:ro \ -DmaxRunningTime=300 \
-v trails_brouter_segments:/dst \ -cp brouter-1.7.8-all.jar \
alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5" btools.server.RouteServer \
/tmp/brouter-segments profiles2 profiles2 \
- name: Start services 17777 2 &
run: docker compose -f docker-compose.dev.yml up -d --wait --build # 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: env:
BROUTER_URL: http://localhost:17777 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 - name: Cache Playwright browsers
id: playwright-cache id: playwright-cache
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }} key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
@ -273,12 +231,6 @@ jobs:
run: pnpm test:e2e run: pnpm test:e2e
env: env:
BROUTER_URL: http://localhost:17777 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 - name: Playwright job summary
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
@ -316,87 +268,3 @@ jobs:
name: playwright-report name: playwright-report
path: playwright-report/ path: playwright-report/
retention-days: 30 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

View file

@ -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"

74
.github/workflows/dependabot-dedupe.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Dependabot dedupe
# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't
# dedupe peer copies of packages (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.
#
# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
# pushes the resulting lockfile update back to the PR branch so the
# subsequent CI run tests the deduped tree.
#
# 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 dedupe 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:
dedupe:
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 dedupe commit re-triggers CI."
echo "::error::See .github/workflows/dependabot-dedupe.yml header."
exit 1
fi
- uses: actions/checkout@v6
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 dedupe 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
- run: pnpm dedupe
- name: Commit dedupe changes
env:
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
run: |
if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then
git config user.name "dependabot[bot]"
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "[github-actions] pnpm dedupe"
git push
echo "Deduped lockfile pushed."
else
echo "Lockfile already deduped."
fi

View file

@ -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."

View file

@ -106,7 +106,7 @@ jobs:
EOF EOF
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env" rm -f "sites/pr-$PR.caddyfile"
done done
rm -f /tmp/cleanup.env rm -f /tmp/cleanup.env
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true

View file

@ -1,103 +0,0 @@
name: Update visual snapshots
# How to use this workflow
# ========================
#
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
# committed to the repo. They are generated by Vitest browser mode running
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
#
# When to update snapshots:
# - You intentionally changed the look of the elevation chart (new color mode,
# layout change, etc.) and the old snapshots are now wrong.
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
#
# Two ways to trigger this workflow:
#
# 1. Manual dispatch (workflow_dispatch):
# Go to Actions → "Update visual snapshots" → "Run workflow".
# Choose the branch you want updated. The workflow will commit the new
# snapshots back to that branch.
#
# 2. PR label (update-snapshots):
# Add the `update-snapshots` label to any PR. The workflow will update
# snapshots on the PR's head branch. Remove the label after to avoid
# re-triggering on every subsequent push.
#
# After the workflow commits updated snapshots, pull the branch locally:
# git pull origin <your-branch>
#
# 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

2
.gitignore vendored
View file

@ -10,10 +10,8 @@ dist/
.crit.json .crit.json
e2e/results/ e2e/results/
test-results/ test-results/
.env.development
playwright-report/ playwright-report/
playwright-results.json playwright-results.json
.claude/worktrees/ .claude/worktrees/
.claude/settings.local.json .claude/settings.local.json
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
docs/reviews/internal/

View file

@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with
Full architecture: `docs/architecture.md` Full architecture: `docs/architecture.md`
Philosophy: `docs/philosophy.md` Philosophy: `docs/philosophy.md`
Roadmap: `docs/roadmap.md` OpenSpec change: `openspec/changes/phase-1-mvp/`
Ideas (pre-spec explorations): `docs/ideas/`
OpenSpec changes: `openspec/changes/`
## Principles ## Principles
@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/`
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack) - **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
- **Maps**: Leaflet + OpenStreetMap tiles - **Maps**: Leaflet + OpenStreetMap tiles
- **CRDT**: Yjs + y-websocket (Planner only) - **CRDT**: Yjs + y-websocket (Planner only)
- **Federation**: Fedify (Journal only) - **Federation**: Fedify (Journal only, Phase 2)
- **Database**: PostgreSQL + PostGIS - **Database**: PostgreSQL + PostGIS
- **Media storage**: S3-compatible (Garage) - **Media storage**: S3-compatible (Garage)
- **Routing engine**: BRouter (Java, runs as separate Docker container) - **Routing engine**: BRouter (Java, runs as separate Docker container)
@ -41,15 +39,11 @@ apps/
planner/ — Planner app (React Router 7) planner/ — Planner app (React Router 7)
journal/ — Journal app (React Router 7 + Fedify) journal/ — Journal app (React Router 7 + Fedify)
packages/ packages/
types/ — Shared wire types both apps exchange (Waypoint) types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side ui/ — Shared React components (Tailwind)
map/ — Leaflet map wrappers and tile layer configs
gpx/ — GPX parsing, generation, validation gpx/ — GPX parsing, generation, validation
fit/ — FIT file generation (Wahoo route push)
i18n/ — react-i18next config + translations 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 infrastructure/ — Terraform + Docker Compose
openspec/ — OpenSpec specs and changes openspec/ — OpenSpec specs and changes
docs/ — Architecture, philosophy, tooling docs docs/ — Architecture, philosophy, tooling docs
@ -123,8 +117,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. - **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) - 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` - Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
- 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 - Map components go in `@trails-cool/map`, not in individual apps
- GPX parsing/generation goes in `@trails-cool/gpx` - GPX parsing/generation goes in `@trails-cool/gpx`
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data - Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save) - Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
@ -255,17 +249,6 @@ The shared file `infrastructure/docker-compose.staging.yml` covers both — env
**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-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. **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-<N> 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 ## OpenSpec Workflow
Specs live in `openspec/`. Use these slash commands: Specs live in `openspec/`. Use these slash commands:

View file

@ -9,24 +9,6 @@ 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<ParsedGpx>`. 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 ## Connected Services
The user-facing surface for linking external accounts and devices to a Journal The user-facing surface for linking external accounts and devices to a Journal
@ -142,57 +124,3 @@ webhooks to the right user via `provider_user_id`. Unknown
`(user_id, provider, workout_id)`. `(user_id, provider, workout_id)`.
- `sync_pushes` — push state per `(user_id, route_id, provider)` - `sync_pushes` — push state per `(user_id, route_id, provider)`
`remote_id`, `last_pushed_version`. `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.

View file

@ -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": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
]
}
```
`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": "<p>Morning trail run — 12.4 km, 480 m up</p>",
"url": "https://trails.example/activities/01H…",
"published": "2026-07-13T07:12:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
```
The Note IRI (`/activities/<id>`) 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`.*

View file

@ -96,14 +96,6 @@ docker compose up -d
See [docs/architecture.md](docs/architecture.md) for details on self-hosting See [docs/architecture.md](docs/architecture.md) for details on self-hosting
configuration. 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 ## Philosophy
- **Privacy by design** — The Planner collects zero user data - **Privacy by design** — The Planner collects zero user data

View file

@ -49,14 +49,6 @@
# WAHOO_CLIENT_SECRET= # WAHOO_CLIENT_SECRET=
# WAHOO_WEBHOOK_TOKEN= # 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 `<origin>/api/sync/callback/garmin`, the
# notification endpoint `<origin>/api/sync/webhook/garmin`.
# GARMIN_CLIENT_ID=
# GARMIN_CLIENT_SECRET=
# Integration test secret (only needed if running the integration # Integration test secret (only needed if running the integration
# test suite that drives the API directly). Generate with # test suite that drives the API directly). Generate with
# `openssl rand -hex 32`. # `openssl rand -hex 32`.

View file

@ -1,4 +1,4 @@
FROM node:26-slim AS base FROM node:25-slim AS base
# curl is used by the docker-compose healthcheck (node:25-slim is Debian # curl is used by the docker-compose healthcheck (node:25-slim is Debian
# trixie-slim and ships neither curl nor wget by default). # trixie-slim and ships neither curl nor wget by default).
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
@ -9,12 +9,13 @@ FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/journal/package.json apps/journal/ COPY apps/journal/package.json apps/journal/
COPY packages/types/package.json packages/types/ 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/gpx/package.json packages/gpx/
COPY packages/i18n/package.json packages/i18n/ COPY packages/i18n/package.json packages/i18n/
COPY packages/sentry-config/package.json packages/sentry-config/ COPY packages/sentry-config/package.json packages/sentry-config/
COPY packages/api/package.json packages/api/ COPY packages/api/package.json packages/api/
COPY packages/map-core/package.json packages/map-core/ 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/db/package.json packages/db/
COPY packages/jobs/package.json packages/jobs/ COPY packages/jobs/package.json packages/jobs/
COPY packages/fit/package.json packages/fit/ COPY packages/fit/package.json packages/fit/
@ -22,16 +23,11 @@ RUN pnpm install --frozen-lockfile
FROM base AS build FROM base AS build
ARG SENTRY_RELEASE 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 --from=deps /app/ ./
COPY . . COPY . .
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \ 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_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
SENTRY_RELEASE="$SENTRY_RELEASE" \ SENTRY_RELEASE="$SENTRY_RELEASE" \
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
pnpm --filter @trails-cool/journal build pnpm --filter @trails-cool/journal build
FROM base AS runtime FROM base AS runtime
@ -40,7 +36,6 @@ COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/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/build ./apps/journal/build
COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts 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/lib ./apps/journal/app/lib
COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json

View file

@ -4,9 +4,6 @@ interface Entry {
username: string; username: string;
displayName: string | null; displayName: string | null;
domain: string; domain: string;
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
profileUrl: string;
remote: boolean;
} }
interface Props { interface Props {
@ -45,10 +42,9 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : ( ) : (
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white"> <ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{entries.map((entry) => ( {entries.map((entry) => (
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3"> <li key={entry.username} className="px-4 py-3">
<a <a
href={entry.profileUrl} href={`/users/${entry.username}`}
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex items-center justify-between hover:underline" className="flex items-center justify-between hover:underline"
> >
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900">

View file

@ -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(
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).toBeNull();
});
it("renders the chart and highest/lowest summary", () => {
const { container, getByText } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} 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(
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} 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(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} 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);
});
});

View file

@ -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<SVGSVGElement>(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 (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
{" · "}
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
</span>
{active && (
<span className="tabular-nums">
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
</span>
)}
</div>
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-40 w-full touch-none select-none"
role="img"
aria-label="Elevation profile"
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
onPointerLeave={() => onActive(null)}
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
>
<defs>
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#elev-fill)" />
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{active && (
<g>
<line
x1={x(active.d)}
y1={PAD.top}
x2={x(active.d)}
y2={baseY}
stroke="#9ca3af"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
</div>
);
}

View file

@ -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(
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.firstChild).toBeNull();
});
it("renders formatted totals", () => {
const { container, getByText } = render(
<ProfileStats
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
/>,
);
// 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(
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.querySelector("p")).toBeNull();
});
});

View file

@ -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 (
<div className={className}>
<StatRow
size="lg"
items={[
{ label: t("profileStats.activities"), value: String(stats.count) },
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
{ label: t("profileStats.ascent"), value: `${formatElevationM(stats.elevationGain)}` },
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
]}
/>
{stats.last4Weeks > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
</p>
)}
</div>
);
}

View file

@ -1,63 +1,9 @@
import { useEffect, useRef } from "react"; 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 L from "leaflet";
import type { GeoJsonObject } from "geojson"; import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css"; 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 (
<CircleMarker
center={[point.lat, point.lng]}
radius={6}
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
/>
);
}
/** 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<number | null>(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 }) { function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap(); const map = useMap();
const fitted = useRef(false); const fitted = useRef(false);
@ -134,35 +80,16 @@ interface RouteMapProps {
dayBreaks?: number[]; dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */ /** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null; 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({ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
geojson,
interactive,
className,
dayBreaks,
highlightedDay,
activePoint,
hoverSeries,
onHoverIndex,
centerOn,
}: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson); const data: GeoJsonObject = JSON.parse(geojson);
return ( return (
<MapContainer <MapContainer
center={[50, 10]} center={[50, 10]}
zoom={6} zoom={6}
// `isolate` gives the Leaflet container its own stacking context so its className={className ?? "h-36 w-full rounded"}
// internal high z-indexes (panes ~200700, zoom controls ~1000) stay
// contained and can't paint over page overlays like the mobile nav
// drawer's backdrop.
className={`${className ?? "h-36 w-full rounded"} isolate`}
zoomControl={interactive ?? false} zoomControl={interactive ?? false}
attributionControl={interactive ?? false} attributionControl={interactive ?? false}
dragging={interactive ?? false} dragging={interactive ?? false}
@ -187,11 +114,6 @@ export function RouteMapThumbnail({
fullData={data} fullData={data}
/> />
)} )}
<ActiveMarker point={activePoint} />
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
)}
<Recenter centerOn={centerOn} />
</MapContainer> </MapContainer>
); );
} }
@ -220,7 +142,7 @@ function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObj
type: "Feature", type: "Feature",
geometry: { type: "LineString", coordinates: seg.coords }, geometry: { type: "LineString", coordinates: seg.coords },
properties: {}, properties: {},
} as GeoJsonObject; } as unknown as GeoJsonObject;
return ( return (
<GeoJSON <GeoJSON
key={`${i}-${highlightedDay}`} key={`${i}-${highlightedDay}`}

View file

@ -1,39 +0,0 @@
import { useTranslation } from "react-i18next";
import type { SportType } from "@trails-cool/db/schema/journal";
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
// label carries the meaning; the glyph is decorative (aria-hidden).
const SPORT_EMOJI: Record<SportType, string> = {
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 (
<span
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
>
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
{t(`activities.sport.${sportType}`)}
</span>
);
}

View file

@ -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(<StatRow items={[]} />);
expect(container.firstChild).toBeNull();
});
it("renders each item's value and label, in order", () => {
const { container } = render(
<StatRow
items={[
{ label: "Distance", value: "30.0 km" },
{ label: "Time", value: "1h 0m" },
{ label: "Avg speed", value: "30.0 km/h" },
]}
/>,
);
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"]);
});
});

View file

@ -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 (
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
{items.map((it) => (
<div key={it.label}>
<dd className={valueCls}>{it.value}</dd>
<dt className="text-xs text-gray-500">{it.label}</dt>
</div>
))}
</dl>
);
}

View file

@ -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(<SurfaceBreakdown breakdown={null} />);
expect(container.firstChild).toBeNull();
});
it("renders nothing when every bucket is zero", () => {
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
expect(container.firstChild).toBeNull();
});
it("renders a legend item per non-zero category with its percentage", () => {
const { container, getByText } = render(
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
);
// 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(
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
);
// 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%");
});
});

View file

@ -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<string, number>;
highway: Record<string, number>;
}
function Bar({
title,
data,
colorFor,
}: {
title: string;
data: Record<string, number>;
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 (
<div className="mt-3 first:mt-0">
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
<div className="flex h-3 w-full overflow-hidden rounded">
{entries.map(([cat, m]) => (
<div
key={cat}
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
title={`${label(cat)} · ${formatDistanceKm(m)}`}
/>
))}
</div>
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
{entries.map(([cat, m]) => (
<li key={cat} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorFor(cat) }}
aria-hidden
/>
<span>{label(cat)}</span>
<span className="tabular-nums text-gray-400">
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* 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 (
<div className={className}>
<Bar
title={t("surface.surface")}
data={breakdown.surface}
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
/>
<Bar
title={t("surface.waytype")}
data={breakdown.highway}
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
/>
</div>
);
}

View file

@ -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(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
expect(container.firstChild).toBeNull();
});
it("renders the chart with a bar only for non-zero weeks", () => {
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
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(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
// 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(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
expect(queryByText("3.0 km")).toBeNull();
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
fireEvent.mouseEnter(hit);
expect(queryByText("3.0 km")).not.toBeNull();
});
});

View file

@ -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<number | null>(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 (
<div className={className}>
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
<span>{t("profileStats.weeklyDistance")}</span>
{active && (
<span className="tabular-nums text-gray-700">
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
</span>
)}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-28 w-full"
role="img"
aria-label={t("profileStats.weeklyDistance")}
onMouseLeave={() => setHover(null)}
>
{/* gridlines + y-axis labels (0 · half · peak) */}
{gridFracs.map((f) => {
const yy = BASE_Y - f * PLOT_H;
return (
<g key={f}>
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
{axisLabel(maxKm * f)}
</text>
</g>
);
})}
{weeks.map((w, i) => {
const isActive = hover === i;
return (
<g key={w.weekStart}>
{/* faint week-slot track so empty weeks stay visible */}
<rect
x={x(i)}
y={PAD.top}
width={barW}
height={PLOT_H}
rx="1.5"
fill={isActive ? "#dbeafe" : "#f3f4f6"}
/>
{/* distance bar */}
{w.distance > 0 && (
<rect
data-week-bar
x={x(i)}
y={yFor(w.distance)}
width={barW}
height={BASE_Y - yFor(w.distance)}
rx="1.5"
fill={isActive ? "#1d4ed8" : "#3b82f6"}
/>
)}
{/* full-height hover hit area */}
<rect
x={PAD.left + i * colW}
y={PAD.top}
width={colW}
height={PLOT_H}
fill="transparent"
onMouseEnter={() => setHover(i)}
>
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
</rect>
</g>
);
})}
</svg>
<div className="flex justify-between px-px text-[10px] text-gray-400">
<span>{firstLabel}</span>
<span>{lastLabel}</span>
</div>
</div>
);
}

View file

@ -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]);
}

View file

@ -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 };
},
});

View file

@ -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 };
},
});

View file

@ -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<string, number>();
const MIN_INTERVAL_MS = 1000;
async function paceHost(host: string): Promise<void> {
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";
}

View file

@ -1,4 +1,4 @@
import { defineJournalJob } from "./payloads.ts"; import type { JobDefinition } from "@trails-cool/jobs";
import { import {
DEMO_BACKFILL_TARGET, DEMO_BACKFILL_TARGET,
DEMO_DAILY_CAP, DEMO_DAILY_CAP,
@ -21,7 +21,7 @@ import { logger } from "../lib/logger.server.ts";
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and * 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
* daily cap; on pass, insert one route+activity via `generateOneWalk`. * daily cap; on pass, insert one route+activity via `generateOneWalk`.
*/ */
export const demoBotGenerateJob = defineJournalJob({ export const demoBotGenerateJob: JobDefinition = {
name: "demo-bot-generate", name: "demo-bot-generate",
cron: "0,30 * * * *", cron: "0,30 * * * *",
retryLimit: 1, retryLimit: 1,
@ -57,4 +57,4 @@ export const demoBotGenerateJob = defineJournalJob({
await refreshDemoBotGauges(); await refreshDemoBotGauges();
return { mode: "single", routeId: id }; return { mode: "single", routeId: id };
}, },
}); };

View file

@ -1,4 +1,4 @@
import { defineJournalJob } from "./payloads.ts"; import type { JobDefinition } from "@trails-cool/jobs";
import { import {
demoRetentionDays, demoRetentionDays,
isDemoBotEnabled, isDemoBotEnabled,
@ -11,7 +11,7 @@ import { logger } from "../lib/logger.server.ts";
* Daily prune. Deletes synthetic rows older than * Daily prune. Deletes synthetic rows older than
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users. * `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
*/ */
export const demoBotPruneJob = defineJournalJob({ export const demoBotPruneJob: JobDefinition = {
name: "demo-bot-prune", name: "demo-bot-prune",
cron: "15 3 * * *", cron: "15 3 * * *",
retryLimit: 1, retryLimit: 1,
@ -24,4 +24,4 @@ export const demoBotPruneJob = defineJournalJob({
await refreshDemoBotGauges(); await refreshDemoBotGauges();
return { days, ...counts }; return { days, ...counts };
}, },
}); };

View file

@ -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 };
},
});

View file

@ -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 };
},
});

View file

@ -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;
}
}
},
});

View file

@ -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");
},
});

View file

@ -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<typeof komootBulkImportJob.handler>[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");
});
});

View file

@ -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;
}
}
},
});

View file

@ -1,17 +1,21 @@
import { defineJournalJob } from "./payloads.ts"; import type { JobDefinition } from "@trails-cool/jobs";
import { and, eq, isNotNull } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm";
import { getDb } from "../lib/db.ts"; import { getDb } from "../lib/db.ts";
import { activities, follows, users } from "@trails-cool/db/schema/journal"; import { activities, follows, users } from "@trails-cool/db/schema/journal";
import { createNotification } from "../lib/notifications.server.ts"; import { createNotification } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts"; import { logger } from "../lib/logger.server.ts";
interface FanoutData {
activityId: string;
}
/** /**
* Fan out an `activity_published` notification to every accepted * Fan out an `activity_published` notification to every accepted
* follower of the activity's owner. Idempotent at the DB level via the * follower of the activity's owner. Idempotent at the DB level via the
* `(recipient_user_id, type, subject_id)` unique partial index a * `(recipient_user_id, type, subject_id)` unique partial index a
* retry after partial failure won't double-insert. * retry after partial failure won't double-insert.
*/ */
export const notificationsFanoutJob = defineJournalJob({ export const notificationsFanoutJob: JobDefinition = {
name: "notifications-fanout", name: "notifications-fanout",
retryLimit: 3, retryLimit: 3,
expireInSeconds: 300, expireInSeconds: 300,
@ -20,10 +24,11 @@ export const notificationsFanoutJob = defineJournalJob({
// process whichever shape we get. // process whichever shape we get.
const batch = Array.isArray(job) ? job : [job]; const batch = Array.isArray(job) ? job : [job];
for (const item of batch) { for (const item of batch) {
await fanout(item.data.activityId); const data = item.data as FanoutData;
await fanout(data.activityId);
} }
}, },
}); };
export async function fanout(activityId: string): Promise<void> { export async function fanout(activityId: string): Promise<void> {
const db = getDb(); const db = getDb();
@ -46,9 +51,6 @@ export async function fanout(activityId: string): Promise<void> {
logger.warn({ activityId }, "fanout: activity not found, skipping"); logger.warn({ activityId }, "fanout: activity not found, skipping");
return; 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 // 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 // recheck here so the job doesn't mistakenly fan out a row that was
// later flipped to private/unlisted before the job ran. // later flipped to private/unlisted before the job ran.
@ -57,9 +59,7 @@ export async function fanout(activityId: string): Promise<void> {
return; return;
} }
// Find every accepted *local* follower of the owner. Remote // Find every accepted follower of the owner.
// followers (follower_id NULL, follower_actor_iri set) get the
// activity via federation push delivery, not via notifications.
const recipients = await db const recipients = await db
.select({ followerId: follows.followerId }) .select({ followerId: follows.followerId })
.from(follows) .from(follows)
@ -67,7 +67,6 @@ export async function fanout(activityId: string): Promise<void> {
and( and(
eq(follows.followedUserId, row.ownerId), eq(follows.followedUserId, row.ownerId),
isNotNull(follows.acceptedAt), isNotNull(follows.acceptedAt),
isNotNull(follows.followerId),
), ),
); );
@ -76,7 +75,7 @@ export async function fanout(activityId: string): Promise<void> {
// Don't notify the owner about their own activity if they happen // Don't notify the owner about their own activity if they happen
// to follow themselves (shouldn't happen — followUser refuses // to follow themselves (shouldn't happen — followUser refuses
// self-follow — but defense in depth). // self-follow — but defense in depth).
if (r.followerId === row.ownerId || r.followerId === null) continue; if (r.followerId === row.ownerId) continue;
const created = await createNotification({ const created = await createNotification({
type: "activity_published", type: "activity_published",
recipientUserId: r.followerId, recipientUserId: r.followerId,

View file

@ -1,4 +1,4 @@
import { defineJournalJob } from "./payloads.ts"; import type { JobDefinition } from "@trails-cool/jobs";
import { purgeReadOlderThan } from "../lib/notifications.server.ts"; import { purgeReadOlderThan } from "../lib/notifications.server.ts";
import { logger } from "../lib/logger.server.ts"; import { logger } from "../lib/logger.server.ts";
@ -7,7 +7,7 @@ import { logger } from "../lib/logger.server.ts";
* than 90 days; unread rows are kept indefinitely so users never miss * than 90 days; unread rows are kept indefinitely so users never miss
* an event. * an event.
*/ */
export const notificationsPurgeJob = defineJournalJob({ export const notificationsPurgeJob: JobDefinition = {
name: "notifications-purge", name: "notifications-purge",
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load) cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
retryLimit: 1, retryLimit: 1,
@ -18,4 +18,4 @@ export const notificationsPurgeJob = defineJournalJob({
logger.info({ days, purged }, "notifications-purge"); logger.info({ days, purged }, "notifications-purge");
return { days, purged }; return { days, purged };
}, },
}); };

View file

@ -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<string, unknown>)
.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_.-]+$/);
}
});
});

View file

@ -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<string, never>;
"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<K extends JobName>(
definition: TypedJobDefinition<JobPayloads[K]> & { name: K },
): JobDefinition {
return defineJob<JobPayloads[K]>(definition);
}

View file

@ -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");
}
},
});

View file

@ -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 };
},
});

View file

@ -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;
}
}
},
});

View file

@ -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();
});
});

View file

@ -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<void> {
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");
}

View file

@ -1,69 +1,44 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm"; import { eq, desc, and, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { getDb } from "./db.ts"; import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal"; import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import type { Visibility, SportType } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal";
import { processGpx, writeGeom } from "./gpx-save.server.ts"; import { parseGpxAsync } from "@trails-cool/gpx";
import type { ProcessedGpx } from "./gpx-save.server.ts"; import { setGeomFromGpx } from "./routes.server.ts";
import { enqueueOptional } from "./boss.server.ts";
import type { OwnedRef } from "./ownership.server.ts";
import {
enqueueActivityDeliveries,
visibilityTransitionAction,
} from "./federation-delivery.server.ts";
export interface ActivityInput { export interface ActivityInput {
name: string; name: string;
description?: string; description?: string;
sportType?: SportType | null;
gpx?: string; gpx?: string;
routeId?: string; routeId?: string;
distance?: number | null; distance?: number | null;
duration?: number | null; duration?: number | null;
startedAt?: Date | null; startedAt?: Date | null;
visibility?: Visibility; visibility?: Visibility;
synthetic?: boolean;
} }
export async function updateActivityVisibility( export async function updateActivityVisibility(
ownedActivity: OwnedRef, id: string,
ownerId: string,
visibility: Visibility, visibility: Visibility,
): Promise<boolean> { ): Promise<boolean> {
const { id, ownerId } = ownedActivity;
const db = getDb(); const db = getDb();
// Read the previous visibility first: the federation action depends on const result = await db
// the *transition*, not the new value alone (a gratuitous Delete
// permanently tombstones the object's URI on Mastodon — see
// visibilityTransitionAction).
const [existing] = await db
.select({ visibility: activities.visibility })
.from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.limit(1);
if (!existing) return false;
await db
.update(activities) .update(activities)
.set({ visibility }) .set({ visibility })
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))); .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
.returning({ id: activities.id });
if (result.length === 0) return false;
// Notify followers when an activity becomes public. The unique // Notify followers when an activity becomes public. The unique
// (recipient, type, subject_id) partial index makes the fan-out // (recipient, type, subject_id) partial index makes the fan-out
// idempotent, so toggling private→public→private→public won't spam // idempotent, so toggling private→public→private→public won't spam
// followers (only the first transition per activity emits). // followers (only the first transition per activity emits).
if (visibility === "public") { if (visibility === "public") {
const { enqueueOptional } = await import("./boss.server.ts");
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" }); 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; return true;
} }
@ -71,30 +46,32 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
const db = getDb(); const db = getDb();
const id = randomUUID(); const id = randomUUID();
let processed: ProcessedGpx | null = null;
let distance: number | null = input.distance ?? null; let distance: number | null = input.distance ?? null;
let elevationGain: number | null = null; let elevationGain: number | null = null;
let elevationLoss: number | null = null; let elevationLoss: number | null = null;
let startedAt: Date | null = input.startedAt ?? null; let startedAt: Date | null = input.startedAt ?? null;
const duration: number | null = input.duration ?? null; const duration: number | null = input.duration ?? null;
if (input.gpx) { if (input.gpx) {
processed = await processGpx(input.gpx); try {
// GPX-derived distance wins unless it is zero; caller input is the fallback const gpxData = await parseGpxAsync(input.gpx);
distance = processed.stats.distance || distance; distance = gpxData.distance || distance;
elevationGain = processed.stats.elevationGain; elevationGain = gpxData.elevation.gain;
elevationLoss = processed.stats.elevationLoss; elevationLoss = gpxData.elevation.loss;
startedAt = startedAt ?? processed.stats.startTime;
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 db.insert(activities).values({
await tx.insert(activities).values({
id, id,
ownerId, ownerId,
routeId: input.routeId ?? null, routeId: input.routeId ?? null,
name: input.name, name: input.name,
description: input.description ?? "", description: input.description ?? "",
sportType: input.sportType ?? null,
gpx: input.gpx, gpx: input.gpx,
distance, distance,
duration, duration,
@ -102,33 +79,18 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
elevationLoss, elevationLoss,
startedAt, startedAt,
...(input.visibility ? { visibility: input.visibility } : {}), ...(input.visibility ? { visibility: input.visibility } : {}),
...(input.synthetic ? { synthetic: true } : {}),
}); });
if (input.gpx && processed) { if (input.gpx) {
await writeGeom(tx, id, "activities", processed.coords); await setGeomFromGpx(id, "activities", input.gpx);
} }
});
// Public activities at creation also fan out (matches the // Public activities at creation also fan out (matches the
// updateActivityVisibility path for the case where visibility is set // updateActivityVisibility path for the case where visibility is set
// up-front rather than flipped later). // up-front rather than flipped later).
if (input.visibility === "public") { if (input.visibility === "public") {
const { enqueueOptional } = await import("./boss.server.ts");
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" }); await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
// Federation push delivery to accepted remote followers (spec 5.3).
await enqueueActivityDeliveries(ownerId, id, "create");
}
// Activities arrive without surface waytags (bare GPX); kick off the async
// Overpass backfill (route-surface-breakdown Path 2). Skipped for synthetic
// demo content. Best-effort — never blocks creation.
if (input.gpx && !input.synthetic) {
await enqueueOptional(
"surface-backfill",
{ kind: "activity", id },
{ source: "createActivity" },
{ singletonKey: `surface:activity:${id}` },
);
} }
return id; return id;
@ -143,20 +105,11 @@ export async function getActivity(id: string) {
return { ...activity, geojson, importSource }; return { ...activity, geojson, importSource };
} }
export async function deleteActivity(ownedActivity: OwnedRef): Promise<boolean> { export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
const { id, ownerId } = ownedActivity;
const db = getDb(); const db = getDb();
// The WHERE ownerId clause stays as defense in depth even though the const [activity] = await db.select({ id: activities.id }).from(activities)
// OwnedRef brand already proves ownership.
const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId))); .where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
if (!activity) return false; 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)); await db.delete(activities).where(eq(activities.id, id));
return true; return true;
} }
@ -170,109 +123,13 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
return row ?? null; return row ?? null;
} }
export interface ActivityStats { export async function listActivities(ownerId: string) {
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<ActivityStats> {
const db = getDb(); 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<string>`count(*)`,
distance: sql<string>`coalesce(sum(${activities.distance}), 0)`,
elevationGain: sql<string>`coalesce(sum(${activities.elevationGain}), 0)`,
duration: sql<string>`coalesce(sum(${activities.duration}), 0)`,
last4Weeks: sql<string>`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 §D1D2). `publicOnly` scopes it like
* `getActivityStats`.
*/
export async function getWeeklyDistance(
ownerId: string,
opts: { publicOnly: boolean; weeks?: number },
): Promise<WeeklyDistanceBucket[]> {
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 const rows = await db
.select() .select()
.from(activities) .from(activities)
.where(eq(activities.ownerId, ownerId)) .where(eq(activities.ownerId, ownerId))
.orderBy(order); .orderBy(desc(activities.createdAt));
const ids = rows.map((r) => r.id); const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -284,19 +141,13 @@ export async function listActivities(
* listings (the public profile page); never includes `unlisted` or * listings (the public profile page); never includes `unlisted` or
* `private` content. * `private` content.
*/ */
export async function listPublicActivitiesForOwner( export async function listPublicActivitiesForOwner(ownerId: string) {
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",
limit: number = 100,
) {
const db = getDb(); const db = getDb();
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
const rows = await db const rows = await db
.select() .select()
.from(activities) .from(activities)
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public"))) .where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
.orderBy(order) .orderBy(desc(activities.createdAt));
.limit(limit);
const ids = rows.map((r) => r.id); const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
@ -304,40 +155,24 @@ export async function listPublicActivitiesForOwner(
} }
/** /**
* Social feed (spec: social-federation §8): aggregated activities from * Social feed: aggregated public activities from users that `followerId`
* actors that `followerId` follows with an *accepted* follow local * follows (accepted only). Reverse-chronological. Joins users for owner
* users and remote trails actors alike. Reverse-chronological on * attribution. Unlisted/private activities never appear, regardless of
* COALESCE(remote_published_at, created_at). * follow state.
*
* 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) { export async function listSocialFeed(followerId: string, limit: number = 50) {
const db = getDb(); const db = getDb();
const rows = await db
const local = db
.select({ .select({
id: activities.id, id: activities.id,
name: activities.name, name: activities.name,
sportType: activities.sportType,
distance: activities.distance, distance: activities.distance,
elevationGain: activities.elevationGain, elevationGain: activities.elevationGain,
duration: activities.duration, duration: activities.duration,
startedAt: activities.startedAt, startedAt: activities.startedAt,
createdAt: activities.createdAt, createdAt: activities.createdAt,
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"), ownerUsername: users.username,
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"), ownerDisplayName: users.displayName,
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`NULL`.as("external_url"),
remote: sql<boolean>`false`.as("remote"),
}) })
.from(activities) .from(activities)
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId)) .innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
@ -345,47 +180,14 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.where( .where(
and( and(
eq(follows.followerId, followerId), eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
eq(activities.visibility, "public"), eq(activities.visibility, "public"),
), ),
); )
.orderBy(desc(activities.createdAt))
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<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
remote: sql<boolean>`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); .limit(limit);
const localIds = rows.filter((r) => !r.remote).map((r) => r.id); const ids = rows.map((r) => r.id);
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map(); const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
} }
@ -402,7 +204,6 @@ export async function listRecentPublicActivities(limit: number = 20) {
.select({ .select({
id: activities.id, id: activities.id,
name: activities.name, name: activities.name,
sportType: activities.sportType,
distance: activities.distance, distance: activities.distance,
elevationGain: activities.elevationGain, elevationGain: activities.elevationGain,
duration: activities.duration, duration: activities.duration,
@ -422,28 +223,21 @@ export async function listRecentPublicActivities(limit: number = 20) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); 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(); const db = getDb();
await db await db
.update(activities) .update(activities)
.set({ routeId: ownedRoute.id }) .set({ routeId })
.where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId))); .where(eq(activities.id, activityId));
} }
export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise<string | null> { export async function createRouteFromActivity(activityId: string, ownerId: string): Promise<string | null> {
const { id: activityId, ownerId } = ownedActivity;
const db = getDb(); const db = getDb();
const [activity] = await db const [activity] = await db.select().from(activities).where(eq(activities.id, activityId));
.select()
.from(activities)
.where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId)));
if (!activity?.gpx) return null; if (!activity?.gpx) return null;
const { coords } = await processGpx(activity.gpx);
const routeId = randomUUID(); const routeId = randomUUID();
await db.insert(routes).values({
await db.transaction(async (tx) => {
await tx.insert(routes).values({
id: routeId, id: routeId,
ownerId, ownerId,
name: `Route from: ${activity.name}`, name: `Route from: ${activity.name}`,
@ -454,10 +248,13 @@ export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise<
elevationLoss: activity.elevationLoss, elevationLoss: activity.elevationLoss,
}); });
await writeGeom(tx, routeId, "routes", coords); await setGeomFromGpx(routeId, "routes", activity.gpx);
await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId)); // Link the activity to the new route
}); await db
.update(activities)
.set({ routeId })
.where(eq(activities.id, activityId));
return routeId; return routeId;
} }
@ -480,19 +277,13 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<str
if (ids.length === 0) return map; if (ids.length === 0) return map;
try { try {
const db = getDb(); const db = getDb();
// Use the query builder for the id list: a raw `ANY(${ids}::text[])` await Promise.all(ids.map(async (id) => {
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid const result = await db.execute(
// `ANY((...)::text[])` — which throws and silently drops every preview. sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
const rows = await db );
.select({ const row = (result as unknown as Array<{ geojson: string }>)[0];
id: activities.id, if (row?.geojson) map.set(id, row.geojson);
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`, }));
})
.from(activities)
.where(and(inArray(activities.id, ids), isNotNull(activities.geom)));
for (const row of rows) {
if (row.geojson) map.set(row.id, row.geojson);
}
} catch { } catch {
// Fallback: no geojson // Fallback: no geojson
} }

View file

@ -1,8 +1,8 @@
import { getOrigin } from "./config.server.ts";
// Canonical ActivityPub actor IRI for a local user. Used as the key in // 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 // `follows.followed_actor_iri` so the column shape is identical for local
// and (future) federated follows. // and (future) federated follows. Reading from `process.env.ORIGIN` keeps
// us aligned with the rest of the auth/federation stack.
export function localActorIri(username: string): string { export function localActorIri(username: string): string {
return `${getOrigin()}/users/${username}`; const origin = process.env.ORIGIN ?? "http://localhost:3000";
return `${origin}/users/${username}`;
} }

View file

@ -1,4 +1,3 @@
import type { z } from "zod";
import { getAuthenticatedUser } from "./oauth.server.ts"; import { getAuthenticatedUser } from "./oauth.server.ts";
import { TERMS_VERSION } from "./legal.ts"; import { TERMS_VERSION } from "./legal.ts";
import { ERROR_CODES } from "@trails-cool/api"; import { ERROR_CODES } from "@trails-cool/api";
@ -37,19 +36,3 @@ export async function requireApiUser(request: Request) {
export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) { export function apiError(status: number, code: string, message: string, fields?: Array<{ field: string; message: string }>) {
return Response.json({ error: message, code, fields }, { status }); 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<S extends z.ZodType>(
schema: S,
payload: z.input<S>,
init?: ResponseInit,
): Response {
return Response.json(schema.parse(payload), init);
}

View file

@ -12,16 +12,12 @@ import type {
AuthenticatorTransportFuture, AuthenticatorTransportFuture,
} from "@simplewebauthn/types"; } from "@simplewebauthn/types";
import { getDb } from "./db.ts"; 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 { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal";
const RP_NAME = "trails.cool"; const RP_NAME = "trails.cool";
const RP_ID = process.env.DOMAIN ?? "localhost"; const RP_ID = process.env.DOMAIN ?? "localhost";
const ORIGIN = getOrigin(); const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`;
// --- Registration --- // --- Registration ---
@ -95,27 +91,9 @@ export async function finishRegistration(
transports: response.response.transports, transports: response.response.transports,
}); });
await generateFederationKeysBestEffort(userId);
return 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<void> {
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 --- // --- Add Passkey to Existing Account ---
export async function addPasskeyStart(userId: string) { export async function addPasskeyStart(userId: string) {
@ -197,8 +175,6 @@ export async function registerWithMagicLink(
termsVersion, termsVersion,
}); });
await generateFederationKeysBestEffort(userId);
// Same shape as login's createMagicToken — token for the click-through // Same shape as login's createMagicToken — token for the click-through
// link, 6-digit code for paste-from-email/SMS flows (mobile). // link, 6-digit code for paste-from-email/SMS flows (mobile).
const token = randomBytes(32).toString("base64url"); const token = randomBytes(32).toString("base64url");
@ -277,16 +253,12 @@ function generateLoginCode(): string {
return String(num).padStart(6, "0"); return String(num).padStart(6, "0");
} }
export async function createMagicToken( export async function createMagicToken(email: string): Promise<{ token: string; code: string }> {
email: string,
): Promise<{ token: string; code: string } | null> {
const db = getDb(); const db = getDb();
// Returns null (not an error) when no account matches, so the caller // Check user exists
// can respond identically whether or not the email is registered —
// closing the account-enumeration oracle on the public login form.
const [user] = await db.select().from(users).where(eq(users.email, email)); 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 token = randomBytes(32).toString("base64url");
const code = generateLoginCode(); const code = generateLoginCode();
@ -306,13 +278,9 @@ export async function createMagicToken(
export async function verifyLoginCode(email: string, code: string): Promise<string> { export async function verifyLoginCode(email: string, code: string): Promise<string> {
const db = getDb(); 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 const [record] = await db
.update(magicTokens) .select()
.set({ usedAt: new Date() }) .from(magicTokens)
.where( .where(
and( and(
eq(magicTokens.email, email), eq(magicTokens.email, email),
@ -321,11 +289,15 @@ export async function verifyLoginCode(email: string, code: string): Promise<stri
gt(magicTokens.expiresAt, new Date()), gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt), isNull(magicTokens.usedAt),
), ),
) );
.returning();
if (!record) throw new Error("Invalid or expired code"); if (!record) throw new Error("Invalid or expired code");
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)); const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found"); if (!user) throw new Error("User not found");
@ -359,10 +331,9 @@ export async function initiateEmailChange(userId: string, newEmail: string): Pro
export async function verifyMagicToken(token: string): Promise<string> { export async function verifyMagicToken(token: string): Promise<string> {
const db = getDb(); const db = getDb();
// Atomic consume — see verifyLoginCode for rationale.
const [record] = await db const [record] = await db
.update(magicTokens) .select()
.set({ usedAt: new Date() }) .from(magicTokens)
.where( .where(
and( and(
eq(magicTokens.token, token), eq(magicTokens.token, token),
@ -370,11 +341,15 @@ export async function verifyMagicToken(token: string): Promise<string> {
gt(magicTokens.expiresAt, new Date()), gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt), isNull(magicTokens.usedAt),
), ),
) );
.returning();
if (!record) throw new Error("Invalid or expired magic link"); 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)); const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found"); if (!user) throw new Error("User not found");
@ -384,14 +359,9 @@ export async function verifyMagicToken(token: string): Promise<string> {
export async function verifyEmailChange(token: string, userId: string): Promise<string> { export async function verifyEmailChange(token: string, userId: string): Promise<string> {
const db = getDb(); 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 const [record] = await db
.update(magicTokens) .select()
.set({ usedAt: new Date() }) .from(magicTokens)
.where( .where(
and( and(
eq(magicTokens.token, token), eq(magicTokens.token, token),
@ -399,20 +369,25 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
gt(magicTokens.expiresAt, new Date()), gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt), isNull(magicTokens.usedAt),
), ),
) );
.returning();
if (!record) throw new Error("Invalid or expired verification link"); if (!record) throw new Error("Invalid or expired verification link");
const newEmail = record.email; const newEmail = record.email;
// Re-check email availability at verification time — someone may have // 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)); const [existing] = await db.select().from(users).where(eq(users.email, newEmail));
if (existing) { 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"); 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 await db
.update(users) .update(users)
.set({ email: newEmail }) .set({ email: newEmail })
@ -421,10 +396,23 @@ export async function verifyEmailChange(token: string, userId: string): Promise<
return newEmail; return newEmail;
} }
// Cookie session storage lives at `./auth/session.ts` (see ADR-0004 + the // --- Sessions ---
// `auth/completion.ts` chokepoint). Import session helpers directly from
// there; this file owns only per-method identity verification (passkey import { createCookieSessionStorage } from "react-router";
// ceremony + magic-token lifecycle) and Terms recording.
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. * A row that carries the minimum a visibility check needs.
@ -478,3 +466,23 @@ export async function recordTermsAcceptance(userId: string, termsVersion: string
.where(eq(users.id, userId)); .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);
}

View file

@ -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<string, string> = {}) {
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("/");
});
});

View file

@ -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<Response> {
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 });
}

View file

@ -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<Request> {
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");
}
});
});

View file

@ -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);
}

View file

@ -1,66 +1,20 @@
// Process-level pg-boss singleton. server.ts initializes the boss + starts // Module-level pg-boss singleton. server.ts initializes the boss + starts
// the worker; feature code (e.g., activities.server.ts) calls `getBoss()` // the worker; feature code (e.g., activities.server.ts) calls `getBoss()`
// to enqueue jobs against the same instance. The singleton's lifecycle // to enqueue jobs against the same instance. The singleton's lifecycle
// is bound to the Node process — startWorker calls boss.start(); the // is bound to the Node process — startWorker calls boss.start(); the
// SIGTERM handler stops it. // 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<T = unknown> {
id: string;
name: string;
data: T;
}
/** Queue depth counters (subset of pg-boss's `QueueResult`). */
export interface BossQueueDepth {
queuedCount: number;
readyCount: number;
deferredCount: number;
}
// Structurally typed (we only need `send`) so we don't have to pull
// pg-boss into the journal app's dep graph just for the typedef.
interface BossLike { interface BossLike {
send(queueName: string, data: unknown, options?: BossSendOptions): Promise<string | null>; send(queueName: string, data: unknown): Promise<string | null>;
work(queueName: string, handler: (jobs: BossJob[]) => Promise<unknown>): Promise<string>;
offWork(queueName: string): Promise<void>;
createQueue(queueName: string, options?: unknown): Promise<void>;
getQueue(queueName: string): Promise<BossQueueDepth | null>;
} }
const BOSS_KEY = Symbol.for("trails-cool.journal.pg-boss"); let _boss: BossLike | null = null;
type BossGlobal = { [BOSS_KEY]?: BossLike | null };
/** Set by server.ts once the boss is created + started. */ /** Set by server.ts once the boss is created + started. */
export function setBoss(boss: BossLike | null): void { export function setBoss(boss: BossLike): void {
(globalThis as BossGlobal)[BOSS_KEY] = boss; _boss = boss;
} }
/** /**
@ -69,25 +23,10 @@ export function setBoss(boss: BossLike | null): void {
* server context). * server context).
*/ */
export function getBoss(): BossLike { export function getBoss(): BossLike {
const boss = (globalThis as BossGlobal)[BOSS_KEY]; if (!_boss) {
if (!boss) {
throw new Error("pg-boss not initialized — getBoss called before server bootstrap"); throw new Error("pg-boss not initialized — getBoss called before server bootstrap");
} }
return boss; 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<K extends JobName>(
queue: K,
data: JobPayloads[K],
options?: BossSendOptions,
): Promise<void> {
await getBoss().send(queue, data, options);
} }
/** /**
@ -95,15 +34,18 @@ export async function enqueue<K extends JobName>(
* outage doesn't fail the user-visible request that triggered the * outage doesn't fail the user-visible request that triggered the
* fan-out. Use this for "fire and forget" notifications work. * fan-out. Use this for "fire and forget" notifications work.
*/ */
export async function enqueueOptional<K extends JobName>( export async function enqueueOptional(
queue: K, queue: string,
data: JobPayloads[K], data: unknown,
ctx: Record<string, unknown> = {}, ctx: Record<string, unknown> = {},
options?: BossSendOptions,
): Promise<void> { ): Promise<void> {
try { try {
await enqueue(queue, data, options); const boss = getBoss();
await boss.send(queue, data);
} catch (err) { } catch (err) {
// Lazy import to avoid cycles in test environments where logger.server
// pulls in env-dependent setup.
const { logger } = await import("./logger.server.ts");
logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing"); logger.warn({ err, queue, ...ctx }, "boss.send failed; continuing");
} }
} }

View file

@ -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/);
});
});

Some files were not shown because too many files have changed in this diff Show more