Garmin Connect as the third connected-services provider, modeled on
wahoo-import but adapted to Garmin's push-first API: OAuth2+PKCE on the
existing oauth credential kind, ping/push webhook ingestion with async
pg-boss processing and an SSRF allowlist on callback URLs, date-range
backfill instead of a pick list (Garmin has no activity-list endpoint),
mandatory deregistration handling. Route push (Courses API) explicitly
deferred to a follow-up, mirroring wahoo-route-push.
Build is fixtures-first; rollout tasks are gated on Garmin Connect
Developer Program approval, which runs in parallel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flagship ⇄ staging, both directions, 2026-06-07: follows settled to
Accepted in seconds, first polls ingested each side's public
activities, both feeds render remote attribution. The cross-origin
Accept fix (#490) verified working in production.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wiring follows the IS_FLAGSHIP pattern: the compose env defaults every
FEDERATION_* var to empty (off — the safe self-host default; all
federation surfaces 404), and cd-apps.yml appends
FEDERATION_ENABLED=true + FEDERATION_LOG_LEVEL=info to the flagship's
app.env. FEDERATION_KEY_ENCRYPTION_KEY was already in SOPS.
Rollout 12.2/12.3 soaked on staging against a real Mastodon
(2026-06-06/07); the trails↔trails Accept bug found by the §11 harness
is fixed and deployed. Rollback: drop the two echo lines, merge, rerun
cd-apps — instant off, follow rows persist.
First boot with the flag enqueues backfill-user-keypairs (idempotent)
and registers the federation jobs. Applied via manual cd-apps dispatch
after merge since workflow-file changes don't trigger the path filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12.1/12.2/12.3/12.6 marked with provenance (additive schema behind the
off flag on prod; the 2026-06-06/07 staging soak against a real
Mastodon covered inbound + push delivery; rollback documented in the
runbook). 12.4 annotated: protocol verified by the two-instance
harness, live staging⇄preview soak queued now that previews federate.
12.5 (prod flag flip) explicitly left as an operator decision.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every PR preview becomes a second live trails instance, so the
trails-to-trails soak (social-federation 12.4) can run against real
DNS + TLS + Caddy: follow from staging.trails.cool to
pr-<N>.staging.trails.cool and back. Same flag + key wiring as
persistent staging; preview teardown leaves the remote side with
ordinary dead-instance delivery failures, which fediverse software
already expires.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 11.4 harness — e2e/federation/: postgres (two DBs) + a Caddy with
an internal CA terminating real HTTPS between two complete journal
containers (journal-a.test / journal-b.test), driven by an opt-in
integration test (FEDERATION_TWO_INSTANCE=1, via run.sh). The driver
seeds users straight into each DB, mints a signed session cookie
(known SESSION_SECRET), follows across instances through the real
/follows/outgoing route, and asserts the full pipeline: Follow →
auto-Accept → settle → first outbox poll → bob's public activity
rendered in alice's /feed with @bob@journal-b.test attribution. Plus a
wire-level 11.3 check: unsigned Create(Note) → 4xx, no DB writes.
And the harness immediately earned its keep — it caught a real
trails↔trails bug Mastodon interop never could: our Follow ids are
fragment URIs on OUR domain, so the Follow embedded in another trails
instance's Accept is cross-origin and Fedify rightly distrusts it;
re-fetching the id returns the actor document, getObject() yields a
Person, and the Accept/Reject/Undo listeners bailed silently — follows
stayed Pending forever. The listeners now fall back to the wire
objectId (captured BEFORE getObject(), which memoizes the fetched
document and changes what objectId reports) validated against our
Follow-id shape + the personal inbox's ctx.recipient. Forgery-safe:
settleOutgoingFollow only matches a Pending row toward the
HTTP-Signature-authenticated sender.
Supporting changes:
- federation.server.ts: env-gated allowPrivateAddress
(FEDERATION_ALLOW_PRIVATE_ADDRESS=true) so the harness's RFC 1918
Docker network is fetchable — testing only, loudly documented.
- Root .dockerignore: local builds shipped a 1GB+ context and
overlaid host (darwin) node_modules over the image's own install
via COPY . . — CI never noticed because it builds from clean
checkouts. Context is now ~5MB.
- tasks.md: 11.1–11.7 marked with provenance notes; design.md gains
the cross-origin embedded-object lesson.
Gate: typecheck ✓ lint ✓ unit+integration (FEDERATION_INTEGRATION=1) ✓
e2e 71/72 + known komoot flake green isolated ✓ harness run clean from
scratch (2/2, teardown included) ✓
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the 7.4 remainder. Fedify's FetchError carries the failed
Response, so a 429 from a remote's outbox now reads Retry-After
(delta-seconds or HTTP-date per RFC 9110), arms a per-host backoff
(15 min default, capped at the 1 h poll interval), and pollRemoteActor
skips backing-off hosts before doing any DB or network work. State is
in-process like the pacing map — a restart costs at most one extra
request that re-arms the backoff. last_polled_at is deliberately not
stamped on a rate-limited poll so the hourly sweep retries.
Unit tests cover the header parser (incl. the V8 footgun where
Date.parse reads bare negative integers as years), default/cap
behavior, and window expiry; an integration test drives the full
FetchError path and asserts the second poll never touches the network.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.1/8.2: listSocialFeed is now a UNION ALL of local and remote
branches, sorted on COALESCE(remote_published_at, created_at):
- local rows: visibility='public' from accepted local follows — and
the previously missing accepted_at filter is added (the spec's
'Pending follows contribute nothing' scenario)
- remote rows: gated structurally by joining the viewer's OWN accepted
follow against the originating actor — which is exactly the
followers-only audience rule (a row reaches only viewers whose
follow brought it in); attribution from the remote_actors cache;
cards link outward to the canonical origin page (no local detail
page for remote rows)
9.1: annotated — local Pending button shipped with locked accounts;
remote Pending lives on /follows/outgoing.
9.2: already enforced + tested since §3 (actor/webfinger 404).
9.3 hardening: deliver-activity now re-checks the OWNER's profile
visibility at send time, closing the enqueue→delivery flip window
(enqueue-side and inbound-side gates already existed).
Integration tests: the §8 audience-leak guard (A sees followers-only,
B does not), public remote attribution + outward link, pending
contributes nothing, mixed local/remote COALESCE ordering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as
remaining). Resolves the activities.owner_id open question.
Schema (design decision from the open question):
- activities.owner_id nullable + check constraint enforcing exactly
one of (owner_id, remote_actor_iri) — the follows pattern again.
- remote_published_at carries the origin's publish time for the §8
feed sort; index on remote_actor_iri for the feed join.
- Compiler-audited fallout: notification fan-out, the Note object
dispatcher, and the activity detail loader explicitly skip/404
remote rows (their canonical page is the origin; feed links there).
Every other surface joins users on owner_id and excludes remote rows
structurally.
Ingestion:
- federation-ingest.server.ts: parseOutboxItem targets exactly our own
outgoing Create(Note) shape (PropertyValue stats, first-paragraph
name, audience from to/cc); unknown items skipped, never fatal.
- ingestRemoteActivities: replay-safe via unique remote_origin_iri,
conflict-streak early exit (outboxes are newest-first).
- pollRemoteActor: signed fetch (Authorized Fetch via a local
follower's key), outbox resolution via remote_actors cache with
actor-doc fallback (which refreshes the cache), 1 req/5 s per-host
pacing, last_polled_at stamping. Network + pacing injectable.
Jobs: poll-remote-actor (per-actor; the §4 Accept listener already
enqueues it as the first poll) + poll-remote-outboxes (5-min cron
sweep over accepted remote follows not polled within the hour).
Tests: parse-shape units; integration suite for ingestion provenance,
audience→visibility mirroring, replay safety, the DB author invariant,
poll flow (actor-doc → collection → page), signer requirement, and
due-polling selection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10.1 Privacy manifest (legal/privacy):
- German legal half: Föderation entry under Empfänger/Drittanbieter —
what a public profile exposes, what accepted followers' servers
receive, the loss-of-control over delivered copies, Art. 6(1)(a)
basis, private profiles don't federate.
- English manifest half: dedicated Federation (ActivityPub) section —
actor object contents, push delivery + retraction semantics (incl.
the tombstone caveat), encrypted-at-rest signing keys, remote actor
cache, ingested remote content with the followers-only viewer gate,
inbox logging/rate limits. PRIVACY_LAST_UPDATED bumped.
10.2 docs/deployment.md federation runbook: enabling per environment,
key rotation posture, abuse monitoring, and the troubleshooting
checklist distilled from the 2026-06-06/07 soak (IP families first,
no outbox backfill, tombstones, the 10s delivery timeout, actor
re-fetch).
10.3 Home marketing blurb (en+de) aligned to what actually ships:
Mastodon can follow you + trails-to-trails outbound — no more 'follow
friends anywhere' overstatement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tasks 6.1–6.6. A local user can follow a user on another trails
instance: WebFinger-resolve the handle, verify the target runs
trails.cool via NodeInfo — checked against the ACTOR IRI's host, never
the handle's domain (split-domain constraint) — record a Pending
follow row, deliver a signed Follow. The §4 inbox listeners already
settle (Accept) or drop (Reject) the row.
- federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow
(Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup,
NodeInfo, delivery) injectable for offline integration tests.
Software allowlist: trails-cool.
- /follows/outgoing: follow-by-handle form + pending/accepted list +
cancel; linked from the feed empty state; i18n en+de. Remote actors
have no local profile page, so the remote Pending state lives here
(the profile Pending button from locked accounts covers local).
- /.well-known/trails-cool now publishes software: trails-cool (6.2).
- Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed,
not_trails (6.3).
- Tests: handle-parser + allowlist units; integration suite for the
Pending lifecycle (create/idempotent/refuse-non-trails/refuse-
unresolvable/own-host/cancel+Undo) with injected network deps; e2e
anonymous-redirect guard for the new page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2.2.x line was uninstallable when federation landed
(@fedify/webfinger@2.2.x wasn't published); fixed upstream, so move to
latest. Exact pin stays deliberate — a federation library defines the
wire shapes remote instances see.
Verified: typecheck, lint, full unit + integration suites (245 tests,
including the wire-shape assertions: JRD, actor attachment arrays,
PropertyValue fields, dereferenceable Note ids) all green on 2.2.5.
Lockfile diff is a clean 24-line swap of the fedify package family.
Note: a transient cross-suite flake surfaced when running both
integration suites concurrently (shared journal.follows state) —
pre-existing, unrelated to the upgrade; clean re-run green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prompted by Hollo's split-domain setup. Two parts:
- docs/ideas/split-domain-handles.md: offering split handle/server
domains to self-hosters is config-level work — Fedify's origin
option natively accepts { handleHost, webOrigin } — and handles are
permanent identity, so apex handles matter. Single-domain stays the
default (Decision #18); post-launch polish.
- Interop constraint pinned in social-federation task 6.1 and
route-federation's gating decision: the trails-to-trails NodeInfo
check must run against the actor IRI's host after WebFinger
resolution, never the handle's domain — split-domain remote
instances would otherwise be wrongly refused.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ullrich's notes from the first real federation exchange (2026-06-06/07
staging ↔ Mastodon soak), expanded with the constraints the soak
surfaced: route map images in Notes, fediverse kudos (inbound
Like/Announce), comments from the fediverse, planned routes as
Events (Mobilizon), Wanderer interop, federated explore.
The fourth note item — remote follower counted but not listed — was a
bug, fixed separately in #475.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gap analysis of docs/architecture.md against openspec/ and docs/ideas/
found the envisioned-but-uncaptured remainder. This commit captures it:
New OpenSpec change (validated):
- route-federation — the collaboration half of the federation vision:
routes as dereferenceable trails:Route objects, Create/Update
fan-out, Invite/Accept collaboration mirroring (arch decision #2),
cross-instance Planner edits via HTTP-Signature-requested scoped
tokens (decisions #3/#12), mirror sync healing (#16). Depends on
social-federation §6 + route-sharing; carries the 2026-06-07 soak
lessons as design constraints.
New docs/ideas/ explorations:
- instance-administration — registration toggle, suspend/ban,
federation blocklists, reports (moderation now gates the federated
comments idea)
- social-interactions — local likes + comments (don't exist even
locally; the foundation federated kudos/comments attach to)
- activity-participants — group tagging with confirm/decline +
federated mentions (the participants jsonb column is an untyped stub)
- multi-day-collections — architecture open question #1, directions
evaluated
architecture.md cleanup:
- Mastodon-compat section annotated with shipped/captured state + the
live-soak interop lessons (attachment arrays, tombstones, 10s
timeout, no backfill)
- api.trails.cool removed (contradicted resolved decision #18)
- Phase 1 ticked (shipped); Phase 2/3 items annotated with where each
is tracked; specs/ → openspec/, activity.* → journal.*, cx21 → cx23
- brouter-web open question marked resolved-in-practice; new section
pointing to where the unshipped vision is tracked
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2026-06-07 disk-full outage (flagship 100%, postgres crash-looping
on its pidfile, prod + staging down): cd-apps DOES prune images after
deploys, but it had been failing early all day (migration bug), so its
prune never ran — while ~10 staging/preview deploys kept pulling fresh
images with no prune of their own.
- cd-staging: prune superseded layers (until=1h guard against racing
in-flight pulls) after persistent staging and preview deploys.
- disk-maintenance.yml: NEW daily scheduled prune (04:30 UTC) that
also FAILS when the disk is still ≥85% after pruning — a redundant
alert channel for exactly the case where the Grafana disk alert
drowns in other noise, as it did during the incident.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
countFollowers counts every accepted follow row, but listFollowers
inner-joined users on follower_id — so a federated follower (NULL
follower_id, follower_actor_iri set) bumped the count while never
appearing in the list. Observed live: profile said '1 follower', list
below was empty. listFollowing had the same latent bug for outbound
trails-to-trails follows.
Both lists now left-join users + remote_actors: local entries link to
the local profile as before; remote entries display cached actor data
(IRI parsing as fallback) and link out to the remote profile.
Integration test pins count/list consistency for a remote follower.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A received Delete is recorded by Mastodon as a permanent tombstone —
later Creates for the same URI are silently refused forever. The old
code sent Delete on every non-public save 'just in case' (the comment
even called over-sending harmless); on the 2026-06-07 soak this
tombstoned an unlisted activity's URI before its first real publish,
making its later flip to public invisible on the remote with no error
anywhere.
- visibilityTransitionAction(previous, next): Create on any transition
to public (re-publish doubles as back-delivery; remotes dedupe by
id), Delete only when leaving public, nothing for
non-public→non-public.
- updateActivityVisibility reads the previous visibility and acts on
the transition.
- design.md documents the tombstone permanence + the user-facing
consequence (un-publish then re-publish won't resurrect the post on
remotes that processed the retraction — same as Mastodon's own
delete-and-redraft).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 0002 migration's allowed list predates the Komoot public-profile
connection mode (api.sync.komoot.verify.ts writes credentialKind
'public'). Against production data — which has such a connection —
the drop-then-add CHECK failed at ATRewriteTable and blocked every
cd-apps deploy since 2026-06-07 06:25 UTC.
Also documents 'public' in the schema comment with a pointer to keep
the two lists in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mastodon's PropertyValue parser requires the actor's attachment to be
a JSON *array* and silently ignores a bare object — and Fedify
compacts single-element arrays to bare objects, so the 🥾 trails.cool
field shipped in #464 never rendered (verified: Mastodon stored
fields = {} after a forced actor refresh on the soak instance).
Ship two fields so the array survives serialization: the 🥾 profile
link (rel=me) plus an Instance link — the latter is genuinely useful
for self-hosted instances anyway. Test now asserts the array shape
explicitly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caddy 2.9 deprecated the nested `servers { metrics }` form; every
deploy logs a deprecation warning. The global `metrics` option is the
replacement and emits the same Prometheus metrics on the admin
endpoint, so the existing scrape config is unaffected.
Validated with `caddy validate` against the caddy:2 image — config
valid, no deprecation warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening from two incidents on 2026-06-06/07:
Schema drift (morning): drizzle-kit push exits 0 even when it aborts
on an interactive prompt it can't render in CI, so cd-staging's
set -euo pipefail never fired and a month of staging schema drift
accumulated silently until new code hit missing columns. All three
drizzle push call sites now tee output and fail the deploy on any
'Error:' line.
Production outage (overnight, ~9h): cd-infra and cd-apps had no
failure handling at all. A network-option change stopped postgres for
a network recreation that then deadlocked on containers from other
compose projects holding trails-shared; the script carried on, the
stack stayed down. Both scripts now run set -euo pipefail and gate on
container health at the end (postgres+journal for cd-infra,
journal+planner for cd-apps) so a deploy that leaves the stack down
is a red X, not a shrug.
docs/deployment.md gains the cross-project manual procedure for
network-changing deploys — the CD workflows only manage their own
compose project and cannot apply those safely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>