diff --git a/.claude/skills/cmux-browser/SKILL.md b/.agents/skills/cmux-browser/SKILL.md similarity index 100% rename from .claude/skills/cmux-browser/SKILL.md rename to .agents/skills/cmux-browser/SKILL.md diff --git a/.claude/skills/cmux-browser/agents/openai.yaml b/.agents/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-browser/agents/openai.yaml rename to .agents/skills/cmux-browser/agents/openai.yaml diff --git a/.claude/skills/cmux-browser/references/authentication.md b/.agents/skills/cmux-browser/references/authentication.md similarity index 100% rename from .claude/skills/cmux-browser/references/authentication.md rename to .agents/skills/cmux-browser/references/authentication.md diff --git a/.claude/skills/cmux-browser/references/commands.md b/.agents/skills/cmux-browser/references/commands.md similarity index 100% rename from .claude/skills/cmux-browser/references/commands.md rename to .agents/skills/cmux-browser/references/commands.md diff --git a/.claude/skills/cmux-browser/references/proxy-support.md b/.agents/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .claude/skills/cmux-browser/references/proxy-support.md rename to .agents/skills/cmux-browser/references/proxy-support.md diff --git a/.claude/skills/cmux-browser/references/session-management.md b/.agents/skills/cmux-browser/references/session-management.md similarity index 100% rename from .claude/skills/cmux-browser/references/session-management.md rename to .agents/skills/cmux-browser/references/session-management.md diff --git a/.claude/skills/cmux-browser/references/snapshot-refs.md b/.agents/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .claude/skills/cmux-browser/references/snapshot-refs.md rename to .agents/skills/cmux-browser/references/snapshot-refs.md diff --git a/.claude/skills/cmux-browser/references/video-recording.md b/.agents/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .claude/skills/cmux-browser/references/video-recording.md rename to .agents/skills/cmux-browser/references/video-recording.md diff --git a/.claude/skills/cmux-browser/templates/authenticated-session.sh b/.agents/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/authenticated-session.sh rename to .agents/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.claude/skills/cmux-browser/templates/capture-workflow.sh b/.agents/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/capture-workflow.sh rename to .agents/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.claude/skills/cmux-browser/templates/form-automation.sh b/.agents/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/form-automation.sh rename to .agents/skills/cmux-browser/templates/form-automation.sh diff --git a/.claude/skills/cmux-debug-windows/SKILL.md b/.agents/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .claude/skills/cmux-debug-windows/SKILL.md rename to .agents/skills/cmux-debug-windows/SKILL.md diff --git a/.claude/skills/cmux-debug-windows/agents/openai.yaml b/.agents/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-debug-windows/agents/openai.yaml rename to .agents/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.claude/skills/cmux-markdown/SKILL.md b/.agents/skills/cmux-markdown/SKILL.md similarity index 100% rename from .claude/skills/cmux-markdown/SKILL.md rename to .agents/skills/cmux-markdown/SKILL.md diff --git a/.claude/skills/cmux-markdown/agents/openai.yaml b/.agents/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-markdown/agents/openai.yaml rename to .agents/skills/cmux-markdown/agents/openai.yaml diff --git a/.claude/skills/cmux-markdown/references/commands.md b/.agents/skills/cmux-markdown/references/commands.md similarity index 100% rename from .claude/skills/cmux-markdown/references/commands.md rename to .agents/skills/cmux-markdown/references/commands.md diff --git a/.claude/skills/cmux-markdown/references/live-reload.md b/.agents/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .claude/skills/cmux-markdown/references/live-reload.md rename to .agents/skills/cmux-markdown/references/live-reload.md diff --git a/.claude/skills/cmux/SKILL.md b/.agents/skills/cmux/SKILL.md similarity index 100% rename from .claude/skills/cmux/SKILL.md rename to .agents/skills/cmux/SKILL.md diff --git a/.claude/skills/cmux/agents/openai.yaml b/.agents/skills/cmux/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux/agents/openai.yaml rename to .agents/skills/cmux/agents/openai.yaml diff --git a/.claude/skills/cmux/references/handles-and-identify.md b/.agents/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .claude/skills/cmux/references/handles-and-identify.md rename to .agents/skills/cmux/references/handles-and-identify.md diff --git a/.claude/skills/cmux/references/panes-surfaces.md b/.agents/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .claude/skills/cmux/references/panes-surfaces.md rename to .agents/skills/cmux/references/panes-surfaces.md diff --git a/.claude/skills/cmux/references/trigger-flash-and-health.md b/.agents/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .claude/skills/cmux/references/trigger-flash-and-health.md rename to .agents/skills/cmux/references/trigger-flash-and-health.md diff --git a/.claude/skills/cmux/references/windows-workspaces.md b/.agents/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .claude/skills/cmux/references/windows-workspaces.md rename to .agents/skills/cmux/references/windows-workspaces.md diff --git a/.claude/skills/crit-cli/SKILL.md b/.agents/skills/crit-cli/SKILL.md similarity index 100% rename from .claude/skills/crit-cli/SKILL.md rename to .agents/skills/crit-cli/SKILL.md diff --git a/.claude/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md similarity index 100% rename from .claude/skills/ia-review/SKILL.md rename to .agents/skills/ia-review/SKILL.md diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md similarity index 100% rename from .claude/skills/openspec-apply-change/SKILL.md rename to .agents/skills/openspec-apply-change/SKILL.md diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md similarity index 100% rename from .claude/skills/openspec-archive-change/SKILL.md rename to .agents/skills/openspec-archive-change/SKILL.md diff --git a/.claude/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md similarity index 100% rename from .claude/skills/openspec-explore/SKILL.md rename to .agents/skills/openspec-explore/SKILL.md diff --git a/.claude/skills/openspec-propose/SKILL.md b/.agents/skills/openspec-propose/SKILL.md similarity index 100% rename from .claude/skills/openspec-propose/SKILL.md rename to .agents/skills/openspec-propose/SKILL.md diff --git a/.claude/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md similarity index 100% rename from .claude/skills/spec-drift-review/SKILL.md rename to .agents/skills/spec-drift-review/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d2af30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Keep Docker build contexts small and hermetic. CI builds from a clean +# checkout so this mostly matters for local builds (e2e/federation +# harness), where node_modules would otherwise bloat the context to +# gigabytes — and worse, `COPY . .` would overlay host-installed +# node_modules over the image's own pnpm install. +**/node_modules +**/.turbo +**/build +**/.react-router +.git +e2e/results +playwright-report +test-results +**/*.log diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index c5cc328..212187e 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -83,6 +83,13 @@ jobs: echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. echo "IS_FLAGSHIP=true" >> infrastructure/app.env + # Federation on (social-federation rollout 12.5, flipped + # 2026-06-07 after the staging + Mastodon soak). The + # FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env + # decrypted above. Rollback: delete these two lines, merge, + # rerun cd-apps — instant off, federation surfaces 404. + echo "FEDERATION_ENABLED=true" >> infrastructure/app.env + echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env # Sentry DSNs (public — see workflow top-level env for context). echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env @@ -93,7 +100,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + source: "infrastructure/docker-compose.yml,infrastructure/caddy" target: /opt/trails-cool strip_components: 1 diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 89c11bf..333251e 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -43,7 +43,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - 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" + 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" target: /opt/trails-cool strip_components: 1 @@ -84,24 +84,48 @@ jobs: docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true fi + # Capture whether prometheus was recreated. A fresh container already + # reads the new config on startup; sending it SIGHUP immediately can + # kill Prometheus 3.10 during early boot (exit 2 observed on + # 2026-06-09). + PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else - # Restart infra services (except Caddy — just reload its config). + # Restart infra services (config reloads handled below). # --remove-orphans cleans up containers whose service was deleted # from the compose file (e.g., the flagship `brouter` removal in # PR #297 left an orphan that had to be removed by hand). docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile fi + PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + + # Apply config-only changes that `up -d` skips — it recreates a + # container only when its compose *definition* changes, not when a + # mounted config file's content changes. The configs are mounted as + # directories (not single files), so a reload/restart re-reads the + # freshly scp'd file; a single-file mount would have pinned the old + # inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when + # the same container stayed up; a recreated container already loaded + # the new config on startup. Loki and Promtail reload their main + # config only on restart; Caddy reloads gracefully (validates, swaps + # live, no downtime). + if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then + docker compose --env-file .env kill -s SIGHUP prometheus + fi + docker compose --env-file .env restart loki promtail + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + docker compose ps - # Gate on the stack actually being up: postgres healthy and — - # since an infra restart bounces the app containers' database — - # journal back to healthy too. A deploy that leaves either down - # must fail loudly (see the 2026-06-06/07 outage). + # 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) @@ -114,6 +138,23 @@ jobs: fi done + PROMETHEUS_READY= + for i in $(seq 1 36); do + prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) + if [ "$prom_status" = "running" ]; then + prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) + if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then + PROMETHEUS_READY=1 + break + fi + fi + sleep 5 + done + if [ -z "$PROMETHEUS_READY" ]; then + echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" + exit 1 + fi + # Annotate deploy in Grafana GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index ed59b70..c0ad3ea 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -252,6 +252,15 @@ jobs: echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + # Federation on previews too (social-federation rollout 12.4): + # makes any PR preview a second live trails instance that + # persistent staging can follow across — the trails-to-trails + # soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the + # SOPS env decrypted above. Preview teardown orphans remote + # follower rows on the other side; remotes handle dead + # instances via ordinary delivery-failure expiry. + echo "FEDERATION_ENABLED=true" + echo "FEDERATION_LOG_LEVEL=debug" } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - name: Generate per-PR Caddyfile snippet diff --git a/CLAUDE.md b/CLAUDE.md index 3f5894e..3a71dc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,6 +257,17 @@ 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- logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. +## Agent Skills & Config + +Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink). + +``` +.agents/skills/ ← canonical location +.claude/skills ← symlink → ../.agents/skills +``` + +Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup. + ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: diff --git a/apps/journal/.env.example b/apps/journal/.env.example index ef8718a..8a51ed0 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,6 +49,14 @@ # WAHOO_CLIENT_SECRET= # WAHOO_WEBHOOK_TOKEN= +# Garmin Connect Developer Program credentials (spec: garmin-import). +# Requires an approved program application; without these the Garmin +# provider is hidden on /settings/connections. The OAuth callback to +# register with Garmin is `/api/sync/callback/garmin`, the +# notification endpoint `/api/sync/webhook/garmin`. +# GARMIN_CLIENT_ID= +# GARMIN_CLIENT_SECRET= + # Integration test secret (only needed if running the integration # test suite that drives the API directly). Generate with # `openssl rand -hex 32`. diff --git a/apps/journal/app/jobs/deliver-activity.ts b/apps/journal/app/jobs/deliver-activity.ts index d158a3f..c40ab44 100644 --- a/apps/journal/app/jobs/deliver-activity.ts +++ b/apps/journal/app/jobs/deliver-activity.ts @@ -1,6 +1,6 @@ import type { JobDefinition } from "@trails-cool/jobs"; import { and, eq } from "drizzle-orm"; -import { activities } from "@trails-cool/db/schema/journal"; +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"; @@ -77,6 +77,17 @@ async function deliverOne(p: DeliveryPayload): Promise { logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping"); return; } + // 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; + } activity = activityToCreate(row as FederatableActivity, p.ownerUsername); } else { activity = activityToDelete(p.objectIri, p.ownerUsername); diff --git a/apps/journal/app/jobs/garmin-import-activity.ts b/apps/journal/app/jobs/garmin-import-activity.ts new file mode 100644 index 0000000..edff614 --- /dev/null +++ b/apps/journal/app/jobs/garmin-import-activity.ts @@ -0,0 +1,33 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { logger } from "../lib/logger.server.ts"; +import { + runGarminActivityImport, + type GarminImportData, +} 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: JobDefinition = { + 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 as GarminImportData; + try { + await runGarminActivityImport(data); + } catch (err) { + logger.warn( + { err, externalId: data.externalId }, + "garmin-import-activity failed (pg-boss will retry)", + ); + throw err; + } + } + }, +}; diff --git a/apps/journal/app/jobs/notifications-fanout.ts b/apps/journal/app/jobs/notifications-fanout.ts index b73f5ef..ef96f78 100644 --- a/apps/journal/app/jobs/notifications-fanout.ts +++ b/apps/journal/app/jobs/notifications-fanout.ts @@ -51,6 +51,9 @@ export async function fanout(activityId: string): Promise { logger.warn({ activityId }, "fanout: activity not found, skipping"); return; } + // Remote-ingested rows have no local owner and never fan out locally + // (the users innerJoin already excludes them; this narrows the type). + if (row.ownerId === null) return; // Defense in depth — the create-side guard already filters this, but // recheck here so the job doesn't mistakenly fan out a row that was // later flipped to private/unlisted before the job ran. diff --git a/apps/journal/app/jobs/poll-remote-actor.ts b/apps/journal/app/jobs/poll-remote-actor.ts new file mode 100644 index 0000000..31582c4 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-actor.ts @@ -0,0 +1,26 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { pollRemoteActor } from "../lib/federation-ingest.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface PollPayload { + actorIri?: string; +} + +/** + * 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: JobDefinition = { + name: "poll-remote-actor", + retryLimit: 2, + expireInSeconds: 120, + async handler(jobs) { + for (const job of jobs) { + const { actorIri } = (job.data ?? {}) as PollPayload; + if (!actorIri) continue; + const result = await pollRemoteActor(actorIri); + logger.info({ actorIri, result }, "poll-remote-actor"); + } + }, +}; diff --git a/apps/journal/app/jobs/poll-remote-outboxes.ts b/apps/journal/app/jobs/poll-remote-outboxes.ts new file mode 100644 index 0000000..c6cf085 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-outboxes.ts @@ -0,0 +1,25 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +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: JobDefinition = { + name: "poll-remote-outboxes", + cron: "*/5 * * * *", + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const due = await listActorsDuePolling(); + for (const actorIri of due) { + await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" }); + } + if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep"); + return { due: due.length }; + }, +}; diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 7705225..6ad22b7 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import { eq, desc, and, sql } from "drizzle-orm"; +import { eq, desc, and, isNotNull, sql } from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; import { getDb } from "./db.ts"; -import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal"; +import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal"; import { validateGpx, writeGeom } from "./gpx-save.server.ts"; import type { GpxData } from "./gpx-save.server.ts"; @@ -195,14 +196,25 @@ export async function listPublicActivitiesForOwner( } /** - * Social feed: aggregated public activities from users that `followerId` - * follows (accepted only). Reverse-chronological. Joins users for owner - * attribution. Unlisted/private activities never appear, regardless of - * follow state. + * Social feed (spec: social-federation §8): aggregated activities from + * actors that `followerId` follows with an *accepted* follow — local + * users and remote trails actors alike. Reverse-chronological on + * COALESCE(remote_published_at, created_at). + * + * Audience rules: + * - local rows: `visibility = 'public'` only (unlisted/private never + * appear regardless of follow state) + * - remote rows: `audience = 'public'` or `followers-only` — the + * latter gated structurally by joining the *viewer's own* accepted + * follow against the originating actor (spec: "Followers-only remote + * content reaches only the right viewer") + * Pending follows contribute nothing (accepted_at IS NOT NULL on both + * branches — previously missing on the local branch). */ export async function listSocialFeed(followerId: string, limit: number = 50) { const db = getDb(); - const rows = await db + + const local = db .select({ id: activities.id, name: activities.name, @@ -211,8 +223,12 @@ export async function listSocialFeed(followerId: string, limit: number = 50) { duration: activities.duration, startedAt: activities.startedAt, createdAt: activities.createdAt, - ownerUsername: users.username, - ownerDisplayName: users.displayName, + sortTime: sql`${activities.createdAt}`.as("sort_time"), + ownerUsername: sql`${users.username}`.as("owner_username"), + ownerDisplayName: sql`${users.displayName}`.as("owner_display_name"), + ownerDomain: sql`${users.domain}`.as("owner_domain"), + externalUrl: sql`NULL`.as("external_url"), + remote: sql`false`.as("remote"), }) .from(activities) .innerJoin(follows, eq(follows.followedUserId, activities.ownerId)) @@ -220,14 +236,46 @@ export async function listSocialFeed(followerId: string, limit: number = 50) { .where( and( eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), eq(activities.visibility, "public"), ), - ) - .orderBy(desc(activities.createdAt)) + ); + + const remote = db + .select({ + id: activities.id, + name: activities.name, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + sortTime: sql`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"), + ownerUsername: sql`${remoteActors.username}`.as("owner_username"), + ownerDisplayName: sql`${remoteActors.displayName}`.as("owner_display_name"), + ownerDomain: sql`${remoteActors.domain}`.as("owner_domain"), + externalUrl: sql`${activities.remoteOriginIri}`.as("external_url"), + remote: sql`true`.as("remote"), + }) + .from(activities) + .innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri)) + .leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), + // public reaches every accepted follower; followers-only is + // already gated by joining the viewer's own accepted follow. + sql`${activities.audience} IN ('public', 'followers-only')`, + ), + ); + + const rows = await unionAll(local, remote) + .orderBy(sql`sort_time DESC`) .limit(limit); - const ids = rows.map((r) => r.id); - const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + const localIds = rows.filter((r) => !r.remote).map((r) => r.id); + const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map(); return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts index 511e3ae..706795f 100644 --- a/apps/journal/app/lib/connected-services/manager.ts +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -168,6 +168,18 @@ export async function markNeedsRelink( console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`); } +// Provider-side revocation (e.g. a Garmin deregistration notification): +// keep the row for audit, flip to 'revoked' so every subsequent +// withFreshCredentials short-circuits and the UI shows a re-connect +// prompt. Imported activities are untouched. +export async function markRevoked(serviceId: string): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "revoked" }) + .where(eq(connectedServices.id, serviceId)); +} + export async function updateGrantedScopes( serviceId: string, grantedScopes: string[], diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts index fe886af..ae0fb8a 100644 --- a/apps/journal/app/lib/connected-services/oauth-state.server.ts +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -21,3 +21,39 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState return {}; } } + +// --- PKCE (RFC 7636) ---------------------------------------------------- +// +// Providers with `pkce: true` (Garmin) need a code verifier that survives +// the connect → provider → callback redirect without ever appearing in a +// URL (the whole point of PKCE is that the verifier stays out of the +// authorization response). The `state` param is visible in redirects, so +// the verifier rides a short-lived httpOnly cookie scoped to the callback +// path instead. + +import { createHash, randomBytes } from "node:crypto"; + +const PKCE_COOKIE = "__oauth_pkce"; +const PKCE_MAX_AGE_S = 600; + +export function generatePkcePair(): { verifier: string; challenge: string } { + // 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128). + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function pkceCookieHeader(verifier: string): string { + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`; +} + +export function clearPkceCookieHeader(): string { + return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`; +} + +export function readPkceVerifier(request: Request): string | null { + const cookie = request.headers.get("Cookie") ?? ""; + const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} diff --git a/apps/journal/app/lib/connected-services/oauth-state.test.ts b/apps/journal/app/lib/connected-services/oauth-state.test.ts new file mode 100644 index 0000000..36d9f90 --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.test.ts @@ -0,0 +1,61 @@ +// PKCE helper tests (RFC 7636 S256) — spec: garmin-import, task 1.2. + +import { createHash } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { + generatePkcePair, + pkceCookieHeader, + readPkceVerifier, + clearPkceCookieHeader, + encodeOAuthState, + decodeOAuthState, +} from "./oauth-state.server.ts"; + +describe("generatePkcePair", () => { + it("produces an RFC 7636-compliant verifier and matching S256 challenge", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); // base64url charset + const expected = createHash("sha256").update(verifier).digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("is unique per call", () => { + expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier); + }); +}); + +describe("PKCE cookie round trip", () => { + it("verifier set on connect is readable on callback", () => { + const { verifier } = generatePkcePair(); + const setCookie = pkceCookieHeader(verifier); + // Browser reflects the cookie value back on the callback request. + const cookieValue = setCookie.split(";")[0]!; + const request = new Request("https://x.example/api/sync/callback/garmin", { + headers: { Cookie: `other=1; ${cookieValue}` }, + }); + expect(readPkceVerifier(request)).toBe(verifier); + }); + + it("returns null without the cookie; clear header expires it", () => { + const request = new Request("https://x.example/", { headers: { Cookie: "other=1" } }); + expect(readPkceVerifier(request)).toBeNull(); + expect(clearPkceCookieHeader()).toContain("Max-Age=0"); + }); + + it("cookie is httpOnly and scoped to the callback path", () => { + const header = pkceCookieHeader("v"); + expect(header).toContain("HttpOnly"); + expect(header).toContain("Path=/api/sync/callback"); + }); +}); + +describe("oauth state encoding (pre-existing behavior)", () => { + it("round-trips and tolerates garbage", () => { + const encoded = encodeOAuthState({ returnTo: "/settings/connections" }); + expect(decodeOAuthState(encoded)).toEqual({ returnTo: "/settings/connections" }); + expect(decodeOAuthState("%%%not-base64%%%")).toEqual({}); + expect(decodeOAuthState(null)).toEqual({}); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts new file mode 100644 index 0000000..9c1be9e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts @@ -0,0 +1,42 @@ +// Unit tests for Garmin backfill chunking + request fan-out +// (spec: garmin-import, "Historical import via backfill"). + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ withFreshCredentials: vi.fn() })); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { chunkRange, BACKFILL_CHUNK_MS } = await import("./backfill.ts"); + +const DAY = 24 * 60 * 60 * 1000; + +describe("chunkRange", () => { + it("returns a single chunk for ranges within the cap", () => { + const chunks = chunkRange(0, 30 * DAY); + expect(chunks).toEqual([{ fromMs: 0, toMs: 30 * DAY }]); + }); + + it("splits ranges larger than the cap, last chunk clamped", () => { + const chunks = chunkRange(0, 200 * DAY); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ fromMs: 0, toMs: BACKFILL_CHUNK_MS }); + expect(chunks[2]!.toMs).toBe(200 * DAY); + // contiguous, no gaps or overlaps + expect(chunks[1]!.fromMs).toBe(chunks[0]!.toMs); + expect(chunks[2]!.fromMs).toBe(chunks[1]!.toMs); + }); + + it("returns [] for empty or inverted ranges", () => { + expect(chunkRange(5, 5)).toEqual([]); + expect(chunkRange(10, 5)).toEqual([]); + }); + + it("covers exactly the requested range at chunk boundaries", () => { + const chunks = chunkRange(0, 2 * BACKFILL_CHUNK_MS); + expect(chunks).toHaveLength(2); + expect(chunks[1]).toEqual({ + fromMs: BACKFILL_CHUNK_MS, + toMs: 2 * BACKFILL_CHUNK_MS, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts new file mode 100644 index 0000000..1055a78 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts @@ -0,0 +1,111 @@ +// Garmin historical import via the Activity API backfill endpoint +// (spec: garmin-import, "Historical import via backfill"). +// +// Garmin has no list-activities endpoint: you ask for a time range and +// Garmin re-delivers those activities asynchronously through the same +// notification pipeline the live webhook uses. Each accepted request +// returns 202; the data arrives whenever Garmin gets to it. + +import { randomUUID } from "node:crypto"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { GARMIN_API } from "./constants.ts"; + +// Garmin caps a single backfill request's window. 90 days per the +// Activity API docs; if program onboarding reveals a different cap for +// our key, this constant is the only thing to change (design.md, open +// questions). +export const BACKFILL_CHUNK_MS = 90 * 24 * 60 * 60 * 1000; + +const BACKFILL_URL = `${GARMIN_API}/wellness-api/rest/backfill/activities`; + +/** + * Split [from, to] into Garmin-sized chunks (inclusive bounds, ms). + * Returns [] for empty/inverted ranges. + */ +export function chunkRange( + fromMs: number, + toMs: number, + chunkMs: number = BACKFILL_CHUNK_MS, +): Array<{ fromMs: number; toMs: number }> { + if (!(fromMs < toMs) || chunkMs <= 0) return []; + const chunks: Array<{ fromMs: number; toMs: number }> = []; + for (let start = fromMs; start < toMs; start += chunkMs) { + chunks.push({ fromMs: start, toMs: Math.min(start + chunkMs, toMs) }); + } + return chunks; +} + +export interface BackfillDeps { + requestChunk( + serviceId: string, + fromSec: number, + toSec: number, + ): Promise; +} + +function defaultDeps(): BackfillDeps { + return { + async requestChunk(serviceId, fromSec, toSec) { + await withFreshCredentials(serviceId, async (credentials) => { + const creds = credentials as OAuthCredentials; + const url = `${BACKFILL_URL}?summaryStartTimeInSeconds=${fromSec}&summaryEndTimeInSeconds=${toSec}`; + const resp = await fetchWithTimeout(url, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + // 202 = accepted. 409 = an identical/overlapping request is + // already in flight — fine, the data will arrive either way + // and sync_imports dedupes. + if (!resp.ok && resp.status !== 409) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin backfill request failed: ${resp.status} ${text}`); + } + }); + }, + }; +} + +/** + * Issue backfill requests covering [from, to] and persist one + * import_batches row describing the whole request (progress UX reads + * it back on the import page). + */ +export async function requestBackfill( + service: { id: string; userId: string }, + from: Date, + to: Date, + deps: BackfillDeps = defaultDeps(), +): Promise<{ batchId: string; chunks: number }> { + const chunks = chunkRange(from.getTime(), to.getTime()); + if (chunks.length === 0) throw new Error("Empty backfill range"); + + for (const chunk of chunks) { + await deps.requestChunk( + service.id, + Math.floor(chunk.fromMs / 1000), + Math.floor(chunk.toMs / 1000), + ); + } + + // Record the request for the import page. Lazy import keeps the DB + // out of this module's graph for pure-function tests (chunkRange). + const { getDb } = await import("../../../db.ts"); + const { importBatches } = await import("@trails-cool/db/schema/journal"); + const batchId = randomUUID(); + await getDb() + .insert(importBatches) + .values({ + id: batchId, + userId: service.userId, + connectionId: service.id, + provider: "garmin", + // Garmin delivers asynchronously — the batch is "running" from + // our perspective until the operator-facing page stops caring. + // totalFound is unknowable up front (no list endpoint). + status: "running", + rangeStart: from, + rangeEnd: to, + }); + return { batchId, chunks: chunks.length }; +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/constants.ts b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts new file mode 100644 index 0000000..955a191 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts @@ -0,0 +1,8 @@ +// Garmin endpoint constants — own module so manifest.ts, webhook.ts, +// import.server.ts, and backfill.ts can all use them without forming +// an import cycle (manifest → webhook → import.server must never loop +// back into manifest for a value needed at module-eval time). + +export const GARMIN_API = "https://apis.garmin.com"; +export const GARMIN_AUTHORIZE = "https://connect.garmin.com/oauth2Confirm"; +export const GARMIN_TOKEN = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts new file mode 100644 index 0000000..6c9f439 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts @@ -0,0 +1,82 @@ +// Garmin activity import — the slow half of the webhook pipeline. +// Runs inside the `garmin-import-activity` pg-boss job: download the +// activity file from the notification's callback URL (Authorized via +// the user's token), convert to GPX, create the activity, record the +// dedupe row. Stats-only when there is no file. + +import { fitToGpx } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { logger } from "../../../logger.server.ts"; +import { GARMIN_API } from "./constants.ts"; + +// SSRF guard: notification callback URLs are attacker-controllable +// input until proven otherwise — only Garmin's API host is fetchable. +const ALLOWED_CALLBACK_HOSTS = new Set([new URL(GARMIN_API).host]); + +export function isAllowedGarminCallback(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && ALLOWED_CALLBACK_HOSTS.has(parsed.host); + } catch { + return false; + } +} + +export interface GarminImportData { + serviceId: string; + userId: string; + externalId: string; + callbackUrl: string | null; + fileType: string | null; + name: string | null; + startedAt: string | null; + duration: number | null; + distance: number | null; +} + +export async function runGarminActivityImport(data: GarminImportData): Promise { + // Connection may have been revoked/relinked between enqueue and run. + const service = await getServiceById(data.serviceId); + if (!service || service.status !== "active") { + logger.info({ serviceId: data.serviceId }, "garmin import: connection not active — skipped"); + return; + } + + if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return; + + let gpx: string | undefined; + if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) { + const buffer = await withFreshCredentials(service.id, async (credentials) => { + const creds = credentials as OAuthCredentials; + const resp = await fetchWithTimeout(data.callbackUrl!, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) { + throw new Error(`Garmin file download failed: ${resp.status}`); + } + return Buffer.from(await resp.arrayBuffer()); + }); + if (data.fileType === "GPX") { + // Garmin can serve GPX directly; createActivity validates it. + gpx = buffer.toString("utf8"); + } else { + // FIT (default) — shared provider-agnostic converter. + gpx = (await fitToGpx(buffer, data.name ?? "Garmin activity")) ?? undefined; + } + } + + await importActivity(data.userId, "garmin", data.externalId, { + name: data.name ?? "Garmin activity", + gpx, + distance: data.distance, + duration: data.duration, + startedAt: data.startedAt ? new Date(data.startedAt) : null, + }); + logger.info( + { externalId: data.externalId, hadFile: !!gpx }, + "garmin import: activity imported", + ); +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts new file mode 100644 index 0000000..531f181 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts @@ -0,0 +1,66 @@ +// Manifest contract tests (spec: garmin-import, "Connect Garmin +// account" + PKCE parameters + env gating). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: vi.fn(), + markRevoked: vi.fn(), + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ enqueueOptional: vi.fn() })); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { garminManifest } = await import("./manifest.ts"); + +const ENV_KEYS = ["GARMIN_CLIENT_ID", "GARMIN_CLIENT_SECRET"] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("garminManifest", () => { + it("declares oauth + PKCE, no pick-list importer, custom import page", () => { + expect(garminManifest.id).toBe("garmin"); + expect(garminManifest.credentialKind).toBe("oauth"); + expect(garminManifest.pkce).toBe(true); + expect(garminManifest.importer).toBeUndefined(); + expect(garminManifest.importUrl).toBe("/sync/import/garmin"); + expect(garminManifest.webhookReceiver).toBeDefined(); + }); + + it("is hidden without instance credentials, shown with them", () => { + delete process.env.GARMIN_CLIENT_ID; + expect(garminManifest.configured!()).toBe(false); + process.env.GARMIN_CLIENT_ID = "test-client"; + expect(garminManifest.configured!()).toBe(true); + }); + + it("buildAuthUrl carries the S256 code challenge", () => { + process.env.GARMIN_CLIENT_ID = "test-client"; + const url = new URL( + garminManifest.buildAuthUrl!( + "https://journal.example/api/sync/callback/garmin", + "state-123", + { codeChallenge: "challenge-abc" }, + ), + ); + expect(url.origin + url.pathname).toBe("https://connect.garmin.com/oauth2Confirm"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-abc"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-123"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts new file mode 100644 index 0000000..c9a9398 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts @@ -0,0 +1,120 @@ +// Garmin provider manifest (spec: garmin-import). OAuth2 + PKCE on the +// shared oauth credential adapter — PKCE is a handshake detail (see +// design.md), the stored blob is plain OAuthCredentials. +// +// Garmin is push-first: there is no list-activities endpoint, so this +// manifest declares no `importer`. Ingestion happens via the webhook +// receiver (ping/push notifications) and history via backfill requests +// (see backfill.ts + the /sync/import/garmin page). +// +// Endpoint references: Garmin Connect Developer Program, Activity API. +// Exact notification shapes are normalized tolerantly in webhook.ts — +// Garmin's docs shift between API versions (design.md, Risks). + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { garminWebhook } from "./webhook.ts"; +import { GARMIN_API, GARMIN_AUTHORIZE, GARMIN_TOKEN } from "./constants.ts"; + +function clientId(): string { + return process.env.GARMIN_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.GARMIN_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return GARMIN_TOKEN; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, +}; + +export const garminManifest: ProviderManifest = { + id: "garmin", + displayName: "Garmin", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + pkce: true, + // No instance credentials → no Garmin row on the connections page. + // (Garmin program keys are per-operator; self-hosted instances + // without one must not render a dead Connect button.) + configured: () => clientId().length > 0, + // Backfill requester, not a pick list — Garmin has no list endpoint. + importUrl: "/sync/import/garmin", + + buildAuthUrl(redirectUri, state, extras): string { + const params = new URLSearchParams({ + client_id: clientId(), + response_type: "code", + redirect_uri: redirectUri, + state, + }); + if (extras?.codeChallenge) { + params.set("code_challenge", extras.codeChallenge); + params.set("code_challenge_method", "S256"); + } + return `${GARMIN_AUTHORIZE}?${params}`; + }, + + async exchangeCode( + code, + redirectUri, + extras, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(GARMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId(), + client_secret: clientSecret(), + code, + code_verifier: extras?.codeVerifier ?? "", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin token exchange failed: ${resp.status} ${text}`); + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + + // Garmin user id — needed to route webhook notifications to the + // right local user. + const userResp = await fetch(`${GARMIN_API}/wellness-api/rest/user/id`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { userId?: string }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.userId ?? null, + grantedScopes: data.scope ? data.scope.split(" ") : [], + }; + }, + + webhookReceiver: garminWebhook, +}; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts new file mode 100644 index 0000000..c360717 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts @@ -0,0 +1,194 @@ +// Contract tests for the Garmin WebhookReceiver (spec: garmin-import). +// +// Seam: parseWebhook(body) -> WebhookEvent[] (Garmin batches notifications) +// handle(event) -> void (enqueues the import job; deregistration +// revokes; SSRF-suspicious callback URLs are dropped) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockGetServiceByProviderUser = vi.fn(); +const mockMarkRevoked = vi.fn(); +const mockEnqueueOptional = vi.fn(); + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + markRevoked: mockMarkRevoked, + // imported transitively via import.server.ts + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ + enqueueOptional: mockEnqueueOptional, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +beforeEach(() => { + mockGetServiceByProviderUser.mockReset(); + mockMarkRevoked.mockReset(); + mockEnqueueOptional.mockReset(); +}); + +const { garminWebhook } = await import("./webhook.ts"); +const { isAllowedGarminCallback } = await import("./import.server.ts"); + +describe("garminWebhook.parseWebhook", () => { + it("parses a ping-style activityFiles batch into file events", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { + userId: "g-user-1", + summaryId: "s-1", + activityId: 1001, + activityName: "Morning Run", + callbackURL: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + startTimeInSeconds: 1780000000, + durationInSeconds: 3600, + distanceInMeters: 10000, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: expect.stringContaining("apis.garmin.com"), + name: "Morning Run", + duration: 3600, + distance: 10000, + fileType: "FIT", + }); + }); + + it("parses push-style activity summaries (FIT-less, stats-only path)", () => { + const events = garminWebhook.parseWebhook({ + activities: [ + { + userId: "g-user-1", + summaryId: "s-2", + activityId: 1002, + activityName: "Indoor Row", + durationInSeconds: 1800, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-summary", + workoutId: "1002", + duration: 1800, + }); + expect(events[0]!.fileUrl).toBeUndefined(); + }); + + it("parses deregistrations", () => { + const events = garminWebhook.parseWebhook({ + deregistrations: [{ userId: "g-user-1" }], + }); + expect(events).toEqual([ + { + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }, + ]); + }); + + it("handles mixed batches and skips malformed entries", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { userId: "u1", activityId: 1 }, + { activityId: 2 }, // no userId — skipped + { userId: "u3" }, // no id — skipped + ], + deregistrations: [{}, { userId: "u4" }], + somethingGarminAddedLater: [{ userId: "u5" }], + }); + expect(events.map((e) => e.eventType)).toEqual([ + "garmin:activity-file", + "garmin:deregistration", + ]); + }); + + it("returns no events for non-object bodies", () => { + expect(garminWebhook.parseWebhook(null)).toEqual([]); + expect(garminWebhook.parseWebhook("x")).toEqual([]); + }); +}); + +describe("garminWebhook.handle", () => { + const service = { id: "svc-g1", userId: "u1", provider: "garmin" }; + + it("enqueues the import job for a known user's file event", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + name: "Morning Run", + }); + + expect(mockEnqueueOptional).toHaveBeenCalledWith( + "garmin-import-activity", + expect.objectContaining({ + serviceId: "svc-g1", + userId: "u1", + externalId: "1001", + callbackUrl: expect.stringContaining("apis.garmin.com"), + fileType: "FIT", + }), + expect.anything(), + ); + }); + + it("silently skips unknown users (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "nobody", + workoutId: "1", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + expect(mockMarkRevoked).not.toHaveBeenCalled(); + }); + + it("drops events whose callback URL is not Garmin's API host (SSRF guard)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://attacker.example/steal?token=", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); + + it("revokes the connection on deregistration", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }); + expect(mockMarkRevoked).toHaveBeenCalledWith("svc-g1"); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); +}); + +describe("isAllowedGarminCallback", () => { + it("allows only https URLs on Garmin's API host", () => { + expect(isAllowedGarminCallback("https://apis.garmin.com/wellness-api/rest/x")).toBe(true); + expect(isAllowedGarminCallback("http://apis.garmin.com/x")).toBe(false); + expect(isAllowedGarminCallback("https://apis.garmin.com.evil.example/x")).toBe(false); + expect(isAllowedGarminCallback("https://evil.example/apis.garmin.com")).toBe(false); + expect(isAllowedGarminCallback("not a url")).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts new file mode 100644 index 0000000..aded436 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts @@ -0,0 +1,154 @@ +// Garmin WebhookReceiver capability adapter (spec: garmin-import, +// "Push-notification activity import" + "Deregistration handling"). +// +// Garmin POSTs notifications to /api/sync/webhook/garmin in batches: +// - `activityFiles`: ping-style entries with a callbackURL to a FIT/GPX +// file (the main import path) +// - `activities` / `activityDetails`: summary entries (stats; used for +// FIT-less activities) +// - `deregistrations`: the user revoked access on Garmin's side +// +// The webhook must answer fast (Garmin retries and throttles slow +// consumers), so `handle` only validates + enqueues; the download and +// FIT→GPX conversion happen in the `garmin-import-activity` pg-boss job +// (same lesson as federation's inbox: never do slow work in an inbound +// hook). Deregistrations are the exception — a single UPDATE is cheap +// and must not be lost to a queue hiccup. +// +// Notification shapes are under-documented and drift between Garmin API +// versions, so parsing is tolerant: unknown keys are ignored, malformed +// entries are skipped, and nothing here ever throws on bad input. + +import { logger } from "../../../logger.server.ts"; +import { enqueueOptional } from "../../../boss.server.ts"; +import { getServiceByProviderUser, markRevoked } from "../../manager.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; +import { isAllowedGarminCallback } from "./import.server.ts"; + +interface GarminNotificationEntry { + userId?: string; + summaryId?: string | number; + activityId?: string | number; + activityName?: string; + activityType?: string; + callbackURL?: string; + fileType?: string; + startTimeInSeconds?: number; + durationInSeconds?: number; + distanceInMeters?: number; +} + +interface GarminWebhookBody { + activityFiles?: GarminNotificationEntry[]; + activities?: GarminNotificationEntry[]; + activityDetails?: GarminNotificationEntry[]; + deregistrations?: { userId?: string }[]; +} + +const EVENT_FILE = "garmin:activity-file"; +const EVENT_SUMMARY = "garmin:activity-summary"; +const EVENT_DEREGISTRATION = "garmin:deregistration"; + +function externalId(entry: GarminNotificationEntry): string { + const id = entry.activityId ?? entry.summaryId; + return id == null ? "" : String(id); +} + +function entryToEvent( + entry: GarminNotificationEntry, + eventType: string, +): WebhookEvent | null { + if (!entry.userId || !externalId(entry)) return null; + return { + eventType, + providerUserId: entry.userId, + workoutId: externalId(entry), + fileUrl: entry.callbackURL, + name: entry.activityName, + startedAt: + entry.startTimeInSeconds != null + ? new Date(entry.startTimeInSeconds * 1000).toISOString() + : undefined, + duration: entry.durationInSeconds ?? null, + distance: entry.distanceInMeters ?? null, + fileType: entry.fileType, + }; +} + +export const garminWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent[] { + if (typeof body !== "object" || body === null) return []; + const payload = body as GarminWebhookBody; + const events: WebhookEvent[] = []; + + for (const entry of payload.activityFiles ?? []) { + const event = entryToEvent(entry, EVENT_FILE); + if (event) events.push(event); + } + // Summaries cover FIT-less activities (stats-only import). A FIT + // notification for the same activityId wins via sync_imports dedupe + // ordering being first-come — both paths import once. + for (const entry of [...(payload.activities ?? []), ...(payload.activityDetails ?? [])]) { + const event = entryToEvent(entry, EVENT_SUMMARY); + if (event) events.push(event); + } + for (const dereg of payload.deregistrations ?? []) { + if (dereg.userId) { + events.push({ + eventType: EVENT_DEREGISTRATION, + providerUserId: dereg.userId, + workoutId: "", + }); + } + } + return events; + }, + + async handle(event: WebhookEvent): Promise { + // Unknown users: silent 200 — never reveal user existence. + const service = await getServiceByProviderUser("garmin", event.providerUserId); + if (!service) return; + + if (event.eventType === EVENT_DEREGISTRATION) { + // Spec: provider-side revocation keeps the row (audit) but stops + // every subsequent Garmin call for this user. + await markRevoked(service.id); + logger.info({ serviceId: service.id }, "garmin: deregistration — connection revoked"); + return; + } + + // SSRF guard: a callback URL that doesn't point at Garmin's API is + // dropped here, before it can ever be fetched (design.md, Risks). + if (event.fileUrl && !isAllowedGarminCallback(event.fileUrl)) { + logger.warn( + { host: safeHost(event.fileUrl) }, + "garmin: notification callback URL not on the Garmin allowlist — dropped", + ); + return; + } + + await enqueueOptional( + "garmin-import-activity", + { + serviceId: service.id, + userId: service.userId, + externalId: event.workoutId, + callbackUrl: event.fileUrl ?? null, + fileType: event.fileType ?? (event.eventType === EVENT_FILE ? "FIT" : null), + name: event.name ?? null, + startedAt: event.startedAt ?? null, + duration: event.duration ?? null, + distance: event.distance ?? null, + }, + { reason: "garmin webhook notification" }, + ); + }, +}; + +function safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts index ff04735..ba54534 100644 --- a/apps/journal/app/lib/connected-services/providers/index.ts +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -5,9 +5,11 @@ import { registerManifest } from "../registry.ts"; import { wahooManifest } from "./wahoo/manifest.ts"; import { komootManifest } from "./komoot/manifest.ts"; +import { garminManifest } from "./garmin/manifest.ts"; registerManifest(wahooManifest); registerManifest(komootManifest); +registerManifest(garminManifest); // Re-export so callers (mostly tests) can grab a manifest directly. -export { wahooManifest, komootManifest }; +export { wahooManifest, komootManifest, garminManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts index 01d2acd..6d03966 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -1,6 +1,6 @@ // Contract tests for the Wahoo WebhookReceiver capability adapter. // -// Seam: parseWebhook(body) -> WebhookEvent | null +// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable) // handle(event) -> void (creates an activity if file present, dedups via sync_imports) import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -41,22 +41,24 @@ describe("wahooWebhook.parseWebhook", () => { file: { url: "https://cdn.example/42.fit" }, }, }); - expect(event).toEqual({ - eventType: "workout_summary", - providerUserId: "7", - workoutId: "42", - fileUrl: "https://cdn.example/42.fit", - }); + expect(event).toEqual([ + { + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }, + ]); }); - it("returns null for unrecognized event types", () => { - expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull(); + it("returns no events for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]); }); - it("returns null when user.id is missing", () => { + it("returns no events when user.id is missing", () => { expect( wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), - ).toBeNull(); + ).toEqual([]); }); }); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts index 6473b0b..8b77f58 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -23,18 +23,20 @@ interface WahooWebhookBody { export const wahooWebhook: WebhookReceiver = { - parseWebhook(body: unknown): WebhookEvent | null { + parseWebhook(body: unknown): WebhookEvent[] { const payload = body as WahooWebhookBody; - if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return []; - return { - eventType: payload.event_type, - providerUserId: String(payload.user.id), - workoutId: String( - payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", - ), - fileUrl: payload.workout_summary?.file?.url, - }; + return [ + { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }, + ]; }, async handle(event: WebhookEvent): Promise { diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts index 457a79b..3bac1c7 100644 --- a/apps/journal/app/lib/connected-services/registry.ts +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -59,6 +59,15 @@ export interface WebhookEvent { providerUserId: string; workoutId: string; fileUrl?: string; + // Optional summary stats carried by providers whose notifications + // include them (Garmin pushes summaries; a FIT-less activity is still + // importable stats-only). Providers without summaries leave these out. + name?: string; + startedAt?: string; + duration?: number | null; + distance?: number | null; + // File format behind fileUrl when the provider says (FIT | GPX | TCX). + fileType?: string; } // CapabilityContext gives capability adapters the tools they need without @@ -81,7 +90,10 @@ export interface RoutePusher { } export interface WebhookReceiver { - parseWebhook(body: unknown): WebhookEvent | null; + // One provider POST can carry many events (Garmin batches + // notifications). Single-event providers return a one-element array; + // an empty array means "nothing actionable" and the route 200s. + parseWebhook(body: unknown): WebhookEvent[]; handle(event: WebhookEvent): Promise; } @@ -99,13 +111,30 @@ export interface ProviderManifest { // Custom connect page URL. When set, the connections settings page links // here instead of the default OAuth connect endpoint. connectUrl?: string; + // Custom import page URL. When set, the connections settings page links + // here instead of the generic /sync/import/ pick-list page (Garmin + // has no list endpoint — its import page is a backfill requester). + importUrl?: string; + // When defined and returning false, the provider is hidden from the + // connections settings page (e.g. instance has no API credentials for + // it). Undefined = always shown. + configured?: () => boolean; + // OAuth2 PKCE: when true, the connect route generates a code verifier + // (carried in an httpOnly cookie across the redirect) and passes the + // S256 challenge to buildAuthUrl / the verifier to exchangeCode. + pkce?: boolean; // OAuth authorization URL builder (for the connect flow). - buildAuthUrl?: (redirectUri: string, state: string) => string; + buildAuthUrl?: ( + redirectUri: string, + state: string, + extras?: { codeChallenge?: string }, + ) => string; // OAuth code exchange (for the callback). Returns the credential blob to // store and the granted scopes. exchangeCode?: ( code: string, redirectUri: string, + extras?: { codeVerifier?: string }, ) => Promise<{ credentials: unknown; providerUserId: string | null; diff --git a/apps/journal/app/lib/federation-delivery.server.ts b/apps/journal/app/lib/federation-delivery.server.ts index 56d0f09..5753bb0 100644 --- a/apps/journal/app/lib/federation-delivery.server.ts +++ b/apps/journal/app/lib/federation-delivery.server.ts @@ -124,10 +124,11 @@ export async function getCachedRemoteActor(actorIri: string): Promise { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + resetHostBackoff(); + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${ACTOR}%`)); + await db.delete(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("inserts remote rows with provenance and audience-mirrored visibility", async () => { + const inserted = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2, "followers-only")]); + expect(inserted).toBe(2); + + const db = getDb(); + const rows = await db + .select() + .from(activities) + .where(like(activities.remoteOriginIri, `${ACTOR}%`)); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.ownerId).toBeNull(); + expect(row.remoteActorIri).toBe(ACTOR); + expect(row.remotePublishedAt).not.toBeNull(); + } + const byIri = new Map(rows.map((r) => [r.remoteOriginIri, r])); + expect(byIri.get(`${ACTOR}/activities/1`)?.visibility).toBe("public"); + expect(byIri.get(`${ACTOR}/activities/2`)?.visibility).toBe("private"); + expect(byIri.get(`${ACTOR}/activities/2`)?.audience).toBe("followers-only"); + }); + + it("is replay-safe: re-ingesting inserts nothing", async () => { + await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + const again = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + expect(again).toBe(0); + }); + + it("database enforces exactly-one-author invariant", async () => { + const db = getDb(); + await expect( + db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + remoteActorIri: null, + name: "orphan", + }), + ).rejects.toThrow(); + }); + + it("pollRemoteActor resolves the outbox via the actor doc, ingests the first page, stamps last_polled_at", async () => { + await makeFollowerOf(ACTOR); + const fetched: string[] = []; + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson(url) { + fetched.push(url); + if (url === ACTOR) { + return { outbox: `${ACTOR}/outbox`, inbox: `${ACTOR}/inbox`, preferredUsername: "alice", name: "Alice" }; + } + if (url === `${ACTOR}/outbox`) { + return { type: "OrderedCollection", totalItems: 1, first: `${ACTOR}/outbox?cursor=0` }; + } + return { + type: "OrderedCollectionPage", + orderedItems: [ + { + type: "Create", + object: { + type: "Note", + id: `${ACTOR}/activities/77`, + content: "

Polled ride

", + published: "2026-06-05T10:00:00Z", + to: "as:Public", + }, + }, + ], + }; + }, + }); + expect(result).toEqual({ inserted: 1 }); + expect(fetched).toEqual([ACTOR, `${ACTOR}/outbox`, `${ACTOR}/outbox?cursor=0`]); + + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.outboxUrl).toBe(`${ACTOR}/outbox`); + expect(cached?.lastPolledAt).not.toBeNull(); + }); + + it("skips actors without an accepted local follower", async () => { + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson() { + throw new Error("must not fetch"); + }, + }); + expect(result).toEqual({ skipped: "no accepted local follower" }); + }); + + it("a 429 with Retry-After backs off the host; the next poll skips without fetching (7.4)", async () => { + await makeFollowerOf(ACTOR); + let fetches = 0; + const deps = { + async pace() {}, + async fetchJson(url: string): Promise { + fetches++; + throw new FetchError( + new URL(url), + "rate limited", + new Response(null, { status: 429, headers: { "Retry-After": "120" } }), + ); + }, + }; + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ skipped: "rate limited" }); + expect(fetches).toBe(1); + + // Host is now backing off — no network attempt at all. + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ + skipped: "host rate-limited (backing off)", + }); + expect(fetches).toBe(1); + + // last_polled_at was NOT stamped, so the sweep will retry later. + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.lastPolledAt ?? null).toBeNull(); + }); + + it("listActorsDuePolling returns followed actors not polled within the hour", async () => { + await makeFollowerOf(ACTOR); + expect(await listActorsDuePolling()).toContain(ACTOR); + + const db = getDb(); + await db.insert(remoteActors).values({ actorIri: ACTOR, lastPolledAt: new Date() }).onConflictDoUpdate({ + target: remoteActors.actorIri, + set: { lastPolledAt: new Date() }, + }); + expect(await listActorsDuePolling()).not.toContain(ACTOR); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.test.ts b/apps/journal/app/lib/federation-ingest.server.test.ts new file mode 100644 index 0000000..80f1fda --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { + parseOutboxItem, + parseRetryAfter, + noteHostRateLimited, + isHostBackingOff, + resetHostBackoff, + pollRemoteActor, +} = await import("./federation-ingest.server.ts"); + +function trailsCreate(overrides: Record = {}, noteOverrides: Record = {}) { + return { + type: "Create", + id: "https://remote.example/activities/a1#create", + published: "2026-06-01T08:00:00Z", + object: { + type: "Note", + id: "https://remote.example/activities/a1", + content: "

Morning ride

\n

42.2 km · ↗ 512 m

\n

link

", + published: "2026-06-01T08:00:00Z", + to: "as:Public", + attachment: [ + { type: "PropertyValue", name: "distance-m", value: "42195" }, + { type: "PropertyValue", name: "elevation-gain-m", value: "512" }, + { type: "PropertyValue", name: "duration-s", value: "9000" }, + ], + ...noteOverrides, + }, + ...overrides, + }; +} + +describe("parseOutboxItem", () => { + it("parses our own outgoing shape", () => { + const parsed = parseOutboxItem(trailsCreate()); + expect(parsed).toEqual({ + originIri: "https://remote.example/activities/a1", + name: "Morning ride", + distance: 42195, + elevationGain: 512, + duration: 9000, + publishedAt: new Date("2026-06-01T08:00:00Z"), + audience: "public", + }); + }); + + it("detects all spellings of the public collection", () => { + for (const to of [ + "https://www.w3.org/ns/activitystreams#Public", + "as:Public", + ["as:Public", "https://remote.example/followers"], + ]) { + expect(parseOutboxItem(trailsCreate({}, { to }))?.audience).toBe("public"); + } + expect(parseOutboxItem(trailsCreate({}, { to: "https://remote.example/followers" }))?.audience).toBe( + "followers-only", + ); + // cc counts too + expect( + parseOutboxItem(trailsCreate({}, { to: undefined, cc: "as:Public" }))?.audience, + ).toBe("public"); + }); + + it("tolerates missing stats and published", () => { + const parsed = parseOutboxItem(trailsCreate({ published: undefined }, { attachment: undefined, published: undefined })); + expect(parsed).toMatchObject({ distance: null, elevationGain: null, duration: null, publishedAt: null }); + }); + + it("skips anything that isn't Create(Note) with an id and a name", () => { + expect(parseOutboxItem(null)).toBeNull(); + expect(parseOutboxItem("string")).toBeNull(); + expect(parseOutboxItem({ type: "Announce" })).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { type: "Article" }))).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { id: undefined }))).toBeNull(); + expect(parseOutboxItem(trailsCreate({}, { content: "" }))).toBeNull(); + }); + + it("strips markup from the name and caps its length", () => { + const longName = "x".repeat(400); + const parsed = parseOutboxItem(trailsCreate({}, { content: `

${longName}

` })); + expect(parsed?.name).toHaveLength(300); + expect(parsed?.name).not.toContain(""); + }); +}); + +describe("429 backoff (7.4)", () => { + afterEach(() => resetHostBackoff()); + + const NOW = Date.parse("2026-06-07T12:00:00Z"); + + it("parses Retry-After as delta-seconds and HTTP-date", () => { + expect(parseRetryAfter("120", NOW)).toBe(120_000); + expect(parseRetryAfter(" 5 ", NOW)).toBe(5_000); + expect(parseRetryAfter("Sun, 07 Jun 2026 12:01:00 GMT", NOW)).toBe(60_000); + // past HTTP-date clamps to zero + expect(parseRetryAfter("Sun, 07 Jun 2026 11:00:00 GMT", NOW)).toBe(0); + expect(parseRetryAfter(null, NOW)).toBeNull(); + expect(parseRetryAfter("soon", NOW)).toBeNull(); + expect(parseRetryAfter("-5", NOW)).toBeNull(); + }); + + it("falls back to the default and caps absurd Retry-After values", () => { + // default (no header): 15 min + expect(noteHostRateLimited("a.example", null, NOW)).toBe(15 * 60 * 1000); + // honored when reasonable + expect(noteHostRateLimited("b.example", "120", NOW)).toBe(120_000); + // capped at the poll interval (1 h) + expect(noteHostRateLimited("c.example", String(7 * 24 * 3600), NOW)).toBe(60 * 60 * 1000); + }); + + it("backs off the host until the window expires", () => { + noteHostRateLimited("d.example", "60", NOW); + expect(isHostBackingOff("d.example", NOW + 59_000)).toBe(true); + expect(isHostBackingOff("d.example", NOW + 61_000)).toBe(false); + // expiry clears the entry; a later check stays false + expect(isHostBackingOff("d.example", NOW)).toBe(false); + expect(isHostBackingOff("other.example", NOW)).toBe(false); + }); + + it("pollRemoteActor skips a backing-off host before touching the database", async () => { + // getDb is mocked to {} — any query would throw, so reaching the + // skip proves the backoff check runs first. + noteHostRateLimited("limited.example", "3600"); + await expect(pollRemoteActor("https://limited.example/users/alice")).resolves.toEqual({ + skipped: "host rate-limited (backing off)", + }); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.ts b/apps/journal/app/lib/federation-ingest.server.ts new file mode 100644 index 0000000..767a95f --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.ts @@ -0,0 +1,351 @@ +// Outbox-poll ingestion of remote trails activities (spec: +// social-federation §7). We are the poller: signed GETs (Authorized +// Fetch) against the outboxes of remote trails actors that local users +// follow, storing new activities for feed display. The wire shape is +// our own outbox format (trails-to-trails only), so parsing targets +// exactly what federation-objects.server.ts emits: Create activities +// wrapping Notes with PropertyValue stat attachments. +// +// Network steps are injectable for offline integration tests. + +import { FetchError } from "@fedify/fedify"; +import { and, eq, isNull, isNotNull, lt, or, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { activities, follows, remoteActors, users } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { getFederation } from "./federation.server.ts"; +import { upsertRemoteActor } from "./federation-delivery.server.ts"; +import { logger } from "./logger.server.ts"; + +/** Spec: fetch at most the 50 most recent items per remote actor. */ +const MAX_ITEMS_PER_POLL = 50; +/** Spec: skip actors polled within the last hour (cron sweep). */ +const POLL_INTERVAL_MS = 60 * 60 * 1000; +/** Spec: per-remote-host pacing of 1 request / 5 seconds. */ +const HOST_PACE_MS = 5_000; +/** Stop early after this many consecutive already-seen items (7.2). */ +const CONFLICT_STREAK_LIMIT = 5; +/** Backoff after a 429 without a usable Retry-After (7.4). */ +const DEFAULT_BACKOFF_MS = 15 * 60 * 1000; +/** + * Cap an honored Retry-After at the poll interval — anything longer is + * equivalent to "skip until a later sweep", and an absurd header from + * a misbehaving remote shouldn't park a host for days. + */ +const MAX_BACKOFF_MS = POLL_INTERVAL_MS; + +export interface ParsedRemoteActivity { + originIri: string; + name: string; + distance: number | null; + elevationGain: number | null; + duration: number | null; + publishedAt: Date | null; + audience: "public" | "followers-only"; +} + +function isPublicAudience(value: unknown): boolean { + const targets = Array.isArray(value) ? value : value == null ? [] : [value]; + return targets.some( + (t) => + t === "https://www.w3.org/ns/activitystreams#Public" || + t === "as:Public" || + t === "Public", + ); +} + +function textOfFirstParagraph(html: string): string { + const match = /

([\s\S]*?)<\/p>/.exec(html); + const raw = match?.[1] ?? html; + return raw.replace(/<[^>]+>/g, "").trim(); +} + +function statFromAttachments(attachments: unknown, name: string): number | null { + const list = Array.isArray(attachments) ? attachments : attachments == null ? [] : [attachments]; + for (const a of list) { + if ( + typeof a === "object" && a !== null && + (a as Record).type === "PropertyValue" && + (a as Record).name === name + ) { + const v = Number.parseFloat(String((a as Record).value)); + return Number.isFinite(v) ? v : null; + } + } + return null; +} + +/** + * Parse one outbox item (a `Create` wrapping a `Note` in our own + * outgoing shape) into an ingestable activity. Returns null for + * anything that doesn't match — unknown items are skipped, never + * fatal (forward compatibility with future trails versions). + */ +export function parseOutboxItem(item: unknown): ParsedRemoteActivity | null { + if (typeof item !== "object" || item === null) return null; + const create = item as Record; + if (create.type !== "Create") return null; + const note = create.object; + if (typeof note !== "object" || note === null) return null; + const n = note as Record; + if (n.type !== "Note" || typeof n.id !== "string") return null; + + const content = typeof n.content === "string" ? n.content : ""; + const name = textOfFirstParagraph(content); + if (!name) return null; + + const publishedRaw = typeof n.published === "string" ? n.published : typeof create.published === "string" ? create.published : null; + const published = publishedRaw ? new Date(publishedRaw) : null; + + return { + originIri: n.id, + name: name.slice(0, 300), + distance: statFromAttachments(n.attachment, "distance-m"), + elevationGain: statFromAttachments(n.attachment, "elevation-gain-m"), + duration: statFromAttachments(n.attachment, "duration-s"), + publishedAt: published && !Number.isNaN(published.getTime()) ? published : null, + audience: isPublicAudience(n.to) || isPublicAudience(n.cc) ? "public" : "followers-only", + }; +} + +/** + * Insert parsed activities for a remote actor. Replay-safe via the + * unique remote_origin_iri (`ON CONFLICT DO NOTHING`); stops early on + * a streak of already-seen items since outboxes are newest-first. + * Returns the number of newly inserted rows. + */ +export async function ingestRemoteActivities( + actorIri: string, + items: ParsedRemoteActivity[], +): Promise { + const db = getDb(); + let inserted = 0; + let conflictStreak = 0; + for (const item of items) { + const rows = await db + .insert(activities) + .values({ + id: randomUUID(), + ownerId: null, + name: item.name, + description: "", + distance: item.distance, + elevationGain: item.elevationGain, + duration: item.duration, + // Mirror the audience into visibility for coherence with + // local rows; the §8 feed query gates remote rows on audience + // + follow, and every other surface joins users on owner_id, + // which excludes remote rows structurally. + visibility: item.audience === "public" ? "public" : "private", + remoteOriginIri: item.originIri, + remoteActorIri: actorIri, + remotePublishedAt: item.publishedAt, + audience: item.audience, + }) + .onConflictDoNothing({ target: activities.remoteOriginIri }) + .returning({ id: activities.id }); + if (rows.length === 0) { + conflictStreak++; + if (conflictStreak >= CONFLICT_STREAK_LIMIT) break; + } else { + conflictStreak = 0; + inserted++; + } + } + return inserted; +} + +export interface PollDeps { + /** + * Fetch a JSON document with an HTTP Signature from `signerUsername` + * (Authorized Fetch — spec: "Polls are signed"). + */ + fetchJson(url: string, signerUsername: string): Promise; + /** Per-host pacing (spec 7.4: 1 req / 5 s). No-op in tests. */ + pace(host: string): Promise; +} + +const lastFetchPerHost = new Map(); + +// 7.4: hosts that answered 429 are skipped until their backoff expires. +// In-process state, like the pacing map above — a restart forgets it, +// which is fine: the worst case is one extra request that gets another +// 429 and re-arms the backoff. +const hostBackoffUntil = new Map(); + +/** + * Parse a Retry-After header value (RFC 9110: delta-seconds or an + * HTTP-date) into a millisecond delay from `now`. Null if absent or + * unparseable. + */ +export function parseRetryAfter(value: string | null, now: number): number | null { + if (value == null) return null; + const trimmed = value.trim(); + // Bare integers are delta-seconds; negative ones are invalid (and + // must not fall through to Date.parse, which reads them as years). + if (/^-?\d+$/.test(trimmed)) { + return /^\d+$/.test(trimmed) ? Number(trimmed) * 1000 : null; + } + const date = Date.parse(trimmed); + if (!Number.isNaN(date)) return Math.max(0, date - now); + return null; +} + +/** + * Record a 429 from `host`: back off for the remote's Retry-After + * (capped) or a default. Returns the applied delay in ms. + */ +export function noteHostRateLimited( + host: string, + retryAfter: string | null, + now = Date.now(), +): number { + const delay = Math.min(parseRetryAfter(retryAfter, now) ?? DEFAULT_BACKOFF_MS, MAX_BACKOFF_MS); + hostBackoffUntil.set(host, now + delay); + return delay; +} + +/** Whether `host` is inside a 429 backoff window. */ +export function isHostBackingOff(host: string, now = Date.now()): boolean { + const until = hostBackoffUntil.get(host) ?? 0; + if (until <= now) { + hostBackoffUntil.delete(host); + return false; + } + return true; +} + +/** Test hook: forget all 429 backoff state. */ +export function resetHostBackoff(): void { + hostBackoffUntil.clear(); +} + +function defaultDeps(): PollDeps { + return { + async fetchJson(url, signerUsername) { + const federation = getFederation(); + const ctx = federation.createContext(new URL(getOrigin()), undefined); + const loader = await ctx.getDocumentLoader({ identifier: signerUsername }); + const { document } = await loader(url); + return document; + }, + async pace(host) { + const last = lastFetchPerHost.get(host) ?? 0; + const wait = last + HOST_PACE_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastFetchPerHost.set(host, Date.now()); + }, + }; +} + +/** + * Poll one remote actor's outbox and ingest new activities (7.2/7.3). + * Returns counts for logging. Honors per-host pacing; a 429/Retry-After + * from the remote aborts this poll quietly (the hourly sweep retries). + */ +export async function pollRemoteActor( + actorIri: string, + deps: PollDeps = defaultDeps(), +): Promise<{ inserted: number } | { skipped: string }> { + // 7.4: respect an active 429 backoff before doing any work. + const host = new URL(actorIri).host; + if (isHostBackingOff(host)) return { skipped: "host rate-limited (backing off)" }; + + const db = getDb(); + + // Signer: any local user with an accepted follow against this actor. + const [signer] = await db + .select({ username: users.username }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where( + and( + eq(follows.followedActorIri, actorIri), + isNotNull(follows.acceptedAt), + isNull(follows.followedUserId), + ), + ) + .limit(1); + if (!signer) return { skipped: "no accepted local follower" }; + + try { + await deps.pace(host); + + // Resolve the outbox URL: cache first, actor document as fallback + // (which also refreshes the cache — 7.3). + const [cached] = await db + .select({ outboxUrl: remoteActors.outboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + let outboxUrl = cached?.outboxUrl ?? null; + if (!outboxUrl) { + const actorDoc = (await deps.fetchJson(actorIri, signer.username)) as Record | null; + outboxUrl = typeof actorDoc?.outbox === "string" ? actorDoc.outbox : null; + if (!outboxUrl) return { skipped: "actor has no outbox" }; + await upsertRemoteActor({ + actorIri, + outboxUrl, + displayName: typeof actorDoc?.name === "string" ? actorDoc.name : null, + username: typeof actorDoc?.preferredUsername === "string" ? actorDoc.preferredUsername : null, + inboxUrl: typeof actorDoc?.inbox === "string" ? actorDoc.inbox : undefined, + domain: host, + }); + } + + // Collection → first page (our outbox serves first=...?cursor=0). + await deps.pace(host); + const collection = (await deps.fetchJson(outboxUrl, signer.username)) as Record; + let itemsRaw = collection.orderedItems ?? collection.items; + if (!itemsRaw && typeof collection.first === "string") { + await deps.pace(host); + const page = (await deps.fetchJson(collection.first, signer.username)) as Record; + itemsRaw = page.orderedItems ?? page.items; + } + const items = (Array.isArray(itemsRaw) ? itemsRaw : itemsRaw == null ? [] : [itemsRaw]) + .slice(0, MAX_ITEMS_PER_POLL) + .map(parseOutboxItem) + .filter((p): p is ParsedRemoteActivity => p !== null); + + const inserted = await ingestRemoteActivities(actorIri, items); + await db + .update(remoteActors) + .set({ lastPolledAt: new Date() }) + .where(eq(remoteActors.actorIri, actorIri)); + logger.info({ actorIri, fetched: items.length, inserted }, "federation: outbox poll"); + return { inserted }; + } catch (err) { + // 429: honor Retry-After (or default) and skip the host until the + // backoff expires (7.4). + if (err instanceof FetchError && err.response?.status === 429) { + const delayMs = noteHostRateLimited(host, err.response.headers.get("retry-after")); + logger.warn({ actorIri, host, delayMs }, "federation: remote rate-limited poll; backing off host"); + return { skipped: "rate limited" }; + } + // Other network failures: log and let the hourly sweep retry. + logger.warn({ err, actorIri }, "federation: outbox poll failed; will retry on next sweep"); + return { skipped: "fetch failed" }; + } +} + +/** + * Remote actor IRIs due for polling (7.1): followed-and-accepted by at + * least one local user, never polled or polled more than an hour ago. + */ +export async function listActorsDuePolling(): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - POLL_INTERVAL_MS); + const rows = await db + .selectDistinct({ actorIri: follows.followedActorIri }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + isNull(follows.followedUserId), + isNotNull(follows.acceptedAt), + or(sql`${remoteActors.lastPolledAt} IS NULL`, lt(remoteActors.lastPolledAt, cutoff)), + ), + ); + return rows.map((r) => r.actorIri); +} diff --git a/apps/journal/app/lib/federation-outbound.integration.test.ts b/apps/journal/app/lib/federation-outbound.integration.test.ts new file mode 100644 index 0000000..dc65e55 --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.integration.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { + followRemoteActor, + cancelRemoteFollow, + listOutgoingRemoteFollows, + type OutboundFollowDeps, + type ResolvedRemoteActor, +} from "./federation-outbound.server.ts"; +import { FollowError } from "./follow.server.ts"; + +// Opt-in: real Postgres; network steps injected so no instance is +// contacted. Same convention as the other *.integration.test.ts files. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const REMOTE = "https://other-trails.example/users/alice"; +const createdUserIds: string[] = []; + +async function makeUser(username: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${username}@example.test`, + username, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +function fakeDeps(opts?: { + software?: string; + resolveTo?: ResolvedRemoteActor | null; +}): OutboundFollowDeps & { delivered: string[]; undone: string[] } { + const delivered: string[] = []; + const undone: string[] = []; + return { + delivered, + undone, + async resolveActor() { + return opts?.resolveTo !== undefined + ? opts.resolveTo + : { iri: REMOTE, inboxUrl: `${REMOTE}/inbox`, username: "alice", displayName: "Alice" }; + }, + async fetchSoftwareName() { + return opts?.software ?? "trails-cool"; + }, + async deliverFollow(_u, followId) { + delivered.push(followId); + }, + async deliverUndoFollow(_u, followId) { + undone.push(followId); + }, + }; +} + +describe.runIf(runIntegration)("outbound trails-to-trails follows (integration)", () => { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + const db = getDb(); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + await db.delete(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + }); + + it("creates a Pending row, caches the actor, and delivers Follow", async () => { + const userId = await makeUser(`out-${Date.now()}`); + const deps = fakeDeps(); + + const state = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(state).toEqual({ pending: true, actorIri: REMOTE }); + expect(deps.delivered).toHaveLength(1); + + const db = getDb(); + const [row] = await db.select().from(follows).where(eq(follows.followerId, userId)); + expect(row!.followedActorIri).toBe(REMOTE); + expect(row!.followedUserId).toBeNull(); + expect(row!.acceptedAt).toBeNull(); + + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + expect(cached!.inboxUrl).toBe(`${REMOTE}/inbox`); + expect(cached!.displayName).toBe("Alice"); + }); + + it("is idempotent — re-following returns the existing state without re-delivering", async () => { + const userId = await makeUser(`out-idem-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + const second = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(second.pending).toBe(true); + expect(deps.delivered).toHaveLength(1); + }); + + it("refuses non-trails instances with a clear code and writes nothing", async () => { + const userId = await makeUser(`out-mast-${Date.now()}`); + const deps = fakeDeps({ software: "mastodon" }); + await expect(followRemoteActor(userId, "@alice@other-trails.example", deps)).rejects.toMatchObject({ + code: "not_trails", + }); + const db = getDb(); + expect(await db.select().from(follows).where(eq(follows.followerId, userId))).toHaveLength(0); + }); + + it("refuses unresolvable handles and local handles", async () => { + const userId = await makeUser(`out-bad-${Date.now()}`); + await expect( + followRemoteActor(userId, "@ghost@other-trails.example", fakeDeps({ resolveTo: null })), + ).rejects.toMatchObject({ code: "remote_resolve_failed" }); + await expect(followRemoteActor(userId, "not a handle", fakeDeps())).rejects.toMatchObject({ + code: "invalid_handle", + }); + // Own-host check needs a parseable host (the default localhost:3000 + // fails the TLD shape first) — point ORIGIN at a real-looking domain. + const prevOrigin = process.env.ORIGIN; + process.env.ORIGIN = "https://own-instance.example"; + try { + await expect( + followRemoteActor(userId, "@someone@own-instance.example", fakeDeps()), + ).rejects.toMatchObject({ code: "local_handle" }); + } finally { + process.env.ORIGIN = prevOrigin; + } + }); + + it("lists outgoing follows and cancel removes the row + delivers Undo", async () => { + const userId = await makeUser(`out-cancel-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + + const entries = await listOutgoingRemoteFollows(userId); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ actorIri: REMOTE, pending: true, username: "alice" }); + + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + expect(await listOutgoingRemoteFollows(userId)).toHaveLength(0); + // Idempotent second cancel + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + }); + + it("FollowError codes are exported for the route layer", () => { + expect(new FollowError("not_trails", "x").code).toBe("not_trails"); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.test.ts b/apps/journal/app/lib/federation-outbound.server.test.ts new file mode 100644 index 0000000..63201bd --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { parseRemoteHandle, isTrailsSoftware } = await import("./federation-outbound.server.ts"); + +describe("parseRemoteHandle", () => { + it("accepts the common handle spellings", () => { + expect(parseRemoteHandle("@alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("acct:alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle(" @alice@other.example ")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("@a_l-i.ce@sub.other.example")).toEqual({ username: "a_l-i.ce", host: "sub.other.example" }); + }); + + it("lowercases hosts but preserves username case", () => { + expect(parseRemoteHandle("@Alice@Other.Example")).toEqual({ username: "Alice", host: "other.example" }); + }); + + it("accepts a port (dev instances)", () => { + expect(parseRemoteHandle("@bob@localhost:3000")).toBeNull(); // bare hostname without TLD is rejected + expect(parseRemoteHandle("@bob@staging.trails.cool:3000")).toEqual({ username: "bob", host: "staging.trails.cool:3000" }); + }); + + it("rejects everything else", () => { + expect(parseRemoteHandle("alice")).toBeNull(); + expect(parseRemoteHandle("https://other.example/users/alice")).toBeNull(); + expect(parseRemoteHandle("@@broken@host.example")).toBeNull(); + expect(parseRemoteHandle("")).toBeNull(); + }); +}); + +describe("isTrailsSoftware", () => { + it("accepts trails-cool and nothing else", () => { + expect(isTrailsSoftware("trails-cool")).toBe(true); + expect(isTrailsSoftware("mastodon")).toBe(false); + expect(isTrailsSoftware("hollo")).toBe(false); + expect(isTrailsSoftware(undefined)).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.ts b/apps/journal/app/lib/federation-outbound.server.ts new file mode 100644 index 0000000..983129b --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.ts @@ -0,0 +1,300 @@ +// Outbound trails-to-trails follows (spec: social-federation §6). +// +// A local user follows a user on another *trails* instance: resolve the +// handle via WebFinger, verify the target instance runs trails.cool via +// NodeInfo (checked against the ACTOR IRI's host, never the handle's +// domain — remote instances may run split domains, see +// docs/ideas/split-domain-handles.md), record a Pending follow row, and +// deliver a signed Follow. The remote's Accept(Follow) settles the row +// (inbox listener from §4); Reject deletes it. +// +// Network-touching steps (actor lookup, NodeInfo fetch, delivery) are +// injectable so the row lifecycle is integration-testable offline. + +import { randomUUID } from "node:crypto"; +import { getNodeInfo } from "@fedify/fedify"; +import { Follow, Undo } from "@fedify/fedify/vocab"; +import { and, eq, isNull, isNotNull, desc } from "drizzle-orm"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { localActorIri } from "./actor-iri.ts"; +import { getFederation } from "./federation.server.ts"; +import { upsertRemoteActor } from "./federation-delivery.server.ts"; +import { FollowError } from "./follow.server.ts"; +import { logger } from "./logger.server.ts"; + +/** + * Software names accepted by the trails-to-trails check. Forks that + * keep the NodeInfo name federate out of the box; rebrands need a PR + * here (see the open question in social-federation design.md; the + * Wanderer-interop idea would extend this list). + */ +const TRAILS_SOFTWARE = new Set(["trails-cool"]); + +export interface RemoteHandle { + username: string; + host: string; +} + +/** + * Parse `@user@host`, `user@host`, or an `acct:` URI into its parts. + * Returns null for anything else (including bare usernames and URLs — + * follows by URL can come later; handles are the user-facing shape). + */ +export function parseRemoteHandle(input: string): RemoteHandle | null { + const trimmed = input.trim().replace(/^acct:/, "").replace(/^@/, ""); + const match = /^([a-z0-9_.-]+)@([a-z0-9.-]+\.[a-z]{2,}(?::\d+)?)$/i.exec(trimmed); + if (!match) return null; + return { username: match[1]!, host: match[2]!.toLowerCase() }; +} + +export function isTrailsSoftware(name: string | undefined): boolean { + return name !== undefined && TRAILS_SOFTWARE.has(name); +} + +export interface ResolvedRemoteActor { + iri: string; + inboxUrl: string; + username: string | null; + displayName: string | null; +} + +export interface OutboundFollowDeps { + resolveActor(handle: RemoteHandle): Promise; + fetchSoftwareName(actorIri: string): Promise; + deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise; + deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise; +} + +function followActivityId(followerUsername: string, followId: string): URL { + return new URL(`${localActorIri(followerUsername)}#follows/${followId}`); +} + +function defaultDeps(): OutboundFollowDeps { + const federation = getFederation(); + const ctx = () => federation.createContext(new URL(getOrigin()), undefined); + return { + async resolveActor(handle) { + const actor = await ctx().lookupObject(`@${handle.username}@${handle.host}`); + if (actor == null || !("inboxId" in actor) || actor.id == null) return null; + const inbox = actor.inboxId as URL | null; + if (inbox == null) return null; + return { + iri: actor.id.href, + inboxUrl: inbox.href, + username: ("preferredUsername" in actor ? (actor.preferredUsername as unknown as string | null) : null) ?? handle.username, + displayName: ("name" in actor ? (actor.name as unknown as string | null) : null), + }; + }, + async fetchSoftwareName(actorIri) { + // Split-domain constraint: NodeInfo is looked up on the ACTOR + // IRI's origin (the server domain), never the handle's domain. + const info = await getNodeInfo(new URL(actorIri).origin); + return info?.software?.name; + }, + async deliverFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + ); + }, + async deliverUndoFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Undo({ + id: new URL(`${localActorIri(followerUsername)}#follows/${followId}/undo`), + actor: new URL(localActorIri(followerUsername)), + object: new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + }), + ); + }, + }; +} + +export interface OutgoingFollowState { + pending: boolean; + actorIri: string; +} + +/** + * Follow a user on another trails instance (spec 6.1/6.4). Pending + * until their Accept(Follow) lands in our inbox. Idempotent: an + * existing row (pending or accepted) is returned unchanged. + */ +export async function followRemoteActor( + followerId: string, + handleInput: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const handle = parseRemoteHandle(handleInput); + if (!handle) { + throw new FollowError("invalid_handle", "Enter a handle like @user@instance.example"); + } + const ownHost = new URL(getOrigin()).host; + if (handle.host === ownHost) { + throw new FollowError("local_handle", "That user is on this instance — follow them from their profile"); + } + + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const actor = await deps.resolveActor(handle); + if (!actor) { + throw new FollowError("remote_resolve_failed", "Couldn't find that user — check the handle"); + } + + // Trails-to-trails gate (spec 6.1/6.3). + let software: string | undefined; + try { + software = await deps.fetchSoftwareName(actor.iri); + } catch { + software = undefined; + } + if (!isTrailsSoftware(software)) { + throw new FollowError( + "not_trails", + "Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool", + ); + } + + // Idempotent re-follow. + const [existing] = await db + .select({ id: follows.id, acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, actor.iri))); + if (existing) { + return { pending: existing.acceptedAt === null, actorIri: actor.iri }; + } + + const followId = randomUUID(); + await db.insert(follows).values({ + id: followId, + followerId, + followedActorIri: actor.iri, + followedUserId: null, + acceptedAt: null, + }); + await upsertRemoteActor({ + actorIri: actor.iri, + inboxUrl: actor.inboxUrl, + username: actor.username, + displayName: actor.displayName, + domain: new URL(actor.iri).host, + }); + + try { + await deps.deliverFollow(follower.username, followId, actor); + } catch (err) { + // Keep the Pending row — Fedify's queue retries delivery; the row + // is also the user's visible record that a request is in flight. + logger.warn({ err, actorIri: actor.iri }, "outbound Follow delivery failed (queued retries may still succeed)"); + } + + logger.info({ followerId, actorIri: actor.iri }, "federation: outbound follow pending"); + return { pending: true, actorIri: actor.iri }; +} + +/** + * Cancel an outgoing remote follow (pending or accepted): delete the + * row and deliver Undo(Follow) (spec 6.6 / social-follows "Cancel a + * Pending follow"). + */ +export async function cancelRemoteFollow( + followerId: string, + actorIri: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const deleted = await db + .delete(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.followedActorIri, actorIri), + isNull(follows.followedUserId), // remote rows only + ), + ) + .returning({ id: follows.id }); + if (deleted.length === 0) return; // idempotent + + const [cached] = await db + .select({ inboxUrl: remoteActors.inboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + if (cached?.inboxUrl) { + try { + await deps.deliverUndoFollow(follower.username, deleted[0]!.id, { + iri: actorIri, + inboxUrl: cached.inboxUrl, + }); + } catch (err) { + logger.warn({ err, actorIri }, "Undo(Follow) delivery failed; row already removed locally"); + } + } + logger.info({ followerId, actorIri }, "federation: outbound follow cancelled"); +} + +export interface OutgoingFollowEntry { + actorIri: string; + pending: boolean; + createdAt: Date; + displayName: string | null; + username: string | null; + domain: string | null; +} + +/** All outgoing remote follows for the user, newest first (spec 6.6). */ +export async function listOutgoingRemoteFollows(followerId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + actorIri: follows.followedActorIri, + acceptedAt: follows.acceptedAt, + createdAt: follows.createdAt, + displayName: remoteActors.displayName, + username: remoteActors.username, + domain: remoteActors.domain, + }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNull(follows.followedUserId), + isNotNull(follows.followedActorIri), + ), + ) + .orderBy(desc(follows.createdAt)); + return rows.map((r) => ({ + actorIri: r.actorIri, + pending: r.acceptedAt === null, + createdAt: r.createdAt, + displayName: r.displayName, + username: r.username, + domain: r.domain, + })); +} diff --git a/apps/journal/app/lib/federation-two-instance.integration.test.ts b/apps/journal/app/lib/federation-two-instance.integration.test.ts new file mode 100644 index 0000000..d8ad1ee --- /dev/null +++ b/apps/journal/app/lib/federation-two-instance.integration.test.ts @@ -0,0 +1,203 @@ +// Two-instance federation drive-through (spec: social-federation 11.4). +// +// Talks to the docker-compose harness in e2e/federation/ — two complete +// journal instances (journal-a.test / journal-b.test) federating over a +// private Docker network with real HTTPS between them. This test is the +// driver: it seeds users straight into each instance's database, mints +// a session cookie for alice (cookie sessions are signed with the +// harness's known SESSION_SECRET), follows bob across instances through +// the real /follows/outgoing route, and then watches the entire +// Follow → Accept → first outbox poll → feed pipeline happen between +// two live servers. +// +// Run via e2e/federation/run.sh — it builds the images, pushes the +// schema into both databases, and sets FEDERATION_TWO_INSTANCE=1. + +import { describe, it, expect, afterAll } from "vitest"; +import postgres, { type Sql } from "postgres"; +import { randomUUID } from "node:crypto"; +import { createCookieSessionStorage } from "react-router"; + +const runTwoInstance = process.env.FEDERATION_TWO_INSTANCE === "1"; + +// Host-published harness endpoints (see e2e/federation/docker-compose.yml). +const A_URL = "http://127.0.0.1:3401"; +const B_URL = "http://127.0.0.1:3402"; +const DB_A = "postgres://trails:trails@127.0.0.1:5499/trails_a"; +const DB_B = "postgres://trails:trails@127.0.0.1:5499/trails_b"; +const B_DOMAIN = "journal-b.test"; +const BOB_ACTOR_IRI = `https://${B_DOMAIN}/users/bob`; +const ACTIVITY_NAME = "Sunrise ride over the ridge"; + +/** Poll `fn` until it returns something truthy. */ +async function until( + what: string, + fn: () => Promise, + timeoutMs = 120_000, +): Promise { + const start = Date.now(); + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() - start > timeoutMs) throw new Error(`Timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 1500)); + } +} + +async function seedUser(sql: Sql, username: string, domain: string): Promise { + const id = randomUUID(); + await sql` + INSERT INTO journal.users (id, email, username, domain, profile_visibility, terms_accepted_at, terms_version) + VALUES (${id}, ${`${username}@${domain}`}, ${username}, ${domain}, 'public', now(), '2026-04-19') + `; + return id; +} + +/** + * Mint a valid `__session` cookie for a user. The harness journals run + * with a known SESSION_SECRET, and sessions are signed cookies (see + * auth/session.server.ts) — so the driver can authenticate as any + * seeded user without driving the WebAuthn ceremony. + */ +async function mintSessionCookie(userId: string): Promise { + const storage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + sameSite: "lax", + path: "/", + secrets: ["two-instance-test-secret"], + }, + }); + const session = await storage.getSession(); + session.set("userId", userId); + const setCookie = await storage.commitSession(session); + return setCookie.split(";")[0]!; +} + +describe.runIf(runTwoInstance)("two-instance federation (11.4)", () => { + const sqlA = postgres(DB_A, { max: 2 }); + const sqlB = postgres(DB_B, { max: 2 }); + + afterAll(async () => { + await sqlA.end(); + await sqlB.end(); + }); + + it( + "alice@journal-a follows bob@journal-b: Follow → Accept → first poll → bob's public activity in alice's feed", + { timeout: 300_000 }, + async () => { + // Both instances are up (run.sh already gated on the compose + // healthchecks; this is a fail-fast sanity check). + for (const base of [A_URL, B_URL]) { + const health = await fetch(`${base}/api/health`); + expect(health.status).toBe(200); + } + + // ---- Seed: alice on A; bob + a public activity on B ---------- + const aliceId = await seedUser(sqlA, "alice", "journal-a.test"); + const bobId = await seedUser(sqlB, "bob", B_DOMAIN); + await sqlB` + INSERT INTO journal.activities (id, owner_id, name, description, visibility) + VALUES (${randomUUID()}, ${bobId}, ${ACTIVITY_NAME}, '', 'public') + `; + + // B serves bob over WebFinger before we point A at him. (Fedify's + // canonical-origin support means the host-port URL works here.) + const wf = await fetch(`${B_URL}/.well-known/webfinger?resource=acct:bob@${B_DOMAIN}`); + expect(wf.status).toBe(200); + + // ---- alice follows bob through the real route ----------------- + const cookie = await mintSessionCookie(aliceId); + const follow = await fetch(`${A_URL}/follows/outgoing`, { + method: "POST", + redirect: "manual", + headers: { cookie, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ intent: "follow", handle: `bob@${B_DOMAIN}` }), + }); + if (follow.status >= 400) { + // FollowError surfaces as a 400 with a code — bubble the body + // up so a harness failure says *why* resolution failed. + throw new Error(`follow action failed: ${follow.status} ${await follow.text()}`); + } + + // Outbound resolution (NodeInfo + WebFinger + actor fetch over the + // Caddy TLS hop) happens inside the action — a Pending row is the + // proof it all worked. + const pending = await until("pending follow row on A", async () => { + const rows = await sqlA` + SELECT id, accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }, 30_000); + expect(pending).toBeDefined(); + + // ---- B auto-accepts (public profile) and delivers Accept ------ + await until("Accept to settle the follow on A", async () => { + const rows = await sqlA` + SELECT accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]?.accepted_at != null; + }); + + // B's side sees a remote follower row for alice. + const bFollow = await sqlB` + SELECT follower_actor_iri FROM journal.follows WHERE followed_user_id = ${bobId} + `; + expect(bFollow[0]?.follower_actor_iri).toBe("https://journal-a.test/users/alice"); + + // ---- First poll ingests bob's outbox into A ------------------- + const ingested = await until("bob's activity to be ingested on A", async () => { + const rows = await sqlA` + SELECT name, audience, owner_id FROM journal.activities + WHERE remote_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }); + expect(ingested.name).toBe(ACTIVITY_NAME); + expect(ingested.audience).toBe("public"); + expect(ingested.owner_id).toBeNull(); + + // ---- And it shows up in alice's rendered feed ------------------ + const feed = await fetch(`${A_URL}/feed`, { headers: { cookie } }); + expect(feed.status).toBe(200); + // JSX puts `` separators between adjacent text expressions + // — strip them so the attribution reads as continuous text. + const html = (await feed.text()).replaceAll("", ""); + expect(html).toContain(ACTIVITY_NAME); + expect(html).toContain(`@bob@${B_DOMAIN}`); + }, + ); + + it("an unsigned Create(Note) posted to the inbox is rejected with no DB writes (11.3)", async () => { + const originIri = `https://intruder.example/notes/${randomUUID()}`; + const res = await fetch(`${A_URL}/users/alice/inbox`, { + method: "POST", + headers: { "content-type": "application/activity+json" }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${originIri}#create`, + type: "Create", + actor: "https://intruder.example/users/mallory", + to: "https://www.w3.org/ns/activitystreams#Public", + object: { + id: originIri, + type: "Note", + content: "

Injected note

", + }, + }), + }); + // Unauthenticated inbox delivery: Fedify refuses it outright + // (400 malformed / 401 unsigned — both fine, never 2xx). + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const rows = await sqlA` + SELECT id FROM journal.activities WHERE remote_origin_iri = ${originIri} + `; + expect(rows).toHaveLength(0); + }); +}); diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts index 530e5d0..021cc5f 100644 --- a/apps/journal/app/lib/federation.server.ts +++ b/apps/journal/app/lib/federation.server.ts @@ -97,6 +97,20 @@ async function findLocalPublicUserByIri(iri: URL | null): Promise#follows/`, see federation-outbound.server.ts) and + * names the recipient of the personal inbox the activity arrived in. + * Used by the Accept/Reject listeners: another trails instance can't + * embed a trustworthy copy of our Follow (its id is cross-origin for + * them), so the id is the only recoverable reference. + */ +function isRecipientFollowUri(ctx: { recipient: string | null }, id: URL | null): boolean { + if (id == null || ctx.recipient == null) return false; + if (!id.hash.startsWith("#follows/")) return false; + return id.href.startsWith(`${localActorIri(ctx.recipient)}#`); +} + let _federation: Federation | null = null; let _logtapeConfigured = false; @@ -146,6 +160,15 @@ function buildFederation(): Federation { // Canonical origin so generated IRIs are correct behind the Caddy // proxy (the Node server itself only sees plain HTTP). origin: getOrigin(), + // SSRF-guard opt-out for the two-instance federation harness + // (e2e/federation/), where both instances live on a private Docker + // network that Fedify's document loader rightly refuses to fetch + // from. Testing only — never set this in a real deployment: the + // private-address block is a genuine security boundary (a malicious + // actor IRI must not be able to point us at internal targets). + ...(process.env.FEDERATION_ALLOW_PRIVATE_ADDRESS === "true" + ? { allowPrivateAddress: true } + : {}), }); if (!process.env.VITEST) { // Fire-and-forget: runs the queue consumer loop for the process @@ -261,25 +284,66 @@ function buildFederation(): Federation { .on(Undo, async (ctx, undo) => { // Spec 4.3: Undo(Follow) removes the follow row. Other Undos are // acknowledged and dropped. + if (undo.actorId == null) return; + const undoObjectId = undo.objectId; // capture before dereference (see Accept) const object = await undo.getObject(ctx); - if (!(object instanceof Follow)) return; - if (undo.actorId == null || object.objectId == null) return; - const parsed = ctx.parseUri(object.objectId); - if (parsed?.type !== "actor") return; - await removeRemoteFollow(undo.actorId.href, parsed.identifier); + if (object instanceof Follow && object.objectId != null) { + const parsed = ctx.parseUri(object.objectId); + if (parsed?.type !== "actor") return; + await removeRemoteFollow(undo.actorId.href, parsed.identifier); + return; + } + // trails→trails fallback: another trails instance's Undo embeds + // a Follow whose id lives on the SENDER's domain, but the embed + // is cross-origin relative to nothing we can verify, and + // dereferencing `…#follows/` yields the sender's actor + // document, not a Follow (fragments aren't separately fetchable). + // Our Follow ids are `#follows/` — when the Undo + // names one owned by the authenticated sender, the recipient of + // this personal inbox is the unfollowed user. + if ( + ctx.recipient != null && + undoObjectId?.hash.startsWith("#follows/") && + undoObjectId.origin === new URL(undo.actorId.href).origin + ) { + await removeRemoteFollow(undo.actorId.href, ctx.recipient); + } }) .on(Accept, async (ctx, accept) => { // Spec 4.4: a remote accepted our outgoing Follow — settle the // Pending row and trigger the first outbox poll for that actor. - const object = await accept.getObject(ctx); - if (!(object instanceof Follow)) return; if (accept.actorId == null) return; - const localUser = await findLocalPublicUserByIri(object.actorId); + // Capture the raw object reference BEFORE dereferencing: + // getObject() memoizes the fetched document, after which objectId + // reports the fetched object's id (fragment stripped) instead of + // the id that was on the wire. + const objectId = accept.objectId; + const object = await accept.getObject(ctx); + logger.debug( + { + recipient: ctx.recipient, + objectId: objectId?.href ?? null, + objectType: object?.constructor?.name ?? null, + }, + "federation: Accept listener dispatch", + ); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // trails→trails: our own Follow id is cross-origin from the + // accepting instance's perspective, so Fedify rightly distrusts + // the embedded copy and a re-fetch of `…#follows/` returns + // our actor document instead of a Follow. The id itself names + // this inbox's recipient, which is all settling needs — and + // settleOutgoingFollow only matches a Pending row toward the + // authenticated sender, so a forged Accept can't settle + // anything that wasn't already directed at that actor. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } if (!localUser) return; const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href); if (settled) { - // Queue worker lands in the outbox-poll change (task 7.x); - // enqueueOptional logs-and-continues until then. await enqueueOptional("poll-remote-actor", { actorIri: accept.actorId.href }, { reason: "first poll after accepted follow", }); @@ -287,10 +351,16 @@ function buildFederation(): Federation { }) .on(Reject, async (ctx, reject) => { // Spec 4.5: remote refused our Follow — drop the Pending row. - const object = await reject.getObject(ctx); - if (!(object instanceof Follow)) return; if (reject.actorId == null) return; - const localUser = await findLocalPublicUserByIri(object.actorId); + const objectId = reject.objectId; // capture before dereference (see Accept) + const object = await reject.getObject(ctx); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // Same trails→trails fallback as the Accept listener above. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } if (!localUser) return; await rejectOutgoingFollow(localUser.id, reject.actorId.href); }) @@ -311,7 +381,10 @@ function buildFederation(): Federation { .from(activities) .where(and(eq(activities.id, values.id), eq(activities.visibility, "public"))) .limit(1); - if (!row) return null; + // Remote-ingested activities are not our objects — only locally + // authored rows dereference here (their canonical IRI is on the + // origin instance). + if (!row || row.ownerId === null) return null; const [owner] = await db .select({ username: users.username, profileVisibility: users.profileVisibility }) .from(users) diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index 0105e5d..1ac3250 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -7,7 +7,16 @@ import { createNotification } from "./notifications.server.ts"; import { logger } from "./logger.server.ts"; export class FollowError extends Error { - readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; + readonly code: + | "self_follow" + | "user_not_found" + | "not_found" + | "forbidden" + // outbound federation codes (federation-outbound.server.ts) + | "invalid_handle" + | "local_handle" + | "remote_resolve_failed" + | "not_trails"; constructor(code: FollowError["code"], message: string) { super(message); this.name = "FollowError"; diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts index 9105096..fc2a9be 100644 --- a/apps/journal/app/lib/legal.ts +++ b/apps/journal/app/lib/legal.ts @@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19"; * require re-acceptance (the policy is informational, not contract), so this * is display-only — not persisted. */ -export const PRIVACY_LAST_UPDATED = "2026-04-20"; +export const PRIVACY_LAST_UPDATED = "2026-06-07"; diff --git a/apps/journal/app/lib/social-feed.integration.test.ts b/apps/journal/app/lib/social-feed.integration.test.ts new file mode 100644 index 0000000..f24774a --- /dev/null +++ b/apps/journal/app/lib/social-feed.integration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, like } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal"; +import { listSocialFeed } from "./activities.server.ts"; + +// Opt-in: real Postgres. Covers social-federation §8 — the audience +// leak guard is THE scenario that must never regress. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const RUN = Date.now(); +const REMOTE_ACTOR = `https://feed-origin.example/users/x-${RUN}`; +const createdUserIds: string[] = []; + +async function makeUser(suffix: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `feed-${suffix}-${RUN}@example.test`, + username: `feed-${suffix}-${RUN}`, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +async function followRemote(followerId: string, accepted: boolean) { + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri: REMOTE_ACTOR, + followedUserId: null, + acceptedAt: accepted ? new Date() : null, + }); +} + +async function remoteActivity(n: number, audience: "public" | "followers-only", publishedAt: Date) { + const db = getDb(); + await db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + name: `Remote ${audience} ${n}`, + visibility: audience === "public" ? "public" : "private", + remoteOriginIri: `${REMOTE_ACTOR}/activities/${n}`, + remoteActorIri: REMOTE_ACTOR, + remotePublishedAt: publishedAt, + audience, + }); +} + +describe.runIf(runIntegration)("social feed with remote rows (§8, integration)", () => { + beforeAll(async () => { + process.env.ORIGIN ??= "http://localhost:3000"; + const db = getDb(); + await db.insert(remoteActors).values({ + actorIri: REMOTE_ACTOR, + username: "x", + displayName: "Remote X", + domain: "feed-origin.example", + }).onConflictDoNothing(); + }); + + afterEach(async () => { + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${REMOTE_ACTOR}%`)); + for (const id of createdUserIds.splice(0)) { + await db.delete(activities).where(eq(activities.ownerId, id)); + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("followers-only remote content reaches only the viewer with the accepted follow", async () => { + const a = await makeUser("a"); + const b = await makeUser("b"); + await followRemote(a, true); // A follows X (accepted) + // B does NOT follow X at all — but the row exists in our DB. + await remoteActivity(1, "followers-only", new Date()); + + const feedA = await listSocialFeed(a); + const feedB = await listSocialFeed(b); + expect(feedA.map((r) => r.name)).toContain("Remote followers-only 1"); + expect(feedB.map((r) => r.name)).not.toContain("Remote followers-only 1"); + }); + + it("public remote content reaches accepted followers with remote attribution", async () => { + const a = await makeUser("pub"); + await followRemote(a, true); + await remoteActivity(2, "public", new Date()); + + const feed = await listSocialFeed(a); + const entry = feed.find((r) => r.name === "Remote public 2"); + expect(entry).toBeDefined(); + expect(entry!.remote).toBe(true); + expect(entry!.externalUrl).toBe(`${REMOTE_ACTOR}/activities/2`); + expect(entry!.ownerUsername).toBe("x"); + expect(entry!.ownerDomain).toBe("feed-origin.example"); + expect(entry!.geojson).toBeNull(); + }); + + it("pending follows contribute nothing", async () => { + const a = await makeUser("pend"); + await followRemote(a, false); // Pending + await remoteActivity(3, "public", new Date()); + expect(await listSocialFeed(a)).toHaveLength(0); + }); + + it("mixes local and remote rows sorted by origin publish time", async () => { + const viewer = await makeUser("viewer"); + const author = await makeUser("author"); + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId: viewer, + followedActorIri: `http://localhost:3000/users/feed-author-${RUN}`, + followedUserId: author, + acceptedAt: new Date(), + }); + await followRemote(viewer, true); + + const now = Date.now(); + // Local activity created "now" (createdAt defaults to now()). + await db.insert(activities).values({ + id: randomUUID(), + ownerId: author, + name: `Local newest ${RUN}`, + visibility: "public", + }); + // Remote activity published an hour ago. + await remoteActivity(4, "public", new Date(now - 3600_000)); + + const feed = await listSocialFeed(viewer); + const names = feed.map((r) => r.name); + expect(names.indexOf(`Local newest ${RUN}`)).toBeLessThan(names.indexOf("Remote public 4")); + }); +}); diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts index 757a201..aa5f3cb 100644 --- a/apps/journal/app/lib/sync/imports.server.ts +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -51,9 +51,23 @@ export async function importActivity( userId: string, provider: string, externalWorkoutId: string, - input: { name: string; gpx?: string }, + input: { + name: string; + gpx?: string; + // Stats-only imports (no GPS file — e.g. Garmin notifications for + // FIT-less activities) carry whatever the provider's summary had. + distance?: number | null; + duration?: number | null; + startedAt?: Date | null; + }, ): Promise<{ activityId: string }> { - const activityId = await createActivity(userId, { name: input.name, gpx: input.gpx }); + const activityId = await createActivity(userId, { + name: input.name, + gpx: input.gpx, + distance: input.distance, + duration: input.duration, + startedAt: input.startedAt, + }); await recordImport(userId, provider, externalWorkoutId, activityId); return { activityId }; } diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 0f630a3..19c02e8 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -36,6 +36,7 @@ export default [ route("api/users/:username/follow", "routes/api.users.$username.follow.ts"), route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"), route("follows/requests", "routes/follows.requests.tsx"), + route("follows/outgoing", "routes/follows.outgoing.tsx"), route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"), route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"), route("feed", "routes/feed.tsx"), @@ -57,6 +58,7 @@ export default [ route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"), route("api/settings/delete-account", "routes/api.settings.delete-account.ts"), route("sync/import/komoot", "routes/sync.import.komoot.tsx"), + route("sync/import/garmin", "routes/sync.import.garmin.tsx"), route("sync/import/:provider", "routes/sync.import.$provider.tsx"), route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"), route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"), diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index b06578f..d75261a 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -17,8 +17,12 @@ import type { Visibility } from "@trails-cool/db/schema/journal"; const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loadActivityDetail(request: Request, id: string | undefined) { - const activity = await getActivity(id ?? ""); - if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); + const fetched = await getActivity(id ?? ""); + if (!fetched) throw data({ error: "Activity not found" }, { status: 404 }); + // Remote-ingested activities have no local detail page — their + // canonical page lives on the origin instance; the feed links there. + if (fetched.ownerId === null) throw data({ error: "Activity not found" }, { status: 404 }); + const activity = { ...fetched, ownerId: fetched.ownerId }; const user = await getSessionUser(request); const isOwner = user?.id === activity.ownerId; diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 800db0c..e9c0db0 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -5,6 +5,8 @@ import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, link } from "~/lib/connected-services"; import { decodeOAuthState, + readPkceVerifier, + clearPkceCookieHeader, } from "~/lib/connected-services/oauth-state.server"; import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; @@ -32,8 +34,18 @@ export async function loader({ params, request }: Route.LoaderArgs) { const origin = getOrigin(); const redirectUri = `${origin}/api/sync/callback/${params.provider}`; + // PKCE providers: recover the verifier from the connect-time cookie. + const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null; + if (manifest.pkce && !codeVerifier) { + return redirect(`${fallbackReturn}?error=sync_failed`); + } + try { - const exchange = await manifest.exchangeCode(code, redirectUri); + const exchange = await manifest.exchangeCode( + code, + redirectUri, + codeVerifier ? { codeVerifier } : undefined, + ); await link({ userId: user.id, provider: manifest.id, @@ -65,5 +77,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { return redirect(`${target}?push=${outcome.status}`); } - return redirect(state.returnTo ?? "/settings"); + return redirect(state.returnTo ?? "/settings", { + // Spent verifier — clear it regardless of which provider this was + // (harmless no-op for non-PKCE providers without the cookie). + headers: { "Set-Cookie": clearPkceCookieHeader() }, + }); } diff --git a/apps/journal/app/routes/api.sync.connect.$provider.ts b/apps/journal/app/routes/api.sync.connect.$provider.ts index cfbc2ca..6e27d09 100644 --- a/apps/journal/app/routes/api.sync.connect.$provider.ts +++ b/apps/journal/app/routes/api.sync.connect.$provider.ts @@ -3,7 +3,11 @@ import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.connect.$provider"; import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; -import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; +import { + encodeOAuthState, + generatePkcePair, + pkceCookieHeader, +} from "~/lib/connected-services/oauth-state.server"; export async function loader({ params, request }: Route.LoaderArgs) { await requireSessionUser(request); @@ -17,5 +21,15 @@ export async function loader({ params, request }: Route.LoaderArgs) { const redirectUri = `${origin}/api/sync/callback/${params.provider}`; const state = encodeOAuthState({ returnTo: "/settings/connections" }); + // PKCE providers (Garmin): the verifier crosses the redirect in an + // httpOnly cookie; only the S256 challenge goes to the provider. + 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)); } diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts index 7c57b2f..0ba4804 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts @@ -30,7 +30,7 @@ describe("POST /api/sync/webhook/:provider", () => { beforeEach(() => { parseWebhook.mockReset(); handle.mockReset(); - parseWebhook.mockReturnValue(null); + parseWebhook.mockReturnValue([]); vi.unstubAllEnvs(); }); @@ -83,7 +83,7 @@ describe("POST /api/sync/webhook/:provider", () => { }); it("invokes parseWebhook with a valid object body", async () => { - parseWebhook.mockReturnValue({ eventType: "x", providerUserId: "1", workoutId: "9" }); + parseWebhook.mockReturnValue([{ eventType: "x", providerUserId: "1", workoutId: "9" }]); handle.mockResolvedValue(undefined); const res = await call({ event_type: "x" }); expect(statusOf(res)).toBe(200); diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.ts b/apps/journal/app/routes/api.sync.webhook.$provider.ts index 1cff459..a78c647 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.ts @@ -37,13 +37,13 @@ export async function action({ params, request }: Route.ActionArgs) { return data({ ok: true }); } - const event = manifest.webhookReceiver.parseWebhook(body); - if (!event) return data({ ok: true }); - - try { - await manifest.webhookReceiver.handle(event); - } catch (e) { - console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + const events = manifest.webhookReceiver.parseWebhook(body); + for (const event of events) { + try { + await manifest.webhookReceiver.handle(event); + } catch (e) { + console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + } } return data({ ok: true }); diff --git a/apps/journal/app/routes/api.well-known.trails-cool.ts b/apps/journal/app/routes/api.well-known.trails-cool.ts index d42fe04..c26e264 100644 --- a/apps/journal/app/routes/api.well-known.trails-cool.ts +++ b/apps/journal/app/routes/api.well-known.trails-cool.ts @@ -15,5 +15,9 @@ export function loader() { apiVersion: API_VERSION, instanceName: domain, apiBaseUrl: `${origin}/api/v1`, + // Software identity for trails-to-trails federation discovery + // (social-federation 6.2). NodeInfo (/nodeinfo/2.1) is the primary + // check; this is the trails-specific secondary signal. + software: "trails-cool", }); } diff --git a/apps/journal/app/routes/feed.server.ts b/apps/journal/app/routes/feed.server.ts index 1f5e7c8..81a013a 100644 --- a/apps/journal/app/routes/feed.server.ts +++ b/apps/journal/app/routes/feed.server.ts @@ -31,6 +31,11 @@ export async function loadFeed(request: Request) { geojson: a.geojson ?? null, ownerUsername: a.ownerUsername, ownerDisplayName: a.ownerDisplayName, + // Remote (federated) attribution — only the followed view can + // contain remote rows; the public view is local-only. + ownerDomain: "ownerDomain" in a ? a.ownerDomain : null, + externalUrl: "externalUrl" in a ? a.externalUrl : null, + remote: "remote" in a ? a.remote : false, })), }; } diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 37f5b67..a99578e 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -81,13 +81,25 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {t("social.feed.seePublic")} )} + {view === "followed" && ( + + {t("social.feed.followRemote")} + + )} ) : (
    {activities.map((a) => (
  • @@ -104,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {

    {a.name}

    - e.stopPropagation()} - > - {a.ownerDisplayName ?? a.ownerUsername} - + {a.remote ? ( + + {a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain} + {a.ownerUsername && a.ownerDomain && ( + @{a.ownerUsername}@{a.ownerDomain} + )} + + ) : ( + e.stopPropagation()} + > + {a.ownerDisplayName ?? a.ownerUsername} + + )} {" · "}
    diff --git a/apps/journal/app/routes/follows.outgoing.tsx b/apps/journal/app/routes/follows.outgoing.tsx new file mode 100644 index 0000000..d462f8c --- /dev/null +++ b/apps/journal/app/routes/follows.outgoing.tsx @@ -0,0 +1,144 @@ +import { data, redirect, Form, useNavigation } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/follows.outgoing"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { FollowError } from "~/lib/follow.server"; +import { + followRemoteActor, + cancelRemoteFollow, + listOutgoingRemoteFollows, +} from "~/lib/federation-outbound.server"; +import { federationEnabled } from "~/lib/federation.server"; +import { ClientDate } from "~/components/ClientDate"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + const entries = await listOutgoingRemoteFollows(user.id); + return data({ entries, federationEnabled: federationEnabled() }); +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const form = await request.formData(); + const intent = form.get("intent"); + try { + if (intent === "follow") { + const handle = String(form.get("handle") ?? ""); + const state = await followRemoteActor(user.id, handle); + return data({ ok: true, ...state }); + } + if (intent === "cancel") { + const actorIri = String(form.get("actorIri") ?? ""); + await cancelRemoteFollow(user.id, actorIri); + return data({ ok: true }); + } + return data({ error: "Unknown intent" }, { status: 400 }); + } catch (e) { + if (e instanceof FollowError) { + return data({ error: e.message, code: e.code }, { status: 400 }); + } + throw e; + } +} + +export function meta() { + return [{ title: "Following across instances — trails.cool" }]; +} + +export default function OutgoingFollows({ loaderData, actionData }: Route.ComponentProps) { + const { entries, federationEnabled } = loaderData; + const { t } = useTranslation("journal"); + const navigation = useNavigation(); + const busy = navigation.state !== "idle"; + const error = actionData && "error" in actionData ? actionData.error : null; + const errorCode = actionData && "code" in actionData ? (actionData.code as string) : null; + + return ( +
    +

    {t("social.outgoing.heading")}

    +

    {t("social.outgoing.subtitle")}

    + + {!federationEnabled ? ( +

    + {t("social.outgoing.disabled")} +

    + ) : ( + <> +
    + +
    + + +
    + +
    + {error && ( +

    + {errorCode === "not_trails" ? t("social.outgoing.notTrails") : error} +

    + )} + + {entries.length === 0 ? ( +

    {t("social.outgoing.empty")}

    + ) : ( +
      + {entries.map((e) => ( +
    • +
      + + {e.displayName ?? e.username ?? e.actorIri} + +

      + {e.username && e.domain ? `@${e.username}@${e.domain}` : e.actorIri} + {" · "} + {e.pending ? ( + {t("social.outgoing.pendingBadge")} + ) : ( + {t("social.outgoing.acceptedBadge")} + )} + {" · "} + +

      +
      +
      + + + +
      +
    • + ))} +
    + )} + + )} +
    + ); +} diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index ce4b14a..21518be 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -260,6 +260,22 @@ export default function PrivacyPage() { , der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist geplant.
  • +
  • + Föderation (ActivityPub) – Wenn Ihr Profil auf{" "} + public steht, ist Ihr Konto über das + ActivityPub-Protokoll föderiert: Andere Server (z. B. + Mastodon-Instanzen oder andere trails.cool-Instanzen) können Ihr + öffentliches Profil (Anzeigename, Nutzername, Bio, öffentlicher + Signaturschlüssel) abrufen und Ihnen folgen. An die Server + angenommener Follower übermitteln wir Ihre als{" "} + public markierten Aktivitäten (Titel, Statistiken, + Link). Diese Server liegen außerhalb unserer Kontrolle; was dort + mit zugestellten Inhalten geschieht (Speicherung, Anzeige, + Weiterverbreitung), richtet sich nach deren Richtlinien. + Rechtsgrundlage ist Ihre Einwilligung durch das aktive + Veröffentlichen (Art. 6 Abs. 1 lit. a DSGVO). Private Profile + föderieren nicht. +
  • BRouter – Routenberechnung läuft auf einer von uns selbst gehosteten Instanz. Keine Weitergabe an Dritte. @@ -293,6 +309,21 @@ export default function PrivacyPage() { importieren. Die Verbindung kann jederzeit in den Einstellungen getrennt werden.
  • +
  • + Garmin (Garmin Ltd.) – Optionaler Import von + Aktivitäten aus Garmin Connect. Beim Verbinden werden + OAuth-Tokens ausgetauscht und die Garmin-Nutzer-ID gespeichert. + Garmin übermittelt anschließend neue Aktivitäten (inkl. + GPS-Aufzeichnung als FIT-Datei) automatisch an diese Instanz; + ältere Aktivitäten nur auf Ihre ausdrückliche Anforderung + (Zeitraum-Import). Es werden keine Gesundheitsdaten (Schlaf, + Herzfrequenz-Tageswerte o. ä.) abgerufen — nur Aktivitäten. + Widerrufen Sie den Zugriff bei Garmin oder trennen Sie die + Verbindung in den Einstellungen, endet die Übermittlung sofort; + bereits importierte Aktivitäten bleiben in Ihrem Journal und + können dort gelöscht werden. Es gilt die Datenschutzerklärung + von Garmin. +
  • Hosting – Die Dienste werden in Rechenzentren innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit @@ -311,7 +342,13 @@ export default function PrivacyPage() { Wahoo” on a route — sent so the route appears on your ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode stores your Komoot user ID only; authenticated mode stores your - encrypted Komoot password); hosting provider in the EU under a DPA. + encrypted Komoot password); Garmin (only when you opt in: OAuth + tokens and your Garmin user ID; Garmin then pushes new activities + including GPS files to this instance automatically, and older + activities only when you request a date range — activities only, + never health metrics; revoking at Garmin or disconnecting here + stops the flow immediately, imported activities stay yours); + hosting provider in the EU under a DPA.

    @@ -452,6 +489,53 @@ export default function PrivacyPage() {
+
+

+ Federation (ActivityPub) +

+

+ Applies only when your profile visibility is public. + Private profiles do not federate at all — no actor object, no + WebFinger, no inbox. +

+
    +
  • + Your actor object (fetchable by any fediverse server) exposes: + username, display name, bio, profile link, and your public + signing key. Never your email, never private content. +
  • +
  • + Activities you mark public are pushed to the + servers of your accepted remote followers and listed in your + public outbox. Once delivered, copies live on those servers + under their policies — un-publishing sends a retraction, but + remote deletion cannot be guaranteed (and remote servers that + processed a retraction will not re-show a later re-publish). +
  • +
  • + Signing keys: one RSA keypair per user. The private key is + stored encrypted at rest and never leaves this server. +
  • +
  • + Remote actor cache: for accounts that interact with this + instance we store their public profile basics (handle, display + name, inbox/outbox URLs, public key) to render follower lists + and feeds without re-fetching. +
  • +
  • + Remote content: public (or followers-only) activities from + trails users you follow on other instances are cached here for + your feed — followers-only items are shown only to the + follower whose follow brought them in. +
  • +
  • + Inbox traffic (signed requests from other servers) appears in + the standard server logs (14-day retention, see section 4) and + is rate-limited per source instance. +
  • +
+
+

Sentry