diff --git a/docs/architecture.md b/docs/architecture.md index 699c40b..c264615 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -190,19 +190,34 @@ Tech stack: ### Mastodon Compatibility Completed activities appear as posts with: -- Text description -- Map preview image (auto-generated) -- Link to full view on trails.cool (or the self hosted instance) -- Photo attachments -- GPX as attachment +- Text description ✅ shipped (`social-federation`: HTML content + stats + + PropertyValue metadata, dereferenceable Note objects) +- Map preview image (auto-generated) — captured in + `docs/ideas/fediverse-enhancements.md` +- Link to full view on trails.cool (or the self hosted instance) ✅ shipped +- Photo attachments — with `activity-photos` +- GPX as attachment — needs a public GPX endpoint for activities first + (see `fediverse-enhancements.md`) Mastodon users can: -- See activities in their timeline -- Like activities (federates back) -- Comment on activities (federates back) +- See activities in their timeline ✅ shipped (verified live 2026-06-07) +- Like activities (federates back) — captured as "fediverse kudos" in + `fediverse-enhancements.md`; v1's narrow inbox drops Like/Announce + by design +- Comment on activities (federates back) — captured in + `fediverse-enhancements.md`; gated on moderation tooling + (`docs/ideas/instance-administration.md`) Route-specific federation (collaboration invites, version updates) uses -custom ActivityPub extensions not visible in Mastodon. +custom ActivityPub extensions not visible in Mastodon — spec'd as +`openspec/changes/route-federation/`. + +Interop lessons from the first live soak (2026-06-06/07, recorded in +`openspec/changes/social-federation/design.md`): Mastodon requires +`attachment` arrays (bare objects are silently ignored), records +received `Delete`s as permanent tombstones, gives inbox deliveries a +10-second timeout, and never backfills outbox history — every new +outgoing shape must be verified against a real instance, not the spec. ## Route Sharing & Permissions @@ -409,14 +424,18 @@ shared host are ingested. ``` planner.trails.cool -> Planner frontend + Yjs sync + BRouter -trails.cool -> Journal frontend + API -api.trails.cool -> ActivityPub endpoints -cdn.trails.cool -> Media + map tiles (optional) +trails.cool -> Journal frontend + API + ActivityPub endpoints +cdn.trails.cool -> Media + map tiles (optional, future) ``` +ActivityPub lives on the main domain per Resolved Decision #18 (single +domain per instance) — there is no `api.trails.cool`. Staging and PR +previews additionally live under `*.staging.trails.cool` (see +CLAUDE.md "Staging & Previews"). + ### Estimated Costs (100 users) -- Hetzner CX21: 5 EUR/month +- Hetzner cx23: ~6 EUR/month - Storage: 3.20 EUR/month - Domain: ~10 EUR/year - Backups: ~2 EUR/month @@ -458,7 +477,7 @@ github.com/trails-cool/trails gpx/ - GPX parsing, generation, validation i18n/ - Shared i18n config + translation strings (react-i18next) infrastructure/ - Terraform + Docker Compose - specs/ - OpenSpec specifications + openspec/ - OpenSpec specifications + changes docker/ brouter/ - BRouter Docker image + segment management docs/ - Documentation @@ -466,55 +485,65 @@ github.com/trails-cool/trails Tooling: **Turborepo** for monorepo management, **pnpm** workspaces. -OpenSpec specs live in `specs/` directory, feeding into both apps. +OpenSpec specs live in the `openspec/` directory, feeding into both apps. This keeps specifications close to implementation and allows Claude Code to reference specs when working on either app. ## MVP Phasing Note: Detailed specifications for each phase will be created using -[OpenSpec](https://openspec.dev/) and stored in the `specs/` directory +[OpenSpec](https://openspec.dev/) and stored in the `openspec/` directory of the monorepo. This architecture plan feeds into OpenSpec as the high-level context for generating implementation specs. -### Phase 1: Foundation (Weeks 1-8) +### Phase 1: Foundation (Weeks 1-8) — ✅ shipped **Planner MVP**: -- [ ] Collaborative waypoint editing (Yjs) -- [ ] BRouter integration (route computation) -- [ ] Map display (Leaflet + OSM overlays) -- [ ] Session sharing (shareable link) -- [ ] Profile selection (bike/hike) -- [ ] Elevation profile display -- [ ] GPX export +- [x] Collaborative waypoint editing (Yjs) +- [x] BRouter integration (route computation) +- [x] Map display (Leaflet + OSM overlays) +- [x] Session sharing (shareable link) +- [x] Profile selection (bike/hike) +- [x] Elevation profile display +- [x] GPX export **Journal MVP**: -- [ ] User accounts (local, no federation) -- [ ] Route CRUD -- [ ] Start Planner session from route (callback integration) -- [ ] GPX import/export -- [ ] Basic profile page -- [ ] Activity feed (own activities) +- [x] User accounts (local, no federation) +- [x] Route CRUD +- [x] Start Planner session from route (callback integration) +- [x] GPX import/export +- [x] Basic profile page +- [x] Activity feed (own activities) -### Phase 2: Social & Federation (Months 3-6) +### Phase 2: Social & Federation (Months 3-6) — in progress -- [ ] ActivityPub federation -- [ ] Following/followers -- [ ] Likes and comments -- [ ] Activity import (Strava/Garmin GPX/FIT upload) -- [ ] Photo attachments on activities -- [ ] Mastodon compatibility -- [ ] Route sharing permissions -- [ ] Route versioning +- [x] ActivityPub federation — `social-federation` (inbound follows + + activity push delivery live on staging since 2026-06-07; + trails-to-trails outbound §6/§7 remaining) +- [x] Following/followers (`social-feed`) +- [ ] Likes and comments — `docs/ideas/social-interactions.md` (local) + + `fediverse-enhancements.md` (federated) +- [x] Activity import (GPX upload, Komoot, Wahoo; Strava/Garmin OAuth + and FIT *import* still open) +- [ ] Photo attachments on activities — `activity-photos` change drafted +- [x] Mastodon compatibility (follow + timeline posts verified live) +- [ ] Route sharing permissions — `route-sharing` change drafted +- [x] Route versioning +- [ ] Route federation (mirroring, cross-instance edits) — + `route-federation` change drafted ### Phase 3: Scale & Mobile (Months 6-12) -- [ ] Mobile app (Capacitor or native) -- [ ] Offline route editing (WASM + cached segments) -- [ ] Multi-day route planning -- [ ] Route recommendations -- [ ] Clubs/groups -- [ ] CDN for map segments (mobile offline) +- [ ] Mobile app — `mobile-app` change in progress +- [ ] Offline route editing (WASM + cached segments) — not yet captured + beyond this line +- [ ] Multi-day route planning — routes ✅ shipped; activity collections: + `docs/ideas/multi-day-collections.md` +- [ ] Route recommendations — distinct from `route-discovery` (spatial + explore); not yet captured beyond this line +- [ ] Clubs/groups — not yet captured beyond this line +- [ ] CDN for map segments (mobile offline) — not yet captured beyond + this line ## Resolved Decisions @@ -713,7 +742,7 @@ allows querying session metadata (last activity, participant count) for garbage collection. The Planner service uses its own PostgreSQL schema (`planner.*`) separate -from the Journal schema (`activity.*`). On the trails.cool flagship, +from the Journal schema (`journal.*`). On the trails.cool flagship, both schemas live in the same PostgreSQL instance. Self-hosters who don't run a Planner don't need the planner schema. @@ -833,7 +862,22 @@ Keep it simple. Can revisit if users request it. ## Remaining Open Questions -1. **Multi-day activity collections**: Exact data model for linking day-activities - into a multi-day trip collection -2. **brouter-web dependencies**: Review https://github.com/nrenner/brouter-web - for proven library choices (map rendering, elevation charts, etc.) +1. **Multi-day activity collections**: Exact data model for linking + day-activities into a multi-day trip collection — exploration started + in `docs/ideas/multi-day-collections.md` +2. ~~**brouter-web dependencies**: Review https://github.com/nrenner/brouter-web + for proven library choices~~ — resolved in practice: the Planner + shipped on Leaflet + its own elevation/coloring implementations + (see `road-type-coloring`, `elevation-map-interaction` specs) + +## Where the rest of this vision is tracked + +Everything in this document that isn't shipped lives in one of: + +- `openspec/changes/` — drafted, buildable changes (`route-federation` + for Decisions #2/#3/#12/#16 and Scenarios 3–4; `route-sharing`, + `activity-photos`, `route-discovery`, `mobile-app`, …) +- `docs/ideas/` — pre-spec explorations (`instance-administration`, + `social-interactions`, `activity-participants`, + `multi-day-collections`, `fediverse-enhancements`, …) +- `docs/roadmap.md` — what ships when, and why diff --git a/docs/ideas/activity-participants.md b/docs/ideas/activity-participants.md new file mode 100644 index 0000000..f7e2c4f --- /dev/null +++ b/docs/ideas/activity-participants.md @@ -0,0 +1,46 @@ +# Activity participants (group tagging) + +Pre-spec exploration. From `docs/architecture.md` §Activity Sharing & +Participants: when people ride/hike together, the activity creator tags +the others; tagged users confirm or decline; confirmed participants see +the activity on their own profile and can attach their own GPS trace and +photos. "Like photo tagging on social media, but for rides." + +The `activities.participants` jsonb column exists as an untyped stub — +no flow, UI, or spec behind it. + +## Scope sketch + +**v1 (local)** +- Tag local users on your activity (search by username, like the + share dialog planned in `route-sharing`) +- Notification `participant_tagged` → confirm / decline (Requests-tab + pattern from follow requests) +- Confirmed participation renders the activity on the participant's + profile (clearly attributed: "with @alice") +- Participant may attach their own trace + photos to the shared + activity — or link their own existing activity as "same outing" + (the second option composes better with imports: Bob's Garmin + recording is already its own activity) +- Schema: promote from jsonb stub to a real + `journal.activity_participants` table + (`activity_id, user_id | participant_actor_iri, status, own_activity_id?`) + — the exactly-one-of local/remote pattern from `follows` again + +**v2 (federated)** +- Tagging across trails instances; Mastodon sees mentions + (`"Rode with @bob@bob.trails.xyz"`) per the architecture +- Confirm/decline crosses instances → needs a small custom activity + vocabulary or reuse of `Invite`/`Accept` (the same pair + route-federation needs — design together) + +## Constraints & notes + +- Privacy: being tagged must never reveal more than the tagger could + already see; declined/pending tags are visible only to tagger + + taggee. Tagging requires the activity to be visible to the taggee. +- The "link own activity as same outing" model doubles as the natural + seed for multi-day collections' "shared trip" case — see + `multi-day-collections.md`. +- Mention-style federation means participant handles end up in public + Note content — privacy manifest update when v2 lands. diff --git a/docs/ideas/instance-administration.md b/docs/ideas/instance-administration.md new file mode 100644 index 0000000..e28422e --- /dev/null +++ b/docs/ideas/instance-administration.md @@ -0,0 +1,49 @@ +# Instance administration & moderation + +Pre-spec exploration. Envisioned in `docs/architecture.md` (§Instance +Administration) since day one, never spec'd. Now that federation is live +on staging — a public inbox accepting traffic from arbitrary instances — +the moderation half has moved from "eventually" to "before the federated +surface grows." + +## Scope sketch + +**Instance settings** +- Instance name, description, rules page (rendered at `/about`) +- Open/close registration (env var today at best; should be a runtime + admin setting) +- Instance-level contact / operator info (some of this exists for the + legal pages — consolidate) + +**User management** +- Admin role (first user? env-designated? explicit grant) +- Suspend / unsuspend users (suspended: no login, content hidden, + federation stops — actor 404s like a private profile) +- Delete user + content (GDPR path; account-management spec covers + self-deletion, not admin-initiated) + +**Federation management** +- Per-instance blocklist: refuse inbox traffic, drop follows, stop + deliveries to blocked domains (checked in the inbox rate-limit layer + we already have — `federationSourceHost` is the natural hook) +- Per-instance silence (content hidden from shared surfaces but + follows still work) — maybe later; block first +- Visibility into federation peers: which instances follow us / + we deliver to, volumes, error rates (remote_actors table + Fedify + logs already hold most of this) + +**Reports & moderation queue** +- Report content/users (local reports first; ActivityPub `Flag` + federation later) +- Simple queue: open → resolved/dismissed, with action taken + +## Constraints & notes + +- Mastodon's admin model is the obvious reference; ours can be a tiny + subset (single-admin instances are the norm for self-hosters). +- The federated-comments idea (`fediverse-enhancements.md`) explicitly + depends on at least instance blocks + report handling existing. +- Suspension must compose with federation the same way profile privacy + does today: suspended ⇒ actor 404, push delivery suppressed (the + `social-federation` spec's 9.3 mechanics generalize). +- Admin surfaces are user-facing strings → i18n from the start (en+de). diff --git a/docs/ideas/multi-day-collections.md b/docs/ideas/multi-day-collections.md new file mode 100644 index 0000000..d64434a --- /dev/null +++ b/docs/ideas/multi-day-collections.md @@ -0,0 +1,45 @@ +# Multi-day activity collections + +Pre-spec exploration. This is `docs/architecture.md`'s **explicit open +question #1** (§Multi-Day Route Support: "Exact data model for +collections TBD — needs more design work"). Multi-day *routes* shipped +(day-break waypoints, per-day stats, track segments per day); the +activity side — recording a 5-day bikepacking trip as one entity with +five day-recordings — is captured nowhere. + +## The shape from the architecture + +- A multi-day activity is a **collection linking individual + day-activities** +- Each day-activity keeps its own GPS trace, photos, description + (possibly imported from different devices/apps per day) +- The collection references the planned multi-day route as a whole +- Day-activities can be imported one at a time (Garmin/Komoot/Wahoo per + day) and attached as the trip progresses + +## Data-model directions to evaluate + +1. **Collection row + membership table** — + `journal.trips (id, owner, name, description, route_id?)` + + `journal.trip_activities (trip_id, activity_id, day_index)`. + Activities stay fully independent (visibility, federation, photos); + the trip is a presentation/grouping layer. Cheapest; probably right. +2. **Self-referencing activities** (`parent_activity_id` + a `kind` + column). Fewer tables but overloads the activity model and makes + feed/outbox queries carry exclusion rules forever. +3. Whatever we pick must answer: trip-level visibility vs per-day + visibility (suggest: trip visibility caps day visibility), trip-level + stats (sum of days), and how a trip federates (one Note per day as + today, plus a trip page link? A trip-level Note at completion?). + +## Notes + +- Pairs naturally with `activity-participants.md` (group trips) and the + "Tour mode" sketch in `fediverse-enhancements.md` (day N/M check-in + posts are trivial once a trip entity exists). +- The route side already encodes day breaks in GPX track segments — + importing a multi-day route's recording per segment could pre-seed + day-activities. +- Keep GPX exportability: a trip should export as one GPX with track + segments per day (mirror of the route format), preserving the + data-ownership principle. diff --git a/docs/ideas/social-interactions.md b/docs/ideas/social-interactions.md new file mode 100644 index 0000000..29daa1f --- /dev/null +++ b/docs/ideas/social-interactions.md @@ -0,0 +1,47 @@ +# Local likes & comments + +Pre-spec exploration. The architecture's Journal feature list has +"Social: Following, likes, comments" — follows shipped +(`social-follows`), likes and comments don't exist **even between local +users**. The federated halves are sketched separately in +`fediverse-enhancements.md` (kudos = inbound Like/Announce, comments = +inbound replies); this idea is the local foundation those would attach +to. + +## Scope sketch + +**Likes (kudos)** +- One per (user, activity); toggleable; count on activity cards + detail +- Notification to the activity owner (`notifications` spec has the + pattern; new type `activity_liked`) +- Storage: `journal.likes (user_id, activity_id, created_at)` or a + generalized reactions table if we ever want more than ❤️ — start + with likes only (simplicity principle) +- Federation hook-in later: inbound Mastodon `Like` increments the same + counter with `remote_actor_iri` instead of `user_id` (same + exactly-one-of pattern as `follows.follower_id`/`follower_actor_iri`) + +**Comments** +- Flat list on activities first (no threading — Mastodon-reply + interop pushes toward flat anyway); plain text → maybe limited + markdown later +- Owner can delete comments on their activities; commenter can delete + their own +- Notification type `activity_commented` +- Federation hook-in later: inbound replies (`inReplyTo`) land in the + same table with remote attribution; outbound, local comments on a + federated activity would need to federate back as replies + +## Constraints & notes + +- Visibility: liking/commenting requires the activity to be visible to + you — public activities only for non-owners today; revisit when + followers-only content lands (§7/§8 of social-federation). +- The `activities.owner_id` NOT NULL open question (design.md of + social-federation) applies to remote comment authors the same way — + decide once. +- Decide whether routes also get likes/comments or activities only. + The architecture says activities; start there. +- Moderation precedes federated comments (see + `instance-administration.md`) but NOT local comments — local users + are accountable accounts on your own instance. diff --git a/docs/roadmap.md b/docs/roadmap.md index d87a6ca..6ee701e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,7 @@ These changes are scoped and designed but not blocking launch: | [`route-discovery`](../openspec/changes/route-discovery/) | Spatial map-based route exploration. The text `/explore` page covers the gap at launch. | | [`activity-photos`](../openspec/changes/activity-photos/) | Photo uploads for activities. Enriches content but not a day-one requirement. | | [`mobile-app`](../openspec/changes/mobile-app/) | React Native unified app. Substantial scope; explicitly post-launch. | +| [`route-federation`](../openspec/changes/route-federation/) | Routes as federated objects: Invite/Accept mirroring, cross-instance Planner edits. The collaboration half of the federation vision; depends on `social-federation` §6 + `route-sharing`. | --- @@ -83,3 +84,7 @@ See `docs/ideas/` for pre-spec explorations: - `mobile-activity-recording/` — GPS tracking and activity logging from a native app - `mobile-nearby-sync/` — BLE-based proximity discovery - `self-host-overpass/` — Self-hosted Overpass API for POI overlays +- `instance-administration.md` — admin role, registration toggle, suspend/ban, federation blocklists, reports +- `social-interactions.md` — local likes + comments (the foundation the federated kudos/comments attach to) +- `activity-participants.md` — tag co-riders on shared activities, confirm/decline, federated mentions +- `multi-day-collections.md` — activity collections for multi-day trips (architecture open question #1) diff --git a/openspec/changes/route-federation/.openspec.yaml b/openspec/changes/route-federation/.openspec.yaml new file mode 100644 index 0000000..11967fc --- /dev/null +++ b/openspec/changes/route-federation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-07 diff --git a/openspec/changes/route-federation/design.md b/openspec/changes/route-federation/design.md new file mode 100644 index 0000000..ca5c672 --- /dev/null +++ b/openspec/changes/route-federation/design.md @@ -0,0 +1,109 @@ +## Context + +Transcribes and refines architecture.md Resolved Decisions #2 (route +mirroring), #3 (cross-instance edits), #12 (cross-instance auth) and +#16 (delivery retry/sync healing) into a buildable change, on top of +what `social-federation` shipped: Fedify mounted in the journal, +per-user keys, the narrow inbox, NodeInfo-based trails-instance +discovery, push delivery via pg-boss, and the hard-won serialization +lessons (attachment arrays, tombstones, async queue, dereferenceable +objects). + +Status: drafted ahead of implementation (2026-06-07) to capture the +architecture vision as a concrete change; revisit against reality once +social-federation §6/§7 and route-sharing have landed. + +## Goals / Non-Goals + +**Goals:** +- A public route is a dereferenceable AP object at its canonical URL. +- Alice (instance A) shares a route with Bob (instance B): Invite → + Accept → B holds a mirror that stays current via Update fan-out. +- Bob edits Alice's route in a Planner session; the save lands on A as + a new version crediting Bob — no drafts on B (canonical-owner model). +- Mirrors self-heal after extended downtime via periodic sync check. + +**Non-Goals:** +- Mastodon rendering of routes (trails-to-trails only). +- Federated forking, federated route discovery/search. +- Moving Planner sessions between instances (a session lives on one + Planner; only the *save destination* is remote). + +## Decisions + +### Decision: Canonical owner, mirrors as read caches (arch #2) + +The owner's instance is the single source of truth. Collaborator +instances store mirrors — metadata + latest GPX — updated by `Update` +fan-out, used for display and as the seed when opening an edit session +while A is reachable; stale mirrors are explicitly labeled in the UI +when the sync check can't reach the origin. + +### Decision: Edits go straight to the owner (arch #3, #12) + +Bob's instance never stores a draft. The edit flow: +1. B requests a scoped edit token from A + (`POST /api/federation/routes/:id/edit-token`, HTTP-Signature + signed by Bob's actor key; A verifies Bob is a collaborator). +2. A issues the same shape of scoped JWT the local edit-in-planner + flow uses (route id, permissions, expiry, jti) — single-use + enforcement via the existing `consumed_jwt_jti` table. +3. B opens the Planner with A's callback URL + token; the existing + callback endpoint on A accepts the save and records Bob's actor IRI + as contributor (contributors become IRIs, matching the route + metadata envelope in arch #8). + +### Decision: Vocabulary — custom `trails:Route` object, standard activity wrappers + +`Create`/`Update`/`Invite`/`Accept` are standard AS2 activities; the +object is a custom type under the trails JSON-LD context (namespace +served from the instance, e.g. `https://trails.cool/ns#Route`) carrying +the metadata envelope (distance, elevation, day breaks, routing +profile, contributors) plus a GPX `Document` attachment. Soak lesson +applied: every shape verified against a real second instance before +merge, arrays for one-element collections, objects dereferenceable +from day one. + +### Decision: Trails-to-trails gating reuses the §6 check + +Same NodeInfo `software.name` allowlist as outbound follows; route +activities are neither delivered to nor accepted from non-trails +instances. (The Wanderer-interop idea would widen this allowlist — +explicitly out of scope here.) + +### Decision: Sync check is a pg-boss cron, owner-driven re-fetch + +Daily job on the *mirror-holding* instance: for each mirror whose +`last_update_at` predates its origin's advertised `updated` (HEAD/GET +on the canonical object), re-fetch and reconcile. Covers the +"instance down > 72h, Update lost" case in arch #16. + +## Risks / Trade-offs + +- **Custom vocabulary is an interop commitment** — version the + context document; additive evolution only. +- **Token issuance endpoint is a new authenticated surface** — + HTTP-Signature + collaborator check + rate limit + audit log; + security-review gate before enabling (same staged-flag pattern as + FEDERATION_ENABLED, possibly its own ROUTE_FEDERATION_ENABLED). +- **Tombstone semantics apply to routes too** (2026-06-07 soak + lesson): unsharing/unpublishing federates a retraction only on an + actual public/shared→not transition, and re-publishing after a + Delete won't resurrect mirrors that processed it. +- **Mirror divergence** if a save lands while fan-out is mid-flight — + versions are sequential on the owner; mirrors always converge to the + owner's latest (last-write-wins on the mirror, never on the origin). + +## Open Questions + +- Mirror storage: dedicated `route_mirrors` table vs provenance + columns on `routes` (the activities precedent says columns + + `remote_origin_iri UNIQUE`; routes carry more state — versions? + Probably mirror latest-only, no version history on mirrors). +- Does `Invite` target the user or the instance? (User — but the + Accept must be signed by the invited actor; what happens when the + user moves instances?) +- Contributor identity display for remote contributors (handle? + cached display name? same remote_actors cache?). +- Whether the planner needs to know anything at all (ideally zero + changes — callback URL + token are opaque to it already). diff --git a/openspec/changes/route-federation/proposal.md b/openspec/changes/route-federation/proposal.md new file mode 100644 index 0000000..798af71 --- /dev/null +++ b/openspec/changes/route-federation/proposal.md @@ -0,0 +1,87 @@ +## Why + +`social-federation` delivers the *social* half of the federation pitch: +activities federate as `Create(Note)`, Mastodon users can follow trails +users, trails users will follow each other across instances (§6/§7). +The *collaboration* half of the architecture vision — routes as +first-class federated objects — is still aspirational: `Create/Update +Route` activities, cross-instance route sharing with Invite/Accept +mirroring, and cross-instance Planner edits saved back to the owner's +instance (architecture.md Resolved Decisions #2, #3, #12, #16 and Data +Flow Scenarios 3 & 4). + +This is the differentiator. Activity federation makes trails.cool a +Mastodon-compatible publisher; route federation makes it the thing the +architecture promises: collaborative route planning across self-hosted +instances, with the owner's instance as the single source of truth. + +## What Changes + +- **Routes federate as objects**: a public route is dereferenceable at + its canonical URL as an ActivityPub object (custom `trails:Route` + type carrying the JSON-LD metadata envelope + GPX attachment; + Mastodon-facing fallback rendering deferred — routes federate + trails-to-trails only). `Create`/`Update` activities fan out to + followers and collaborators on publish and on new versions. +- **Cross-instance sharing via Invite/Accept**: sharing a route with a + remote trails user sends an `Invite`; their `Accept` registers them + as collaborator on the owner's instance and creates a **mirror** (a + read cache: metadata + latest GPX) on theirs. Route `Update`s push + new versions to all collaborator mirrors. +- **Cross-instance edits**: a collaborator's instance requests a + **scoped edit token** from the owner's instance (HTTP-Signature + authenticated instance-to-instance request). The Planner session + opens with the owner's callback URL + that token; saves create new + versions directly on the owner's instance, crediting the + collaborator as contributor (the existing JWT callback machinery, + issued across instances). +- **Mirror healing**: a periodic sync check detects mirrors that + missed `Update`s (instance down > Fedify's retry budget) and + re-fetches the canonical object. +- **Trails-to-trails only**, enforced with the same NodeInfo + `software.name` check as outbound follows. Mastodon never sees + `trails:Route` objects (they're not Notes and aren't delivered to + non-trails inboxes). + +## Capabilities + +### New Capabilities + +- `route-federation`: Route objects over ActivityPub — dereferenceable + routes, Create/Update fan-out, Invite/Accept collaboration + mirroring, cross-instance scoped edit tokens, mirror sync healing. + +### Modified Capabilities + +- `social-federation`: outbox/delivery extended beyond `Create(Note)` + with the route activity vocabulary; inbox accepts + `Invite`/`Accept(Invite)`/`Update(Route)` from trails instances. +- `route-management`: routes gain remote provenance (mirrors) and + remote collaborators; version creation accepts cross-instance + contributors. +- `route-sharing`: the share dialog can target remote trails handles + (`@bob@bob.trails.xyz`), entering the Invite lifecycle instead of a + local share row. +- `planner-callback`: callback tokens verifiable when issued by THIS + instance to a session initiated from a REMOTE instance, and the + edit-in-planner flow can carry a remote owner's callback. + +## Impact + +- **Dependencies**: requires `social-federation` §6 (outbound + trails-to-trails follows + NodeInfo check) and `route-sharing` + (local share model the Invite extends). Build after both. +- **Schema**: `route_mirrors` (or `routes.remote_origin_iri` + + provenance columns mirroring the activities approach), + `route_collaborators` extension for remote actor IRIs (the + exactly-one-of local/remote pattern from `follows`), token-issuance + audit table. +- **Federation surface**: new inbound activity types (gated to + verified trails instances), instance-to-instance token endpoint — + security-review before exposure; privacy manifest update (what a + collaborator instance learns and stores). +- **Out of scope**: rendering trails Route objects on Mastodon; + federated forking (fork-from-mirror can be a follow-up); real-time + *federated* presence in Planner sessions (sessions remain on one + Planner instance — federation is about where the route lives, not + where the session runs). diff --git a/openspec/changes/route-federation/specs/route-federation/spec.md b/openspec/changes/route-federation/specs/route-federation/spec.md new file mode 100644 index 0000000..a2162c8 --- /dev/null +++ b/openspec/changes/route-federation/specs/route-federation/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Public routes are dereferenceable ActivityPub objects +The Journal SHALL serve a public route at its canonical URL as a +`trails:Route` object (JSON-LD metadata envelope + GPX attachment) when +requested with an ActivityPub Accept header, and SHALL announce new +public routes and new versions to followers and collaborators as +`Create`/`Update` activities. Non-public routes SHALL NOT be +dereferenceable or announced. + +#### Scenario: Route object resolves +- **WHEN** a trails instance GETs a public route's canonical URL with `Accept: application/activity+json` +- **THEN** the response is a `trails:Route` object containing the metadata envelope (distance, elevation, day breaks, routing profile, contributors) and a GPX `Document` attachment + +#### Scenario: New version fans out +- **WHEN** a new version of a shared public route is created +- **THEN** an `Update` activity carrying the new version is delivered to every collaborator instance's inbox + +### Requirement: Cross-instance sharing via Invite/Accept mirroring +Sharing a route with a user on another trails instance SHALL send an +ActivityPub `Invite`; the remote user's `Accept` SHALL register them as +collaborator on the owner's instance and establish a mirror (metadata + +latest GPX, no version history) on theirs, kept current by `Update` +fan-out. The owner's instance SHALL remain the canonical source. + +#### Scenario: Invite accepted creates a mirror +- **WHEN** Alice (instance A) shares a route with `@bob@b.example` and Bob accepts +- **THEN** A records Bob as collaborator, sends the current route, and B stores a mirror visible in Bob's collection attributed to A + +#### Scenario: Invite to a non-trails instance is refused +- **WHEN** a share targets a handle whose instance does not pass the trails-instance check +- **THEN** the share is refused at the API layer with a clear "route federation is trails-to-trails only" error + +### Requirement: Cross-instance edits store to the owner via scoped tokens +A collaborator's instance SHALL obtain a scoped, single-use edit token +from the owner's instance via an HTTP-Signature-authenticated request, +SHALL be refused when the requester is not an accepted collaborator, +and the resulting Planner session SHALL save new versions directly to +the owner's instance with the collaborator credited as contributor by +actor IRI. + +#### Scenario: Collaborator edits across instances +- **WHEN** Bob starts an edit session on his mirror of Alice's route +- **THEN** B obtains a token from A, the Planner opens with A's callback, and Bob's save creates the next sequential version on A crediting Bob's actor IRI + +#### Scenario: Non-collaborator cannot obtain a token +- **WHEN** an instance requests an edit token for an actor who is not an accepted collaborator +- **THEN** the owner's instance refuses with 403 and records the attempt + +### Requirement: Mirror sync healing +Each instance holding mirrors SHALL periodically verify them against +the canonical object and re-fetch when stale, and SHALL visibly mark a +mirror whose origin has been unreachable past the verification window. + +#### Scenario: Missed update is healed +- **WHEN** instance B was offline long enough to miss an `Update` and its retry window +- **THEN** B's next sync check detects the version gap and re-fetches the canonical route from A diff --git a/openspec/changes/route-federation/tasks.md b/openspec/changes/route-federation/tasks.md new file mode 100644 index 0000000..d079fa2 --- /dev/null +++ b/openspec/changes/route-federation/tasks.md @@ -0,0 +1,39 @@ +## 0. Preconditions + +- [ ] 0.1 `social-federation` §6 (outbound trails-to-trails follows + NodeInfo instance check) shipped +- [ ] 0.2 `route-sharing` shipped (local share model the Invite extends) +- [ ] 0.3 Two-instance integration environment exists (social-federation task 11.4's docker-compose setup — shared prerequisite) + +## 1. Vocabulary + object surface + +- [ ] 1.1 Define the trails JSON-LD context document (versioned, served at a stable URL); `trails:Route` type with metadata envelope fields +- [ ] 1.2 Fedify object dispatcher for routes at the canonical route URL (public-only; owner-private ⇒ 404), content-negotiation middleware on the route detail page (same pattern as activities) +- [ ] 1.3 GPX `Document` attachment on the object; verify shapes against a second live instance before merging (soak lesson: arrays, dereferenceability, tombstones) + +## 2. Create/Update fan-out + +- [ ] 2.1 Publish-on-public + version-created hooks enqueue route activity deliveries (reuse deliver-activity job patterns; transition-aware retraction rules apply to routes too) +- [ ] 2.2 Inbox listeners accept `Update(trails:Route)` from verified trails instances holding a mirror relationship; reject otherwise + +## 3. Invite/Accept collaboration + +- [ ] 3.1 Schema: route collaborators support remote actor IRIs (exactly-one-of local/remote pattern); mirror provenance on routes (`remote_origin_iri` et al.) +- [ ] 3.2 Share dialog accepts remote trails handles; outbound `Invite` delivery with Pending state +- [ ] 3.3 Inbox: `Accept(Invite)` registers the collaborator + sends current route; `Reject(Invite)`/`Undo` lifecycle +- [ ] 3.4 Mirror storage + rendering (read-only route page attributed to origin, "mirrored from" affordance, stale badge) + +## 4. Cross-instance edit tokens + +- [ ] 4.1 Token issuance endpoint (HTTP-Signature verified, collaborator-checked, single-use jti, rate-limited, audited) +- [ ] 4.2 Edit-in-planner flow from a mirror: request token from origin, open Planner with origin callback +- [ ] 4.3 Callback path records remote contributors by actor IRI; contributor display via remote_actors cache + +## 5. Sync healing + +- [ ] 5.1 pg-boss cron: verify mirrors against canonical objects, re-fetch on version gap, mark unreachable origins + +## 6. Hardening + rollout + +- [ ] 6.1 Feature flag (own flag or FEDERATION_ENABLED tier), staged soak with a second instance, security review of the token endpoint +- [ ] 6.2 Privacy manifest: what collaborator instances learn/store +- [ ] 6.3 Two-instance integration tests: invite→accept→mirror, update fan-out, cross-instance edit roundtrip, sync healing after simulated downtime