Merge branch 'main' into dependabot/npm_and_yarn/production-e9efbc881e
This commit is contained in:
commit
8eeb775f54
122 changed files with 4617 additions and 190 deletions
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../.agents/skills
|
||||||
14
.dockerignore
Normal file
14
.dockerignore
Normal file
|
|
@ -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
|
||||||
9
.github/workflows/cd-apps.yml
vendored
9
.github/workflows/cd-apps.yml
vendored
|
|
@ -83,6 +83,13 @@ jobs:
|
||||||
echo "DOMAIN=trails.cool" >> infrastructure/app.env
|
echo "DOMAIN=trails.cool" >> infrastructure/app.env
|
||||||
# Flagship marker — see cd-infra.yml for what this gates.
|
# Flagship marker — see cd-infra.yml for what this gates.
|
||||||
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
|
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
|
||||||
|
# Federation on (social-federation rollout 12.5, flipped
|
||||||
|
# 2026-06-07 after the staging + Mastodon soak). The
|
||||||
|
# FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env
|
||||||
|
# decrypted above. Rollback: delete these two lines, merge,
|
||||||
|
# rerun cd-apps — instant off, federation surfaces 404.
|
||||||
|
echo "FEDERATION_ENABLED=true" >> infrastructure/app.env
|
||||||
|
echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env
|
||||||
# Sentry DSNs (public — see workflow top-level env for context).
|
# Sentry DSNs (public — see workflow top-level env for context).
|
||||||
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
|
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
|
||||||
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
|
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
|
||||||
|
|
@ -93,7 +100,7 @@ jobs:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
|
source: "infrastructure/docker-compose.yml,infrastructure/caddy"
|
||||||
target: /opt/trails-cool
|
target: /opt/trails-cool
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
|
|
||||||
|
|
|
||||||
55
.github/workflows/cd-infra.yml
vendored
55
.github/workflows/cd-infra.yml
vendored
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
username: root
|
username: root
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
source: "infrastructure/docker-compose.yml,infrastructure/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
|
target: /opt/trails-cool
|
||||||
strip_components: 1
|
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
|
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Capture whether prometheus was recreated. A fresh container already
|
||||||
|
# reads the new config on startup; sending it SIGHUP immediately can
|
||||||
|
# kill Prometheus 3.10 during early boot (exit 2 observed on
|
||||||
|
# 2026-06-09).
|
||||||
|
PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
|
||||||
|
|
||||||
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
|
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
|
||||||
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
|
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
|
||||||
docker compose --env-file .env up -d --remove-orphans
|
docker compose --env-file .env up -d --remove-orphans
|
||||||
else
|
else
|
||||||
# Restart infra services (except Caddy — just reload its config).
|
# Restart infra services (config reloads handled below).
|
||||||
# --remove-orphans cleans up containers whose service was deleted
|
# --remove-orphans cleans up containers whose service was deleted
|
||||||
# from the compose file (e.g., the flagship `brouter` removal in
|
# from the compose file (e.g., the flagship `brouter` removal in
|
||||||
# PR #297 left an orphan that had to be removed by hand).
|
# PR #297 left an orphan that had to be removed by hand).
|
||||||
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
|
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
|
||||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Apply config-only changes that `up -d` skips — it recreates a
|
||||||
|
# container only when its compose *definition* changes, not when a
|
||||||
|
# mounted config file's content changes. The configs are mounted as
|
||||||
|
# directories (not single files), so a reload/restart re-reads the
|
||||||
|
# freshly scp'd file; a single-file mount would have pinned the old
|
||||||
|
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
|
||||||
|
# the same container stayed up; a recreated container already loaded
|
||||||
|
# the new config on startup. Loki and Promtail reload their main
|
||||||
|
# config only on restart; Caddy reloads gracefully (validates, swaps
|
||||||
|
# live, no downtime).
|
||||||
|
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
|
||||||
|
docker compose --env-file .env kill -s SIGHUP prometheus
|
||||||
|
fi
|
||||||
|
docker compose --env-file .env restart loki promtail
|
||||||
|
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
# Gate on the stack actually being up: postgres healthy and —
|
# Gate on the stack actually being up: postgres healthy, journal
|
||||||
# since an infra restart bounces the app containers' database —
|
# back to healthy after the DB bounce, and Prometheus answering
|
||||||
# journal back to healthy too. A deploy that leaves either down
|
# /-/ready. A deploy that leaves any of them down must fail loudly
|
||||||
# must fail loudly (see the 2026-06-06/07 outage).
|
# (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 ctr in trails-cool-postgres-1 trails-cool-journal-1; do
|
||||||
for i in $(seq 1 36); do
|
for i in $(seq 1 36); do
|
||||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
|
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
|
||||||
|
|
@ -114,6 +138,23 @@ jobs:
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
PROMETHEUS_READY=
|
||||||
|
for i in $(seq 1 36); do
|
||||||
|
prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing)
|
||||||
|
if [ "$prom_status" = "running" ]; then
|
||||||
|
prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1)
|
||||||
|
if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then
|
||||||
|
PROMETHEUS_READY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ -z "$PROMETHEUS_READY" ]; then
|
||||||
|
echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Annotate deploy in Grafana
|
# Annotate deploy in Grafana
|
||||||
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
|
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
|
||||||
if [ -n "$GRAFANA_TOKEN" ]; then
|
if [ -n "$GRAFANA_TOKEN" ]; then
|
||||||
|
|
|
||||||
9
.github/workflows/cd-staging.yml
vendored
9
.github/workflows/cd-staging.yml
vendored
|
|
@ -252,6 +252,15 @@ jobs:
|
||||||
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
|
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
|
||||||
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
|
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
|
||||||
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
|
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
|
||||||
|
# Federation on previews too (social-federation rollout 12.4):
|
||||||
|
# makes any PR preview a second live trails instance that
|
||||||
|
# persistent staging can follow across — the trails-to-trails
|
||||||
|
# soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the
|
||||||
|
# SOPS env decrypted above. Preview teardown orphans remote
|
||||||
|
# follower rows on the other side; remotes handle dead
|
||||||
|
# instances via ordinary delivery-failure expiry.
|
||||||
|
echo "FEDERATION_ENABLED=true"
|
||||||
|
echo "FEDERATION_LOG_LEVEL=debug"
|
||||||
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||||
|
|
||||||
- name: Generate per-PR Caddyfile snippet
|
- name: Generate per-PR Caddyfile snippet
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
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-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
|
**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
|
||||||
|
|
||||||
|
## Agent Skills & Config
|
||||||
|
|
||||||
|
Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink).
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/skills/ ← canonical location
|
||||||
|
.claude/skills ← symlink → ../.agents/skills
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup.
|
||||||
|
|
||||||
## OpenSpec Workflow
|
## OpenSpec Workflow
|
||||||
|
|
||||||
Specs live in `openspec/`. Use these slash commands:
|
Specs live in `openspec/`. Use these slash commands:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@
|
||||||
# WAHOO_CLIENT_SECRET=
|
# WAHOO_CLIENT_SECRET=
|
||||||
# WAHOO_WEBHOOK_TOKEN=
|
# WAHOO_WEBHOOK_TOKEN=
|
||||||
|
|
||||||
|
# Garmin Connect Developer Program credentials (spec: garmin-import).
|
||||||
|
# Requires an approved program application; without these the Garmin
|
||||||
|
# provider is hidden on /settings/connections. The OAuth callback to
|
||||||
|
# register with Garmin is `<origin>/api/sync/callback/garmin`, the
|
||||||
|
# notification endpoint `<origin>/api/sync/webhook/garmin`.
|
||||||
|
# GARMIN_CLIENT_ID=
|
||||||
|
# GARMIN_CLIENT_SECRET=
|
||||||
|
|
||||||
# Integration test secret (only needed if running the integration
|
# Integration test secret (only needed if running the integration
|
||||||
# test suite that drives the API directly). Generate with
|
# test suite that drives the API directly). Generate with
|
||||||
# `openssl rand -hex 32`.
|
# `openssl rand -hex 32`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { JobDefinition } from "@trails-cool/jobs";
|
import type { JobDefinition } from "@trails-cool/jobs";
|
||||||
import { and, eq } from "drizzle-orm";
|
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 { getDb } from "../lib/db.ts";
|
||||||
import { getOrigin } from "../lib/config.server.ts";
|
import { getOrigin } from "../lib/config.server.ts";
|
||||||
import { getFederation } from "../lib/federation.server.ts";
|
import { getFederation } from "../lib/federation.server.ts";
|
||||||
|
|
@ -77,6 +77,17 @@ async function deliverOne(p: DeliveryPayload): Promise<void> {
|
||||||
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
|
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
|
||||||
return;
|
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);
|
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
|
||||||
} else {
|
} else {
|
||||||
activity = activityToDelete(p.objectIri, p.ownerUsername);
|
activity = activityToDelete(p.objectIri, p.ownerUsername);
|
||||||
|
|
|
||||||
33
apps/journal/app/jobs/garmin-import-activity.ts
Normal file
33
apps/journal/app/jobs/garmin-import-activity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -51,6 +51,9 @@ export async function fanout(activityId: string): Promise<void> {
|
||||||
logger.warn({ activityId }, "fanout: activity not found, skipping");
|
logger.warn({ activityId }, "fanout: activity not found, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Remote-ingested rows have no local owner and never fan out locally
|
||||||
|
// (the users innerJoin already excludes them; this narrows the type).
|
||||||
|
if (row.ownerId === null) return;
|
||||||
// Defense in depth — the create-side guard already filters this, but
|
// Defense in depth — the create-side guard already filters this, but
|
||||||
// recheck here so the job doesn't mistakenly fan out a row that was
|
// recheck here so the job doesn't mistakenly fan out a row that was
|
||||||
// later flipped to private/unlisted before the job ran.
|
// later flipped to private/unlisted before the job ran.
|
||||||
|
|
|
||||||
26
apps/journal/app/jobs/poll-remote-actor.ts
Normal file
26
apps/journal/app/jobs/poll-remote-actor.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
25
apps/journal/app/jobs/poll-remote-outboxes.ts
Normal file
25
apps/journal/app/jobs/poll-remote-outboxes.ts
Normal file
|
|
@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { randomUUID } from "node:crypto";
|
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 { 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 type { Visibility } from "@trails-cool/db/schema/journal";
|
||||||
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
|
||||||
import type { GpxData } 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`
|
* Social feed (spec: social-federation §8): aggregated activities from
|
||||||
* follows (accepted only). Reverse-chronological. Joins users for owner
|
* actors that `followerId` follows with an *accepted* follow — local
|
||||||
* attribution. Unlisted/private activities never appear, regardless of
|
* users and remote trails actors alike. Reverse-chronological on
|
||||||
* follow state.
|
* 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) {
|
export async function listSocialFeed(followerId: string, limit: number = 50) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const rows = await db
|
|
||||||
|
const local = db
|
||||||
.select({
|
.select({
|
||||||
id: activities.id,
|
id: activities.id,
|
||||||
name: activities.name,
|
name: activities.name,
|
||||||
|
|
@ -211,8 +223,12 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
|
||||||
duration: activities.duration,
|
duration: activities.duration,
|
||||||
startedAt: activities.startedAt,
|
startedAt: activities.startedAt,
|
||||||
createdAt: activities.createdAt,
|
createdAt: activities.createdAt,
|
||||||
ownerUsername: users.username,
|
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
|
||||||
ownerDisplayName: users.displayName,
|
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"),
|
||||||
|
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
|
||||||
|
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
|
||||||
|
externalUrl: sql<string | null>`NULL`.as("external_url"),
|
||||||
|
remote: sql<boolean>`false`.as("remote"),
|
||||||
})
|
})
|
||||||
.from(activities)
|
.from(activities)
|
||||||
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
|
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
|
||||||
|
|
@ -220,14 +236,46 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(follows.followerId, followerId),
|
eq(follows.followerId, followerId),
|
||||||
|
isNotNull(follows.acceptedAt),
|
||||||
eq(activities.visibility, "public"),
|
eq(activities.visibility, "public"),
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.orderBy(desc(activities.createdAt))
|
|
||||||
|
const remote = db
|
||||||
|
.select({
|
||||||
|
id: activities.id,
|
||||||
|
name: activities.name,
|
||||||
|
distance: activities.distance,
|
||||||
|
elevationGain: activities.elevationGain,
|
||||||
|
duration: activities.duration,
|
||||||
|
startedAt: activities.startedAt,
|
||||||
|
createdAt: activities.createdAt,
|
||||||
|
sortTime: sql<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
|
||||||
|
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
|
||||||
|
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
|
||||||
|
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
|
||||||
|
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
|
||||||
|
remote: sql<boolean>`true`.as("remote"),
|
||||||
|
})
|
||||||
|
.from(activities)
|
||||||
|
.innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri))
|
||||||
|
.leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(follows.followerId, followerId),
|
||||||
|
isNotNull(follows.acceptedAt),
|
||||||
|
// public reaches every accepted follower; followers-only is
|
||||||
|
// already gated by joining the viewer's own accepted follow.
|
||||||
|
sql`${activities.audience} IN ('public', 'followers-only')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await unionAll(local, remote)
|
||||||
|
.orderBy(sql`sort_time DESC`)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
const ids = rows.map((r) => r.id);
|
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
|
||||||
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
|
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
|
||||||
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,18 @@ export async function markNeedsRelink(
|
||||||
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`);
|
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<void> {
|
||||||
|
const db = getDb();
|
||||||
|
await db
|
||||||
|
.update(connectedServices)
|
||||||
|
.set({ status: "revoked" })
|
||||||
|
.where(eq(connectedServices.id, serviceId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateGrantedScopes(
|
export async function updateGrantedScopes(
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
grantedScopes: string[],
|
grantedScopes: string[],
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,39 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PKCE (RFC 7636) ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// Providers with `pkce: true` (Garmin) need a code verifier that survives
|
||||||
|
// the connect → provider → callback redirect without ever appearing in a
|
||||||
|
// URL (the whole point of PKCE is that the verifier stays out of the
|
||||||
|
// authorization response). The `state` param is visible in redirects, so
|
||||||
|
// the verifier rides a short-lived httpOnly cookie scoped to the callback
|
||||||
|
// path instead.
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const PKCE_COOKIE = "__oauth_pkce";
|
||||||
|
const PKCE_MAX_AGE_S = 600;
|
||||||
|
|
||||||
|
export function generatePkcePair(): { verifier: string; challenge: string } {
|
||||||
|
// 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128).
|
||||||
|
const verifier = randomBytes(32).toString("base64url");
|
||||||
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pkceCookieHeader(verifier: string): string {
|
||||||
|
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||||
|
return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPkceCookieHeader(): string {
|
||||||
|
return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPkceVerifier(request: Request): string | null {
|
||||||
|
const cookie = request.headers.get("Cookie") ?? "";
|
||||||
|
const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`));
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
|
||||||
61
apps/journal/app/lib/connected-services/oauth-state.test.ts
Normal file
61
apps/journal/app/lib/connected-services/oauth-state.test.ts
Normal file
|
|
@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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<void> {
|
||||||
|
// 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, string | undefined> = {};
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<void> {
|
||||||
|
// 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 "<unparseable>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,11 @@
|
||||||
import { registerManifest } from "../registry.ts";
|
import { registerManifest } from "../registry.ts";
|
||||||
import { wahooManifest } from "./wahoo/manifest.ts";
|
import { wahooManifest } from "./wahoo/manifest.ts";
|
||||||
import { komootManifest } from "./komoot/manifest.ts";
|
import { komootManifest } from "./komoot/manifest.ts";
|
||||||
|
import { garminManifest } from "./garmin/manifest.ts";
|
||||||
|
|
||||||
registerManifest(wahooManifest);
|
registerManifest(wahooManifest);
|
||||||
registerManifest(komootManifest);
|
registerManifest(komootManifest);
|
||||||
|
registerManifest(garminManifest);
|
||||||
|
|
||||||
// Re-export so callers (mostly tests) can grab a manifest directly.
|
// Re-export so callers (mostly tests) can grab a manifest directly.
|
||||||
export { wahooManifest, komootManifest };
|
export { wahooManifest, komootManifest, garminManifest };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Contract tests for the Wahoo WebhookReceiver capability adapter.
|
// 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)
|
// handle(event) -> void (creates an activity if file present, dedups via sync_imports)
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
@ -41,22 +41,24 @@ describe("wahooWebhook.parseWebhook", () => {
|
||||||
file: { url: "https://cdn.example/42.fit" },
|
file: { url: "https://cdn.example/42.fit" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(event).toEqual({
|
expect(event).toEqual([
|
||||||
eventType: "workout_summary",
|
{
|
||||||
providerUserId: "7",
|
eventType: "workout_summary",
|
||||||
workoutId: "42",
|
providerUserId: "7",
|
||||||
fileUrl: "https://cdn.example/42.fit",
|
workoutId: "42",
|
||||||
});
|
fileUrl: "https://cdn.example/42.fit",
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for unrecognized event types", () => {
|
it("returns no events for unrecognized event types", () => {
|
||||||
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull();
|
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(
|
expect(
|
||||||
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
|
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
|
||||||
).toBeNull();
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,20 @@ interface WahooWebhookBody {
|
||||||
|
|
||||||
|
|
||||||
export const wahooWebhook: WebhookReceiver = {
|
export const wahooWebhook: WebhookReceiver = {
|
||||||
parseWebhook(body: unknown): WebhookEvent | null {
|
parseWebhook(body: unknown): WebhookEvent[] {
|
||||||
const payload = body as WahooWebhookBody;
|
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 {
|
return [
|
||||||
eventType: payload.event_type,
|
{
|
||||||
providerUserId: String(payload.user.id),
|
eventType: payload.event_type,
|
||||||
workoutId: String(
|
providerUserId: String(payload.user.id),
|
||||||
payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "",
|
workoutId: String(
|
||||||
),
|
payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "",
|
||||||
fileUrl: payload.workout_summary?.file?.url,
|
),
|
||||||
};
|
fileUrl: payload.workout_summary?.file?.url,
|
||||||
|
},
|
||||||
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
async handle(event: WebhookEvent): Promise<void> {
|
async handle(event: WebhookEvent): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,15 @@ export interface WebhookEvent {
|
||||||
providerUserId: string;
|
providerUserId: string;
|
||||||
workoutId: string;
|
workoutId: string;
|
||||||
fileUrl?: 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
|
// CapabilityContext gives capability adapters the tools they need without
|
||||||
|
|
@ -81,7 +90,10 @@ export interface RoutePusher {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookReceiver {
|
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<void>;
|
handle(event: WebhookEvent): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,13 +111,30 @@ export interface ProviderManifest {
|
||||||
// Custom connect page URL. When set, the connections settings page links
|
// Custom connect page URL. When set, the connections settings page links
|
||||||
// here instead of the default OAuth connect endpoint.
|
// here instead of the default OAuth connect endpoint.
|
||||||
connectUrl?: string;
|
connectUrl?: string;
|
||||||
|
// Custom import page URL. When set, the connections settings page links
|
||||||
|
// here instead of the generic /sync/import/<id> 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).
|
// 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
|
// OAuth code exchange (for the callback). Returns the credential blob to
|
||||||
// store and the granted scopes.
|
// store and the granted scopes.
|
||||||
exchangeCode?: (
|
exchangeCode?: (
|
||||||
code: string,
|
code: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
|
extras?: { codeVerifier?: string },
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
credentials: unknown;
|
credentials: unknown;
|
||||||
providerUserId: string | null;
|
providerUserId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,11 @@ export async function getCachedRemoteActor(actorIri: string): Promise<CachedRemo
|
||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upsert the fields delivery learns about a remote actor. */
|
/** Upsert the fields delivery/polling learn about a remote actor. */
|
||||||
export async function upsertRemoteActor(fields: {
|
export async function upsertRemoteActor(fields: {
|
||||||
actorIri: string;
|
actorIri: string;
|
||||||
inboxUrl?: string | null;
|
inboxUrl?: string | null;
|
||||||
|
outboxUrl?: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
|
|
|
||||||
199
apps/journal/app/lib/federation-ingest.integration.test.ts
Normal file
199
apps/journal/app/lib/federation-ingest.integration.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
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 { FetchError } from "@fedify/fedify";
|
||||||
|
import {
|
||||||
|
ingestRemoteActivities,
|
||||||
|
pollRemoteActor,
|
||||||
|
listActorsDuePolling,
|
||||||
|
resetHostBackoff,
|
||||||
|
type ParsedRemoteActivity,
|
||||||
|
} from "./federation-ingest.server.ts";
|
||||||
|
|
||||||
|
// Opt-in: real Postgres; network injected. Same convention as the
|
||||||
|
// other *.integration.test.ts files.
|
||||||
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
||||||
|
|
||||||
|
const ACTOR = `https://poll-target.example/users/alice-${Date.now()}`;
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
async function makeFollowerOf(actorIri: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const id = randomUUID();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id,
|
||||||
|
email: `poll-${id}@example.test`,
|
||||||
|
username: `poll-${id.slice(0, 8)}`,
|
||||||
|
domain: "test.local",
|
||||||
|
profileVisibility: "public",
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
await db.insert(follows).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
followerId: id,
|
||||||
|
followedActorIri: actorIri,
|
||||||
|
followedUserId: null,
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsed(n: number, audience: "public" | "followers-only" = "public"): ParsedRemoteActivity {
|
||||||
|
return {
|
||||||
|
originIri: `${ACTOR}/activities/${n}`,
|
||||||
|
name: `Remote activity ${n}`,
|
||||||
|
distance: 1000 * n,
|
||||||
|
elevationGain: null,
|
||||||
|
duration: null,
|
||||||
|
publishedAt: new Date(Date.now() - n * 60_000),
|
||||||
|
audience,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(runIntegration)("outbox-poll ingestion (integration)", () => {
|
||||||
|
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: "<p>Polled ride</p>",
|
||||||
|
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<unknown> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
apps/journal/app/lib/federation-ingest.server.test.ts
Normal file
131
apps/journal/app/lib/federation-ingest.server.test.ts
Normal file
|
|
@ -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<string, unknown> = {}, noteOverrides: Record<string, unknown> = {}) {
|
||||||
|
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: "<p>Morning ride</p>\n<p>42.2 km · ↗ 512 m</p>\n<p><a href=\"https://remote.example/activities/a1\">link</a></p>",
|
||||||
|
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: `<p><b>${longName}</b></p>` }));
|
||||||
|
expect(parsed?.name).toHaveLength(300);
|
||||||
|
expect(parsed?.name).not.toContain("<b>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
351
apps/journal/app/lib/federation-ingest.server.ts
Normal file
351
apps/journal/app/lib/federation-ingest.server.ts
Normal file
|
|
@ -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 = /<p>([\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<string, unknown>).type === "PropertyValue" &&
|
||||||
|
(a as Record<string, unknown>).name === name
|
||||||
|
) {
|
||||||
|
const v = Number.parseFloat(String((a as Record<string, unknown>).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<string, unknown>;
|
||||||
|
if (create.type !== "Create") return null;
|
||||||
|
const note = create.object;
|
||||||
|
if (typeof note !== "object" || note === null) return null;
|
||||||
|
const n = note as Record<string, unknown>;
|
||||||
|
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<number> {
|
||||||
|
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<unknown>;
|
||||||
|
/** Per-host pacing (spec 7.4: 1 req / 5 s). No-op in tests. */
|
||||||
|
pace(host: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastFetchPerHost = new Map<string, number>();
|
||||||
|
|
||||||
|
// 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<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, unknown> | 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
155
apps/journal/app/lib/federation-outbound.integration.test.ts
Normal file
155
apps/journal/app/lib/federation-outbound.integration.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/journal/app/lib/federation-outbound.server.test.ts
Normal file
40
apps/journal/app/lib/federation-outbound.server.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
300
apps/journal/app/lib/federation-outbound.server.ts
Normal file
300
apps/journal/app/lib/federation-outbound.server.ts
Normal file
|
|
@ -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<ResolvedRemoteActor | null>;
|
||||||
|
fetchSoftwareName(actorIri: string): Promise<string | undefined>;
|
||||||
|
deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise<void>;
|
||||||
|
deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OutgoingFollowState> {
|
||||||
|
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<void> {
|
||||||
|
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<OutgoingFollowEntry[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
203
apps/journal/app/lib/federation-two-instance.integration.test.ts
Normal file
203
apps/journal/app/lib/federation-two-instance.integration.test.ts
Normal file
|
|
@ -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<T>(
|
||||||
|
what: string,
|
||||||
|
fn: () => Promise<T | null | undefined | false>,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
): Promise<T> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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: "<p>Injected note</p>",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -97,6 +97,20 @@ async function findLocalPublicUserByIri(iri: URL | null): Promise<UserRow | null
|
||||||
return user && user.profileVisibility === "public" ? user : null;
|
return user && user.profileVisibility === "public" ? user : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `id` is one of OUR outgoing Follow activity ids
|
||||||
|
* (`<actorIri>#follows/<uuid>`, 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<void> | null = null;
|
let _federation: Federation<void> | null = null;
|
||||||
let _logtapeConfigured = false;
|
let _logtapeConfigured = false;
|
||||||
|
|
||||||
|
|
@ -146,6 +160,15 @@ function buildFederation(): Federation<void> {
|
||||||
// Canonical origin so generated IRIs are correct behind the Caddy
|
// Canonical origin so generated IRIs are correct behind the Caddy
|
||||||
// proxy (the Node server itself only sees plain HTTP).
|
// proxy (the Node server itself only sees plain HTTP).
|
||||||
origin: getOrigin(),
|
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) {
|
if (!process.env.VITEST) {
|
||||||
// Fire-and-forget: runs the queue consumer loop for the process
|
// Fire-and-forget: runs the queue consumer loop for the process
|
||||||
|
|
@ -261,25 +284,66 @@ function buildFederation(): Federation<void> {
|
||||||
.on(Undo, async (ctx, undo) => {
|
.on(Undo, async (ctx, undo) => {
|
||||||
// Spec 4.3: Undo(Follow) removes the follow row. Other Undos are
|
// Spec 4.3: Undo(Follow) removes the follow row. Other Undos are
|
||||||
// acknowledged and dropped.
|
// acknowledged and dropped.
|
||||||
|
if (undo.actorId == null) return;
|
||||||
|
const undoObjectId = undo.objectId; // capture before dereference (see Accept)
|
||||||
const object = await undo.getObject(ctx);
|
const object = await undo.getObject(ctx);
|
||||||
if (!(object instanceof Follow)) return;
|
if (object instanceof Follow && object.objectId != null) {
|
||||||
if (undo.actorId == null || object.objectId == null) return;
|
const parsed = ctx.parseUri(object.objectId);
|
||||||
const parsed = ctx.parseUri(object.objectId);
|
if (parsed?.type !== "actor") return;
|
||||||
if (parsed?.type !== "actor") return;
|
await removeRemoteFollow(undo.actorId.href, parsed.identifier);
|
||||||
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/<id>` yields the sender's actor
|
||||||
|
// document, not a Follow (fragments aren't separately fetchable).
|
||||||
|
// Our Follow ids are `<actorIri>#follows/<uuid>` — 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) => {
|
.on(Accept, async (ctx, accept) => {
|
||||||
// Spec 4.4: a remote accepted our outgoing Follow — settle the
|
// Spec 4.4: a remote accepted our outgoing Follow — settle the
|
||||||
// Pending row and trigger the first outbox poll for that actor.
|
// 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;
|
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<ReturnType<typeof findLocalPublicUserByIri>> = 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/<id>` 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;
|
if (!localUser) return;
|
||||||
const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href);
|
const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href);
|
||||||
if (settled) {
|
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 }, {
|
await enqueueOptional("poll-remote-actor", { actorIri: accept.actorId.href }, {
|
||||||
reason: "first poll after accepted follow",
|
reason: "first poll after accepted follow",
|
||||||
});
|
});
|
||||||
|
|
@ -287,10 +351,16 @@ function buildFederation(): Federation<void> {
|
||||||
})
|
})
|
||||||
.on(Reject, async (ctx, reject) => {
|
.on(Reject, async (ctx, reject) => {
|
||||||
// Spec 4.5: remote refused our Follow — drop the Pending row.
|
// 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;
|
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<ReturnType<typeof findLocalPublicUserByIri>> = 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;
|
if (!localUser) return;
|
||||||
await rejectOutgoingFollow(localUser.id, reject.actorId.href);
|
await rejectOutgoingFollow(localUser.id, reject.actorId.href);
|
||||||
})
|
})
|
||||||
|
|
@ -311,7 +381,10 @@ function buildFederation(): Federation<void> {
|
||||||
.from(activities)
|
.from(activities)
|
||||||
.where(and(eq(activities.id, values.id), eq(activities.visibility, "public")))
|
.where(and(eq(activities.id, values.id), eq(activities.visibility, "public")))
|
||||||
.limit(1);
|
.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
|
const [owner] = await db
|
||||||
.select({ username: users.username, profileVisibility: users.profileVisibility })
|
.select({ username: users.username, profileVisibility: users.profileVisibility })
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,16 @@ import { createNotification } from "./notifications.server.ts";
|
||||||
import { logger } from "./logger.server.ts";
|
import { logger } from "./logger.server.ts";
|
||||||
|
|
||||||
export class FollowError extends Error {
|
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) {
|
constructor(code: FollowError["code"], message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "FollowError";
|
this.name = "FollowError";
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19";
|
||||||
* require re-acceptance (the policy is informational, not contract), so this
|
* require re-acceptance (the policy is informational, not contract), so this
|
||||||
* is display-only — not persisted.
|
* is display-only — not persisted.
|
||||||
*/
|
*/
|
||||||
export const PRIVACY_LAST_UPDATED = "2026-04-20";
|
export const PRIVACY_LAST_UPDATED = "2026-06-07";
|
||||||
|
|
|
||||||
140
apps/journal/app/lib/social-feed.integration.test.ts
Normal file
140
apps/journal/app/lib/social-feed.integration.test.ts
Normal file
|
|
@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -51,9 +51,23 @@ export async function importActivity(
|
||||||
userId: string,
|
userId: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
externalWorkoutId: 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 }> {
|
): 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);
|
await recordImport(userId, provider, externalWorkoutId, activityId);
|
||||||
return { activityId };
|
return { activityId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export default [
|
||||||
route("api/users/:username/follow", "routes/api.users.$username.follow.ts"),
|
route("api/users/:username/follow", "routes/api.users.$username.follow.ts"),
|
||||||
route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"),
|
route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"),
|
||||||
route("follows/requests", "routes/follows.requests.tsx"),
|
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/approve", "routes/api.follows.$id.approve.ts"),
|
||||||
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
|
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
|
||||||
route("feed", "routes/feed.tsx"),
|
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/passkey/delete", "routes/api.settings.passkey.delete.ts"),
|
||||||
route("api/settings/delete-account", "routes/api.settings.delete-account.ts"),
|
route("api/settings/delete-account", "routes/api.settings.delete-account.ts"),
|
||||||
route("sync/import/komoot", "routes/sync.import.komoot.tsx"),
|
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("sync/import/:provider", "routes/sync.import.$provider.tsx"),
|
||||||
route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"),
|
route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"),
|
||||||
route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"),
|
route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"),
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,12 @@ import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||||
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
||||||
|
|
||||||
export async function loadActivityDetail(request: Request, id: string | undefined) {
|
export async function loadActivityDetail(request: Request, id: string | undefined) {
|
||||||
const activity = await getActivity(id ?? "");
|
const fetched = await getActivity(id ?? "");
|
||||||
if (!activity) throw data({ error: "Activity not found" }, { status: 404 });
|
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 user = await getSessionUser(request);
|
||||||
const isOwner = user?.id === activity.ownerId;
|
const isOwner = user?.id === activity.ownerId;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { requireSessionUser } from "~/lib/auth/session.server";
|
||||||
import { getManifest, link } from "~/lib/connected-services";
|
import { getManifest, link } from "~/lib/connected-services";
|
||||||
import {
|
import {
|
||||||
decodeOAuthState,
|
decodeOAuthState,
|
||||||
|
readPkceVerifier,
|
||||||
|
clearPkceCookieHeader,
|
||||||
} from "~/lib/connected-services/oauth-state.server";
|
} from "~/lib/connected-services/oauth-state.server";
|
||||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.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 origin = getOrigin();
|
||||||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
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 {
|
try {
|
||||||
const exchange = await manifest.exchangeCode(code, redirectUri);
|
const exchange = await manifest.exchangeCode(
|
||||||
|
code,
|
||||||
|
redirectUri,
|
||||||
|
codeVerifier ? { codeVerifier } : undefined,
|
||||||
|
);
|
||||||
await link({
|
await link({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: manifest.id,
|
provider: manifest.id,
|
||||||
|
|
@ -65,5 +77,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
return redirect(`${target}?push=${outcome.status}`);
|
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() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { getOrigin } from "~/lib/config.server";
|
||||||
import type { Route } from "./+types/api.sync.connect.$provider";
|
import type { Route } from "./+types/api.sync.connect.$provider";
|
||||||
import { requireSessionUser } from "~/lib/auth/session.server";
|
import { requireSessionUser } from "~/lib/auth/session.server";
|
||||||
import { getManifest } from "~/lib/connected-services";
|
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) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
await requireSessionUser(request);
|
await requireSessionUser(request);
|
||||||
|
|
@ -17,5 +21,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||||
const state = encodeOAuthState({ returnTo: "/settings/connections" });
|
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));
|
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ describe("POST /api/sync/webhook/:provider", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
parseWebhook.mockReset();
|
parseWebhook.mockReset();
|
||||||
handle.mockReset();
|
handle.mockReset();
|
||||||
parseWebhook.mockReturnValue(null);
|
parseWebhook.mockReturnValue([]);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe("POST /api/sync/webhook/:provider", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("invokes parseWebhook with a valid object body", async () => {
|
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);
|
handle.mockResolvedValue(undefined);
|
||||||
const res = await call({ event_type: "x" });
|
const res = await call({ event_type: "x" });
|
||||||
expect(statusOf(res)).toBe(200);
|
expect(statusOf(res)).toBe(200);
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = manifest.webhookReceiver.parseWebhook(body);
|
const events = manifest.webhookReceiver.parseWebhook(body);
|
||||||
if (!event) return data({ ok: true });
|
for (const event of events) {
|
||||||
|
try {
|
||||||
try {
|
await manifest.webhookReceiver.handle(event);
|
||||||
await manifest.webhookReceiver.handle(event);
|
} catch (e) {
|
||||||
} catch (e) {
|
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
|
||||||
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return data({ ok: true });
|
return data({ ok: true });
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,9 @@ export function loader() {
|
||||||
apiVersion: API_VERSION,
|
apiVersion: API_VERSION,
|
||||||
instanceName: domain,
|
instanceName: domain,
|
||||||
apiBaseUrl: `${origin}/api/v1`,
|
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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export async function loadFeed(request: Request) {
|
||||||
geojson: a.geojson ?? null,
|
geojson: a.geojson ?? null,
|
||||||
ownerUsername: a.ownerUsername,
|
ownerUsername: a.ownerUsername,
|
||||||
ownerDisplayName: a.ownerDisplayName,
|
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,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,25 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
|
||||||
{t("social.feed.seePublic")}
|
{t("social.feed.seePublic")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{view === "followed" && (
|
||||||
|
<Link
|
||||||
|
to="/follows/outgoing"
|
||||||
|
className="mt-1 block text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{t("social.feed.followRemote")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-6 space-y-4">
|
<ul className="mt-6 space-y-4">
|
||||||
{activities.map((a) => (
|
{activities.map((a) => (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
<a
|
<a
|
||||||
href={`/activities/${a.id}`}
|
// Remote (federated) activities link to their canonical
|
||||||
|
// page on the origin instance — there is no local detail
|
||||||
|
// page for them.
|
||||||
|
href={a.remote && a.externalUrl ? a.externalUrl : `/activities/${a.id}`}
|
||||||
|
{...(a.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||||
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
|
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|
@ -104,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
|
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
|
||||||
<div className="mt-1 text-sm text-gray-500">
|
<div className="mt-1 text-sm text-gray-500">
|
||||||
<a
|
{a.remote ? (
|
||||||
href={`/users/${a.ownerUsername}`}
|
<span>
|
||||||
className="hover:text-gray-700 hover:underline"
|
{a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain}
|
||||||
onClick={(e) => e.stopPropagation()}
|
{a.ownerUsername && a.ownerDomain && (
|
||||||
>
|
<span className="text-gray-400"> @{a.ownerUsername}@{a.ownerDomain}</span>
|
||||||
{a.ownerDisplayName ?? a.ownerUsername}
|
)}
|
||||||
</a>
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`/users/${a.ownerUsername}`}
|
||||||
|
className="hover:text-gray-700 hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{a.ownerDisplayName ?? a.ownerUsername}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{" · "}
|
{" · "}
|
||||||
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
144
apps/journal/app/routes/follows.outgoing.tsx
Normal file
144
apps/journal/app/routes/follows.outgoing.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{t("social.outgoing.heading")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{t("social.outgoing.subtitle")}</p>
|
||||||
|
|
||||||
|
{!federationEnabled ? (
|
||||||
|
<p className="mt-8 rounded-md bg-gray-50 p-4 text-sm text-gray-600">
|
||||||
|
{t("social.outgoing.disabled")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form method="post" className="mt-6 flex items-end gap-3">
|
||||||
|
<input type="hidden" name="intent" value="follow" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="handle" className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("social.outgoing.handleLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="handle"
|
||||||
|
name="handle"
|
||||||
|
type="text"
|
||||||
|
placeholder="@alice@other-trails.example"
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("social.outgoing.followButton")}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">
|
||||||
|
{errorCode === "not_trails" ? t("social.outgoing.notTrails") : error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="mt-10 text-center text-gray-500">{t("social.outgoing.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-8 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.actorIri} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={e.actorIri}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-gray-900 hover:underline"
|
||||||
|
>
|
||||||
|
{e.displayName ?? e.username ?? e.actorIri}
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{e.username && e.domain ? `@${e.username}@${e.domain}` : e.actorIri}
|
||||||
|
{" · "}
|
||||||
|
{e.pending ? (
|
||||||
|
<span className="text-amber-600">{t("social.outgoing.pendingBadge")}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-700">{t("social.outgoing.acceptedBadge")}</span>
|
||||||
|
)}
|
||||||
|
{" · "}
|
||||||
|
<ClientDate iso={e.createdAt as unknown as string} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="cancel" />
|
||||||
|
<input type="hidden" name="actorIri" value={e.actorIri} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{e.pending ? t("social.outgoing.cancelRequest") : t("social.outgoing.unfollow")}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -260,6 +260,22 @@ export default function PrivacyPage() {
|
||||||
, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist
|
, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist
|
||||||
geplant.
|
geplant.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Föderation (ActivityPub)</strong> – Wenn Ihr Profil auf{" "}
|
||||||
|
<code>public</code> 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{" "}
|
||||||
|
<code>public</code> 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.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>BRouter</strong> – Routenberechnung läuft auf einer von uns
|
<strong>BRouter</strong> – Routenberechnung läuft auf einer von uns
|
||||||
selbst gehosteten Instanz. Keine Weitergabe an Dritte.
|
selbst gehosteten Instanz. Keine Weitergabe an Dritte.
|
||||||
|
|
@ -293,6 +309,21 @@ export default function PrivacyPage() {
|
||||||
importieren. Die Verbindung kann jederzeit in den Einstellungen
|
importieren. Die Verbindung kann jederzeit in den Einstellungen
|
||||||
getrennt werden.
|
getrennt werden.
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Garmin</strong> (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.
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Hosting</strong> – Die Dienste werden in Rechenzentren
|
<strong>Hosting</strong> – Die Dienste werden in Rechenzentren
|
||||||
innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit
|
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
|
Wahoo” on a route — sent so the route appears on your
|
||||||
ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode
|
ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode
|
||||||
stores your Komoot user ID only; authenticated mode stores your
|
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.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -452,6 +489,53 @@ export default function PrivacyPage() {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
|
Federation (ActivityPub)
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-gray-700">
|
||||||
|
Applies only when your profile visibility is <code>public</code>.
|
||||||
|
Private profiles do not federate at all — no actor object, no
|
||||||
|
WebFinger, no inbox.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 list-disc pl-6 text-gray-700 space-y-1">
|
||||||
|
<li>
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Activities you mark <code>public</code> 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).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Signing keys: one RSA keypair per user. The private key is
|
||||||
|
stored encrypted at rest and never leaves this server.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mt-8">
|
<section className="mt-8">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Sentry</h3>
|
<h3 className="text-xl font-semibold text-gray-900">Sentry</h3>
|
||||||
<ul className="mt-3 list-disc pl-6 text-gray-700 space-y-1">
|
<ul className="mt-3 list-disc pl-6 text-gray-700 space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,21 @@ export async function loadConnectionsSettings(request: Request) {
|
||||||
.from(connectedServices)
|
.from(connectedServices)
|
||||||
.where(eq(connectedServices.userId, user.id));
|
.where(eq(connectedServices.userId, user.id));
|
||||||
|
|
||||||
const providers = getAllManifests().map((m) => {
|
const providers = getAllManifests()
|
||||||
const conn = connections.find((c) => c.provider === m.id);
|
// Providers can hide themselves when the instance lacks their API
|
||||||
return {
|
// credentials (Garmin: program keys are per-operator).
|
||||||
id: m.id,
|
.filter((m) => m.configured?.() ?? true)
|
||||||
name: m.displayName,
|
.map((m) => {
|
||||||
connected: !!conn,
|
const conn = connections.find((c) => c.provider === m.id);
|
||||||
providerUserId: conn?.providerUserId,
|
return {
|
||||||
connectUrl: m.connectUrl ?? null,
|
id: m.id,
|
||||||
};
|
name: m.displayName,
|
||||||
});
|
connected: !!conn,
|
||||||
|
providerUserId: conn?.providerUserId,
|
||||||
|
connectUrl: m.connectUrl ?? null,
|
||||||
|
importUrl: m.importUrl ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return { providers };
|
return { providers };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps
|
||||||
{p.connected ? (
|
{p.connected ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/sync/import/${p.id}`}
|
href={p.importUrl ?? `/sync/import/${p.id}`}
|
||||||
className="text-sm text-blue-600 hover:underline"
|
className="text-sm text-blue-600 hover:underline"
|
||||||
>
|
>
|
||||||
{t("sync.import")}
|
{t("sync.import")}
|
||||||
|
|
|
||||||
91
apps/journal/app/routes/sync.import.garmin.server.ts
Normal file
91
apps/journal/app/routes/sync.import.garmin.server.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Server logic for the Garmin import page (spec: garmin-import,
|
||||||
|
// "Historical import via backfill"). Garmin has no list endpoint, so
|
||||||
|
// this page is a date-range backfill requester with honest async
|
||||||
|
// progress — not a pick list.
|
||||||
|
|
||||||
|
import { and, desc, eq, gte, count } from "drizzle-orm";
|
||||||
|
import { requireSessionUser } from "~/lib/auth/session.server";
|
||||||
|
import { getDb } from "~/lib/db";
|
||||||
|
import { importBatches, syncImports } from "@trails-cool/db/schema/journal";
|
||||||
|
import { getService } from "~/lib/connected-services/manager";
|
||||||
|
import { requestBackfill } from "~/lib/connected-services/providers/garmin/backfill";
|
||||||
|
|
||||||
|
export interface GarminBackfillRow {
|
||||||
|
id: string;
|
||||||
|
rangeStart: string | null;
|
||||||
|
rangeEnd: string | null;
|
||||||
|
requestedAt: string;
|
||||||
|
// Garmin activities imported since this request was made. Activities
|
||||||
|
// arrive asynchronously and notifications don't carry a batch id, so
|
||||||
|
// "imports since the request" is the honest measurable proxy for
|
||||||
|
// range progress (overlapping ranges share arrivals).
|
||||||
|
importedSince: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadGarminImportPage(request: Request) {
|
||||||
|
const user = await requireSessionUser(request);
|
||||||
|
const service = await getService(user.id, "garmin");
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
return { connected: false as const, status: null, batches: [] as GarminBackfillRow[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(importBatches)
|
||||||
|
.where(and(eq(importBatches.userId, user.id), eq(importBatches.provider, "garmin")))
|
||||||
|
.orderBy(desc(importBatches.startedAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
const batches: GarminBackfillRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const [imported] = await db
|
||||||
|
.select({ n: count() })
|
||||||
|
.from(syncImports)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(syncImports.userId, user.id),
|
||||||
|
eq(syncImports.provider, "garmin"),
|
||||||
|
gte(syncImports.importedAt, row.startedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
batches.push({
|
||||||
|
id: row.id,
|
||||||
|
rangeStart: row.rangeStart?.toISOString() ?? null,
|
||||||
|
rangeEnd: row.rangeEnd?.toISOString() ?? null,
|
||||||
|
requestedAt: row.startedAt.toISOString(),
|
||||||
|
importedSince: imported?.n ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { connected: true as const, status: service.status, batches };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GarminBackfillActionResult =
|
||||||
|
| { ok: true; chunks: number }
|
||||||
|
| { ok: false; error: "not_connected" | "needs_relink" | "invalid_range" | "request_failed" };
|
||||||
|
|
||||||
|
export async function handleGarminBackfillAction(
|
||||||
|
request: Request,
|
||||||
|
): Promise<GarminBackfillActionResult> {
|
||||||
|
const user = await requireSessionUser(request);
|
||||||
|
const service = await getService(user.id, "garmin");
|
||||||
|
if (!service) return { ok: false, error: "not_connected" };
|
||||||
|
if (service.status !== "active") return { ok: false, error: "needs_relink" };
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const from = new Date(String(form.get("from") ?? ""));
|
||||||
|
const to = new Date(String(form.get("to") ?? ""));
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime()) || from >= to || to > new Date()) {
|
||||||
|
return { ok: false, error: "invalid_range" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { chunks } = await requestBackfill({ id: service.id, userId: user.id }, from, to);
|
||||||
|
return { ok: true, chunks };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("garmin backfill request failed:", e);
|
||||||
|
return { ok: false, error: "request_failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
112
apps/journal/app/routes/sync.import.garmin.tsx
Normal file
112
apps/journal/app/routes/sync.import.garmin.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { data, Form, Link, useNavigation } from "react-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { Route } from "./+types/sync.import.garmin";
|
||||||
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
|
|
||||||
|
export function meta() {
|
||||||
|
return [{ title: "Garmin import — trails.cool" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
const { loadGarminImportPage } = await import("./sync.import.garmin.server");
|
||||||
|
return data(await loadGarminImportPage(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const { handleGarminBackfillAction } = await import("./sync.import.garmin.server");
|
||||||
|
return data(await handleGarminBackfillAction(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GarminImport({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { t } = useTranslation(["journal"]);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const busy = navigation.state !== "idle";
|
||||||
|
|
||||||
|
if (!loaderData.connected) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
|
||||||
|
<p className="mt-4 text-sm text-gray-600">{t("sync.garmin.notConnected")}</p>
|
||||||
|
<Link to="/settings/connections" className="mt-2 inline-block text-sm text-blue-600 hover:underline">
|
||||||
|
{t("sync.garmin.goConnect")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{t("sync.garmin.title")}</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">{t("sync.garmin.subtitle")}</p>
|
||||||
|
|
||||||
|
{loaderData.status !== "active" && (
|
||||||
|
<div role="alert" className="mt-4 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
{t("sync.garmin.needsRelink")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form method="post" className="mt-6 flex flex-wrap items-end gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-gray-700">{t("sync.garmin.from")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="from"
|
||||||
|
required
|
||||||
|
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-gray-700">{t("sync.garmin.to")}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="to"
|
||||||
|
required
|
||||||
|
className="mt-1 block rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || loaderData.status !== "active"}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("sync.garmin.requesting") : t("sync.garmin.request")}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{actionData && "ok" in actionData && actionData.ok && (
|
||||||
|
<p className="mt-3 text-sm text-green-700">{t("sync.garmin.requested")}</p>
|
||||||
|
)}
|
||||||
|
{actionData && "ok" in actionData && !actionData.ok && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||||
|
{t(`sync.garmin.errors.${actionData.error}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-gray-500">{t("sync.garmin.asyncNote")}</p>
|
||||||
|
|
||||||
|
{loaderData.batches.length > 0 && (
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{loaderData.batches.map((b) => (
|
||||||
|
<li
|
||||||
|
key={b.id}
|
||||||
|
className="flex items-center justify-between rounded-md border border-gray-200 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-gray-900">
|
||||||
|
{b.rangeStart && b.rangeEnd ? (
|
||||||
|
<>
|
||||||
|
<ClientDate iso={b.rangeStart} /> – <ClientDate iso={b.rangeEnd} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ClientDate iso={b.requestedAt} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{t("sync.garmin.importedSince", { count: b.importedSince })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -177,13 +177,16 @@ server.listen(port, async () => {
|
||||||
const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts");
|
const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts");
|
||||||
const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts");
|
const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.ts");
|
||||||
const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts");
|
const { consumedJtiSweepJob } = await import("./app/jobs/consumed-jti-sweep.ts");
|
||||||
jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob);
|
const { garminImportActivityJob } = await import("./app/jobs/garmin-import-activity.ts");
|
||||||
|
jobs.push(notificationsFanoutJob, notificationsPurgeJob, komootBulkImportJob, importBatchesSweepJob, sendWelcomeEmailJob, consumedJtiSweepJob, garminImportActivityJob);
|
||||||
// Federation jobs — registered only when federation is on.
|
// Federation jobs — registered only when federation is on.
|
||||||
if (process.env.FEDERATION_ENABLED === "true") {
|
if (process.env.FEDERATION_ENABLED === "true") {
|
||||||
const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts");
|
const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts");
|
||||||
const { federationKvSweepJob } = await import("./app/jobs/federation-kv-sweep.ts");
|
const { federationKvSweepJob } = await import("./app/jobs/federation-kv-sweep.ts");
|
||||||
const { deliverActivityJob } = await import("./app/jobs/deliver-activity.ts");
|
const { deliverActivityJob } = await import("./app/jobs/deliver-activity.ts");
|
||||||
jobs.push(backfillUserKeypairsJob, federationKvSweepJob, deliverActivityJob);
|
const { pollRemoteActorJob } = await import("./app/jobs/poll-remote-actor.ts");
|
||||||
|
const { pollRemoteOutboxesJob } = await import("./app/jobs/poll-remote-outboxes.ts");
|
||||||
|
jobs.push(backfillUserKeypairsJob, federationKvSweepJob, deliverActivityJob, pollRemoteActorJob, pollRemoteOutboxesJob);
|
||||||
}
|
}
|
||||||
|
|
||||||
const boss = createBoss(getDatabaseUrl());
|
const boss = createBoss(getDatabaseUrl());
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "9090:9090"
|
- "9090:9090"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
# Directory mount (not the file) to match prod — a single-file bind
|
||||||
|
# mount pins the host inode, so a replaced config is never seen.
|
||||||
|
- ./infrastructure/prometheus:/etc/prometheus:ro
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
|
|
@ -60,9 +62,10 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3100:3100"
|
- "3100:3100"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infrastructure/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
# Directory mount (not the file) to match prod.
|
||||||
|
- ./infrastructure/loki:/etc/loki:ro
|
||||||
- loki_data:/loki
|
- loki_data:/loki
|
||||||
command: ["-config.file=/etc/loki/local-config.yaml"]
|
command: ["-config.file=/etc/loki/loki-config.yml"]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,66 @@ curl -sf https://trails.cool/api/health && curl -sf https://staging.trails.cool/
|
||||||
Plan it as a short maintenance window (~2–3 min downtime); don't ship
|
Plan it as a short maintenance window (~2–3 min downtime); don't ship
|
||||||
network-option changes expecting the workflows to apply them.
|
network-option changes expecting the workflows to apply them.
|
||||||
|
|
||||||
|
## Federation runbook
|
||||||
|
|
||||||
|
Federation (ActivityPub via Fedify) is gated by `FEDERATION_ENABLED=true`
|
||||||
|
per environment. Currently: staging **on**, production **off**. The full
|
||||||
|
change history and design live in `openspec/changes/social-federation/`.
|
||||||
|
|
||||||
|
### Enabling on an environment
|
||||||
|
|
||||||
|
1. Ensure `FEDERATION_KEY_ENCRYPTION_KEY` is in `secrets.app.env`
|
||||||
|
(SOPS) — keypair generation fails closed in production without it.
|
||||||
|
2. Set `FEDERATION_ENABLED=true` in the environment's compose env (see
|
||||||
|
`cd-staging.yml` for the staging pattern).
|
||||||
|
3. First boot with the flag on enqueues `backfill-user-keypairs`
|
||||||
|
(generates RSA keys for existing users — idempotent) and registers
|
||||||
|
the federation jobs (`deliver-activity`, `poll-remote-actor`,
|
||||||
|
`poll-remote-outboxes`, `federation-kv-sweep`).
|
||||||
|
4. Smoke: `curl -s "https://<domain>/.well-known/webfinger?resource=acct:<user>@<domain>"`
|
||||||
|
(200 for a public user, 404 otherwise) and `/nodeinfo/2.1`.
|
||||||
|
|
||||||
|
`FEDERATION_LOG_LEVEL=debug` turns on Fedify's per-request signature
|
||||||
|
diagnostics (staging runs debug during soaks; dial back to `info`).
|
||||||
|
|
||||||
|
### Key rotation
|
||||||
|
|
||||||
|
Rotating `FEDERATION_KEY_ENCRYPTION_KEY` requires re-encrypting every
|
||||||
|
`users.private_key_encrypted` (decrypt with old, encrypt with new) —
|
||||||
|
there is no script yet; write one against
|
||||||
|
`apps/journal/app/lib/federation-keys.server.ts` primitives when first
|
||||||
|
needed. Rotating a single user's keypair: NULL their key columns and
|
||||||
|
let the backfill regenerate (remotes pick the new key up from the actor
|
||||||
|
document; deliveries signed with the old key fail until then).
|
||||||
|
|
||||||
|
### Abuse monitoring
|
||||||
|
|
||||||
|
- Inbox is rate-limited 60 req/5 min per source instance
|
||||||
|
(`federationSourceHost`); 429s show in journal logs.
|
||||||
|
- Outbound is paced (1 req/s delivery, 1 req/5 s polling per host).
|
||||||
|
- Watch `docker logs <journal>` for `fedify·federation` lines; Loki
|
||||||
|
picks them up. No per-instance blocklist yet — see
|
||||||
|
`docs/ideas/instance-administration.md` (manual emergency lever:
|
||||||
|
block the source IP/host in Caddy).
|
||||||
|
|
||||||
|
### Troubleshooting deliveries
|
||||||
|
|
||||||
|
Hard-won checklist from the 2026-06-06/07 soak (details in
|
||||||
|
`openspec/changes/social-federation/design.md`):
|
||||||
|
|
||||||
|
1. **Test both IP families from inside the journal container** before
|
||||||
|
suspecting signatures (`family: 6` vs `4` — dangling A records and
|
||||||
|
v6-only instances are common; our containers have IPv6 since
|
||||||
|
#466/#468).
|
||||||
|
2. Remote shows the post count but no posts → remotes never backfill
|
||||||
|
outbox history; only pushed or individually-fetched objects appear.
|
||||||
|
3. Delivered but invisible, no errors anywhere → check the remote's
|
||||||
|
`tombstones` table; a previously-retracted URI is refused forever.
|
||||||
|
4. Mastodon gives deliveries a 10s read timeout — anything synchronous
|
||||||
|
and slow in the inbox path breaks federation silently.
|
||||||
|
5. Actor changes (profile fields, keys) need the remote to re-fetch:
|
||||||
|
`tootctl accounts refresh user@domain` on a Mastodon you control.
|
||||||
|
|
||||||
## cd-brouter manual trigger
|
## cd-brouter manual trigger
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
16
e2e/federation/Caddyfile
Normal file
16
e2e/federation/Caddyfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# TLS terminator for the two-instance federation harness. `local_certs`
|
||||||
|
# issues every site cert from Caddy's internal CA; the journals trust it
|
||||||
|
# via NODE_EXTRA_CA_CERTS (see docker-compose.yml).
|
||||||
|
{
|
||||||
|
local_certs
|
||||||
|
# No public reachability — don't try (or wait on) ACME/OCSP anything.
|
||||||
|
skip_install_trust
|
||||||
|
}
|
||||||
|
|
||||||
|
journal-a.test {
|
||||||
|
reverse_proxy journal-a:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
journal-b.test {
|
||||||
|
reverse_proxy journal-b:3000
|
||||||
|
}
|
||||||
56
e2e/federation/README.md
Normal file
56
e2e/federation/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Two-instance federation harness
|
||||||
|
|
||||||
|
Spec: `openspec/changes/social-federation/` task 11.4.
|
||||||
|
|
||||||
|
Two complete journal instances — `journal-a.test` and `journal-b.test` —
|
||||||
|
federate with each other inside a Docker network, with real HTTPS
|
||||||
|
between them (a Caddy with `local_certs` terminates TLS; the journals
|
||||||
|
trust its internal CA via `NODE_EXTRA_CA_CERTS`). The driver test seeds
|
||||||
|
a user on each side, follows across instances through the real UI
|
||||||
|
route, and asserts the full pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
alice@journal-a ──Follow──▶ bob@journal-b (NodeInfo + WebFinger +
|
||||||
|
◀──Accept── actor fetch over TLS)
|
||||||
|
──signed outbox poll──▶
|
||||||
|
alice's /feed shows bob's public activity with @bob@journal-b.test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./e2e/federation/run.sh # build, test, tear down
|
||||||
|
KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up afterwards
|
||||||
|
```
|
||||||
|
|
||||||
|
First run builds the journal image (a few minutes); later runs reuse it.
|
||||||
|
Opt-in only — nothing here runs under `pnpm test`, `pnpm test:e2e`, or
|
||||||
|
CI. The driver lives at
|
||||||
|
`apps/journal/app/lib/federation-two-instance.integration.test.ts`
|
||||||
|
(gated by `FEDERATION_TWO_INSTANCE=1`).
|
||||||
|
|
||||||
|
## How the driver authenticates
|
||||||
|
|
||||||
|
Sessions are signed cookies (`auth/session.server.ts`). The harness
|
||||||
|
journals run with a known `SESSION_SECRET`, so the driver mints a valid
|
||||||
|
`__session` cookie for the seeded user instead of driving the WebAuthn
|
||||||
|
ceremony. That secret (and everything else in the compose env) is a
|
||||||
|
throwaway test value on a loopback-only stack.
|
||||||
|
|
||||||
|
## Why `FEDERATION_ALLOW_PRIVATE_ADDRESS`
|
||||||
|
|
||||||
|
Fedify's document loader refuses private network addresses (SSRF
|
||||||
|
guard). Both instances here live on RFC 1918 Docker addresses, so the
|
||||||
|
harness sets `FEDERATION_ALLOW_PRIVATE_ADDRESS=true` — the env-gated
|
||||||
|
opt-out in `federation.server.ts`. Never set it in a real deployment.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f e2e/federation/docker-compose.yml logs -f journal-a
|
||||||
|
docker compose -f e2e/federation/docker-compose.yml exec postgres \
|
||||||
|
psql -U trails trails_a -c 'TABLE journal.follows'
|
||||||
|
```
|
||||||
|
|
||||||
|
`FEDERATION_LOG_LEVEL=debug` is already on — Fedify logs every
|
||||||
|
signature verification and delivery attempt.
|
||||||
145
e2e/federation/docker-compose.yml
Normal file
145
e2e/federation/docker-compose.yml
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Two-instance federation harness (social-federation task 11.4).
|
||||||
|
#
|
||||||
|
# Brings up two complete journal instances — journal-a.test and
|
||||||
|
# journal-b.test — that federate with each other over a private Docker
|
||||||
|
# network, with real HTTPS between them (Caddy internal CA; the journals
|
||||||
|
# trust it via NODE_EXTRA_CA_CERTS). Driven by
|
||||||
|
# apps/journal/app/lib/federation-two-instance.integration.test.ts via
|
||||||
|
# ./run.sh — see README.md.
|
||||||
|
#
|
||||||
|
# Ports published to the host (driver-facing, loopback only):
|
||||||
|
# 127.0.0.1:5499 → postgres (schema push + seeding + asserts)
|
||||||
|
# 127.0.0.1:3401 → journal-a :3000 (plain HTTP; the TLS hop is only
|
||||||
|
# 127.0.0.1:3402 → journal-b :3000 needed for instance↔instance)
|
||||||
|
|
||||||
|
name: trails-federation-e2e
|
||||||
|
# Shared journal environment. Secrets here are throwaway test values —
|
||||||
|
# the whole stack is loopback-only and torn down by run.sh.
|
||||||
|
x-journal-env: &journal-env
|
||||||
|
SESSION_SECRET: two-instance-test-secret
|
||||||
|
FEDERATION_ENABLED: "true"
|
||||||
|
FEDERATION_KEY_ENCRYPTION_KEY: two-instance-test-encryption-key
|
||||||
|
FEDERATION_LOG_LEVEL: debug
|
||||||
|
LOG_LEVEL: debug
|
||||||
|
# Both instances live on RFC 1918 Docker addresses; Fedify's SSRF
|
||||||
|
# guard must be told to stand down. Testing only — see the comment in
|
||||||
|
# apps/journal/app/lib/federation.server.ts.
|
||||||
|
FEDERATION_ALLOW_PRIVATE_ADDRESS: "true"
|
||||||
|
# Trust Caddy's internal CA for the instance↔instance HTTPS hop.
|
||||||
|
# Published world-readable by the ca-publisher one-shot — the journal
|
||||||
|
# runs as a non-root user and can't read caddy's 0700 data volume.
|
||||||
|
NODE_EXTRA_CA_CERTS: /ca/root.crt
|
||||||
|
SENTRY_DISABLED: "true"
|
||||||
|
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: imresamu/postgis:16-3.4 # multi-arch, same as docker-compose.dev.yml
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: trails
|
||||||
|
POSTGRES_PASSWORD: trails
|
||||||
|
POSTGRES_DB: trails_a
|
||||||
|
volumes:
|
||||||
|
- ./init-dbs.sql:/docker-entrypoint-initdb.d/90-init-dbs.sql:ro
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5499:5432"
|
||||||
|
healthcheck:
|
||||||
|
# -h 127.0.0.1 forces TCP: during first-boot initdb a temporary
|
||||||
|
# postgres answers on the unix socket only, and a socket-based
|
||||||
|
# pg_isready reports healthy before the init scripts have run.
|
||||||
|
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U trails -d trails_b"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
# The instances reach each other through Caddy: these aliases
|
||||||
|
# make journal-a.test / journal-b.test resolve to the proxy
|
||||||
|
# inside the compose network.
|
||||||
|
aliases:
|
||||||
|
- journal-a.test
|
||||||
|
- journal-b.test
|
||||||
|
healthcheck:
|
||||||
|
# The journals read the internal CA root at process start
|
||||||
|
# (NODE_EXTRA_CA_CERTS) — gate their startup on it existing.
|
||||||
|
test: ["CMD", "test", "-f", "/data/caddy/pki/authorities/local/root.crt"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
# One-shot: copies Caddy's internal root CA into a volume the
|
||||||
|
# non-root journal user can actually read (caddy's own data dir is
|
||||||
|
# 0700 root).
|
||||||
|
ca-publisher:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
entrypoint:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
until [ -f /data/caddy/pki/authorities/local/root.crt ]; do sleep 0.5; done
|
||||||
|
cp /data/caddy/pki/authorities/local/root.crt /ca/root.crt
|
||||||
|
chmod 444 /ca/root.crt
|
||||||
|
volumes:
|
||||||
|
- caddy-data:/data:ro
|
||||||
|
- ca-cert:/ca
|
||||||
|
depends_on:
|
||||||
|
caddy:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
journal-a:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: apps/journal/Dockerfile
|
||||||
|
image: trails-federation-e2e-journal
|
||||||
|
pull_policy: never
|
||||||
|
environment:
|
||||||
|
<<: *journal-env
|
||||||
|
DATABASE_URL: postgres://trails:trails@postgres:5432/trails_a
|
||||||
|
ORIGIN: https://journal-a.test
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3401:3000"
|
||||||
|
volumes:
|
||||||
|
- ca-cert:/ca:ro
|
||||||
|
healthcheck: &journal-health
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/health"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 40
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ca-publisher:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
journal-b:
|
||||||
|
# Image-only on purpose: run.sh builds it once via journal-a before
|
||||||
|
# `up`. Building from both services races the export of the shared
|
||||||
|
# tag ("already exists"), and a registry pull must never happen.
|
||||||
|
image: trails-federation-e2e-journal
|
||||||
|
pull_policy: never
|
||||||
|
environment:
|
||||||
|
<<: *journal-env
|
||||||
|
DATABASE_URL: postgres://trails:trails@postgres:5432/trails_b
|
||||||
|
ORIGIN: https://journal-b.test
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3402:3000"
|
||||||
|
volumes:
|
||||||
|
- ca-cert:/ca:ro
|
||||||
|
healthcheck: *journal-health
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ca-publisher:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy-data:
|
||||||
|
ca-cert:
|
||||||
|
|
||||||
6
e2e/federation/init-dbs.sql
Normal file
6
e2e/federation/init-dbs.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Second instance database. trails_a is the image's POSTGRES_DB (the
|
||||||
|
-- postgis image creates the extension there itself); trails_b needs
|
||||||
|
-- both created by hand. Runs once on first postgres boot.
|
||||||
|
CREATE DATABASE trails_b;
|
||||||
|
\connect trails_b
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
55
e2e/federation/run.sh
Executable file
55
e2e/federation/run.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Two-instance federation harness runner (social-federation 11.4).
|
||||||
|
#
|
||||||
|
# ./e2e/federation/run.sh # build, test, tear down
|
||||||
|
# KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up for poking
|
||||||
|
#
|
||||||
|
# Brings up two journal instances that federate with each other (see
|
||||||
|
# docker-compose.yml), pushes the Drizzle schema into both databases,
|
||||||
|
# and runs the driver test. Opt-in and self-contained — nothing here is
|
||||||
|
# wired into `pnpm test` or CI.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
compose() { docker compose -f "$SCRIPT_DIR/docker-compose.yml" "$@"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "${KEEP_STACK:-}" == "1" ]]; then
|
||||||
|
echo "KEEP_STACK=1 — leaving the stack up. Tear down with:"
|
||||||
|
echo " docker compose -f $SCRIPT_DIR/docker-compose.yml down -v"
|
||||||
|
else
|
||||||
|
compose down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> postgres + caddy (internal CA)"
|
||||||
|
compose up -d --wait postgres caddy
|
||||||
|
|
||||||
|
echo "==> pushing schema into trails_a and trails_b"
|
||||||
|
push_schema() {
|
||||||
|
# A transient postgres backend crash mid-introspection has been seen
|
||||||
|
# under Docker Desktop load ("Connection terminated unexpectedly" +
|
||||||
|
# crash recovery). Retry after the healthcheck reports the server
|
||||||
|
# recovered — without hiding a deterministic failure.
|
||||||
|
local url="$1" attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if DATABASE_URL="$url" pnpm --dir "$ROOT" db:push; then return 0; fi
|
||||||
|
echo "schema push failed (attempt $attempt) — waiting for postgres to recover"
|
||||||
|
sleep 5
|
||||||
|
compose up -d --wait postgres
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
push_schema postgres://trails:trails@127.0.0.1:5499/trails_a
|
||||||
|
push_schema postgres://trails:trails@127.0.0.1:5499/trails_b
|
||||||
|
|
||||||
|
echo "==> building the journal image (slow on first run)"
|
||||||
|
compose build journal-a
|
||||||
|
|
||||||
|
echo "==> journal-a + journal-b"
|
||||||
|
compose up -d --wait journal-a journal-b
|
||||||
|
|
||||||
|
echo "==> driving the federation flow"
|
||||||
|
FEDERATION_TWO_INSTANCE=1 pnpm --dir "$ROOT/apps/journal" exec vitest run app/lib/federation-two-instance.integration.test.ts
|
||||||
|
|
@ -47,6 +47,11 @@ test.describe("Social follows + /feed", () => {
|
||||||
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
|
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("/follows/outgoing redirects anonymous visitors to login", async ({ page }) => {
|
||||||
|
await page.goto("/follows/outgoing");
|
||||||
|
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => {
|
test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => {
|
||||||
// The route was folded into the Notifications page as a tab. The
|
// The route was folded into the Notifications page as a tab. The
|
||||||
// 301 still resolves; for anonymous visitors the notifications
|
// 301 still resolves; for anonymous visitors the notifications
|
||||||
|
|
|
||||||
3
infrastructure/caddy/sites/.gitkeep
Normal file
3
infrastructure/caddy/sites/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Mountpoint for the runtime ./sites overlay (per-PR staging snippets).
|
||||||
|
# Caddy reads site blocks from /etc/caddy/sites; this keeps the dir present
|
||||||
|
# inside the read-only ./caddy bind mount so the overlay can attach.
|
||||||
|
|
@ -60,10 +60,12 @@ services:
|
||||||
WAHOO_CLIENT_ID: ""
|
WAHOO_CLIENT_ID: ""
|
||||||
WAHOO_CLIENT_SECRET: ""
|
WAHOO_CLIENT_SECRET: ""
|
||||||
WAHOO_WEBHOOK_TOKEN: ""
|
WAHOO_WEBHOOK_TOKEN: ""
|
||||||
|
GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-}
|
||||||
|
GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-}
|
||||||
DEMO_BOT_ENABLED: ""
|
DEMO_BOT_ENABLED: ""
|
||||||
# Federation soak (social-federation rollout step 12.2). Enabled by
|
# Federation (social-federation rollout). Enabled by cd-staging.yml
|
||||||
# cd-staging.yml for persistent staging only; PR previews leave both
|
# for persistent staging (12.2) and for PR previews (12.4 — every
|
||||||
# empty (flag off) so preview journals never emit federation traffic.
|
# preview is a second live instance staging can follow across).
|
||||||
FEDERATION_ENABLED: ${FEDERATION_ENABLED:-}
|
FEDERATION_ENABLED: ${FEDERATION_ENABLED:-}
|
||||||
FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-}
|
FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-}
|
||||||
FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-}
|
FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DOMAIN: ${DOMAIN:-trails.cool}
|
DOMAIN: ${DOMAIN:-trails.cool}
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
# Mount the caddy/ DIRECTORY, not the Caddyfile directly. A single-
|
||||||
# Per-PR staging snippets dropped in by cd-staging.yml. Bind mount
|
# file bind mount pins to the host file's inode at create time, so a
|
||||||
# auto-creates the host directory if it doesn't exist, so a fresh
|
# deploy that replaces the file (scp) leaves the container reading the
|
||||||
# server doesn't need pre-provisioning.
|
# old inode — `caddy reload` then reloads stale config. A directory
|
||||||
|
# mount resolves children live, so reload picks up the new Caddyfile.
|
||||||
|
# The container path /etc/caddy/Caddyfile is unchanged.
|
||||||
|
- ./caddy:/etc/caddy:ro
|
||||||
|
# Per-PR staging snippets dropped in by cd-staging.yml, overlaid onto
|
||||||
|
# the read-only caddy dir above (caddy/sites/.gitkeep keeps the
|
||||||
|
# mountpoint present). Bind mount auto-creates the host directory if
|
||||||
|
# it doesn't exist, so a fresh server doesn't need pre-provisioning.
|
||||||
- ./sites:/etc/caddy/sites:ro
|
- ./sites:/etc/caddy/sites:ro
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
- caddy_config:/config
|
- caddy_config:/config
|
||||||
|
|
@ -53,6 +60,19 @@ services:
|
||||||
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-}
|
||||||
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-}
|
||||||
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-}
|
||||||
|
# Garmin Connect Developer Program keys (spec: garmin-import).
|
||||||
|
# Empty = the Garmin provider is hidden on /settings/connections.
|
||||||
|
GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-}
|
||||||
|
GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-}
|
||||||
|
# Federation (ActivityPub, social-federation rollout 12.5). Empty
|
||||||
|
# = off: every federation surface 404s — the safe self-host
|
||||||
|
# default. The flagship turns it on via cd-apps.yml (same pattern
|
||||||
|
# as IS_FLAGSHIP); the encryption key comes from SOPS. Rollback is
|
||||||
|
# dropping the flag and redeploying — instant, follow rows
|
||||||
|
# persist. See docs/deployment.md "Federation runbook".
|
||||||
|
FEDERATION_ENABLED: ${FEDERATION_ENABLED:-}
|
||||||
|
FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-}
|
||||||
|
FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-}
|
||||||
INTEGRATION_SECRET: ${INTEGRATION_SECRET:?INTEGRATION_SECRET must be set in SOPS secrets.app.env}
|
INTEGRATION_SECRET: ${INTEGRATION_SECRET:?INTEGRATION_SECRET must be set in SOPS secrets.app.env}
|
||||||
# Demo-activity-bot. Disabled by default everywhere; flip to "true"
|
# Demo-activity-bot. Disabled by default everywhere; flip to "true"
|
||||||
# only in prod. When unset, DEMO_BOT_PERSONA uses the built-in
|
# only in prod. When unset, DEMO_BOT_PERSONA uses the built-in
|
||||||
|
|
@ -174,8 +194,10 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
- ./promtail/promtail-config.yml:/etc/promtail/config.yml:ro
|
# Directory mount (not the file) so a deploy's replaced config is seen
|
||||||
command: ["-config.file=/etc/promtail/config.yml"]
|
# live and a SIGHUP reload picks it up — see the caddy note above.
|
||||||
|
- ./promtail:/etc/promtail:ro
|
||||||
|
command: ["-config.file=/etc/promtail/promtail-config.yml"]
|
||||||
depends_on:
|
depends_on:
|
||||||
- loki
|
- loki
|
||||||
|
|
||||||
|
|
@ -183,20 +205,26 @@ services:
|
||||||
image: prom/prometheus:latest
|
image: prom/prometheus:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
# Directory mount (not the file) so config changes are seen live and a
|
||||||
|
# SIGHUP reload applies them without recreating the container — see the
|
||||||
|
# caddy note above. --config.file path below is unchanged.
|
||||||
|
- ./prometheus:/etc/prometheus:ro
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
command:
|
command:
|
||||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||||
- "--storage.tsdb.retention.time=15d"
|
- "--storage.tsdb.retention.time=15d"
|
||||||
- "--storage.tsdb.retention.size=1GB"
|
- "--storage.tsdb.retention.size=2GB"
|
||||||
|
|
||||||
loki:
|
loki:
|
||||||
image: grafana/loki:latest
|
image: grafana/loki:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro
|
# Directory mount (not the file) so a replaced config is seen live —
|
||||||
|
# see the caddy note above. Loki only reloads its main config on
|
||||||
|
# restart (no SIGHUP), so cd-infra restarts loki rather than HUPs it.
|
||||||
|
- ./loki:/etc/loki:ro
|
||||||
- loki_data:/loki
|
- loki_data:/loki
|
||||||
command: ["-config.file=/etc/loki/local-config.yaml"]
|
command: ["-config.file=/etc/loki/loki-config.yml"]
|
||||||
# Publish only on the vSwitch IP so Promtail running on the
|
# Publish only on the vSwitch IP so Promtail running on the
|
||||||
# dedicated BRouter host can push logs in. Hetzner Cloud firewall
|
# dedicated BRouter host can push logs in. Hetzner Cloud firewall
|
||||||
# still blocks 3100 from the public internet. Internal services on
|
# still blocks 3100 from the public internet. Internal services on
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue