Compare commits
1 commit
main
...
spec-drift
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38617ce0aa |
674 changed files with 9556 additions and 33870 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -62,10 +59,10 @@ Depending on what the user brings, you might:
|
||||||
│ Use ASCII diagrams liberally │
|
│ Use ASCII diagrams liberally │
|
||||||
├─────────────────────────────────────────┤
|
├─────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌────────┐ ┌────────┐ │
|
│ ┌────────┐ ┌────────┐ │
|
||||||
│ │ State │────────▶│ State │ │
|
│ │ State │────────▶│ State │ │
|
||||||
│ │ A │ │ B │ │
|
│ │ A │ │ B │ │
|
||||||
│ └────────┘ └────────┘ │
|
│ └────────┘ └────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ System diagrams, state machines, │
|
│ System diagrams, state machines, │
|
||||||
│ data flows, architecture sketches, │
|
│ data flows, architecture sketches, │
|
||||||
|
|
@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
If the user mentions a change or you detect one is relevant:
|
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..."
|
||||||
|
|
@ -121,14 +119,14 @@ 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` |
|
||||||
| Scope changed | `proposal.md` |
|
| Scope changed | `proposal.md` |
|
||||||
| New work identified | `tasks.md` |
|
| New work identified | `tasks.md` |
|
||||||
| Assumption invalidated | Relevant artifact |
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
Example offers:
|
Example offers:
|
||||||
- "That's a design decision. Capture it in design.md?"
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
|
|
||||||
|
|
@ -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>"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../.agents/skills
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -59,10 +56,10 @@ Depending on what the user brings, you might:
|
||||||
│ Use ASCII diagrams liberally │
|
│ Use ASCII diagrams liberally │
|
||||||
├─────────────────────────────────────────┤
|
├─────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌────────┐ ┌────────┐ │
|
│ ┌────────┐ ┌────────┐ │
|
||||||
│ │ State │────────▶│ State │ │
|
│ │ State │────────▶│ State │ │
|
||||||
│ │ A │ │ B │ │
|
│ │ A │ │ B │ │
|
||||||
│ └────────┘ └────────┘ │
|
│ └────────┘ └────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ System diagrams, state machines, │
|
│ System diagrams, state machines, │
|
||||||
│ data flows, architecture sketches, │
|
│ data flows, architecture sketches, │
|
||||||
|
|
@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
If the user mentions a change or you detect one is relevant:
|
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..."
|
||||||
|
|
@ -116,14 +114,14 @@ 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` |
|
||||||
| Scope changed | `proposal.md` |
|
| Scope changed | `proposal.md` |
|
||||||
| New work identified | `tasks.md` |
|
| New work identified | `tasks.md` |
|
||||||
| Assumption invalidated | Relevant artifact |
|
| Assumption invalidated | Relevant artifact |
|
||||||
|
|
||||||
Example offers:
|
Example offers:
|
||||||
- "That's a design decision. Capture it in design.md?"
|
- "That's a design decision. Capture it in design.md?"
|
||||||
|
|
@ -229,7 +227,7 @@ User: A CLI tool that tracks local dev environments
|
||||||
You: That changes everything.
|
You: That changes everything.
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────┐
|
||||||
│ CLI TOOL DATA STORAGE │
|
│ CLI TOOL DATA STORAGE │
|
||||||
└─────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
Key constraints:
|
Key constraints:
|
||||||
|
|
@ -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>"
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
|
|
@ -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: "/"
|
||||||
|
|
|
||||||
67
.github/workflows/cd-apps.yml
vendored
67
.github/workflows/cd-apps.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
7
.github/workflows/cd-brouter.yml
vendored
7
.github/workflows/cd-brouter.yml
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
70
.github/workflows/cd-infra.yml
vendored
70
.github/workflows/cd-infra.yml
vendored
|
|
@ -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
|
||||||
|
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
fi
|
fi
|
||||||
|
|
||||||
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
|
|
||||||
|
|
||||||
# Apply config-only changes that `up -d` skips — it recreates a
|
|
||||||
# container only when its compose *definition* changes, not when a
|
|
||||||
# mounted config file's content changes. The configs are mounted as
|
|
||||||
# directories (not single files), so a reload/restart re-reads the
|
|
||||||
# freshly scp'd file; a single-file mount would have pinned the old
|
|
||||||
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
|
|
||||||
# the same container stayed up; a recreated container already loaded
|
|
||||||
# the new config on startup. Loki and Promtail reload their main
|
|
||||||
# config only on restart; Caddy reloads gracefully (validates, swaps
|
|
||||||
# live, no downtime).
|
|
||||||
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
|
|
||||||
docker compose --env-file .env kill -s SIGHUP prometheus
|
|
||||||
fi
|
|
||||||
docker compose --env-file .env restart loki promtail
|
|
||||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|
||||||
|
|
||||||
docker compose ps
|
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
|
||||||
|
|
|
||||||
95
.github/workflows/cd-staging.yml
vendored
95
.github/workflows/cd-staging.yml
vendored
|
|
@ -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,30 @@ 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 -->`
|
# Skip GH-Actions-only Dependabot PRs — there is no app image to preview.
|
||||||
# marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there
|
|
||||||
# is no app image to preview.
|
|
||||||
if: >
|
if: >
|
||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
github.event.action != 'closed' &&
|
github.event.action != 'closed' &&
|
||||||
!startsWith(github.head_ref, 'dependabot/github_actions/') &&
|
!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
|
||||||
|
|
@ -264,17 +220,6 @@ 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"
|
|
||||||
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
|
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||||
|
|
||||||
- name: Generate per-PR Caddyfile snippet
|
- name: Generate per-PR Caddyfile snippet
|
||||||
|
|
@ -374,22 +319,12 @@ jobs:
|
||||||
|
|
||||||
# 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 "$ENV_FILE" 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 "$ENV_FILE" 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 "$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
|
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 —
|
|
||||||
# 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
|
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
|
||||||
|
|
@ -424,15 +359,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,7 +373,7 @@ 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: |
|
||||||
|
|
|
||||||
130
.github/workflows/ci.yml
vendored
130
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||||
|
|
@ -117,7 +117,7 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
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:
|
||||||
|
|
@ -127,7 +127,7 @@ jobs:
|
||||||
|
|
||||||
- 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') }}
|
||||||
|
|
@ -183,7 +183,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:
|
||||||
|
|
@ -193,7 +193,7 @@ jobs:
|
||||||
|
|
||||||
- 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-v1.7.9
|
||||||
|
|
@ -231,27 +231,9 @@ jobs:
|
||||||
- name: Seed database
|
- name: Seed database
|
||||||
run: pnpm db:seed
|
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,10 +255,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"
|
E2E: "true"
|
||||||
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
|
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
|
||||||
|
|
||||||
|
|
@ -316,87 +294,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
|
|
||||||
|
|
|
||||||
94
.github/workflows/dependabot-auto-fix.yml
vendored
94
.github/workflows/dependabot-auto-fix.yml
vendored
|
|
@ -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
74
.github/workflows/dependabot-dedupe.yml
vendored
Normal 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
|
||||||
56
.github/workflows/disk-maintenance.yml
vendored
56
.github/workflows/disk-maintenance.yml
vendored
|
|
@ -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."
|
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
|
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
|
# Use a token with push rights so the commit-back step can push
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,4 +16,3 @@ 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/
|
|
||||||
|
|
|
||||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -41,7 +41,9 @@ 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)
|
||||||
|
ui/ — Shared React components (Tailwind)
|
||||||
|
map/ — React/Leaflet components (MapView, RouteLayer); re-exports map-core
|
||||||
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
|
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
|
||||||
gpx/ — GPX parsing, generation, validation
|
gpx/ — GPX parsing, generation, validation
|
||||||
fit/ — FIT file generation (Wahoo route push)
|
fit/ — FIT file generation (Wahoo route push)
|
||||||
|
|
@ -123,8 +125,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 +257,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:
|
||||||
|
|
|
||||||
198
FEDERATION.md
198
FEDERATION.md
|
|
@ -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`.*
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 ~200–700, 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}`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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%");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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]);
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { defineJournalJob } from "./payloads.ts";
|
import type { JobDefinition } from "@trails-cool/jobs";
|
||||||
import { and, lt, inArray, count } from "drizzle-orm";
|
import { and, lt, inArray, count } from "drizzle-orm";
|
||||||
import { getDb } from "../lib/db.ts";
|
import { getDb } from "../lib/db.ts";
|
||||||
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
|
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
|
||||||
|
|
@ -7,7 +7,7 @@ import { logger } from "../lib/logger.server.ts";
|
||||||
const STALE_MS = 10 * 60 * 1000;
|
const STALE_MS = 10 * 60 * 1000;
|
||||||
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
|
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
|
||||||
|
|
||||||
export const importBatchesSweepJob = defineJournalJob({
|
export const importBatchesSweepJob: JobDefinition = {
|
||||||
name: "import-batches-sweep",
|
name: "import-batches-sweep",
|
||||||
cron: "* * * * *",
|
cron: "* * * * *",
|
||||||
retryLimit: 0,
|
retryLimit: 0,
|
||||||
|
|
@ -39,4 +39,4 @@ export const importBatchesSweepJob = defineJournalJob({
|
||||||
|
|
||||||
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
|
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,36 +1,27 @@
|
||||||
import { defineJournalJob } from "./payloads.ts";
|
import type { JobDefinition } from "@trails-cool/jobs";
|
||||||
import { logger } from "../lib/logger.server.ts";
|
import { logger } from "../lib/logger.server.ts";
|
||||||
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
|
import { runKomootBulkImport } from "../lib/komoot-bulk-import.server.ts";
|
||||||
import {
|
|
||||||
markBatchFailed,
|
|
||||||
runKomootBulkImport,
|
|
||||||
type KomootCreds,
|
|
||||||
} from "../lib/komoot-bulk-import.server.ts";
|
|
||||||
|
|
||||||
export const komootBulkImportJob = defineJournalJob({
|
type KomootCreds =
|
||||||
|
| { mode: "public"; komootUserId: string }
|
||||||
|
| { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string };
|
||||||
|
|
||||||
|
interface KomootBulkImportData extends Record<string, unknown> {
|
||||||
|
batchId: string;
|
||||||
|
userId: string;
|
||||||
|
creds: KomootCreds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const komootBulkImportJob: JobDefinition<KomootBulkImportData> = {
|
||||||
name: "komoot-bulk-import",
|
name: "komoot-bulk-import",
|
||||||
retryLimit: 1,
|
retryLimit: 1,
|
||||||
expireInSeconds: 1800,
|
expireInSeconds: 1800,
|
||||||
async handler(jobs) {
|
async handler(jobs) {
|
||||||
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
||||||
for (const job of batch) {
|
for (const job of batch) {
|
||||||
const { batchId, userId, serviceId } = job.data;
|
const { batchId, userId, creds } = job.data;
|
||||||
logger.info({ batchId, userId }, "komoot bulk import job started");
|
logger.info({ batchId, userId }, "komoot bulk import job started");
|
||||||
try {
|
await runKomootBulkImport(batchId, userId, creds);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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_.-]+$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import { defineJournalJob } from "./payloads.ts";
|
import type { JobDefinition } from "@trails-cool/jobs";
|
||||||
import { sendWelcome } from "../lib/email.server.ts";
|
import { sendWelcome } from "../lib/email.server.ts";
|
||||||
import { logger } from "../lib/logger.server.ts";
|
import { logger } from "../lib/logger.server.ts";
|
||||||
|
|
||||||
|
interface WelcomeEmailData {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue-backed welcome email send. The old code did
|
* Queue-backed welcome email send. The old code did
|
||||||
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
|
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
|
||||||
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
|
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
|
||||||
* failures via the dead-letter queue / logs.
|
* failures via the dead-letter queue / logs.
|
||||||
*/
|
*/
|
||||||
export const sendWelcomeEmailJob = defineJournalJob({
|
export const sendWelcomeEmailJob: JobDefinition = {
|
||||||
name: "send-welcome-email",
|
name: "send-welcome-email",
|
||||||
retryLimit: 3,
|
retryLimit: 3,
|
||||||
expireInSeconds: 120,
|
expireInSeconds: 120,
|
||||||
async handler(job) {
|
async handler(job) {
|
||||||
const batch = Array.isArray(job) ? job : [job];
|
const batch = Array.isArray(job) ? job : [job];
|
||||||
for (const item of batch) {
|
for (const item of batch) {
|
||||||
const { email, username } = item.data;
|
const { email, username } = item.data as WelcomeEmailData;
|
||||||
try {
|
try {
|
||||||
await sendWelcome(email, username);
|
await sendWelcome(email, username);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -24,4 +29,4 @@ export const sendWelcomeEmailJob = defineJournalJob({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
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 { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
||||||
import type { ProcessedGpx } from "./gpx-save.server.ts";
|
import type { GpxData } from "./gpx-save.server.ts";
|
||||||
import { enqueueOptional } from "./boss.server.ts";
|
|
||||||
import type { OwnedRef } from "./ownership.server.ts";
|
|
||||||
import {
|
|
||||||
enqueueActivityDeliveries,
|
|
||||||
visibilityTransitionAction,
|
|
||||||
} from "./federation-delivery.server.ts";
|
|
||||||
|
|
||||||
export interface ActivityInput {
|
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;
|
||||||
|
|
@ -27,43 +19,27 @@ export interface ActivityInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
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,7 +47,7 @@ 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 parsed: GpxData | 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;
|
||||||
|
|
@ -79,12 +55,13 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||||
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);
|
parsed = await validateGpx(input.gpx);
|
||||||
// GPX-derived distance wins unless it is zero; caller input is the fallback
|
distance = parsed.distance || distance;
|
||||||
distance = processed.stats.distance || distance;
|
elevationGain = parsed.elevation.gain;
|
||||||
elevationGain = processed.stats.elevationGain;
|
elevationLoss = parsed.elevation.loss;
|
||||||
elevationLoss = processed.stats.elevationLoss;
|
if (!startedAt && parsed.tracks[0]?.[0]?.time) {
|
||||||
startedAt = startedAt ?? processed.stats.startTime;
|
startedAt = new Date(parsed.tracks[0][0].time);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
@ -94,7 +71,6 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||||
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,
|
||||||
|
|
@ -105,8 +81,9 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||||
...(input.synthetic ? { synthetic: true } : {}),
|
...(input.synthetic ? { synthetic: true } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (input.gpx && processed) {
|
if (input.gpx && parsed) {
|
||||||
await writeGeom(tx, id, "activities", processed.coords);
|
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||||
|
await writeGeom(tx, id, "activities", coords);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,21 +91,8 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||||
// 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 +107,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,98 +125,6 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityStats {
|
|
||||||
count: number;
|
|
||||||
/** metres */
|
|
||||||
distance: number;
|
|
||||||
/** metres */
|
|
||||||
elevationGain: number;
|
|
||||||
/** seconds, elapsed */
|
|
||||||
duration: number;
|
|
||||||
/** activities started in the last 28 days */
|
|
||||||
last4Weeks: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregate roll-up for a profile. One indexed aggregate over stored columns
|
|
||||||
* (no GPX parsing) — `owner_id` leads two existing indexes, so this is cheap
|
|
||||||
* even for power users (see profile-stats design §D1). `publicOnly` scopes the
|
|
||||||
* roll-up to what a visitor may see; the owner passes `false` for full totals.
|
|
||||||
*/
|
|
||||||
export async function getActivityStats(
|
|
||||||
ownerId: string,
|
|
||||||
opts: { publicOnly: boolean },
|
|
||||||
): Promise<ActivityStats> {
|
|
||||||
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 §D1–D2). `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(
|
export async function listActivities(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
sort: "startedAt" | "addedAt" = "startedAt",
|
sort: "startedAt" | "addedAt" = "startedAt",
|
||||||
|
|
@ -304,40 +167,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 +192,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 +216,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,24 +235,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 parsed = await validateGpx(activity.gpx);
|
||||||
|
const coords = parsed.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
|
||||||
const routeId = randomUUID();
|
const routeId = randomUUID();
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
@ -480,17 +290,12 @@ 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[])`
|
const result = await db.execute(
|
||||||
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid
|
sql`SELECT id, ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson
|
||||||
// `ANY((...)::text[])` — which throws and silently drops every preview.
|
FROM journal.activities
|
||||||
const rows = await db
|
WHERE id = ANY(${ids}::text[]) AND geom IS NOT NULL`,
|
||||||
.select({
|
);
|
||||||
id: activities.id,
|
for (const row of result as unknown as Array<{ id: string; geojson: string }>) {
|
||||||
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);
|
if (row.geojson) map.set(row.id, row.geojson);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,6 @@ import type {
|
||||||
} from "@simplewebauthn/types";
|
} from "@simplewebauthn/types";
|
||||||
import { getDb } from "./db.ts";
|
import { getDb } from "./db.ts";
|
||||||
import { getOrigin } from "./config.server.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";
|
||||||
|
|
||||||
|
|
@ -95,27 +92,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 +176,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 +254,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 +279,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 +290,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 +332,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 +342,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 +360,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 +370,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 })
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ import { createCookieSessionStorage, redirect } from "react-router";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { users } from "@trails-cool/db/schema/journal";
|
import { users } from "@trails-cool/db/schema/journal";
|
||||||
import { getDb } from "../db.ts";
|
import { getDb } from "../db.ts";
|
||||||
import { requireSecret } from "../config.server.ts";
|
|
||||||
|
|
||||||
const sessionSecret = requireSecret("SESSION_SECRET", "dev-secret-change-in-production");
|
const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production";
|
||||||
|
|
||||||
export const sessionStorage = createCookieSessionStorage({
|
export const sessionStorage = createCookieSessionStorage({
|
||||||
cookie: {
|
cookie: {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,38 +17,3 @@ describe("getOrigin", () => {
|
||||||
expect(getOrigin()).toBe("http://localhost:3000");
|
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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -5,31 +5,3 @@
|
||||||
export function getOrigin(): string {
|
export function getOrigin(): string {
|
||||||
return process.env.ORIGIN ?? "http://localhost:3000";
|
return process.env.ORIGIN ?? "http://localhost:3000";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a required secret from the environment. Returns the env value when
|
|
||||||
* set. In production, throws if the env var is missing or matches the
|
|
||||||
* known-public dev fallback — silently shipping a default secret to prod
|
|
||||||
* is a credential leak. In dev/test, returns the supplied fallback so the
|
|
||||||
* local loop keeps working without ceremony.
|
|
||||||
*
|
|
||||||
* Use this for any value where a leaked default would be a security
|
|
||||||
* incident: signing keys, session secrets, database credentials.
|
|
||||||
*/
|
|
||||||
export function requireSecret(name: string, devFallback: string): string {
|
|
||||||
const value = process.env[name];
|
|
||||||
// Playwright runs `react-router serve` (NODE_ENV=production) against a
|
|
||||||
// local stack. E2E=true is the explicit opt-out so the guard still
|
|
||||||
// bites in real prod deploys.
|
|
||||||
const isProd = process.env.NODE_ENV === "production" && process.env.E2E !== "true";
|
|
||||||
if (isProd) {
|
|
||||||
if (!value || value === devFallback) {
|
|
||||||
throw new Error(
|
|
||||||
`Refusing to start: ${name} is unset or matches the known-public dev fallback. ` +
|
|
||||||
`Set ${name} to a strong, unique value in production.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return value ?? devFallback;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock fit-file-parser to return whatever synthetic parsed object a test sets,
|
|
||||||
// so we exercise the converter's logic (segmentation, validation, sport) over
|
|
||||||
// the parser's OUTPUT shape without needing binary fixtures. `h.parsed` is the
|
|
||||||
// { records, events, sessions } object fit-file-parser would produce.
|
|
||||||
const h = vi.hoisted(() => ({ parsed: {} as Record<string, unknown> }));
|
|
||||||
vi.mock("fit-file-parser", () => ({
|
|
||||||
default: class {
|
|
||||||
parse(_buffer: unknown, cb: (err: unknown, data: unknown) => void) {
|
|
||||||
cb(null, h.parsed);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { fitToGpx } = await import("./fit.ts");
|
|
||||||
|
|
||||||
const buf = Buffer.from("");
|
|
||||||
const trksegCount = (gpx: string) => (gpx.match(/<trkseg>/g) ?? []).length;
|
|
||||||
const trkptCount = (gpx: string) => (gpx.match(/<trkpt/g) ?? []).length;
|
|
||||||
|
|
||||||
// A GPS record `n` seconds after the base time.
|
|
||||||
const BASE = Date.parse("2026-01-01T10:00:00Z");
|
|
||||||
const rec = (secs: number, extra: Record<string, unknown> = {}) => ({
|
|
||||||
position_lat: 52.5 + secs * 1e-5,
|
|
||||||
position_long: 13.4 + secs * 1e-5,
|
|
||||||
altitude: 40 + secs,
|
|
||||||
timestamp: new Date(BASE + secs * 1000),
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fitToGpx — extraction & validation", () => {
|
|
||||||
it("converts GPS records to a GPX track", async () => {
|
|
||||||
h.parsed = { records: [rec(0), rec(10), rec(20)] };
|
|
||||||
const { gpx } = await fitToGpx(buf, "ride");
|
|
||||||
expect(gpx).not.toBeNull();
|
|
||||||
expect(trksegCount(gpx!)).toBe(1);
|
|
||||||
expect(trkptCount(gpx!)).toBe(3);
|
|
||||||
expect(gpx!).toContain("52.5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null gpx for a file with no GPS records (indoor)", async () => {
|
|
||||||
h.parsed = {
|
|
||||||
records: [{ altitude: 40, timestamp: new Date(BASE) }, { altitude: 41, timestamp: new Date(BASE + 1000) }],
|
|
||||||
sessions: [{ sport: "cycling", sub_sport: "indoor_cycling" }],
|
|
||||||
};
|
|
||||||
const { gpx, sport } = await fitToGpx(buf, "trainer");
|
|
||||||
expect(gpx).toBeNull();
|
|
||||||
expect(sport).toBe("ride"); // sport still surfaced
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops out-of-range coords and records without a timestamp", async () => {
|
|
||||||
h.parsed = {
|
|
||||||
records: [
|
|
||||||
rec(0),
|
|
||||||
{ position_lat: 999, position_long: 13.4, timestamp: new Date(BASE + 5000) }, // bad lat
|
|
||||||
{ position_lat: 52.5, position_long: 13.4 }, // no timestamp
|
|
||||||
rec(10),
|
|
||||||
rec(20),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { gpx } = await fitToGpx(buf, "r");
|
|
||||||
expect(trkptCount(gpx!)).toBe(3); // only the 3 valid records
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers enhanced_altitude and drops non-finite altitude", async () => {
|
|
||||||
h.parsed = {
|
|
||||||
records: [
|
|
||||||
rec(0, { altitude: 40, enhanced_altitude: 100 }),
|
|
||||||
rec(10, { altitude: Infinity }),
|
|
||||||
rec(20, { altitude: 42 }),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { gpx } = await fitToGpx(buf, "r");
|
|
||||||
expect(gpx!).toContain("<ele>100</ele>"); // enhanced wins
|
|
||||||
expect(trkptCount(gpx!)).toBe(3); // non-finite altitude → point kept, no ele
|
|
||||||
// the middle point has no <ele>
|
|
||||||
expect((gpx!.match(/<ele>/g) ?? []).length).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fitToGpx — segmentation", () => {
|
|
||||||
it("splits on a timer stop event (short pause, no gap)", async () => {
|
|
||||||
h.parsed = {
|
|
||||||
records: [rec(0), rec(10), rec(120), rec(130)],
|
|
||||||
events: [{ event: "timer", event_type: "stop_all", timestamp: new Date(BASE + 15_000) }],
|
|
||||||
};
|
|
||||||
const { gpx } = await fitToGpx(buf, "r");
|
|
||||||
expect(trksegCount(gpx!)).toBe(2); // split at the pause, not merged
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits on a record gap > 5 min when there are no events", async () => {
|
|
||||||
h.parsed = { records: [rec(0), rec(10), rec(400), rec(410)] }; // 390s gap between #2 and #3
|
|
||||||
const { gpx } = await fitToGpx(buf, "r");
|
|
||||||
expect(trksegCount(gpx!)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a single segment for a continuous ride", async () => {
|
|
||||||
h.parsed = { records: [rec(0), rec(10), rec(20), rec(30)] };
|
|
||||||
const { gpx } = await fitToGpx(buf, "r");
|
|
||||||
expect(trksegCount(gpx!)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits per-session segments for a multisport file (one activity)", async () => {
|
|
||||||
h.parsed = {
|
|
||||||
records: [rec(0), rec(10), rec(600), rec(610)],
|
|
||||||
sessions: [
|
|
||||||
{ start_time: new Date(BASE), total_elapsed_time: 60, sport: "running" },
|
|
||||||
{ start_time: new Date(BASE + 600_000), total_elapsed_time: 60, sport: "cycling" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { gpx } = await fitToGpx(buf, "tri");
|
|
||||||
expect(trksegCount(gpx!)).toBe(2); // one segment per session window
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fitToGpx — sport mapping", () => {
|
|
||||||
const sportOf = async (sport?: string, sub_sport?: string) => {
|
|
||||||
h.parsed = { records: [rec(0), rec(10)], sessions: [{ sport, sub_sport }] };
|
|
||||||
return (await fitToGpx(buf, "r")).sport;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("maps base sports and sub-sport refinements", async () => {
|
|
||||||
expect(await sportOf("running")).toBe("run");
|
|
||||||
expect(await sportOf("cycling")).toBe("ride");
|
|
||||||
expect(await sportOf("cycling", "gravel_cycling")).toBe("gravel");
|
|
||||||
expect(await sportOf("cycling", "mountain")).toBe("mtb");
|
|
||||||
expect(await sportOf("running", "trail")).toBe("run");
|
|
||||||
expect(await sportOf("hiking")).toBe("hike");
|
|
||||||
expect(await sportOf("walking")).toBe("walk");
|
|
||||||
expect(await sportOf("alpine_skiing")).toBe("ski");
|
|
||||||
expect(await sportOf("swimming")).toBe("other");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for generic/absent sport (caller falls back to provider)", async () => {
|
|
||||||
expect(await sportOf("generic")).toBeNull();
|
|
||||||
expect(await sportOf(undefined)).toBeNull();
|
|
||||||
h.parsed = { records: [rec(0), rec(10)] }; // no sessions at all
|
|
||||||
expect((await fitToGpx(buf, "r")).sport).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,249 +1,38 @@
|
||||||
// Shared FIT file → GPX converter for provider importers.
|
// Shared FIT file → GPX converter for provider importers.
|
||||||
//
|
//
|
||||||
// FIT is an open standard (Garmin/ANT+) used by Wahoo, Garmin, Coros, and
|
// FIT is an open standard (Garmin/ANT+) used by Wahoo, Garmin, Coros, and
|
||||||
// others. This module is the single conversion seam for every provider that
|
// others. This module is shared across all providers that produce FIT files
|
||||||
// produces FIT files.
|
// so the conversion logic lives in one place.
|
||||||
//
|
|
||||||
// Beyond flattening records to a track, it is pause- and session-aware:
|
|
||||||
// - timer stop/start events (or, as a fallback, record gaps > 5 min) split
|
|
||||||
// the output into multiple <trkseg>s, so downstream moving-time and speed
|
|
||||||
// math never bridges a pause;
|
|
||||||
// - multi-session (multisport) files emit one-or-more segments per session,
|
|
||||||
// records sliced to each session's time window (still one activity);
|
|
||||||
// - it prefers the corrected `enhanced_altitude`, validates coordinates, and
|
|
||||||
// surfaces the file's sport mapped to the Journal's SportType.
|
|
||||||
//
|
|
||||||
// Validation policy intentionally mirrors gpx-parser-robustness so both import
|
|
||||||
// formats behave identically downstream.
|
|
||||||
|
|
||||||
import FitParser from "fit-file-parser";
|
import FitParser from "fit-file-parser";
|
||||||
import { generateGpx, type TrackPoint } from "@trails-cool/gpx";
|
import { generateGpx } from "@trails-cool/gpx";
|
||||||
import type { SportType } from "@trails-cool/db/schema/journal";
|
|
||||||
|
|
||||||
// A record-to-record timestamp gap larger than this starts a new segment in
|
export async function fitToGpx(buffer: Buffer, name: string): Promise<string | null> {
|
||||||
// files without (reliable) timer events. Matches movingTime's MAX_GAP
|
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||||
// philosophy at a coarser grain.
|
|
||||||
const GAP_SPLIT_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
// --- Raw fit-file-parser shapes (only the fields we consume) ---
|
|
||||||
|
|
||||||
interface FitRecord {
|
|
||||||
position_lat?: number; // degrees (parser converts semicircles)
|
|
||||||
position_long?: number;
|
|
||||||
altitude?: number;
|
|
||||||
enhanced_altitude?: number;
|
|
||||||
timestamp?: string | Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FitEvent {
|
|
||||||
event?: string; // "timer"
|
|
||||||
event_type?: string; // "stop_all" | "stop" | "start"
|
|
||||||
timestamp?: string | Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FitSession {
|
|
||||||
start_time?: string | Date;
|
|
||||||
total_elapsed_time?: number; // seconds, wall-clock (includes pauses)
|
|
||||||
total_timer_time?: number; // seconds, moving
|
|
||||||
sport?: string;
|
|
||||||
sub_sport?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedFit {
|
|
||||||
records?: FitRecord[];
|
|
||||||
events?: FitEvent[];
|
|
||||||
sessions?: FitSession[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A record that passed validation, with its timestamp resolved to epoch ms. */
|
|
||||||
interface ValidPoint {
|
|
||||||
lat: number;
|
|
||||||
lon: number;
|
|
||||||
ele?: number;
|
|
||||||
t: number; // epoch ms
|
|
||||||
time: string; // ISO 8601
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FitConversion {
|
|
||||||
/** GPX string, or null when the file has no usable GPS track. */
|
|
||||||
gpx: string | null;
|
|
||||||
/** The file's sport mapped to a Journal SportType, or null if unknown. */
|
|
||||||
sport: SportType | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parse(buffer: Buffer): Promise<ParsedFit> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parser = new FitParser({ force: true });
|
const parser = new FitParser({ force: true });
|
||||||
// fit-file-parser's typing requires `Buffer<ArrayBuffer>` specifically;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// a generic Node `Buffer` is structurally `Buffer<ArrayBufferLike>`.
|
parser.parse(buffer as any, (error: unknown, data: any) => {
|
||||||
// The runtime accepts either, so coerce the underlying buffer slot.
|
|
||||||
parser.parse(buffer as Buffer<ArrayBuffer>, (error, data) => {
|
|
||||||
if (error) reject(error);
|
if (error) reject(error);
|
||||||
else resolve((data ?? {}) as ParsedFit);
|
else resolve(data ?? {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
const records = (parsed.records ?? []) as Array<{
|
||||||
function toMillis(value: string | Date | undefined): number | null {
|
position_lat?: number;
|
||||||
if (value instanceof Date) {
|
position_long?: number;
|
||||||
const t = value.getTime();
|
altitude?: number;
|
||||||
return Number.isFinite(t) ? t : null;
|
timestamp?: string | Date;
|
||||||
}
|
}>;
|
||||||
if (typeof value === "string") {
|
|
||||||
const t = Date.parse(value);
|
const trackPoints = records
|
||||||
return Number.isFinite(t) ? t : null;
|
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||||
}
|
.map((r) => ({
|
||||||
return null;
|
lat: r.position_lat!,
|
||||||
}
|
lon: r.position_long!,
|
||||||
|
ele: r.altitude,
|
||||||
/**
|
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||||
* Validate + normalize one record. Drops records with missing/non-finite/
|
}));
|
||||||
* out-of-range coordinates or no timestamp; prefers enhanced altitude; treats
|
|
||||||
* non-finite altitude as absent (point kept). Records without a position are
|
if (trackPoints.length < 2) return null;
|
||||||
* dropped (indoor/no-GPS samples never become track points).
|
return generateGpx({ name, tracks: [trackPoints] });
|
||||||
*/
|
|
||||||
function toValidPoint(r: FitRecord): ValidPoint | null {
|
|
||||||
const { position_lat: lat, position_long: lon } = r;
|
|
||||||
if (lat == null || lon == null) return null;
|
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
|
||||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
|
||||||
|
|
||||||
const t = toMillis(r.timestamp);
|
|
||||||
if (t === null) return null; // FIT records are timestamped by spec; absence = corruption
|
|
||||||
|
|
||||||
const rawEle = r.enhanced_altitude ?? r.altitude;
|
|
||||||
const ele = typeof rawEle === "number" && Number.isFinite(rawEle) ? rawEle : undefined;
|
|
||||||
|
|
||||||
return { lat, lon, ele, t, time: new Date(t).toISOString() };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a session's ordered points at pauses (timer stops) and long gaps. */
|
|
||||||
function splitByPausesAndGaps(points: ValidPoint[], stopTimes: number[]): ValidPoint[][] {
|
|
||||||
const segments: ValidPoint[][] = [];
|
|
||||||
let current: ValidPoint[] = [];
|
|
||||||
for (const p of points) {
|
|
||||||
if (current.length > 0) {
|
|
||||||
const prev = current[current.length - 1]!;
|
|
||||||
const gap = p.t - prev.t > GAP_SPLIT_MS;
|
|
||||||
const pausedBetween = stopTimes.some((ts) => ts >= prev.t && ts < p.t);
|
|
||||||
if (gap || pausedBetween) {
|
|
||||||
segments.push(current);
|
|
||||||
current = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current.push(p);
|
|
||||||
}
|
|
||||||
if (current.length > 0) segments.push(current);
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `[start, end]` epoch-ms windows per session, sorted by start. */
|
|
||||||
function sessionWindows(sessions: FitSession[]): Array<[number, number]> {
|
|
||||||
const starts = sessions
|
|
||||||
.map((s) => ({
|
|
||||||
start: toMillis(s.start_time),
|
|
||||||
dur: s.total_elapsed_time ?? s.total_timer_time,
|
|
||||||
}))
|
|
||||||
.filter((s): s is { start: number; dur: number | undefined } => s.start !== null)
|
|
||||||
.sort((a, b) => a.start - b.start);
|
|
||||||
|
|
||||||
return starts.map(({ start, dur }, i) => {
|
|
||||||
const end =
|
|
||||||
typeof dur === "number" && Number.isFinite(dur)
|
|
||||||
? start + dur * 1000 + 1000 // +1s tolerance for rounding
|
|
||||||
: i + 1 < starts.length
|
|
||||||
? starts[i + 1]!.start - 1
|
|
||||||
: Number.POSITIVE_INFINITY;
|
|
||||||
return [start, end] as [number, number];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSegments(
|
|
||||||
points: ValidPoint[],
|
|
||||||
sessions: FitSession[],
|
|
||||||
events: FitEvent[],
|
|
||||||
): ValidPoint[][] {
|
|
||||||
const sorted = [...points].sort((a, b) => a.t - b.t);
|
|
||||||
|
|
||||||
const stopTimes = events
|
|
||||||
.filter((e) => e.event === "timer" && (e.event_type === "stop_all" || e.event_type === "stop"))
|
|
||||||
.map((e) => toMillis(e.timestamp))
|
|
||||||
.filter((t): t is number => t !== null);
|
|
||||||
|
|
||||||
const windows = sessionWindows(sessions);
|
|
||||||
if (windows.length === 0) {
|
|
||||||
// No session info — one implicit session covering all points.
|
|
||||||
return splitByPausesAndGaps(sorted, stopTimes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments: ValidPoint[][] = [];
|
|
||||||
for (const [start, end] of windows) {
|
|
||||||
const inWindow = sorted.filter((p) => p.t >= start && p.t <= end);
|
|
||||||
for (const seg of splitByPausesAndGaps(inWindow, stopTimes)) segments.push(seg);
|
|
||||||
}
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map the file's session sport/sub-sport to a Journal SportType. First session
|
|
||||||
* wins (multisport still imports as one activity). Returns null when there's no
|
|
||||||
* session or the sport is generic/unknown, so callers can fall back to the
|
|
||||||
* provider's own type.
|
|
||||||
*/
|
|
||||||
function mapSport(sessions: FitSession[]): SportType | null {
|
|
||||||
const s = sessions[0];
|
|
||||||
if (!s) return null;
|
|
||||||
const sport = String(s.sport ?? "").toLowerCase();
|
|
||||||
const sub = String(s.sub_sport ?? "").toLowerCase();
|
|
||||||
|
|
||||||
// Sub-sport refinements take precedence over the base sport.
|
|
||||||
if (sub.includes("trail")) return "run";
|
|
||||||
if (sub.includes("gravel")) return "gravel";
|
|
||||||
if (sub.includes("mountain")) return "mtb";
|
|
||||||
if (sub.includes("backcountry") || sub.includes("alpine")) return "ski";
|
|
||||||
|
|
||||||
switch (sport) {
|
|
||||||
case "running":
|
|
||||||
return "run";
|
|
||||||
case "cycling":
|
|
||||||
return "ride";
|
|
||||||
case "hiking":
|
|
||||||
case "mountaineering":
|
|
||||||
return "hike";
|
|
||||||
case "walking":
|
|
||||||
return "walk";
|
|
||||||
case "alpine_skiing":
|
|
||||||
case "cross_country_skiing":
|
|
||||||
case "backcountry_skiing":
|
|
||||||
case "skiing":
|
|
||||||
return "ski";
|
|
||||||
case "":
|
|
||||||
case "generic":
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fitToGpx(buffer: Buffer, name: string): Promise<FitConversion> {
|
|
||||||
const parsed = await parse(buffer);
|
|
||||||
const records = parsed.records ?? [];
|
|
||||||
const events = parsed.events ?? [];
|
|
||||||
const sessions = parsed.sessions ?? [];
|
|
||||||
|
|
||||||
const sport = mapSport(sessions);
|
|
||||||
|
|
||||||
const points = records
|
|
||||||
.map(toValidPoint)
|
|
||||||
.filter((p): p is ValidPoint => p !== null);
|
|
||||||
|
|
||||||
if (points.length < 2) return { gpx: null, sport };
|
|
||||||
|
|
||||||
const tracks: TrackPoint[][] = buildSegments(points, sessions, events)
|
|
||||||
.map((seg) => seg.map((p): TrackPoint => ({ lat: p.lat, lon: p.lon, ele: p.ele, time: p.time })))
|
|
||||||
// A lone point isn't a drawable track segment; GPX needs >= 2 per <trkseg>.
|
|
||||||
.filter((seg) => seg.length >= 2);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return { gpx: null, sport };
|
|
||||||
return { gpx: generateGpx({ name, tracks }), sport };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,22 +165,7 @@ export async function markNeedsRelink(
|
||||||
.set({ status: "needs_relink" })
|
.set({ status: "needs_relink" })
|
||||||
.where(eq(connectedServices.id, serviceId));
|
.where(eq(connectedServices.id, serviceId));
|
||||||
// Reason is logged but not persisted yet — add a column if/when we surface it in UI.
|
// Reason is logged but not persisted yet — add a column if/when we surface it in UI.
|
||||||
// Bounded: `reason` can carry provider-side error text, so cap its length
|
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`);
|
||||||
// to avoid dumping a large/sensitive blob into logs.
|
|
||||||
const safeReason = reason.length > 200 ? `${reason.slice(0, 200)}…` : reason;
|
|
||||||
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${safeReason}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider-side revocation (e.g. a Garmin deregistration notification):
|
|
||||||
// keep the row for audit, flip to 'revoked' so every subsequent
|
|
||||||
// withFreshCredentials short-circuits and the UI shows a re-connect
|
|
||||||
// prompt. Imported activities are untouched.
|
|
||||||
export async function markRevoked(serviceId: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.update(connectedServices)
|
|
||||||
.set({ status: "revoked" })
|
|
||||||
.where(eq(connectedServices.id, serviceId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGrantedScopes(
|
export async function updateGrantedScopes(
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
|
|
||||||
const link = vi.fn();
|
|
||||||
vi.mock("./manager.ts", () => ({
|
|
||||||
link: (...args: unknown[]) => link(...args),
|
|
||||||
}));
|
|
||||||
vi.mock("../config.server.ts", () => ({
|
|
||||||
getOrigin: () => "https://journal.test",
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { initiateOAuthFlow, completeOAuthFlow } from "./oauth-flow.server.ts";
|
|
||||||
import { decodeOAuthState } from "./oauth-state.server.ts";
|
|
||||||
import type { ProviderManifest } from "./registry.ts";
|
|
||||||
|
|
||||||
function fakeManifest(overrides: Partial<ProviderManifest> = {}): ProviderManifest {
|
|
||||||
return {
|
|
||||||
id: "fakeprov",
|
|
||||||
displayName: "Fake Provider",
|
|
||||||
credentialKind: "oauth",
|
|
||||||
credentialAdapter: { isExpired: () => false, refresh: vi.fn() },
|
|
||||||
buildAuthUrl: vi.fn(
|
|
||||||
(redirectUri: string, state: string, extras?: { codeChallenge?: string }) =>
|
|
||||||
`https://provider.test/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}` +
|
|
||||||
(extras?.codeChallenge ? `&code_challenge=${extras.codeChallenge}` : ""),
|
|
||||||
),
|
|
||||||
exchangeCode: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
} as unknown as ProviderManifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function callbackRequest(params: Record<string, string>, cookie?: string): Request {
|
|
||||||
const url = new URL("https://journal.test/api/sync/callback/fakeprov");
|
|
||||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
||||||
return new Request(url, { headers: cookie ? { Cookie: cookie } : {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
describe("initiateOAuthFlow", () => {
|
|
||||||
it("redirects to the provider with the intent in the state, no cookie for non-PKCE", () => {
|
|
||||||
const manifest = fakeManifest();
|
|
||||||
const resp = initiateOAuthFlow(manifest, { returnTo: "/settings/connections" });
|
|
||||||
|
|
||||||
expect(resp.status).toBe(302);
|
|
||||||
const location = new URL(resp.headers.get("Location")!);
|
|
||||||
expect(location.origin).toBe("https://provider.test");
|
|
||||||
expect(location.searchParams.get("redirect_uri")).toBe(
|
|
||||||
"https://journal.test/api/sync/callback/fakeprov",
|
|
||||||
);
|
|
||||||
expect(decodeOAuthState(location.searchParams.get("state"))).toEqual({
|
|
||||||
returnTo: "/settings/connections",
|
|
||||||
});
|
|
||||||
expect(resp.headers.get("Set-Cookie")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PKCE: sets the verifier cookie and sends its S256 challenge", () => {
|
|
||||||
const manifest = fakeManifest({ pkce: true });
|
|
||||||
const resp = initiateOAuthFlow(manifest, { pushAfter: { routeId: "r-1" }, returnTo: "/r" });
|
|
||||||
|
|
||||||
const cookie = resp.headers.get("Set-Cookie")!;
|
|
||||||
expect(cookie).toContain("__oauth_pkce=");
|
|
||||||
expect(cookie).toContain("HttpOnly");
|
|
||||||
const verifier = cookie.match(/__oauth_pkce=([^;]+)/)![1]!;
|
|
||||||
|
|
||||||
const location = new URL(resp.headers.get("Location")!);
|
|
||||||
const expectedChallenge = createHash("sha256").update(verifier).digest("base64url");
|
|
||||||
expect(location.searchParams.get("code_challenge")).toBe(expectedChallenge);
|
|
||||||
// push intent rides the state even on the PKCE path
|
|
||||||
expect(decodeOAuthState(location.searchParams.get("state"))).toEqual({
|
|
||||||
pushAfter: { routeId: "r-1" },
|
|
||||||
returnTo: "/r",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("completeOAuthFlow", () => {
|
|
||||||
it("maps access_denied to denied with decoded state", async () => {
|
|
||||||
const manifest = fakeManifest();
|
|
||||||
const state = new URL(
|
|
||||||
initiateOAuthFlow(manifest, { returnTo: "/x" }).headers.get("Location")!,
|
|
||||||
).searchParams.get("state")!;
|
|
||||||
|
|
||||||
const result = await completeOAuthFlow(
|
|
||||||
manifest,
|
|
||||||
callbackRequest({ error: "access_denied", state }),
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
expect(result).toEqual({ status: "denied", state: { returnTo: "/x" } });
|
|
||||||
expect(link).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns missing_code when no code param", async () => {
|
|
||||||
const result = await completeOAuthFlow(fakeManifest(), callbackRequest({}), "user-1");
|
|
||||||
expect(result.status).toBe("missing_code");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PKCE: returns missing_verifier when the cookie is gone", async () => {
|
|
||||||
const result = await completeOAuthFlow(
|
|
||||||
fakeManifest({ pkce: true }),
|
|
||||||
callbackRequest({ code: "abc" }),
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
expect(result.status).toBe("missing_verifier");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exchanges the code and links the connection", async () => {
|
|
||||||
const manifest = fakeManifest();
|
|
||||||
(manifest.exchangeCode as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
credentials: { accessToken: "t" },
|
|
||||||
providerUserId: "prov-9",
|
|
||||||
grantedScopes: ["routes_write"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await completeOAuthFlow(manifest, callbackRequest({ code: "abc" }), "user-1");
|
|
||||||
|
|
||||||
expect(result.status).toBe("linked");
|
|
||||||
expect(manifest.exchangeCode).toHaveBeenCalledWith(
|
|
||||||
"abc",
|
|
||||||
"https://journal.test/api/sync/callback/fakeprov",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(link).toHaveBeenCalledWith({
|
|
||||||
userId: "user-1",
|
|
||||||
provider: "fakeprov",
|
|
||||||
credentialKind: "oauth",
|
|
||||||
credentials: { accessToken: "t" },
|
|
||||||
providerUserId: "prov-9",
|
|
||||||
grantedScopes: ["routes_write"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("PKCE: passes the cookie verifier to exchangeCode", async () => {
|
|
||||||
const manifest = fakeManifest({ pkce: true });
|
|
||||||
(manifest.exchangeCode as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
credentials: {},
|
|
||||||
providerUserId: "p",
|
|
||||||
grantedScopes: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
await completeOAuthFlow(
|
|
||||||
manifest,
|
|
||||||
callbackRequest({ code: "abc" }, "__oauth_pkce=my-verifier"),
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
expect(manifest.exchangeCode).toHaveBeenCalledWith(
|
|
||||||
"abc",
|
|
||||||
expect.any(String),
|
|
||||||
{ codeVerifier: "my-verifier" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps exchange failures to error with the provider code", async () => {
|
|
||||||
const manifest = fakeManifest();
|
|
||||||
(manifest.exchangeCode as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
||||||
Object.assign(new Error("nope"), { code: "rate_limited" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await completeOAuthFlow(manifest, callbackRequest({ code: "x" }), "user-1");
|
|
||||||
expect(result).toMatchObject({ status: "error", code: "rate_limited" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
// The OAuth connect → callback lifecycle as one module. Routes stay
|
|
||||||
// thin adapters: they look up the manifest, call initiate/complete,
|
|
||||||
// and map the result to redirects. Everything protocol-shaped —
|
|
||||||
// state encoding, PKCE pair generation, the verifier cookie's
|
|
||||||
// lifecycle, redirect-URI construction, code exchange, linking the
|
|
||||||
// connection — lives here, so the next OAuth provider (Coros, Strava)
|
|
||||||
// reuses the flow instead of the pattern.
|
|
||||||
|
|
||||||
import { redirect } from "react-router";
|
|
||||||
import { getOrigin } from "../config.server.ts";
|
|
||||||
import { link } from "./manager.ts";
|
|
||||||
import type { ProviderManifest } from "./registry.ts";
|
|
||||||
import {
|
|
||||||
clearPkceCookieHeader,
|
|
||||||
decodeOAuthState,
|
|
||||||
encodeOAuthState,
|
|
||||||
generatePkcePair,
|
|
||||||
pkceCookieHeader,
|
|
||||||
readPkceVerifier,
|
|
||||||
type PushOAuthState,
|
|
||||||
} from "./oauth-state.server.ts";
|
|
||||||
|
|
||||||
function callbackUri(manifest: ProviderManifest): string {
|
|
||||||
return `${getOrigin()}/api/sync/callback/${manifest.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the provider authorization redirect. Encodes post-callback
|
|
||||||
* intent into the state param and, for PKCE providers, sets the
|
|
||||||
* httpOnly verifier cookie — including when the flow is initiated
|
|
||||||
* from a push-resume (the old inline code only handled PKCE on the
|
|
||||||
* plain connect path).
|
|
||||||
*
|
|
||||||
* Caller must have verified `manifest.buildAuthUrl` exists.
|
|
||||||
*/
|
|
||||||
export function initiateOAuthFlow(
|
|
||||||
manifest: ProviderManifest,
|
|
||||||
intent: PushOAuthState,
|
|
||||||
): Response {
|
|
||||||
if (!manifest.buildAuthUrl) {
|
|
||||||
throw new Error(`Provider ${manifest.id} has no buildAuthUrl`);
|
|
||||||
}
|
|
||||||
const state = encodeOAuthState(intent);
|
|
||||||
const redirectUri = callbackUri(manifest);
|
|
||||||
|
|
||||||
if (manifest.pkce) {
|
|
||||||
const { verifier, challenge } = generatePkcePair();
|
|
||||||
return redirect(manifest.buildAuthUrl(redirectUri, state, { codeChallenge: challenge }), {
|
|
||||||
headers: { "Set-Cookie": pkceCookieHeader(verifier) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OAuthCompletion =
|
|
||||||
/** Provider sent the user back with access_denied. */
|
|
||||||
| { status: "denied"; state: PushOAuthState }
|
|
||||||
/** No authorization code in the callback URL. */
|
|
||||||
| { status: "missing_code"; state: PushOAuthState }
|
|
||||||
/** PKCE provider but the verifier cookie is gone (expired / cleared). */
|
|
||||||
| { status: "missing_verifier"; state: PushOAuthState }
|
|
||||||
/** Code exchange or linking failed; `code` is provider-specific or "sync_failed". */
|
|
||||||
| { status: "error"; code: string; state: PushOAuthState }
|
|
||||||
/** Connection linked. */
|
|
||||||
| { status: "linked"; state: PushOAuthState };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume the provider callback: decode state, recover the PKCE
|
|
||||||
* verifier, exchange the code, and link the connection. Never throws —
|
|
||||||
* every outcome is a variant the route maps to a redirect. The spent
|
|
||||||
* verifier cookie should be cleared on whatever response the route
|
|
||||||
* returns (`clearPkceCookieHeader`).
|
|
||||||
*/
|
|
||||||
export async function completeOAuthFlow(
|
|
||||||
manifest: ProviderManifest,
|
|
||||||
request: Request,
|
|
||||||
userId: string,
|
|
||||||
): Promise<OAuthCompletion> {
|
|
||||||
if (!manifest.exchangeCode) {
|
|
||||||
throw new Error(`Provider ${manifest.id} has no exchangeCode`);
|
|
||||||
}
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const state = decodeOAuthState(url.searchParams.get("state"));
|
|
||||||
|
|
||||||
if (url.searchParams.get("error") === "access_denied") {
|
|
||||||
return { status: "denied", state };
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = url.searchParams.get("code");
|
|
||||||
if (!code) return { status: "missing_code", state };
|
|
||||||
|
|
||||||
const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null;
|
|
||||||
if (manifest.pkce && !codeVerifier) {
|
|
||||||
return { status: "missing_verifier", state };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const exchange = await manifest.exchangeCode(
|
|
||||||
code,
|
|
||||||
callbackUri(manifest),
|
|
||||||
codeVerifier ? { codeVerifier } : undefined,
|
|
||||||
);
|
|
||||||
await link({
|
|
||||||
userId,
|
|
||||||
provider: manifest.id,
|
|
||||||
credentialKind: manifest.credentialKind,
|
|
||||||
credentials: exchange.credentials as Record<string, unknown>,
|
|
||||||
providerUserId: exchange.providerUserId,
|
|
||||||
grantedScopes: exchange.grantedScopes,
|
|
||||||
});
|
|
||||||
return { status: "linked", state };
|
|
||||||
} catch (e) {
|
|
||||||
// Log only the error message, never the raw exception object — a
|
|
||||||
// provider's thrown error can embed the authorization code or token
|
|
||||||
// exchange response, which would then land in logs / Sentry.
|
|
||||||
console.error(
|
|
||||||
`OAuth callback failed for ${manifest.id}:`,
|
|
||||||
e instanceof Error ? e.message : String(e),
|
|
||||||
);
|
|
||||||
const code =
|
|
||||||
typeof (e as { code?: string }).code === "string"
|
|
||||||
? (e as { code: string }).code
|
|
||||||
: "sync_failed";
|
|
||||||
return { status: "error", code, state };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { clearPkceCookieHeader };
|
|
||||||
|
|
@ -21,39 +21,3 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PKCE (RFC 7636) ----------------------------------------------------
|
|
||||||
//
|
|
||||||
// Providers with `pkce: true` (Garmin) need a code verifier that survives
|
|
||||||
// the connect → provider → callback redirect without ever appearing in a
|
|
||||||
// URL (the whole point of PKCE is that the verifier stays out of the
|
|
||||||
// authorization response). The `state` param is visible in redirects, so
|
|
||||||
// the verifier rides a short-lived httpOnly cookie scoped to the callback
|
|
||||||
// path instead.
|
|
||||||
|
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
|
||||||
|
|
||||||
const PKCE_COOKIE = "__oauth_pkce";
|
|
||||||
const PKCE_MAX_AGE_S = 600;
|
|
||||||
|
|
||||||
export function generatePkcePair(): { verifier: string; challenge: string } {
|
|
||||||
// 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128).
|
|
||||||
const verifier = randomBytes(32).toString("base64url");
|
|
||||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
||||||
return { verifier, challenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pkceCookieHeader(verifier: string): string {
|
|
||||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
|
||||||
return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPkceCookieHeader(): string {
|
|
||||||
return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readPkceVerifier(request: Request): string | null {
|
|
||||||
const cookie = request.headers.get("Cookie") ?? "";
|
|
||||||
const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`));
|
|
||||||
return match?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue