Merge branch 'main' into dependabot/npm_and_yarn/production-e9efbc881e

This commit is contained in:
Ullrich Schäfer 2026-06-10 01:28:40 +02:00 committed by GitHub
commit 8eeb775f54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
122 changed files with 4617 additions and 190 deletions

View file

@ -49,6 +49,14 @@
# WAHOO_CLIENT_SECRET=
# WAHOO_WEBHOOK_TOKEN=
# Garmin Connect Developer Program credentials (spec: garmin-import).
# Requires an approved program application; without these the Garmin
# provider is hidden on /settings/connections. The OAuth callback to
# register with Garmin is `<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
# test suite that drives the API directly). Generate with
# `openssl rand -hex 32`.

View file

@ -1,6 +1,6 @@
import type { JobDefinition } from "@trails-cool/jobs";
import { and, eq } from "drizzle-orm";
import { activities } from "@trails-cool/db/schema/journal";
import { activities, users } from "@trails-cool/db/schema/journal";
import { getDb } from "../lib/db.ts";
import { getOrigin } from "../lib/config.server.ts";
import { getFederation } from "../lib/federation.server.ts";
@ -77,6 +77,17 @@ async function deliverOne(p: DeliveryPayload): Promise<void> {
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
return;
}
// Spec 9.3: flipping the profile to private stops federation — also
// for deliveries already enqueued when the flip happened.
const [owner] = await db
.select({ profileVisibility: users.profileVisibility })
.from(users)
.where(eq(users.username, p.ownerUsername))
.limit(1);
if (!owner || owner.profileVisibility !== "public") {
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
return;
}
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
} else {
activity = activityToDelete(p.objectIri, p.ownerUsername);

View 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;
}
}
},
};

View file

@ -51,6 +51,9 @@ export async function fanout(activityId: string): Promise<void> {
logger.warn({ activityId }, "fanout: activity not found, skipping");
return;
}
// Remote-ingested rows have no local owner and never fan out locally
// (the users innerJoin already excludes them; this narrows the type).
if (row.ownerId === null) return;
// Defense in depth — the create-side guard already filters this, but
// recheck here so the job doesn't mistakenly fan out a row that was
// later flipped to private/unlisted before the job ran.

View 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");
}
},
};

View 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 };
},
};

View file

@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { eq, desc, and, isNotNull, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { validateGpx, writeGeom } from "./gpx-save.server.ts";
import type { GpxData } from "./gpx-save.server.ts";
@ -195,14 +196,25 @@ export async function listPublicActivitiesForOwner(
}
/**
* Social feed: aggregated public activities from users that `followerId`
* follows (accepted only). Reverse-chronological. Joins users for owner
* attribution. Unlisted/private activities never appear, regardless of
* follow state.
* Social feed (spec: social-federation §8): aggregated activities from
* actors that `followerId` follows with an *accepted* follow local
* users and remote trails actors alike. Reverse-chronological on
* COALESCE(remote_published_at, created_at).
*
* Audience rules:
* - local rows: `visibility = 'public'` only (unlisted/private never
* appear regardless of follow state)
* - remote rows: `audience = 'public'` or `followers-only` the
* latter gated structurally by joining the *viewer's own* accepted
* follow against the originating actor (spec: "Followers-only remote
* content reaches only the right viewer")
* Pending follows contribute nothing (accepted_at IS NOT NULL on both
* branches previously missing on the local branch).
*/
export async function listSocialFeed(followerId: string, limit: number = 50) {
const db = getDb();
const rows = await db
const local = db
.select({
id: activities.id,
name: activities.name,
@ -211,8 +223,12 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
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)
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
@ -220,14 +236,46 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.where(
and(
eq(follows.followerId, followerId),
isNotNull(follows.acceptedAt),
eq(activities.visibility, "public"),
),
)
.orderBy(desc(activities.createdAt))
);
const remote = db
.select({
id: activities.id,
name: activities.name,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
sortTime: sql<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);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}

View file

@ -168,6 +168,18 @@ export async function markNeedsRelink(
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`);
}
// Provider-side revocation (e.g. a Garmin deregistration notification):
// keep the row for audit, flip to 'revoked' so every subsequent
// withFreshCredentials short-circuits and the UI shows a re-connect
// prompt. Imported activities are untouched.
export async function markRevoked(serviceId: string): Promise<void> {
const db = getDb();
await db
.update(connectedServices)
.set({ status: "revoked" })
.where(eq(connectedServices.id, serviceId));
}
export async function updateGrantedScopes(
serviceId: string,
grantedScopes: string[],

View file

@ -21,3 +21,39 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState
return {};
}
}
// --- PKCE (RFC 7636) ----------------------------------------------------
//
// Providers with `pkce: true` (Garmin) need a code verifier that survives
// the connect → provider → callback redirect without ever appearing in a
// URL (the whole point of PKCE is that the verifier stays out of the
// authorization response). The `state` param is visible in redirects, so
// the verifier rides a short-lived httpOnly cookie scoped to the callback
// path instead.
import { createHash, randomBytes } from "node:crypto";
const PKCE_COOKIE = "__oauth_pkce";
const PKCE_MAX_AGE_S = 600;
export function generatePkcePair(): { verifier: string; challenge: string } {
// 32 random bytes → 43-char base64url verifier (within RFC 7636's 43128).
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;
}

View 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({});
});
});

View file

@ -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,
});
});
});

View file

@ -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 };
}

View file

@ -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";

View file

@ -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",
);
}

View file

@ -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");
});
});

View file

@ -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,
};

View file

@ -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);
});
});

View file

@ -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>";
}
}

View file

@ -5,9 +5,11 @@
import { registerManifest } from "../registry.ts";
import { wahooManifest } from "./wahoo/manifest.ts";
import { komootManifest } from "./komoot/manifest.ts";
import { garminManifest } from "./garmin/manifest.ts";
registerManifest(wahooManifest);
registerManifest(komootManifest);
registerManifest(garminManifest);
// Re-export so callers (mostly tests) can grab a manifest directly.
export { wahooManifest, komootManifest };
export { wahooManifest, komootManifest, garminManifest };

View file

@ -1,6 +1,6 @@
// Contract tests for the Wahoo WebhookReceiver capability adapter.
//
// Seam: parseWebhook(body) -> WebhookEvent | null
// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable)
// handle(event) -> void (creates an activity if file present, dedups via sync_imports)
import { describe, it, expect, beforeEach, vi } from "vitest";
@ -41,22 +41,24 @@ describe("wahooWebhook.parseWebhook", () => {
file: { url: "https://cdn.example/42.fit" },
},
});
expect(event).toEqual({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
fileUrl: "https://cdn.example/42.fit",
});
expect(event).toEqual([
{
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
fileUrl: "https://cdn.example/42.fit",
},
]);
});
it("returns null for unrecognized event types", () => {
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull();
it("returns no events for unrecognized event types", () => {
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]);
});
it("returns null when user.id is missing", () => {
it("returns no events when user.id is missing", () => {
expect(
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
).toBeNull();
).toEqual([]);
});
});

View file

@ -23,18 +23,20 @@ interface WahooWebhookBody {
export const wahooWebhook: WebhookReceiver = {
parseWebhook(body: unknown): WebhookEvent | null {
parseWebhook(body: unknown): WebhookEvent[] {
const payload = body as WahooWebhookBody;
if (payload.event_type !== "workout_summary" || !payload.user?.id) return null;
if (payload.event_type !== "workout_summary" || !payload.user?.id) return [];
return {
eventType: payload.event_type,
providerUserId: String(payload.user.id),
workoutId: String(
payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "",
),
fileUrl: payload.workout_summary?.file?.url,
};
return [
{
eventType: payload.event_type,
providerUserId: String(payload.user.id),
workoutId: String(
payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "",
),
fileUrl: payload.workout_summary?.file?.url,
},
];
},
async handle(event: WebhookEvent): Promise<void> {

View file

@ -59,6 +59,15 @@ export interface WebhookEvent {
providerUserId: string;
workoutId: string;
fileUrl?: string;
// Optional summary stats carried by providers whose notifications
// include them (Garmin pushes summaries; a FIT-less activity is still
// importable stats-only). Providers without summaries leave these out.
name?: string;
startedAt?: string;
duration?: number | null;
distance?: number | null;
// File format behind fileUrl when the provider says (FIT | GPX | TCX).
fileType?: string;
}
// CapabilityContext gives capability adapters the tools they need without
@ -81,7 +90,10 @@ export interface RoutePusher {
}
export interface WebhookReceiver {
parseWebhook(body: unknown): WebhookEvent | null;
// One provider POST can carry many events (Garmin batches
// notifications). Single-event providers return a one-element array;
// an empty array means "nothing actionable" and the route 200s.
parseWebhook(body: unknown): WebhookEvent[];
handle(event: WebhookEvent): Promise<void>;
}
@ -99,13 +111,30 @@ export interface ProviderManifest {
// Custom connect page URL. When set, the connections settings page links
// here instead of the default OAuth connect endpoint.
connectUrl?: string;
// Custom import page URL. When set, the connections settings page links
// here instead of the generic /sync/import/<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).
buildAuthUrl?: (redirectUri: string, state: string) => string;
buildAuthUrl?: (
redirectUri: string,
state: string,
extras?: { codeChallenge?: string },
) => string;
// OAuth code exchange (for the callback). Returns the credential blob to
// store and the granted scopes.
exchangeCode?: (
code: string,
redirectUri: string,
extras?: { codeVerifier?: string },
) => Promise<{
credentials: unknown;
providerUserId: string | null;

View file

@ -124,10 +124,11 @@ export async function getCachedRemoteActor(actorIri: string): Promise<CachedRemo
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: {
actorIri: string;
inboxUrl?: string | null;
outboxUrl?: string | null;
displayName?: string | null;
username?: string | null;
domain?: string | null;

View 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);
});
});

View 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)",
});
});
});

View 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);
}

View 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");
});
});

View 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);
});
});

View 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,
}));
}

View 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);
});
});

View file

@ -97,6 +97,20 @@ async function findLocalPublicUserByIri(iri: URL | null): Promise<UserRow | 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 _logtapeConfigured = false;
@ -146,6 +160,15 @@ function buildFederation(): Federation<void> {
// Canonical origin so generated IRIs are correct behind the Caddy
// proxy (the Node server itself only sees plain HTTP).
origin: getOrigin(),
// SSRF-guard opt-out for the two-instance federation harness
// (e2e/federation/), where both instances live on a private Docker
// network that Fedify's document loader rightly refuses to fetch
// from. Testing only — never set this in a real deployment: the
// private-address block is a genuine security boundary (a malicious
// actor IRI must not be able to point us at internal targets).
...(process.env.FEDERATION_ALLOW_PRIVATE_ADDRESS === "true"
? { allowPrivateAddress: true }
: {}),
});
if (!process.env.VITEST) {
// Fire-and-forget: runs the queue consumer loop for the process
@ -261,25 +284,66 @@ function buildFederation(): Federation<void> {
.on(Undo, async (ctx, undo) => {
// Spec 4.3: Undo(Follow) removes the follow row. Other Undos are
// acknowledged and dropped.
if (undo.actorId == null) return;
const undoObjectId = undo.objectId; // capture before dereference (see Accept)
const object = await undo.getObject(ctx);
if (!(object instanceof Follow)) return;
if (undo.actorId == null || object.objectId == null) return;
const parsed = ctx.parseUri(object.objectId);
if (parsed?.type !== "actor") return;
await removeRemoteFollow(undo.actorId.href, parsed.identifier);
if (object instanceof Follow && object.objectId != null) {
const parsed = ctx.parseUri(object.objectId);
if (parsed?.type !== "actor") return;
await removeRemoteFollow(undo.actorId.href, parsed.identifier);
return;
}
// trails→trails fallback: another trails instance's Undo embeds
// a Follow whose id lives on the SENDER's domain, but the embed
// is cross-origin relative to nothing we can verify, and
// dereferencing `…#follows/<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) => {
// Spec 4.4: a remote accepted our outgoing Follow — settle the
// Pending row and trigger the first outbox poll for that actor.
const object = await accept.getObject(ctx);
if (!(object instanceof Follow)) return;
if (accept.actorId == null) return;
const localUser = await findLocalPublicUserByIri(object.actorId);
// Capture the raw object reference BEFORE dereferencing:
// getObject() memoizes the fetched document, after which objectId
// reports the fetched object's id (fragment stripped) instead of
// the id that was on the wire.
const objectId = accept.objectId;
const object = await accept.getObject(ctx);
logger.debug(
{
recipient: ctx.recipient,
objectId: objectId?.href ?? null,
objectType: object?.constructor?.name ?? null,
},
"federation: Accept listener dispatch",
);
let localUser: Awaited<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;
const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href);
if (settled) {
// Queue worker lands in the outbox-poll change (task 7.x);
// enqueueOptional logs-and-continues until then.
await enqueueOptional("poll-remote-actor", { actorIri: accept.actorId.href }, {
reason: "first poll after accepted follow",
});
@ -287,10 +351,16 @@ function buildFederation(): Federation<void> {
})
.on(Reject, async (ctx, reject) => {
// Spec 4.5: remote refused our Follow — drop the Pending row.
const object = await reject.getObject(ctx);
if (!(object instanceof Follow)) return;
if (reject.actorId == null) return;
const localUser = await findLocalPublicUserByIri(object.actorId);
const objectId = reject.objectId; // capture before dereference (see Accept)
const object = await reject.getObject(ctx);
let localUser: Awaited<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;
await rejectOutgoingFollow(localUser.id, reject.actorId.href);
})
@ -311,7 +381,10 @@ function buildFederation(): Federation<void> {
.from(activities)
.where(and(eq(activities.id, values.id), eq(activities.visibility, "public")))
.limit(1);
if (!row) return null;
// Remote-ingested activities are not our objects — only locally
// authored rows dereference here (their canonical IRI is on the
// origin instance).
if (!row || row.ownerId === null) return null;
const [owner] = await db
.select({ username: users.username, profileVisibility: users.profileVisibility })
.from(users)

View file

@ -7,7 +7,16 @@ import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
export class FollowError extends Error {
readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden";
readonly code:
| "self_follow"
| "user_not_found"
| "not_found"
| "forbidden"
// outbound federation codes (federation-outbound.server.ts)
| "invalid_handle"
| "local_handle"
| "remote_resolve_failed"
| "not_trails";
constructor(code: FollowError["code"], message: string) {
super(message);
this.name = "FollowError";

View file

@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19";
* require re-acceptance (the policy is informational, not contract), so this
* is display-only not persisted.
*/
export const PRIVACY_LAST_UPDATED = "2026-04-20";
export const PRIVACY_LAST_UPDATED = "2026-06-07";

View 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"));
});
});

View file

@ -51,9 +51,23 @@ export async function importActivity(
userId: string,
provider: string,
externalWorkoutId: string,
input: { name: string; gpx?: string },
input: {
name: string;
gpx?: string;
// Stats-only imports (no GPS file — e.g. Garmin notifications for
// FIT-less activities) carry whatever the provider's summary had.
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
},
): Promise<{ activityId: string }> {
const activityId = await createActivity(userId, { name: input.name, gpx: input.gpx });
const activityId = await createActivity(userId, {
name: input.name,
gpx: input.gpx,
distance: input.distance,
duration: input.duration,
startedAt: input.startedAt,
});
await recordImport(userId, provider, externalWorkoutId, activityId);
return { activityId };
}

View file

@ -36,6 +36,7 @@ export default [
route("api/users/:username/follow", "routes/api.users.$username.follow.ts"),
route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"),
route("follows/requests", "routes/follows.requests.tsx"),
route("follows/outgoing", "routes/follows.outgoing.tsx"),
route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"),
route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"),
route("feed", "routes/feed.tsx"),
@ -57,6 +58,7 @@ export default [
route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"),
route("api/settings/delete-account", "routes/api.settings.delete-account.ts"),
route("sync/import/komoot", "routes/sync.import.komoot.tsx"),
route("sync/import/garmin", "routes/sync.import.garmin.tsx"),
route("sync/import/:provider", "routes/sync.import.$provider.tsx"),
route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"),
route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"),

View file

@ -17,8 +17,12 @@ import type { Visibility } from "@trails-cool/db/schema/journal";
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
export async function loadActivityDetail(request: Request, id: string | undefined) {
const activity = await getActivity(id ?? "");
if (!activity) throw data({ error: "Activity not found" }, { status: 404 });
const fetched = await getActivity(id ?? "");
if (!fetched) throw data({ error: "Activity not found" }, { status: 404 });
// Remote-ingested activities have no local detail page — their
// canonical page lives on the origin instance; the feed links there.
if (fetched.ownerId === null) throw data({ error: "Activity not found" }, { status: 404 });
const activity = { ...fetched, ownerId: fetched.ownerId };
const user = await getSessionUser(request);
const isOwner = user?.id === activity.ownerId;

View file

@ -5,6 +5,8 @@ import { requireSessionUser } from "~/lib/auth/session.server";
import { getManifest, link } from "~/lib/connected-services";
import {
decodeOAuthState,
readPkceVerifier,
clearPkceCookieHeader,
} from "~/lib/connected-services/oauth-state.server";
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
@ -32,8 +34,18 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const origin = getOrigin();
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
// PKCE providers: recover the verifier from the connect-time cookie.
const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null;
if (manifest.pkce && !codeVerifier) {
return redirect(`${fallbackReturn}?error=sync_failed`);
}
try {
const exchange = await manifest.exchangeCode(code, redirectUri);
const exchange = await manifest.exchangeCode(
code,
redirectUri,
codeVerifier ? { codeVerifier } : undefined,
);
await link({
userId: user.id,
provider: manifest.id,
@ -65,5 +77,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
return redirect(`${target}?push=${outcome.status}`);
}
return redirect(state.returnTo ?? "/settings");
return redirect(state.returnTo ?? "/settings", {
// Spent verifier — clear it regardless of which provider this was
// (harmless no-op for non-PKCE providers without the cookie).
headers: { "Set-Cookie": clearPkceCookieHeader() },
});
}

View file

@ -3,7 +3,11 @@ import { getOrigin } from "~/lib/config.server";
import type { Route } from "./+types/api.sync.connect.$provider";
import { requireSessionUser } from "~/lib/auth/session.server";
import { getManifest } from "~/lib/connected-services";
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
import {
encodeOAuthState,
generatePkcePair,
pkceCookieHeader,
} from "~/lib/connected-services/oauth-state.server";
export async function loader({ params, request }: Route.LoaderArgs) {
await requireSessionUser(request);
@ -17,5 +21,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
const state = encodeOAuthState({ returnTo: "/settings/connections" });
// PKCE providers (Garmin): the verifier crosses the redirect in an
// httpOnly cookie; only the S256 challenge goes to the provider.
if (manifest.pkce) {
const { verifier, challenge } = generatePkcePair();
return redirect(
manifest.buildAuthUrl(redirectUri, state, { codeChallenge: challenge }),
{ headers: { "Set-Cookie": pkceCookieHeader(verifier) } },
);
}
return redirect(manifest.buildAuthUrl(redirectUri, state));
}

View file

@ -30,7 +30,7 @@ describe("POST /api/sync/webhook/:provider", () => {
beforeEach(() => {
parseWebhook.mockReset();
handle.mockReset();
parseWebhook.mockReturnValue(null);
parseWebhook.mockReturnValue([]);
vi.unstubAllEnvs();
});
@ -83,7 +83,7 @@ describe("POST /api/sync/webhook/:provider", () => {
});
it("invokes parseWebhook with a valid object body", async () => {
parseWebhook.mockReturnValue({ eventType: "x", providerUserId: "1", workoutId: "9" });
parseWebhook.mockReturnValue([{ eventType: "x", providerUserId: "1", workoutId: "9" }]);
handle.mockResolvedValue(undefined);
const res = await call({ event_type: "x" });
expect(statusOf(res)).toBe(200);

View file

@ -37,13 +37,13 @@ export async function action({ params, request }: Route.ActionArgs) {
return data({ ok: true });
}
const event = manifest.webhookReceiver.parseWebhook(body);
if (!event) return data({ ok: true });
try {
await manifest.webhookReceiver.handle(event);
} catch (e) {
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
const events = manifest.webhookReceiver.parseWebhook(body);
for (const event of events) {
try {
await manifest.webhookReceiver.handle(event);
} catch (e) {
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
}
}
return data({ ok: true });

View file

@ -15,5 +15,9 @@ export function loader() {
apiVersion: API_VERSION,
instanceName: domain,
apiBaseUrl: `${origin}/api/v1`,
// Software identity for trails-to-trails federation discovery
// (social-federation 6.2). NodeInfo (/nodeinfo/2.1) is the primary
// check; this is the trails-specific secondary signal.
software: "trails-cool",
});
}

View file

@ -31,6 +31,11 @@ export async function loadFeed(request: Request) {
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
// Remote (federated) attribution — only the followed view can
// contain remote rows; the public view is local-only.
ownerDomain: "ownerDomain" in a ? a.ownerDomain : null,
externalUrl: "externalUrl" in a ? a.externalUrl : null,
remote: "remote" in a ? a.remote : false,
})),
};
}

View file

@ -81,13 +81,25 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
{t("social.feed.seePublic")}
</Link>
)}
{view === "followed" && (
<Link
to="/follows/outgoing"
className="mt-1 block text-sm text-blue-600 hover:underline"
>
{t("social.feed.followRemote")}
</Link>
)}
</div>
) : (
<ul className="mt-6 space-y-4">
{activities.map((a) => (
<li key={a.id}>
<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"
>
<div className="flex gap-4">
@ -104,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
<div>
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
<div className="mt-1 text-sm text-gray-500">
<a
href={`/users/${a.ownerUsername}`}
className="hover:text-gray-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.ownerDisplayName ?? a.ownerUsername}
</a>
{a.remote ? (
<span>
{a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain}
{a.ownerUsername && a.ownerDomain && (
<span className="text-gray-400"> @{a.ownerUsername}@{a.ownerDomain}</span>
)}
</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} />
</div>

View 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>
);
}

View file

@ -260,6 +260,22 @@ export default function PrivacyPage() {
, der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist
geplant.
</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.&nbsp;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>
<strong>BRouter</strong> Routenberechnung läuft auf einer von uns
selbst gehosteten Instanz. Keine Weitergabe an Dritte.
@ -293,6 +309,21 @@ export default function PrivacyPage() {
importieren. Die Verbindung kann jederzeit in den Einstellungen
getrennt werden.
</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.&nbsp;ä.) 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>
<strong>Hosting</strong> Die Dienste werden in Rechenzentren
innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit
@ -311,7 +342,13 @@ export default function PrivacyPage() {
Wahoo&rdquo; on a route sent so the route appears on your
ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode
stores your Komoot user ID only; authenticated mode stores your
encrypted Komoot password); hosting provider in the EU under a DPA.
encrypted Komoot password); Garmin (only when you opt in: OAuth
tokens and your Garmin user ID; Garmin then pushes new activities
including GPS files to this instance automatically, and older
activities only when you request a date range activities only,
never health metrics; revoking at Garmin or disconnecting here
stops the flow immediately, imported activities stay yours);
hosting provider in the EU under a DPA.
</p>
</section>
@ -452,6 +489,53 @@ export default function PrivacyPage() {
</ul>
</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">
<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">

View file

@ -21,16 +21,21 @@ export async function loadConnectionsSettings(request: Request) {
.from(connectedServices)
.where(eq(connectedServices.userId, user.id));
const providers = getAllManifests().map((m) => {
const conn = connections.find((c) => c.provider === m.id);
return {
id: m.id,
name: m.displayName,
connected: !!conn,
providerUserId: conn?.providerUserId,
connectUrl: m.connectUrl ?? null,
};
});
const providers = getAllManifests()
// Providers can hide themselves when the instance lacks their API
// credentials (Garmin: program keys are per-operator).
.filter((m) => m.configured?.() ?? true)
.map((m) => {
const conn = connections.find((c) => c.provider === m.id);
return {
id: m.id,
name: m.displayName,
connected: !!conn,
providerUserId: conn?.providerUserId,
connectUrl: m.connectUrl ?? null,
importUrl: m.importUrl ?? null,
};
});
return { providers };
}

View file

@ -52,7 +52,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps
{p.connected ? (
<div className="flex items-center gap-3">
<a
href={`/sync/import/${p.id}`}
href={p.importUrl ?? `/sync/import/${p.id}`}
className="text-sm text-blue-600 hover:underline"
>
{t("sync.import")}

View 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" };
}
}

View 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>
);
}

View file

@ -177,13 +177,16 @@ server.listen(port, async () => {
const { importBatchesSweepJob } = await import("./app/jobs/import-batches-sweep.ts");
const { sendWelcomeEmailJob } = await import("./app/jobs/send-welcome-email.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.
if (process.env.FEDERATION_ENABLED === "true") {
const { backfillUserKeypairsJob } = await import("./app/jobs/backfill-user-keypairs.ts");
const { federationKvSweepJob } = await import("./app/jobs/federation-kv-sweep.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());