From 62b40a25f35965ab40bde850fadadee4410102f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 11:50:35 +0200 Subject: [PATCH 01/22] =?UTF-8?q?feat(journal):=20outbound=20trails-to-tra?= =?UTF-8?q?ils=20follows=20(social-federation=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 6.1–6.6. A local user can follow a user on another trails instance: WebFinger-resolve the handle, verify the target runs trails.cool via NodeInfo — checked against the ACTOR IRI's host, never the handle's domain (split-domain constraint) — record a Pending follow row, deliver a signed Follow. The §4 inbox listeners already settle (Accept) or drop (Reject) the row. - federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow (Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup, NodeInfo, delivery) injectable for offline integration tests. Software allowlist: trails-cool. - /follows/outgoing: follow-by-handle form + pending/accepted list + cancel; linked from the feed empty state; i18n en+de. Remote actors have no local profile page, so the remote Pending state lives here (the profile Pending button from locked accounts covers local). - /.well-known/trails-cool now publishes software: trails-cool (6.2). - Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed, not_trails (6.3). - Tests: handle-parser + allowlist units; integration suite for the Pending lifecycle (create/idempotent/refuse-non-trails/refuse- unresolvable/own-host/cancel+Undo) with injected network deps; e2e anonymous-redirect guard for the new page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../federation-outbound.integration.test.ts | 155 +++++++++ .../lib/federation-outbound.server.test.ts | 40 +++ .../app/lib/federation-outbound.server.ts | 300 ++++++++++++++++++ apps/journal/app/lib/follow.server.ts | 11 +- apps/journal/app/routes.ts | 1 + .../app/routes/api.well-known.trails-cool.ts | 4 + apps/journal/app/routes/feed.tsx | 8 + apps/journal/app/routes/follows.outgoing.tsx | 144 +++++++++ e2e/social.test.ts | 5 + openspec/changes/social-federation/tasks.md | 14 +- packages/i18n/src/locales/de.ts | 14 + packages/i18n/src/locales/en.ts | 14 + 12 files changed, 703 insertions(+), 7 deletions(-) create mode 100644 apps/journal/app/lib/federation-outbound.integration.test.ts create mode 100644 apps/journal/app/lib/federation-outbound.server.test.ts create mode 100644 apps/journal/app/lib/federation-outbound.server.ts create mode 100644 apps/journal/app/routes/follows.outgoing.tsx diff --git a/apps/journal/app/lib/federation-outbound.integration.test.ts b/apps/journal/app/lib/federation-outbound.integration.test.ts new file mode 100644 index 0000000..dc65e55 --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.integration.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { + followRemoteActor, + cancelRemoteFollow, + listOutgoingRemoteFollows, + type OutboundFollowDeps, + type ResolvedRemoteActor, +} from "./federation-outbound.server.ts"; +import { FollowError } from "./follow.server.ts"; + +// Opt-in: real Postgres; network steps injected so no instance is +// contacted. Same convention as the other *.integration.test.ts files. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const REMOTE = "https://other-trails.example/users/alice"; +const createdUserIds: string[] = []; + +async function makeUser(username: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `${username}@example.test`, + username, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +function fakeDeps(opts?: { + software?: string; + resolveTo?: ResolvedRemoteActor | null; +}): OutboundFollowDeps & { delivered: string[]; undone: string[] } { + const delivered: string[] = []; + const undone: string[] = []; + return { + delivered, + undone, + async resolveActor() { + return opts?.resolveTo !== undefined + ? opts.resolveTo + : { iri: REMOTE, inboxUrl: `${REMOTE}/inbox`, username: "alice", displayName: "Alice" }; + }, + async fetchSoftwareName() { + return opts?.software ?? "trails-cool"; + }, + async deliverFollow(_u, followId) { + delivered.push(followId); + }, + async deliverUndoFollow(_u, followId) { + undone.push(followId); + }, + }; +} + +describe.runIf(runIntegration)("outbound trails-to-trails follows (integration)", () => { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + const db = getDb(); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + await db.delete(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + }); + + it("creates a Pending row, caches the actor, and delivers Follow", async () => { + const userId = await makeUser(`out-${Date.now()}`); + const deps = fakeDeps(); + + const state = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(state).toEqual({ pending: true, actorIri: REMOTE }); + expect(deps.delivered).toHaveLength(1); + + const db = getDb(); + const [row] = await db.select().from(follows).where(eq(follows.followerId, userId)); + expect(row!.followedActorIri).toBe(REMOTE); + expect(row!.followedUserId).toBeNull(); + expect(row!.acceptedAt).toBeNull(); + + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, REMOTE)); + expect(cached!.inboxUrl).toBe(`${REMOTE}/inbox`); + expect(cached!.displayName).toBe("Alice"); + }); + + it("is idempotent — re-following returns the existing state without re-delivering", async () => { + const userId = await makeUser(`out-idem-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + const second = await followRemoteActor(userId, "@alice@other-trails.example", deps); + expect(second.pending).toBe(true); + expect(deps.delivered).toHaveLength(1); + }); + + it("refuses non-trails instances with a clear code and writes nothing", async () => { + const userId = await makeUser(`out-mast-${Date.now()}`); + const deps = fakeDeps({ software: "mastodon" }); + await expect(followRemoteActor(userId, "@alice@other-trails.example", deps)).rejects.toMatchObject({ + code: "not_trails", + }); + const db = getDb(); + expect(await db.select().from(follows).where(eq(follows.followerId, userId))).toHaveLength(0); + }); + + it("refuses unresolvable handles and local handles", async () => { + const userId = await makeUser(`out-bad-${Date.now()}`); + await expect( + followRemoteActor(userId, "@ghost@other-trails.example", fakeDeps({ resolveTo: null })), + ).rejects.toMatchObject({ code: "remote_resolve_failed" }); + await expect(followRemoteActor(userId, "not a handle", fakeDeps())).rejects.toMatchObject({ + code: "invalid_handle", + }); + // Own-host check needs a parseable host (the default localhost:3000 + // fails the TLD shape first) — point ORIGIN at a real-looking domain. + const prevOrigin = process.env.ORIGIN; + process.env.ORIGIN = "https://own-instance.example"; + try { + await expect( + followRemoteActor(userId, "@someone@own-instance.example", fakeDeps()), + ).rejects.toMatchObject({ code: "local_handle" }); + } finally { + process.env.ORIGIN = prevOrigin; + } + }); + + it("lists outgoing follows and cancel removes the row + delivers Undo", async () => { + const userId = await makeUser(`out-cancel-${Date.now()}`); + const deps = fakeDeps(); + await followRemoteActor(userId, "@alice@other-trails.example", deps); + + const entries = await listOutgoingRemoteFollows(userId); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ actorIri: REMOTE, pending: true, username: "alice" }); + + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + expect(await listOutgoingRemoteFollows(userId)).toHaveLength(0); + // Idempotent second cancel + await cancelRemoteFollow(userId, REMOTE, deps); + expect(deps.undone).toHaveLength(1); + }); + + it("FollowError codes are exported for the route layer", () => { + expect(new FollowError("not_trails", "x").code).toBe("not_trails"); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.test.ts b/apps/journal/app/lib/federation-outbound.server.test.ts new file mode 100644 index 0000000..63201bd --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { parseRemoteHandle, isTrailsSoftware } = await import("./federation-outbound.server.ts"); + +describe("parseRemoteHandle", () => { + it("accepts the common handle spellings", () => { + expect(parseRemoteHandle("@alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("acct:alice@other.example")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle(" @alice@other.example ")).toEqual({ username: "alice", host: "other.example" }); + expect(parseRemoteHandle("@a_l-i.ce@sub.other.example")).toEqual({ username: "a_l-i.ce", host: "sub.other.example" }); + }); + + it("lowercases hosts but preserves username case", () => { + expect(parseRemoteHandle("@Alice@Other.Example")).toEqual({ username: "Alice", host: "other.example" }); + }); + + it("accepts a port (dev instances)", () => { + expect(parseRemoteHandle("@bob@localhost:3000")).toBeNull(); // bare hostname without TLD is rejected + expect(parseRemoteHandle("@bob@staging.trails.cool:3000")).toEqual({ username: "bob", host: "staging.trails.cool:3000" }); + }); + + it("rejects everything else", () => { + expect(parseRemoteHandle("alice")).toBeNull(); + expect(parseRemoteHandle("https://other.example/users/alice")).toBeNull(); + expect(parseRemoteHandle("@@broken@host.example")).toBeNull(); + expect(parseRemoteHandle("")).toBeNull(); + }); +}); + +describe("isTrailsSoftware", () => { + it("accepts trails-cool and nothing else", () => { + expect(isTrailsSoftware("trails-cool")).toBe(true); + expect(isTrailsSoftware("mastodon")).toBe(false); + expect(isTrailsSoftware("hollo")).toBe(false); + expect(isTrailsSoftware(undefined)).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/federation-outbound.server.ts b/apps/journal/app/lib/federation-outbound.server.ts new file mode 100644 index 0000000..983129b --- /dev/null +++ b/apps/journal/app/lib/federation-outbound.server.ts @@ -0,0 +1,300 @@ +// Outbound trails-to-trails follows (spec: social-federation §6). +// +// A local user follows a user on another *trails* instance: resolve the +// handle via WebFinger, verify the target instance runs trails.cool via +// NodeInfo (checked against the ACTOR IRI's host, never the handle's +// domain — remote instances may run split domains, see +// docs/ideas/split-domain-handles.md), record a Pending follow row, and +// deliver a signed Follow. The remote's Accept(Follow) settles the row +// (inbox listener from §4); Reject deletes it. +// +// Network-touching steps (actor lookup, NodeInfo fetch, delivery) are +// injectable so the row lifecycle is integration-testable offline. + +import { randomUUID } from "node:crypto"; +import { getNodeInfo } from "@fedify/fedify"; +import { Follow, Undo } from "@fedify/fedify/vocab"; +import { and, eq, isNull, isNotNull, desc } from "drizzle-orm"; +import { users, follows, remoteActors } from "@trails-cool/db/schema/journal"; +import { getDb } from "./db.ts"; +import { getOrigin } from "./config.server.ts"; +import { localActorIri } from "./actor-iri.ts"; +import { getFederation } from "./federation.server.ts"; +import { upsertRemoteActor } from "./federation-delivery.server.ts"; +import { FollowError } from "./follow.server.ts"; +import { logger } from "./logger.server.ts"; + +/** + * Software names accepted by the trails-to-trails check. Forks that + * keep the NodeInfo name federate out of the box; rebrands need a PR + * here (see the open question in social-federation design.md; the + * Wanderer-interop idea would extend this list). + */ +const TRAILS_SOFTWARE = new Set(["trails-cool"]); + +export interface RemoteHandle { + username: string; + host: string; +} + +/** + * Parse `@user@host`, `user@host`, or an `acct:` URI into its parts. + * Returns null for anything else (including bare usernames and URLs — + * follows by URL can come later; handles are the user-facing shape). + */ +export function parseRemoteHandle(input: string): RemoteHandle | null { + const trimmed = input.trim().replace(/^acct:/, "").replace(/^@/, ""); + const match = /^([a-z0-9_.-]+)@([a-z0-9.-]+\.[a-z]{2,}(?::\d+)?)$/i.exec(trimmed); + if (!match) return null; + return { username: match[1]!, host: match[2]!.toLowerCase() }; +} + +export function isTrailsSoftware(name: string | undefined): boolean { + return name !== undefined && TRAILS_SOFTWARE.has(name); +} + +export interface ResolvedRemoteActor { + iri: string; + inboxUrl: string; + username: string | null; + displayName: string | null; +} + +export interface OutboundFollowDeps { + resolveActor(handle: RemoteHandle): Promise; + fetchSoftwareName(actorIri: string): Promise; + deliverFollow(followerUsername: string, followId: string, target: ResolvedRemoteActor): Promise; + deliverUndoFollow(followerUsername: string, followId: string, target: { iri: string; inboxUrl: string }): Promise; +} + +function followActivityId(followerUsername: string, followId: string): URL { + return new URL(`${localActorIri(followerUsername)}#follows/${followId}`); +} + +function defaultDeps(): OutboundFollowDeps { + const federation = getFederation(); + const ctx = () => federation.createContext(new URL(getOrigin()), undefined); + return { + async resolveActor(handle) { + const actor = await ctx().lookupObject(`@${handle.username}@${handle.host}`); + if (actor == null || !("inboxId" in actor) || actor.id == null) return null; + const inbox = actor.inboxId as URL | null; + if (inbox == null) return null; + return { + iri: actor.id.href, + inboxUrl: inbox.href, + username: ("preferredUsername" in actor ? (actor.preferredUsername as unknown as string | null) : null) ?? handle.username, + displayName: ("name" in actor ? (actor.name as unknown as string | null) : null), + }; + }, + async fetchSoftwareName(actorIri) { + // Split-domain constraint: NodeInfo is looked up on the ACTOR + // IRI's origin (the server domain), never the handle's domain. + const info = await getNodeInfo(new URL(actorIri).origin); + return info?.software?.name; + }, + async deliverFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + ); + }, + async deliverUndoFollow(followerUsername, followId, target) { + await ctx().sendActivity( + { identifier: followerUsername }, + { id: new URL(target.iri), inboxId: new URL(target.inboxUrl) }, + new Undo({ + id: new URL(`${localActorIri(followerUsername)}#follows/${followId}/undo`), + actor: new URL(localActorIri(followerUsername)), + object: new Follow({ + id: followActivityId(followerUsername, followId), + actor: new URL(localActorIri(followerUsername)), + object: new URL(target.iri), + }), + }), + ); + }, + }; +} + +export interface OutgoingFollowState { + pending: boolean; + actorIri: string; +} + +/** + * Follow a user on another trails instance (spec 6.1/6.4). Pending + * until their Accept(Follow) lands in our inbox. Idempotent: an + * existing row (pending or accepted) is returned unchanged. + */ +export async function followRemoteActor( + followerId: string, + handleInput: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const handle = parseRemoteHandle(handleInput); + if (!handle) { + throw new FollowError("invalid_handle", "Enter a handle like @user@instance.example"); + } + const ownHost = new URL(getOrigin()).host; + if (handle.host === ownHost) { + throw new FollowError("local_handle", "That user is on this instance — follow them from their profile"); + } + + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const actor = await deps.resolveActor(handle); + if (!actor) { + throw new FollowError("remote_resolve_failed", "Couldn't find that user — check the handle"); + } + + // Trails-to-trails gate (spec 6.1/6.3). + let software: string | undefined; + try { + software = await deps.fetchSoftwareName(actor.iri); + } catch { + software = undefined; + } + if (!isTrailsSoftware(software)) { + throw new FollowError( + "not_trails", + "Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool", + ); + } + + // Idempotent re-follow. + const [existing] = await db + .select({ id: follows.id, acceptedAt: follows.acceptedAt }) + .from(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followedActorIri, actor.iri))); + if (existing) { + return { pending: existing.acceptedAt === null, actorIri: actor.iri }; + } + + const followId = randomUUID(); + await db.insert(follows).values({ + id: followId, + followerId, + followedActorIri: actor.iri, + followedUserId: null, + acceptedAt: null, + }); + await upsertRemoteActor({ + actorIri: actor.iri, + inboxUrl: actor.inboxUrl, + username: actor.username, + displayName: actor.displayName, + domain: new URL(actor.iri).host, + }); + + try { + await deps.deliverFollow(follower.username, followId, actor); + } catch (err) { + // Keep the Pending row — Fedify's queue retries delivery; the row + // is also the user's visible record that a request is in flight. + logger.warn({ err, actorIri: actor.iri }, "outbound Follow delivery failed (queued retries may still succeed)"); + } + + logger.info({ followerId, actorIri: actor.iri }, "federation: outbound follow pending"); + return { pending: true, actorIri: actor.iri }; +} + +/** + * Cancel an outgoing remote follow (pending or accepted): delete the + * row and deliver Undo(Follow) (spec 6.6 / social-follows "Cancel a + * Pending follow"). + */ +export async function cancelRemoteFollow( + followerId: string, + actorIri: string, + deps: OutboundFollowDeps = defaultDeps(), +): Promise { + const db = getDb(); + const [follower] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, followerId)) + .limit(1); + if (!follower) throw new FollowError("user_not_found", "User not found"); + + const deleted = await db + .delete(follows) + .where( + and( + eq(follows.followerId, followerId), + eq(follows.followedActorIri, actorIri), + isNull(follows.followedUserId), // remote rows only + ), + ) + .returning({ id: follows.id }); + if (deleted.length === 0) return; // idempotent + + const [cached] = await db + .select({ inboxUrl: remoteActors.inboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + if (cached?.inboxUrl) { + try { + await deps.deliverUndoFollow(follower.username, deleted[0]!.id, { + iri: actorIri, + inboxUrl: cached.inboxUrl, + }); + } catch (err) { + logger.warn({ err, actorIri }, "Undo(Follow) delivery failed; row already removed locally"); + } + } + logger.info({ followerId, actorIri }, "federation: outbound follow cancelled"); +} + +export interface OutgoingFollowEntry { + actorIri: string; + pending: boolean; + createdAt: Date; + displayName: string | null; + username: string | null; + domain: string | null; +} + +/** All outgoing remote follows for the user, newest first (spec 6.6). */ +export async function listOutgoingRemoteFollows(followerId: string): Promise { + const db = getDb(); + const rows = await db + .select({ + actorIri: follows.followedActorIri, + acceptedAt: follows.acceptedAt, + createdAt: follows.createdAt, + displayName: remoteActors.displayName, + username: remoteActors.username, + domain: remoteActors.domain, + }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNull(follows.followedUserId), + isNotNull(follows.followedActorIri), + ), + ) + .orderBy(desc(follows.createdAt)); + return rows.map((r) => ({ + actorIri: r.actorIri, + pending: r.acceptedAt === null, + createdAt: r.createdAt, + displayName: r.displayName, + username: r.username, + domain: r.domain, + })); +} diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts index 0105e5d..1ac3250 100644 --- a/apps/journal/app/lib/follow.server.ts +++ b/apps/journal/app/lib/follow.server.ts @@ -7,7 +7,16 @@ import { createNotification } from "./notifications.server.ts"; import { logger } from "./logger.server.ts"; export class FollowError extends Error { - readonly code: "self_follow" | "user_not_found" | "not_found" | "forbidden"; + readonly code: + | "self_follow" + | "user_not_found" + | "not_found" + | "forbidden" + // outbound federation codes (federation-outbound.server.ts) + | "invalid_handle" + | "local_handle" + | "remote_resolve_failed" + | "not_trails"; constructor(code: FollowError["code"], message: string) { super(message); this.name = "FollowError"; diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 0f630a3..77077c8 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -36,6 +36,7 @@ export default [ route("api/users/:username/follow", "routes/api.users.$username.follow.ts"), route("api/users/:username/unfollow", "routes/api.users.$username.unfollow.ts"), route("follows/requests", "routes/follows.requests.tsx"), + route("follows/outgoing", "routes/follows.outgoing.tsx"), route("api/follows/:id/approve", "routes/api.follows.$id.approve.ts"), route("api/follows/:id/reject", "routes/api.follows.$id.reject.ts"), route("feed", "routes/feed.tsx"), diff --git a/apps/journal/app/routes/api.well-known.trails-cool.ts b/apps/journal/app/routes/api.well-known.trails-cool.ts index d42fe04..c26e264 100644 --- a/apps/journal/app/routes/api.well-known.trails-cool.ts +++ b/apps/journal/app/routes/api.well-known.trails-cool.ts @@ -15,5 +15,9 @@ export function loader() { apiVersion: API_VERSION, instanceName: domain, apiBaseUrl: `${origin}/api/v1`, + // Software identity for trails-to-trails federation discovery + // (social-federation 6.2). NodeInfo (/nodeinfo/2.1) is the primary + // check; this is the trails-specific secondary signal. + software: "trails-cool", }); } diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index 37f5b67..b6cf51d 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -81,6 +81,14 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {t("social.feed.seePublic")} )} + {view === "followed" && ( + + {t("social.feed.followRemote")} + + )} ) : (
    diff --git a/apps/journal/app/routes/follows.outgoing.tsx b/apps/journal/app/routes/follows.outgoing.tsx new file mode 100644 index 0000000..d462f8c --- /dev/null +++ b/apps/journal/app/routes/follows.outgoing.tsx @@ -0,0 +1,144 @@ +import { data, redirect, Form, useNavigation } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/follows.outgoing"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { FollowError } from "~/lib/follow.server"; +import { + followRemoteActor, + cancelRemoteFollow, + listOutgoingRemoteFollows, +} from "~/lib/federation-outbound.server"; +import { federationEnabled } from "~/lib/federation.server"; +import { ClientDate } from "~/components/ClientDate"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + const entries = await listOutgoingRemoteFollows(user.id); + return data({ entries, federationEnabled: federationEnabled() }); +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return data({ error: "Unauthorized" }, { status: 401 }); + + const form = await request.formData(); + const intent = form.get("intent"); + try { + if (intent === "follow") { + const handle = String(form.get("handle") ?? ""); + const state = await followRemoteActor(user.id, handle); + return data({ ok: true, ...state }); + } + if (intent === "cancel") { + const actorIri = String(form.get("actorIri") ?? ""); + await cancelRemoteFollow(user.id, actorIri); + return data({ ok: true }); + } + return data({ error: "Unknown intent" }, { status: 400 }); + } catch (e) { + if (e instanceof FollowError) { + return data({ error: e.message, code: e.code }, { status: 400 }); + } + throw e; + } +} + +export function meta() { + return [{ title: "Following across instances — trails.cool" }]; +} + +export default function OutgoingFollows({ loaderData, actionData }: Route.ComponentProps) { + const { entries, federationEnabled } = loaderData; + const { t } = useTranslation("journal"); + const navigation = useNavigation(); + const busy = navigation.state !== "idle"; + const error = actionData && "error" in actionData ? actionData.error : null; + const errorCode = actionData && "code" in actionData ? (actionData.code as string) : null; + + return ( +
    +

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

    +

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

    + + {!federationEnabled ? ( +

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

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

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

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

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

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

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

      +
      +
      + + + +
      +
    • + ))} +
    + )} + + )} +
    + ); +} diff --git a/e2e/social.test.ts b/e2e/social.test.ts index b18587f..210afd9 100644 --- a/e2e/social.test.ts +++ b/e2e/social.test.ts @@ -47,6 +47,11 @@ test.describe("Social follows + /feed", () => { await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 }); }); + test("/follows/outgoing redirects anonymous visitors to login", async ({ page }) => { + await page.goto("/follows/outgoing"); + await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 }); + }); + test("/follows/requests redirects to /notifications?tab=requests", async ({ page, browser }) => { // The route was folded into the Notifications page as a tab. The // 301 still resolves; for anonymous visitors the notifications diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index d41da8d..5e5eced 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -45,13 +45,15 @@ ## 6. Outbound follow + trails-to-trails check -- [ ] 6.1 Update `followUser` to allow remote IRIs; before creating, fetch the remote actor object and inspect `software`/discovery endpoint +- [x] 6.1 Update `followUser` to allow remote IRIs; before creating, fetch the remote actor object and inspect `software`/discovery endpoint > Split-domain interop constraint (2026-06-07): resolve the handle via WebFinger first, then run the NodeInfo/`software` check against the **actor IRI's host** (the server domain), never the handle's domain — Hollo/Mastodon-style split-domain instances (`@user@example.com` served from `social.example.com`) would otherwise be wrongly refused. See `docs/ideas/split-domain-handles.md`. -- [ ] 6.2 Implement `/.well-known/trails-cool` endpoint on our side (publishes our software identity) so other trails instances recognize us -- [ ] 6.3 If the discovery check fails, return 4xx with a clear "outbound federation to non-trails instances isn't supported yet" message + docs link -- [ ] 6.4 If the check passes, write follow row with `accepted_at = NULL`, push `Follow` activity to remote inbox -- [ ] 6.5 UI: "Pending" button state on profile (replaces Follow/Unfollow) when `accepted_at IS NULL` -- [ ] 6.6 Outgoing-follows page or section listing Pending requests with cancel control (`Undo(Follow)` + delete row) +- [x] 6.2 Implement `/.well-known/trails-cool` endpoint on our side (publishes our software identity) so other trails instances recognize us +- [x] 6.3 If the discovery check fails, return 4xx with a clear "outbound federation to non-trails instances isn't supported yet" message + docs link +- [x] 6.4 If the check passes, write follow row with `accepted_at = NULL`, push `Follow` activity to remote inbox +- [x] 6.5 UI: "Pending" button state on profile (replaces Follow/Unfollow) when `accepted_at IS NULL` + > The Pending button already existed from locked accounts. Remote actors have no local profile page, so the remote Pending state lives on /follows/outgoing (6.6) instead. +- [x] 6.6 Outgoing-follows page or section listing Pending requests with cancel control (`Undo(Follow)` + delete row) + > Shipped as /follows/outgoing: follow-by-handle form + pending/accepted list + cancel (also satisfies the Reject-surfacing note on 4.5 — rejected rows simply disappear from this list). Linked from the feed empty state. ## 7. Outbox-poll ingestion diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index b5f5714..347aaba 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -321,6 +321,19 @@ export default { count_other: "Folgt {{count}}", empty: "Folgt noch niemandem.", }, + outgoing: { + heading: "Folgen über Instanzen hinweg", + subtitle: "Folge Leuten auf anderen trails.cool-Instanzen. Anfragen bleiben ausstehend, bis ihre Instanz sie annimmt.", + handleLabel: "Handle der Person", + followButton: "Folgen", + pendingBadge: "Ausstehend", + acceptedBadge: "Folge ich", + cancelRequest: "Anfrage zurückziehen", + unfollow: "Entfolgen", + empty: "Noch keine Follows auf anderen Instanzen. Gib oben ein Handle ein, um zu starten.", + notTrails: "Ausgehende Föderation ist derzeit nur zwischen trails-Instanzen möglich — diese Instanz nutzt kein trails.cool.", + disabled: "Föderation ist auf dieser Instanz nicht aktiviert.", + }, prevPage: "Zurück", nextPage: "Weiter", pageOfTotal: "Seite {{page}} von {{totalPages}}", @@ -328,6 +341,7 @@ export default { title: "Feed", heading: "Folge ich", empty: "Du folgst noch niemandem. Stöbere Profile durch und klicke auf Folgen, um deinen Feed aufzubauen.", + followRemote: "Folge jemandem auf einer anderen trails-Instanz →", seePublic: "Oder durchstöbere den öffentlichen Feed dieser Instanz →", toggle: { followed: "Folge ich", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e6c5770..26b26a3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -321,6 +321,19 @@ export default { count_other: "Following {{count}}", empty: "Not following anyone yet.", }, + outgoing: { + heading: "Following across instances", + subtitle: "Follow people on other trails.cool instances. Requests stay pending until their instance accepts.", + handleLabel: "Their handle", + followButton: "Follow", + pendingBadge: "Pending", + acceptedBadge: "Following", + cancelRequest: "Cancel request", + unfollow: "Unfollow", + empty: "No follows on other instances yet. Enter a handle above to start.", + notTrails: "Outbound federation is currently trails-to-trails only — that instance doesn't run trails.cool.", + disabled: "Federation is not enabled on this instance.", + }, prevPage: "Previous", nextPage: "Next", pageOfTotal: "Page {{page}} of {{totalPages}}", @@ -328,6 +341,7 @@ export default { title: "Feed", heading: "Following", empty: "You're not following anyone yet. Browse profiles and tap Follow to start building your feed.", + followRemote: "Follow someone on another trails instance →", seePublic: "Or browse the instance public feed →", toggle: { followed: "Following", From f4d2cf027c48494e3752e5020facdb13e65ee62b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 12:21:51 +0200 Subject: [PATCH 02/22] =?UTF-8?q?docs(journal):=20federation=20privacy=20m?= =?UTF-8?q?anifest=20+=20runbook=20+=20honest=20home=20blurb=20(=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10.1 Privacy manifest (legal/privacy): - German legal half: Föderation entry under Empfänger/Drittanbieter — what a public profile exposes, what accepted followers' servers receive, the loss-of-control over delivered copies, Art. 6(1)(a) basis, private profiles don't federate. - English manifest half: dedicated Federation (ActivityPub) section — actor object contents, push delivery + retraction semantics (incl. the tombstone caveat), encrypted-at-rest signing keys, remote actor cache, ingested remote content with the followers-only viewer gate, inbox logging/rate limits. PRIVACY_LAST_UPDATED bumped. 10.2 docs/deployment.md federation runbook: enabling per environment, key rotation posture, abuse monitoring, and the troubleshooting checklist distilled from the 2026-06-06/07 soak (IP families first, no outbox backfill, tombstones, the 10s delivery timeout, actor re-fetch). 10.3 Home marketing blurb (en+de) aligned to what actually ships: Mastodon can follow you + trails-to-trails outbound — no more 'follow friends anywhere' overstatement. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/app/lib/legal.ts | 2 +- apps/journal/app/routes/legal.privacy.tsx | 63 +++++++++++++++++++++ docs/deployment.md | 60 ++++++++++++++++++++ openspec/changes/social-federation/tasks.md | 6 +- packages/i18n/src/locales/de.ts | 2 +- packages/i18n/src/locales/en.ts | 2 +- 6 files changed, 129 insertions(+), 6 deletions(-) diff --git a/apps/journal/app/lib/legal.ts b/apps/journal/app/lib/legal.ts index 9105096..fc2a9be 100644 --- a/apps/journal/app/lib/legal.ts +++ b/apps/journal/app/lib/legal.ts @@ -16,4 +16,4 @@ export const TERMS_VERSION = "2026-04-19"; * require re-acceptance (the policy is informational, not contract), so this * is display-only — not persisted. */ -export const PRIVACY_LAST_UPDATED = "2026-04-20"; +export const PRIVACY_LAST_UPDATED = "2026-06-07"; diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index ce4b14a..303f26c 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -260,6 +260,22 @@ export default function PrivacyPage() { , der ohne Query-Logs arbeitet. Eine selbst gehostete Instanz ist geplant. +
  • + Föderation (ActivityPub) – Wenn Ihr Profil auf{" "} + public steht, ist Ihr Konto über das + ActivityPub-Protokoll föderiert: Andere Server (z. B. + Mastodon-Instanzen oder andere trails.cool-Instanzen) können Ihr + öffentliches Profil (Anzeigename, Nutzername, Bio, öffentlicher + Signaturschlüssel) abrufen und Ihnen folgen. An die Server + angenommener Follower übermitteln wir Ihre als{" "} + public markierten Aktivitäten (Titel, Statistiken, + Link). Diese Server liegen außerhalb unserer Kontrolle; was dort + mit zugestellten Inhalten geschieht (Speicherung, Anzeige, + Weiterverbreitung), richtet sich nach deren Richtlinien. + Rechtsgrundlage ist Ihre Einwilligung durch das aktive + Veröffentlichen (Art. 6 Abs. 1 lit. a DSGVO). Private Profile + föderieren nicht. +
  • BRouter – Routenberechnung läuft auf einer von uns selbst gehosteten Instanz. Keine Weitergabe an Dritte. @@ -452,6 +468,53 @@ export default function PrivacyPage() {
+
+

+ Federation (ActivityPub) +

+

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

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

Sentry

    diff --git a/docs/deployment.md b/docs/deployment.md index f4998e8..3a785ed 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -102,6 +102,66 @@ curl -sf https://trails.cool/api/health && curl -sf https://staging.trails.cool/ Plan it as a short maintenance window (~2–3 min downtime); don't ship network-option changes expecting the workflows to apply them. +## Federation runbook + +Federation (ActivityPub via Fedify) is gated by `FEDERATION_ENABLED=true` +per environment. Currently: staging **on**, production **off**. The full +change history and design live in `openspec/changes/social-federation/`. + +### Enabling on an environment + +1. Ensure `FEDERATION_KEY_ENCRYPTION_KEY` is in `secrets.app.env` + (SOPS) — keypair generation fails closed in production without it. +2. Set `FEDERATION_ENABLED=true` in the environment's compose env (see + `cd-staging.yml` for the staging pattern). +3. First boot with the flag on enqueues `backfill-user-keypairs` + (generates RSA keys for existing users — idempotent) and registers + the federation jobs (`deliver-activity`, `poll-remote-actor`, + `poll-remote-outboxes`, `federation-kv-sweep`). +4. Smoke: `curl -s "https:///.well-known/webfinger?resource=acct:@"` + (200 for a public user, 404 otherwise) and `/nodeinfo/2.1`. + +`FEDERATION_LOG_LEVEL=debug` turns on Fedify's per-request signature +diagnostics (staging runs debug during soaks; dial back to `info`). + +### Key rotation + +Rotating `FEDERATION_KEY_ENCRYPTION_KEY` requires re-encrypting every +`users.private_key_encrypted` (decrypt with old, encrypt with new) — +there is no script yet; write one against +`apps/journal/app/lib/federation-keys.server.ts` primitives when first +needed. Rotating a single user's keypair: NULL their key columns and +let the backfill regenerate (remotes pick the new key up from the actor +document; deliveries signed with the old key fail until then). + +### Abuse monitoring + +- Inbox is rate-limited 60 req/5 min per source instance + (`federationSourceHost`); 429s show in journal logs. +- Outbound is paced (1 req/s delivery, 1 req/5 s polling per host). +- Watch `docker logs ` for `fedify·federation` lines; Loki + picks them up. No per-instance blocklist yet — see + `docs/ideas/instance-administration.md` (manual emergency lever: + block the source IP/host in Caddy). + +### Troubleshooting deliveries + +Hard-won checklist from the 2026-06-06/07 soak (details in +`openspec/changes/social-federation/design.md`): + +1. **Test both IP families from inside the journal container** before + suspecting signatures (`family: 6` vs `4` — dangling A records and + v6-only instances are common; our containers have IPv6 since + #466/#468). +2. Remote shows the post count but no posts → remotes never backfill + outbox history; only pushed or individually-fetched objects appear. +3. Delivered but invisible, no errors anywhere → check the remote's + `tombstones` table; a previously-retracted URI is refused forever. +4. Mastodon gives deliveries a 10s read timeout — anything synchronous + and slow in the inbox path breaks federation silently. +5. Actor changes (profile fields, keys) need the remote to re-fetch: + `tootctl accounts refresh user@domain` on a Mastodon you control. + ## cd-brouter manual trigger ```bash diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 5e5eced..3684b5b 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -76,9 +76,9 @@ ## 10. Privacy + docs -- [ ] 10.1 Update privacy manifest: federation traffic, remote actor cache, what data is exposed in the actor object (display name, avatar, public key) -- [ ] 10.2 `docs/deployment.md`: federation runbook — feature flag, key rotation procedure, abuse monitoring, troubleshooting -- [ ] 10.3 Soften the Journal home "Federated by design" blurb on flagship now (or align it to "ActivityPub federation: inbound + trails-to-trails outbound" once this lands) +- [x] 10.1 Update privacy manifest: federation traffic, remote actor cache, what data is exposed in the actor object (display name, avatar, public key) +- [x] 10.2 `docs/deployment.md`: federation runbook — feature flag, key rotation procedure, abuse monitoring, troubleshooting +- [x] 10.3 Soften the Journal home "Federated by design" blurb on flagship now (or align it to "ActivityPub federation: inbound + trails-to-trails outbound" once this lands) ## 11. Testing diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 347aaba..a1c8d84 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -192,7 +192,7 @@ export default { }, federation: { title: "Föderiert by design", - body: "Deine Journal-Instanz spricht via ActivityPub mit anderen trails.cool-Servern. Folge Freundinnen und Freunden überall.", + body: "Teil des Fediverse via ActivityPub: Mastodon-Nutzer:innen können dir folgen und deine öffentlichen Aktivitäten sehen, und du folgst trails-Nutzer:innen auf jeder Instanz.", }, ownership: { title: "Deine Daten, immer", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 26b26a3..f6ee07f 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -192,7 +192,7 @@ export default { }, federation: { title: "Federated by design", - body: "Your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere.", + body: "Part of the fediverse via ActivityPub: Mastodon users can follow you and see your public activities, and you can follow trails users on any instance.", }, ownership: { title: "Your data, always", From b96bef91a96879c6edac5d26e97a9674c118e7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 12:05:32 +0200 Subject: [PATCH 03/22] =?UTF-8?q?feat(journal):=20outbox-poll=20ingestion?= =?UTF-8?q?=20of=20remote=20trails=20activities=20(=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 7.1–7.3, 7.5 (+7.4 pacing; Retry-After-duration backoff noted as remaining). Resolves the activities.owner_id open question. Schema (design decision from the open question): - activities.owner_id nullable + check constraint enforcing exactly one of (owner_id, remote_actor_iri) — the follows pattern again. - remote_published_at carries the origin's publish time for the §8 feed sort; index on remote_actor_iri for the feed join. - Compiler-audited fallout: notification fan-out, the Note object dispatcher, and the activity detail loader explicitly skip/404 remote rows (their canonical page is the origin; feed links there). Every other surface joins users on owner_id and excludes remote rows structurally. Ingestion: - federation-ingest.server.ts: parseOutboxItem targets exactly our own outgoing Create(Note) shape (PropertyValue stats, first-paragraph name, audience from to/cc); unknown items skipped, never fatal. - ingestRemoteActivities: replay-safe via unique remote_origin_iri, conflict-streak early exit (outboxes are newest-first). - pollRemoteActor: signed fetch (Authorized Fetch via a local follower's key), outbox resolution via remote_actors cache with actor-doc fallback (which refreshes the cache), 1 req/5 s per-host pacing, last_polled_at stamping. Network + pacing injectable. Jobs: poll-remote-actor (per-actor; the §4 Accept listener already enqueues it as the first poll) + poll-remote-outboxes (5-min cron sweep over accepted remote follows not polled within the hour). Tests: parse-shape units; integration suite for ingestion provenance, audience→visibility mirroring, replay safety, the DB author invariant, poll flow (actor-doc → collection → page), signer requirement, and due-polling selection. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/app/jobs/notifications-fanout.ts | 3 + apps/journal/app/jobs/poll-remote-actor.ts | 26 ++ apps/journal/app/jobs/poll-remote-outboxes.ts | 25 ++ .../app/lib/federation-delivery.server.ts | 3 +- .../lib/federation-ingest.integration.test.ts | 167 +++++++++++ .../app/lib/federation-ingest.server.test.ts | 80 +++++ .../app/lib/federation-ingest.server.ts | 279 ++++++++++++++++++ apps/journal/app/lib/federation.server.ts | 5 +- .../app/routes/activities.$id.server.ts | 8 +- apps/journal/server.ts | 4 +- openspec/changes/social-federation/design.md | 18 +- openspec/changes/social-federation/tasks.md | 9 +- packages/db/src/schema/journal.ts | 22 +- 13 files changed, 628 insertions(+), 21 deletions(-) create mode 100644 apps/journal/app/jobs/poll-remote-actor.ts create mode 100644 apps/journal/app/jobs/poll-remote-outboxes.ts create mode 100644 apps/journal/app/lib/federation-ingest.integration.test.ts create mode 100644 apps/journal/app/lib/federation-ingest.server.test.ts create mode 100644 apps/journal/app/lib/federation-ingest.server.ts diff --git a/apps/journal/app/jobs/notifications-fanout.ts b/apps/journal/app/jobs/notifications-fanout.ts index b73f5ef..ef96f78 100644 --- a/apps/journal/app/jobs/notifications-fanout.ts +++ b/apps/journal/app/jobs/notifications-fanout.ts @@ -51,6 +51,9 @@ export async function fanout(activityId: string): Promise { logger.warn({ activityId }, "fanout: activity not found, skipping"); return; } + // Remote-ingested rows have no local owner and never fan out locally + // (the users innerJoin already excludes them; this narrows the type). + if (row.ownerId === null) return; // Defense in depth — the create-side guard already filters this, but // recheck here so the job doesn't mistakenly fan out a row that was // later flipped to private/unlisted before the job ran. diff --git a/apps/journal/app/jobs/poll-remote-actor.ts b/apps/journal/app/jobs/poll-remote-actor.ts new file mode 100644 index 0000000..31582c4 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-actor.ts @@ -0,0 +1,26 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { pollRemoteActor } from "../lib/federation-ingest.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface PollPayload { + actorIri?: string; +} + +/** + * Poll one remote trails actor's outbox (spec §7). Enqueued by the + * inbox Accept(Follow) listener (first poll, 7.5) and fanned out by + * the poll-remote-outboxes cron sweep (7.1). + */ +export const pollRemoteActorJob: JobDefinition = { + name: "poll-remote-actor", + retryLimit: 2, + expireInSeconds: 120, + async handler(jobs) { + for (const job of jobs) { + const { actorIri } = (job.data ?? {}) as PollPayload; + if (!actorIri) continue; + const result = await pollRemoteActor(actorIri); + logger.info({ actorIri, result }, "poll-remote-actor"); + } + }, +}; diff --git a/apps/journal/app/jobs/poll-remote-outboxes.ts b/apps/journal/app/jobs/poll-remote-outboxes.ts new file mode 100644 index 0000000..c6cf085 --- /dev/null +++ b/apps/journal/app/jobs/poll-remote-outboxes.ts @@ -0,0 +1,25 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { listActorsDuePolling } from "../lib/federation-ingest.server.ts"; +import { enqueueOptional } from "../lib/boss.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +/** + * Cron sweep (spec 7.1): every 5 minutes, find remote trails actors + * that at least one local user follows (accepted) and that haven't + * been polled within the last hour, and fan out one poll-remote-actor + * job each. Per-host pacing lives in the poll itself. + */ +export const pollRemoteOutboxesJob: JobDefinition = { + name: "poll-remote-outboxes", + cron: "*/5 * * * *", + retryLimit: 1, + expireInSeconds: 60, + async handler() { + const due = await listActorsDuePolling(); + for (const actorIri of due) { + await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" }); + } + if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep"); + return { due: due.length }; + }, +}; diff --git a/apps/journal/app/lib/federation-delivery.server.ts b/apps/journal/app/lib/federation-delivery.server.ts index 56d0f09..5753bb0 100644 --- a/apps/journal/app/lib/federation-delivery.server.ts +++ b/apps/journal/app/lib/federation-delivery.server.ts @@ -124,10 +124,11 @@ export async function getCachedRemoteActor(actorIri: string): Promise { + beforeAll(() => { + process.env.ORIGIN ??= "http://localhost:3000"; + }); + + afterEach(async () => { + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${ACTOR}%`)); + await db.delete(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + for (const id of createdUserIds.splice(0)) { + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("inserts remote rows with provenance and audience-mirrored visibility", async () => { + const inserted = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2, "followers-only")]); + expect(inserted).toBe(2); + + const db = getDb(); + const rows = await db + .select() + .from(activities) + .where(like(activities.remoteOriginIri, `${ACTOR}%`)); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.ownerId).toBeNull(); + expect(row.remoteActorIri).toBe(ACTOR); + expect(row.remotePublishedAt).not.toBeNull(); + } + const byIri = new Map(rows.map((r) => [r.remoteOriginIri, r])); + expect(byIri.get(`${ACTOR}/activities/1`)?.visibility).toBe("public"); + expect(byIri.get(`${ACTOR}/activities/2`)?.visibility).toBe("private"); + expect(byIri.get(`${ACTOR}/activities/2`)?.audience).toBe("followers-only"); + }); + + it("is replay-safe: re-ingesting inserts nothing", async () => { + await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + const again = await ingestRemoteActivities(ACTOR, [parsed(1), parsed(2)]); + expect(again).toBe(0); + }); + + it("database enforces exactly-one-author invariant", async () => { + const db = getDb(); + await expect( + db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + remoteActorIri: null, + name: "orphan", + }), + ).rejects.toThrow(); + }); + + it("pollRemoteActor resolves the outbox via the actor doc, ingests the first page, stamps last_polled_at", async () => { + await makeFollowerOf(ACTOR); + const fetched: string[] = []; + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson(url) { + fetched.push(url); + if (url === ACTOR) { + return { outbox: `${ACTOR}/outbox`, inbox: `${ACTOR}/inbox`, preferredUsername: "alice", name: "Alice" }; + } + if (url === `${ACTOR}/outbox`) { + return { type: "OrderedCollection", totalItems: 1, first: `${ACTOR}/outbox?cursor=0` }; + } + return { + type: "OrderedCollectionPage", + orderedItems: [ + { + type: "Create", + object: { + type: "Note", + id: `${ACTOR}/activities/77`, + content: "

    Polled ride

    ", + published: "2026-06-05T10:00:00Z", + to: "as:Public", + }, + }, + ], + }; + }, + }); + expect(result).toEqual({ inserted: 1 }); + expect(fetched).toEqual([ACTOR, `${ACTOR}/outbox`, `${ACTOR}/outbox?cursor=0`]); + + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.outboxUrl).toBe(`${ACTOR}/outbox`); + expect(cached?.lastPolledAt).not.toBeNull(); + }); + + it("skips actors without an accepted local follower", async () => { + const result = await pollRemoteActor(ACTOR, { + async pace() {}, + async fetchJson() { + throw new Error("must not fetch"); + }, + }); + expect(result).toEqual({ skipped: "no accepted local follower" }); + }); + + it("listActorsDuePolling returns followed actors not polled within the hour", async () => { + await makeFollowerOf(ACTOR); + expect(await listActorsDuePolling()).toContain(ACTOR); + + const db = getDb(); + await db.insert(remoteActors).values({ actorIri: ACTOR, lastPolledAt: new Date() }).onConflictDoUpdate({ + target: remoteActors.actorIri, + set: { lastPolledAt: new Date() }, + }); + expect(await listActorsDuePolling()).not.toContain(ACTOR); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.test.ts b/apps/journal/app/lib/federation-ingest.server.test.ts new file mode 100644 index 0000000..8167e53 --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("./db.ts", () => ({ getDb: () => ({}) })); + +const { parseOutboxItem } = await import("./federation-ingest.server.ts"); + +function trailsCreate(overrides: Record = {}, noteOverrides: Record = {}) { + return { + type: "Create", + id: "https://remote.example/activities/a1#create", + published: "2026-06-01T08:00:00Z", + object: { + type: "Note", + id: "https://remote.example/activities/a1", + content: "

    Morning ride

    \n

    42.2 km · ↗ 512 m

    \n

    link

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

    ${longName}

    ` })); + expect(parsed?.name).toHaveLength(300); + expect(parsed?.name).not.toContain(""); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.ts b/apps/journal/app/lib/federation-ingest.server.ts new file mode 100644 index 0000000..ae7fa5d --- /dev/null +++ b/apps/journal/app/lib/federation-ingest.server.ts @@ -0,0 +1,279 @@ +// 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 { 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; + +export interface ParsedRemoteActivity { + originIri: string; + name: string; + distance: number | null; + elevationGain: number | null; + duration: number | null; + publishedAt: Date | null; + audience: "public" | "followers-only"; +} + +function isPublicAudience(value: unknown): boolean { + const targets = Array.isArray(value) ? value : value == null ? [] : [value]; + return targets.some( + (t) => + t === "https://www.w3.org/ns/activitystreams#Public" || + t === "as:Public" || + t === "Public", + ); +} + +function textOfFirstParagraph(html: string): string { + const match = /

    ([\s\S]*?)<\/p>/.exec(html); + const raw = match?.[1] ?? html; + return raw.replace(/<[^>]+>/g, "").trim(); +} + +function statFromAttachments(attachments: unknown, name: string): number | null { + const list = Array.isArray(attachments) ? attachments : attachments == null ? [] : [attachments]; + for (const a of list) { + if ( + typeof a === "object" && a !== null && + (a as Record).type === "PropertyValue" && + (a as Record).name === name + ) { + const v = Number.parseFloat(String((a as Record).value)); + return Number.isFinite(v) ? v : null; + } + } + return null; +} + +/** + * Parse one outbox item (a `Create` wrapping a `Note` in our own + * outgoing shape) into an ingestable activity. Returns null for + * anything that doesn't match — unknown items are skipped, never + * fatal (forward compatibility with future trails versions). + */ +export function parseOutboxItem(item: unknown): ParsedRemoteActivity | null { + if (typeof item !== "object" || item === null) return null; + const create = item as Record; + if (create.type !== "Create") return null; + const note = create.object; + if (typeof note !== "object" || note === null) return null; + const n = note as Record; + if (n.type !== "Note" || typeof n.id !== "string") return null; + + const content = typeof n.content === "string" ? n.content : ""; + const name = textOfFirstParagraph(content); + if (!name) return null; + + const publishedRaw = typeof n.published === "string" ? n.published : typeof create.published === "string" ? create.published : null; + const published = publishedRaw ? new Date(publishedRaw) : null; + + return { + originIri: n.id, + name: name.slice(0, 300), + distance: statFromAttachments(n.attachment, "distance-m"), + elevationGain: statFromAttachments(n.attachment, "elevation-gain-m"), + duration: statFromAttachments(n.attachment, "duration-s"), + publishedAt: published && !Number.isNaN(published.getTime()) ? published : null, + audience: isPublicAudience(n.to) || isPublicAudience(n.cc) ? "public" : "followers-only", + }; +} + +/** + * Insert parsed activities for a remote actor. Replay-safe via the + * unique remote_origin_iri (`ON CONFLICT DO NOTHING`); stops early on + * a streak of already-seen items since outboxes are newest-first. + * Returns the number of newly inserted rows. + */ +export async function ingestRemoteActivities( + actorIri: string, + items: ParsedRemoteActivity[], +): Promise { + const db = getDb(); + let inserted = 0; + let conflictStreak = 0; + for (const item of items) { + const rows = await db + .insert(activities) + .values({ + id: randomUUID(), + ownerId: null, + name: item.name, + description: "", + distance: item.distance, + elevationGain: item.elevationGain, + duration: item.duration, + // Mirror the audience into visibility for coherence with + // local rows; the §8 feed query gates remote rows on audience + // + follow, and every other surface joins users on owner_id, + // which excludes remote rows structurally. + visibility: item.audience === "public" ? "public" : "private", + remoteOriginIri: item.originIri, + remoteActorIri: actorIri, + remotePublishedAt: item.publishedAt, + audience: item.audience, + }) + .onConflictDoNothing({ target: activities.remoteOriginIri }) + .returning({ id: activities.id }); + if (rows.length === 0) { + conflictStreak++; + if (conflictStreak >= CONFLICT_STREAK_LIMIT) break; + } else { + conflictStreak = 0; + inserted++; + } + } + return inserted; +} + +export interface PollDeps { + /** + * Fetch a JSON document with an HTTP Signature from `signerUsername` + * (Authorized Fetch — spec: "Polls are signed"). + */ + fetchJson(url: string, signerUsername: string): Promise; + /** Per-host pacing (spec 7.4: 1 req / 5 s). No-op in tests. */ + pace(host: string): Promise; +} + +const lastFetchPerHost = new Map(); + +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 }> { + 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" }; + + const host = new URL(actorIri).host; + try { + await deps.pace(host); + + // Resolve the outbox URL: cache first, actor document as fallback + // (which also refreshes the cache — 7.3). + const [cached] = await db + .select({ outboxUrl: remoteActors.outboxUrl }) + .from(remoteActors) + .where(eq(remoteActors.actorIri, actorIri)) + .limit(1); + let outboxUrl = cached?.outboxUrl ?? null; + if (!outboxUrl) { + const actorDoc = (await deps.fetchJson(actorIri, signer.username)) as Record | null; + outboxUrl = typeof actorDoc?.outbox === "string" ? actorDoc.outbox : null; + if (!outboxUrl) return { skipped: "actor has no outbox" }; + await upsertRemoteActor({ + actorIri, + outboxUrl, + displayName: typeof actorDoc?.name === "string" ? actorDoc.name : null, + username: typeof actorDoc?.preferredUsername === "string" ? actorDoc.preferredUsername : null, + inboxUrl: typeof actorDoc?.inbox === "string" ? actorDoc.inbox : undefined, + domain: host, + }); + } + + // Collection → first page (our outbox serves first=...?cursor=0). + await deps.pace(host); + const collection = (await deps.fetchJson(outboxUrl, signer.username)) as Record; + let itemsRaw = collection.orderedItems ?? collection.items; + if (!itemsRaw && typeof collection.first === "string") { + await deps.pace(host); + const page = (await deps.fetchJson(collection.first, signer.username)) as Record; + itemsRaw = page.orderedItems ?? page.items; + } + const items = (Array.isArray(itemsRaw) ? itemsRaw : itemsRaw == null ? [] : [itemsRaw]) + .slice(0, MAX_ITEMS_PER_POLL) + .map(parseOutboxItem) + .filter((p): p is ParsedRemoteActivity => p !== null); + + const inserted = await ingestRemoteActivities(actorIri, items); + await db + .update(remoteActors) + .set({ lastPolledAt: new Date() }) + .where(eq(remoteActors.actorIri, actorIri)); + logger.info({ actorIri, fetched: items.length, inserted }, "federation: outbox poll"); + return { inserted }; + } catch (err) { + // 429 / network failures: log and let the hourly sweep retry. + logger.warn({ err, actorIri }, "federation: outbox poll failed; will retry on next sweep"); + return { skipped: "fetch failed" }; + } +} + +/** + * Remote actor IRIs due for polling (7.1): followed-and-accepted by at + * least one local user, never polled or polled more than an hour ago. + */ +export async function listActorsDuePolling(): Promise { + const db = getDb(); + const cutoff = new Date(Date.now() - POLL_INTERVAL_MS); + const rows = await db + .selectDistinct({ actorIri: follows.followedActorIri }) + .from(follows) + .leftJoin(remoteActors, eq(follows.followedActorIri, remoteActors.actorIri)) + .where( + and( + isNull(follows.followedUserId), + isNotNull(follows.acceptedAt), + or(sql`${remoteActors.lastPolledAt} IS NULL`, lt(remoteActors.lastPolledAt, cutoff)), + ), + ); + return rows.map((r) => r.actorIri); +} diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts index 530e5d0..fa25f68 100644 --- a/apps/journal/app/lib/federation.server.ts +++ b/apps/journal/app/lib/federation.server.ts @@ -311,7 +311,10 @@ function buildFederation(): Federation { .from(activities) .where(and(eq(activities.id, values.id), eq(activities.visibility, "public"))) .limit(1); - if (!row) return null; + // Remote-ingested activities are not our objects — only locally + // authored rows dereference here (their canonical IRI is on the + // origin instance). + if (!row || row.ownerId === null) return null; const [owner] = await db .select({ username: users.username, profileVisibility: users.profileVisibility }) .from(users) diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index b06578f..d75261a 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -17,8 +17,12 @@ import type { Visibility } from "@trails-cool/db/schema/journal"; const VISIBILITY_VALUES = new Set(["private", "unlisted", "public"]); export async function loadActivityDetail(request: Request, id: string | undefined) { - const activity = await getActivity(id ?? ""); - if (!activity) throw data({ error: "Activity not found" }, { status: 404 }); + const fetched = await getActivity(id ?? ""); + if (!fetched) throw data({ error: "Activity not found" }, { status: 404 }); + // Remote-ingested activities have no local detail page — their + // canonical page lives on the origin instance; the feed links there. + if (fetched.ownerId === null) throw data({ error: "Activity not found" }, { status: 404 }); + const activity = { ...fetched, ownerId: fetched.ownerId }; const user = await getSessionUser(request); const isOwner = user?.id === activity.ownerId; diff --git a/apps/journal/server.ts b/apps/journal/server.ts index aad8035..93342ac 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -183,7 +183,9 @@ server.listen(port, async () => { 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()); diff --git a/openspec/changes/social-federation/design.md b/openspec/changes/social-federation/design.md index a104644..0081bd4 100644 --- a/openspec/changes/social-federation/design.md +++ b/openspec/changes/social-federation/design.md @@ -147,13 +147,17 @@ Inbound signature verification uses the actor's public key from their actor obje ## Open Questions -- **`activities.owner_id` is NOT NULL but remote-ingested rows have no local - owner** (surfaced during task 2.3, 2026-06-06). Options: make `owner_id` - nullable with a check constraint (`owner_id IS NOT NULL OR remote_actor_iri - IS NOT NULL`), or key remote rows purely off `remote_actor_iri` in a way - that never touches owner-joined queries. Decide in task 7.2 (ingestion) - before any remote row is written; the columns landed in 2.3 don't prejudge - either option. +- ~~**`activities.owner_id` is NOT NULL but remote-ingested rows have no local + owner**~~ — **Resolved in task 7.2 (2026-06-07):** `owner_id` is nullable + with a check constraint enforcing exactly one of (`owner_id`, + `remote_actor_iri`) — the same pattern as `follows.follower_id` / + `follower_actor_iri`. Compiler-audited fallout: notification fan-out, + the Note object dispatcher, and the activity detail loader all + explicitly 404/skip remote rows (their canonical page is the origin + instance; the feed links outward). Every other surface joins `users` + on `owner_id` and excludes remote rows structurally. A + `remote_published_at` column carries the origin's publish time for + the §8 feed sort. - Custom AS extension for activity-type vs. plain `Create(Note)`? Mastodon will only render Notes; an extension type means non-trails clients see nothing useful. Lean toward `Create(Note)` with structured metadata in `attachment` so Mastodon shows the text + GPX link, and trails clients consuming the outbox can read the structured fields. - Per-instance inbox vs per-user inbox? Mastodon supports a "shared inbox" optimization. Worth doing for delivery efficiency once we have any volume; v1 can use per-user inboxes only. diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 3684b5b..16a6d92 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -57,11 +57,12 @@ ## 7. Outbox-poll ingestion -- [ ] 7.1 pg-boss recurring job `poll-remote-outboxes` (cron every 5 min): iterate distinct accepted remote actor IRIs in `follows` where `remote_actors.last_polled_at < now() - interval '1 hour'`; for each, signed GET on the outbox using one of the local accepted-followers' keys -- [ ] 7.2 Insert returned activities into `journal.activities` with `remote_origin_iri`, `remote_actor_iri`, `audience`. `ON CONFLICT (remote_origin_iri) DO NOTHING` for replay safety. Stop early on streak of conflicts -- [ ] 7.3 Refresh `remote_actors` cache row on each poll (display name, avatar, outbox URL, public key) +- [x] 7.1 pg-boss recurring job `poll-remote-outboxes` (cron every 5 min): iterate distinct accepted remote actor IRIs in `follows` where `remote_actors.last_polled_at < now() - interval '1 hour'`; for each, signed GET on the outbox using one of the local accepted-followers' keys +- [x] 7.2 Insert returned activities into `journal.activities` with `remote_origin_iri`, `remote_actor_iri`, `audience`. `ON CONFLICT (remote_origin_iri) DO NOTHING` for replay safety. Stop early on streak of conflicts +- [x] 7.3 Refresh `remote_actors` cache row on each poll (display name, avatar, outbox URL, public key) - [ ] 7.4 Per-remote-host rate limit (1 req / 5 sec); honor `Retry-After` / `429`; back off the host -- [ ] 7.5 First-poll trigger on `accepted_at` transition (already enqueued from 4.4) + > Partially done (2026-06-07): 1 req/5 s pacing implemented; any fetch failure (incl. 429) skips the host until the next 5-min sweep. Explicit Retry-After-duration backoff remains — needs header access through Fedify's document loader errors. +- [x] 7.5 First-poll trigger on `accepted_at` transition (already enqueued from 4.4) ## 8. Feed query update diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 3ed696e..7bf9bce 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -144,9 +144,12 @@ export type Audience = "public" | "followers-only"; export const activities = journalSchema.table("activities", { id: text("id").primaryKey(), - ownerId: text("owner_id") - .notNull() - .references(() => users.id), + // Local author. NULL for activities ingested from a remote trails + // actor's outbox, where `remoteActorIri` identifies the author — + // exactly one of the two is set (check constraint below; same + // pattern as follows.follower_id / follower_actor_iri). Resolves + // the owner_id open question in social-federation design.md. + ownerId: text("owner_id").references(() => users.id), routeId: text("route_id").references(() => routes.id), name: text("name").notNull(), description: text("description").default(""), @@ -164,16 +167,25 @@ export const activities = journalSchema.table("activities", { // Federation provenance (spec: social-federation). NULL for local // activities. For rows ingested from a remote trails actor's outbox: // `remoteOriginIri` is the activity's IRI on the origin instance - // (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion), and - // `remoteActorIri` keys into `remote_actors`. + // (unique → replay-safe `ON CONFLICT DO NOTHING` ingestion), + // `remoteActorIri` keys into `remote_actors`, and + // `remotePublishedAt` carries the origin's publish time (feed sort + // uses COALESCE(remote_published_at, created_at)). remoteOriginIri: text("remote_origin_iri").unique(), remoteActorIri: text("remote_actor_iri"), + remotePublishedAt: timestamp("remote_published_at", { withTimezone: true }), audience: text("audience").$type().notNull().default("public"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ // Hot paths: listActivities (sort by started_at) and listPublicActivitiesForOwner. ownerStartedIdx: index("activities_owner_started_idx").on(t.ownerId, t.startedAt.desc()), ownerCreatedIdx: index("activities_owner_created_idx").on(t.ownerId, t.createdAt.desc()), + // Feed join for remote rows (spec §8). + remoteActorIdx: index("activities_remote_actor_idx").on(t.remoteActorIri), + hasAuthorCheck: check( + "activities_has_author_check", + sql`(${t.ownerId} IS NOT NULL) <> (${t.remoteActorIri} IS NOT NULL)`, + ), })); // --- OAuth2 PKCE (mobile app auth) --- From bf2d8bfbd476478483cd0841d0a299bea5ec7c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 12:13:23 +0200 Subject: [PATCH 04/22] =?UTF-8?q?feat(journal):=20audience-aware=20social?= =?UTF-8?q?=20feed=20with=20remote=20activities=20(=C2=A78+=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8.1/8.2: listSocialFeed is now a UNION ALL of local and remote branches, sorted on COALESCE(remote_published_at, created_at): - local rows: visibility='public' from accepted local follows — and the previously missing accepted_at filter is added (the spec's 'Pending follows contribute nothing' scenario) - remote rows: gated structurally by joining the viewer's OWN accepted follow against the originating actor — which is exactly the followers-only audience rule (a row reaches only viewers whose follow brought it in); attribution from the remote_actors cache; cards link outward to the canonical origin page (no local detail page for remote rows) 9.1: annotated — local Pending button shipped with locked accounts; remote Pending lives on /follows/outgoing. 9.2: already enforced + tested since §3 (actor/webfinger 404). 9.3 hardening: deliver-activity now re-checks the OWNER's profile visibility at send time, closing the enqueue→delivery flip window (enqueue-side and inbound-side gates already existed). Integration tests: the §8 audience-leak guard (A sees followers-only, B does not), public remote attribution + outward link, pending contributes nothing, mixed local/remote COALESCE ordering. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/app/jobs/deliver-activity.ts | 13 +- apps/journal/app/lib/activities.server.ts | 74 +++++++-- .../app/lib/social-feed.integration.test.ts | 140 ++++++++++++++++++ apps/journal/app/routes/feed.server.ts | 5 + apps/journal/app/routes/feed.tsx | 29 +++- openspec/changes/social-federation/tasks.md | 11 +- 6 files changed, 245 insertions(+), 27 deletions(-) create mode 100644 apps/journal/app/lib/social-feed.integration.test.ts diff --git a/apps/journal/app/jobs/deliver-activity.ts b/apps/journal/app/jobs/deliver-activity.ts index d158a3f..c40ab44 100644 --- a/apps/journal/app/jobs/deliver-activity.ts +++ b/apps/journal/app/jobs/deliver-activity.ts @@ -1,6 +1,6 @@ import type { JobDefinition } from "@trails-cool/jobs"; import { and, eq } from "drizzle-orm"; -import { activities } from "@trails-cool/db/schema/journal"; +import { activities, users } from "@trails-cool/db/schema/journal"; import { getDb } from "../lib/db.ts"; import { getOrigin } from "../lib/config.server.ts"; import { getFederation } from "../lib/federation.server.ts"; @@ -77,6 +77,17 @@ async function deliverOne(p: DeliveryPayload): Promise { logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping"); return; } + // Spec 9.3: flipping the profile to private stops federation — also + // for deliveries already enqueued when the flip happened. + const [owner] = await db + .select({ profileVisibility: users.profileVisibility }) + .from(users) + .where(eq(users.username, p.ownerUsername)) + .limit(1); + if (!owner || owner.profileVisibility !== "public") { + logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping"); + return; + } activity = activityToCreate(row as FederatableActivity, p.ownerUsername); } else { activity = activityToDelete(p.objectIri, p.ownerUsername); diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 7705225..6ad22b7 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; -import { eq, desc, and, sql } from "drizzle-orm"; +import { eq, desc, and, isNotNull, sql } from "drizzle-orm"; +import { unionAll } from "drizzle-orm/pg-core"; import { getDb } from "./db.ts"; -import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal"; +import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal"; import type { Visibility } from "@trails-cool/db/schema/journal"; import { validateGpx, writeGeom } from "./gpx-save.server.ts"; import type { GpxData } from "./gpx-save.server.ts"; @@ -195,14 +196,25 @@ export async function listPublicActivitiesForOwner( } /** - * Social feed: aggregated public activities from users that `followerId` - * follows (accepted only). Reverse-chronological. Joins users for owner - * attribution. Unlisted/private activities never appear, regardless of - * follow state. + * Social feed (spec: social-federation §8): aggregated activities from + * actors that `followerId` follows with an *accepted* follow — local + * users and remote trails actors alike. Reverse-chronological on + * COALESCE(remote_published_at, created_at). + * + * Audience rules: + * - local rows: `visibility = 'public'` only (unlisted/private never + * appear regardless of follow state) + * - remote rows: `audience = 'public'` or `followers-only` — the + * latter gated structurally by joining the *viewer's own* accepted + * follow against the originating actor (spec: "Followers-only remote + * content reaches only the right viewer") + * Pending follows contribute nothing (accepted_at IS NOT NULL on both + * branches — previously missing on the local branch). */ export async function listSocialFeed(followerId: string, limit: number = 50) { const db = getDb(); - const rows = await db + + const local = db .select({ id: activities.id, name: activities.name, @@ -211,8 +223,12 @@ export async function listSocialFeed(followerId: string, limit: number = 50) { duration: activities.duration, startedAt: activities.startedAt, createdAt: activities.createdAt, - ownerUsername: users.username, - ownerDisplayName: users.displayName, + sortTime: sql`${activities.createdAt}`.as("sort_time"), + ownerUsername: sql`${users.username}`.as("owner_username"), + ownerDisplayName: sql`${users.displayName}`.as("owner_display_name"), + ownerDomain: sql`${users.domain}`.as("owner_domain"), + externalUrl: sql`NULL`.as("external_url"), + remote: sql`false`.as("remote"), }) .from(activities) .innerJoin(follows, eq(follows.followedUserId, activities.ownerId)) @@ -220,14 +236,46 @@ export async function listSocialFeed(followerId: string, limit: number = 50) { .where( and( eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), eq(activities.visibility, "public"), ), - ) - .orderBy(desc(activities.createdAt)) + ); + + const remote = db + .select({ + id: activities.id, + name: activities.name, + distance: activities.distance, + elevationGain: activities.elevationGain, + duration: activities.duration, + startedAt: activities.startedAt, + createdAt: activities.createdAt, + sortTime: sql`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"), + ownerUsername: sql`${remoteActors.username}`.as("owner_username"), + ownerDisplayName: sql`${remoteActors.displayName}`.as("owner_display_name"), + ownerDomain: sql`${remoteActors.domain}`.as("owner_domain"), + externalUrl: sql`${activities.remoteOriginIri}`.as("external_url"), + remote: sql`true`.as("remote"), + }) + .from(activities) + .innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri)) + .leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri)) + .where( + and( + eq(follows.followerId, followerId), + isNotNull(follows.acceptedAt), + // public reaches every accepted follower; followers-only is + // already gated by joining the viewer's own accepted follow. + sql`${activities.audience} IN ('public', 'followers-only')`, + ), + ); + + const rows = await unionAll(local, remote) + .orderBy(sql`sort_time DESC`) .limit(limit); - const ids = rows.map((r) => r.id); - const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map(); + const localIds = rows.filter((r) => !r.remote).map((r) => r.id); + const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map(); return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null })); } diff --git a/apps/journal/app/lib/social-feed.integration.test.ts b/apps/journal/app/lib/social-feed.integration.test.ts new file mode 100644 index 0000000..f24774a --- /dev/null +++ b/apps/journal/app/lib/social-feed.integration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { eq, like } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { getDb } from "./db.ts"; +import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal"; +import { listSocialFeed } from "./activities.server.ts"; + +// Opt-in: real Postgres. Covers social-federation §8 — the audience +// leak guard is THE scenario that must never regress. +const runIntegration = process.env.FEDERATION_INTEGRATION === "1"; + +const RUN = Date.now(); +const REMOTE_ACTOR = `https://feed-origin.example/users/x-${RUN}`; +const createdUserIds: string[] = []; + +async function makeUser(suffix: string) { + const db = getDb(); + const id = randomUUID(); + await db.insert(users).values({ + id, + email: `feed-${suffix}-${RUN}@example.test`, + username: `feed-${suffix}-${RUN}`, + domain: "test.local", + profileVisibility: "public", + }); + createdUserIds.push(id); + return id; +} + +async function followRemote(followerId: string, accepted: boolean) { + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId, + followedActorIri: REMOTE_ACTOR, + followedUserId: null, + acceptedAt: accepted ? new Date() : null, + }); +} + +async function remoteActivity(n: number, audience: "public" | "followers-only", publishedAt: Date) { + const db = getDb(); + await db.insert(activities).values({ + id: randomUUID(), + ownerId: null, + name: `Remote ${audience} ${n}`, + visibility: audience === "public" ? "public" : "private", + remoteOriginIri: `${REMOTE_ACTOR}/activities/${n}`, + remoteActorIri: REMOTE_ACTOR, + remotePublishedAt: publishedAt, + audience, + }); +} + +describe.runIf(runIntegration)("social feed with remote rows (§8, integration)", () => { + beforeAll(async () => { + process.env.ORIGIN ??= "http://localhost:3000"; + const db = getDb(); + await db.insert(remoteActors).values({ + actorIri: REMOTE_ACTOR, + username: "x", + displayName: "Remote X", + domain: "feed-origin.example", + }).onConflictDoNothing(); + }); + + afterEach(async () => { + const db = getDb(); + await db.delete(activities).where(like(activities.remoteOriginIri, `${REMOTE_ACTOR}%`)); + for (const id of createdUserIds.splice(0)) { + await db.delete(activities).where(eq(activities.ownerId, id)); + await db.delete(follows).where(eq(follows.followerId, id)); + await db.delete(users).where(eq(users.id, id)); + } + }); + + it("followers-only remote content reaches only the viewer with the accepted follow", async () => { + const a = await makeUser("a"); + const b = await makeUser("b"); + await followRemote(a, true); // A follows X (accepted) + // B does NOT follow X at all — but the row exists in our DB. + await remoteActivity(1, "followers-only", new Date()); + + const feedA = await listSocialFeed(a); + const feedB = await listSocialFeed(b); + expect(feedA.map((r) => r.name)).toContain("Remote followers-only 1"); + expect(feedB.map((r) => r.name)).not.toContain("Remote followers-only 1"); + }); + + it("public remote content reaches accepted followers with remote attribution", async () => { + const a = await makeUser("pub"); + await followRemote(a, true); + await remoteActivity(2, "public", new Date()); + + const feed = await listSocialFeed(a); + const entry = feed.find((r) => r.name === "Remote public 2"); + expect(entry).toBeDefined(); + expect(entry!.remote).toBe(true); + expect(entry!.externalUrl).toBe(`${REMOTE_ACTOR}/activities/2`); + expect(entry!.ownerUsername).toBe("x"); + expect(entry!.ownerDomain).toBe("feed-origin.example"); + expect(entry!.geojson).toBeNull(); + }); + + it("pending follows contribute nothing", async () => { + const a = await makeUser("pend"); + await followRemote(a, false); // Pending + await remoteActivity(3, "public", new Date()); + expect(await listSocialFeed(a)).toHaveLength(0); + }); + + it("mixes local and remote rows sorted by origin publish time", async () => { + const viewer = await makeUser("viewer"); + const author = await makeUser("author"); + const db = getDb(); + await db.insert(follows).values({ + id: randomUUID(), + followerId: viewer, + followedActorIri: `http://localhost:3000/users/feed-author-${RUN}`, + followedUserId: author, + acceptedAt: new Date(), + }); + await followRemote(viewer, true); + + const now = Date.now(); + // Local activity created "now" (createdAt defaults to now()). + await db.insert(activities).values({ + id: randomUUID(), + ownerId: author, + name: `Local newest ${RUN}`, + visibility: "public", + }); + // Remote activity published an hour ago. + await remoteActivity(4, "public", new Date(now - 3600_000)); + + const feed = await listSocialFeed(viewer); + const names = feed.map((r) => r.name); + expect(names.indexOf(`Local newest ${RUN}`)).toBeLessThan(names.indexOf("Remote public 4")); + }); +}); diff --git a/apps/journal/app/routes/feed.server.ts b/apps/journal/app/routes/feed.server.ts index 1f5e7c8..81a013a 100644 --- a/apps/journal/app/routes/feed.server.ts +++ b/apps/journal/app/routes/feed.server.ts @@ -31,6 +31,11 @@ export async function loadFeed(request: Request) { geojson: a.geojson ?? null, ownerUsername: a.ownerUsername, ownerDisplayName: a.ownerDisplayName, + // Remote (federated) attribution — only the followed view can + // contain remote rows; the public view is local-only. + ownerDomain: "ownerDomain" in a ? a.ownerDomain : null, + externalUrl: "externalUrl" in a ? a.externalUrl : null, + remote: "remote" in a ? a.remote : false, })), }; } diff --git a/apps/journal/app/routes/feed.tsx b/apps/journal/app/routes/feed.tsx index b6cf51d..a99578e 100644 --- a/apps/journal/app/routes/feed.tsx +++ b/apps/journal/app/routes/feed.tsx @@ -95,7 +95,11 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {activities.map((a) => (

  • @@ -112,13 +116,22 @@ export default function Feed({ loaderData }: Route.ComponentProps) {

    {a.name}

    - e.stopPropagation()} - > - {a.ownerDisplayName ?? a.ownerUsername} - + {a.remote ? ( + + {a.ownerDisplayName ?? a.ownerUsername ?? a.ownerDomain} + {a.ownerUsername && a.ownerDomain && ( + @{a.ownerUsername}@{a.ownerDomain} + )} + + ) : ( + e.stopPropagation()} + > + {a.ownerDisplayName ?? a.ownerUsername} + + )} {" · "}
    diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 16a6d92..0594e26 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -66,14 +66,15 @@ ## 8. Feed query update -- [ ] 8.1 Update `listSocialFeed` to include audience-aware filtering: include `(audience='public' OR (audience='followers-only' AND f.followed_actor_iri = a.remote_actor_iri))` predicate -- [ ] 8.2 Update feed sort to use `COALESCE(created_at, remote_created_at)` for mixed local/remote rows +- [x] 8.1 Update `listSocialFeed` to include audience-aware filtering: include `(audience='public' OR (audience='followers-only' AND f.followed_actor_iri = a.remote_actor_iri))` predicate +- [x] 8.2 Update feed sort to use `COALESCE(created_at, remote_created_at)` for mixed local/remote rows ## 9. Profile page updates -- [ ] 9.1 Pending state on Follow button (distinct from Follow/Unfollow) -- [ ] 9.2 Federation gate: actor object endpoint returns 404 if `profile_visibility = 'private'`, mirroring the human profile gate -- [ ] 9.3 When a user flips to `private`, federation must stop: outgoing `Accept(Follow)` rows where they're the followed party are no longer honored, push delivery is suppressed, and inbound Follows return 404. (Existing accepted follow rows on the remote side are not actively reaped — remotes will discover via their next outbox poll returning 404 / via a broadcast `Update` of the actor — pick the right approach during implementation) +- [x] 9.1 Pending state on Follow button (distinct from Follow/Unfollow) + > Shipped with locked accounts for local profiles; remote Pending lives on /follows/outgoing (no local profile page for remote actors). +- [x] 9.2 Federation gate: actor object endpoint returns 404 if `profile_visibility = 'private'`, mirroring the human profile gate +- [x] 9.3 When a user flips to `private`, federation must stop: outgoing `Accept(Follow)` rows where they're the followed party are no longer honored, push delivery is suppressed, and inbound Follows return 404. (Existing accepted follow rows on the remote side are not actively reaped — remotes will discover via their next outbox poll returning 404 / via a broadcast `Update` of the actor — pick the right approach during implementation) ## 10. Privacy + docs From 3c2bdfd2bdf9584527f0bfd310fc98af0975a0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 12:35:13 +0200 Subject: [PATCH 05/22] =?UTF-8?q?feat(journal):=20honor=20429/Retry-After?= =?UTF-8?q?=20in=20outbox=20polling=20(=C2=A77.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 7.4 remainder. Fedify's FetchError carries the failed Response, so a 429 from a remote's outbox now reads Retry-After (delta-seconds or HTTP-date per RFC 9110), arms a per-host backoff (15 min default, capped at the 1 h poll interval), and pollRemoteActor skips backing-off hosts before doing any DB or network work. State is in-process like the pacing map — a restart costs at most one extra request that re-arms the backoff. last_polled_at is deliberately not stamped on a rate-limited poll so the hourly sweep retries. Unit tests cover the header parser (incl. the V8 footgun where Date.parse reads bare negative integers as years), default/cap behavior, and window expiry; an integration test drives the full FetchError path and asserts the second poll never touches the network. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/federation-ingest.integration.test.ts | 32 ++++++++ .../app/lib/federation-ingest.server.test.ts | 55 +++++++++++++- .../app/lib/federation-ingest.server.ts | 76 ++++++++++++++++++- openspec/changes/social-federation/tasks.md | 4 +- 4 files changed, 161 insertions(+), 6 deletions(-) diff --git a/apps/journal/app/lib/federation-ingest.integration.test.ts b/apps/journal/app/lib/federation-ingest.integration.test.ts index 1f746e1..d5fcd98 100644 --- a/apps/journal/app/lib/federation-ingest.integration.test.ts +++ b/apps/journal/app/lib/federation-ingest.integration.test.ts @@ -3,10 +3,12 @@ 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"; @@ -56,6 +58,7 @@ describe.runIf(runIntegration)("outbox-poll ingestion (integration)", () => { }); afterEach(async () => { + resetHostBackoff(); const db = getDb(); await db.delete(activities).where(like(activities.remoteOriginIri, `${ACTOR}%`)); await db.delete(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); @@ -153,6 +156,35 @@ describe.runIf(runIntegration)("outbox-poll ingestion (integration)", () => { expect(result).toEqual({ skipped: "no accepted local follower" }); }); + it("a 429 with Retry-After backs off the host; the next poll skips without fetching (7.4)", async () => { + await makeFollowerOf(ACTOR); + let fetches = 0; + const deps = { + async pace() {}, + async fetchJson(url: string): Promise { + fetches++; + throw new FetchError( + new URL(url), + "rate limited", + new Response(null, { status: 429, headers: { "Retry-After": "120" } }), + ); + }, + }; + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ skipped: "rate limited" }); + expect(fetches).toBe(1); + + // Host is now backing off — no network attempt at all. + expect(await pollRemoteActor(ACTOR, deps)).toEqual({ + skipped: "host rate-limited (backing off)", + }); + expect(fetches).toBe(1); + + // last_polled_at was NOT stamped, so the sweep will retry later. + const db = getDb(); + const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, ACTOR)); + expect(cached?.lastPolledAt ?? null).toBeNull(); + }); + it("listActorsDuePolling returns followed actors not polled within the hour", async () => { await makeFollowerOf(ACTOR); expect(await listActorsDuePolling()).toContain(ACTOR); diff --git a/apps/journal/app/lib/federation-ingest.server.test.ts b/apps/journal/app/lib/federation-ingest.server.test.ts index 8167e53..80f1fda 100644 --- a/apps/journal/app/lib/federation-ingest.server.test.ts +++ b/apps/journal/app/lib/federation-ingest.server.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; vi.mock("./db.ts", () => ({ getDb: () => ({}) })); -const { parseOutboxItem } = await import("./federation-ingest.server.ts"); +const { + parseOutboxItem, + parseRetryAfter, + noteHostRateLimited, + isHostBackingOff, + resetHostBackoff, + pollRemoteActor, +} = await import("./federation-ingest.server.ts"); function trailsCreate(overrides: Record = {}, noteOverrides: Record = {}) { return { @@ -78,3 +85,47 @@ describe("parseOutboxItem", () => { expect(parsed?.name).not.toContain(""); }); }); + +describe("429 backoff (7.4)", () => { + afterEach(() => resetHostBackoff()); + + const NOW = Date.parse("2026-06-07T12:00:00Z"); + + it("parses Retry-After as delta-seconds and HTTP-date", () => { + expect(parseRetryAfter("120", NOW)).toBe(120_000); + expect(parseRetryAfter(" 5 ", NOW)).toBe(5_000); + expect(parseRetryAfter("Sun, 07 Jun 2026 12:01:00 GMT", NOW)).toBe(60_000); + // past HTTP-date clamps to zero + expect(parseRetryAfter("Sun, 07 Jun 2026 11:00:00 GMT", NOW)).toBe(0); + expect(parseRetryAfter(null, NOW)).toBeNull(); + expect(parseRetryAfter("soon", NOW)).toBeNull(); + expect(parseRetryAfter("-5", NOW)).toBeNull(); + }); + + it("falls back to the default and caps absurd Retry-After values", () => { + // default (no header): 15 min + expect(noteHostRateLimited("a.example", null, NOW)).toBe(15 * 60 * 1000); + // honored when reasonable + expect(noteHostRateLimited("b.example", "120", NOW)).toBe(120_000); + // capped at the poll interval (1 h) + expect(noteHostRateLimited("c.example", String(7 * 24 * 3600), NOW)).toBe(60 * 60 * 1000); + }); + + it("backs off the host until the window expires", () => { + noteHostRateLimited("d.example", "60", NOW); + expect(isHostBackingOff("d.example", NOW + 59_000)).toBe(true); + expect(isHostBackingOff("d.example", NOW + 61_000)).toBe(false); + // expiry clears the entry; a later check stays false + expect(isHostBackingOff("d.example", NOW)).toBe(false); + expect(isHostBackingOff("other.example", NOW)).toBe(false); + }); + + it("pollRemoteActor skips a backing-off host before touching the database", async () => { + // getDb is mocked to {} — any query would throw, so reaching the + // skip proves the backoff check runs first. + noteHostRateLimited("limited.example", "3600"); + await expect(pollRemoteActor("https://limited.example/users/alice")).resolves.toEqual({ + skipped: "host rate-limited (backing off)", + }); + }); +}); diff --git a/apps/journal/app/lib/federation-ingest.server.ts b/apps/journal/app/lib/federation-ingest.server.ts index ae7fa5d..767a95f 100644 --- a/apps/journal/app/lib/federation-ingest.server.ts +++ b/apps/journal/app/lib/federation-ingest.server.ts @@ -8,6 +8,7 @@ // // 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"; @@ -25,6 +26,14 @@ const POLL_INTERVAL_MS = 60 * 60 * 1000; 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; @@ -159,6 +168,59 @@ export interface PollDeps { const lastFetchPerHost = new Map(); +// 7.4: hosts that answered 429 are skipped until their backoff expires. +// In-process state, like the pacing map above — a restart forgets it, +// which is fine: the worst case is one extra request that gets another +// 429 and re-arms the backoff. +const hostBackoffUntil = new Map(); + +/** + * Parse a Retry-After header value (RFC 9110: delta-seconds or an + * HTTP-date) into a millisecond delay from `now`. Null if absent or + * unparseable. + */ +export function parseRetryAfter(value: string | null, now: number): number | null { + if (value == null) return null; + const trimmed = value.trim(); + // Bare integers are delta-seconds; negative ones are invalid (and + // must not fall through to Date.parse, which reads them as years). + if (/^-?\d+$/.test(trimmed)) { + return /^\d+$/.test(trimmed) ? Number(trimmed) * 1000 : null; + } + const date = Date.parse(trimmed); + if (!Number.isNaN(date)) return Math.max(0, date - now); + return null; +} + +/** + * Record a 429 from `host`: back off for the remote's Retry-After + * (capped) or a default. Returns the applied delay in ms. + */ +export function noteHostRateLimited( + host: string, + retryAfter: string | null, + now = Date.now(), +): number { + const delay = Math.min(parseRetryAfter(retryAfter, now) ?? DEFAULT_BACKOFF_MS, MAX_BACKOFF_MS); + hostBackoffUntil.set(host, now + delay); + return delay; +} + +/** Whether `host` is inside a 429 backoff window. */ +export function isHostBackingOff(host: string, now = Date.now()): boolean { + const until = hostBackoffUntil.get(host) ?? 0; + if (until <= now) { + hostBackoffUntil.delete(host); + return false; + } + return true; +} + +/** Test hook: forget all 429 backoff state. */ +export function resetHostBackoff(): void { + hostBackoffUntil.clear(); +} + function defaultDeps(): PollDeps { return { async fetchJson(url, signerUsername) { @@ -186,6 +248,10 @@ 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. @@ -203,7 +269,6 @@ export async function pollRemoteActor( .limit(1); if (!signer) return { skipped: "no accepted local follower" }; - const host = new URL(actorIri).host; try { await deps.pace(host); @@ -251,7 +316,14 @@ export async function pollRemoteActor( logger.info({ actorIri, fetched: items.length, inserted }, "federation: outbox poll"); return { inserted }; } catch (err) { - // 429 / network failures: log and let the hourly sweep retry. + // 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" }; } diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 0594e26..5ef6bab 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -60,8 +60,8 @@ - [x] 7.1 pg-boss recurring job `poll-remote-outboxes` (cron every 5 min): iterate distinct accepted remote actor IRIs in `follows` where `remote_actors.last_polled_at < now() - interval '1 hour'`; for each, signed GET on the outbox using one of the local accepted-followers' keys - [x] 7.2 Insert returned activities into `journal.activities` with `remote_origin_iri`, `remote_actor_iri`, `audience`. `ON CONFLICT (remote_origin_iri) DO NOTHING` for replay safety. Stop early on streak of conflicts - [x] 7.3 Refresh `remote_actors` cache row on each poll (display name, avatar, outbox URL, public key) -- [ ] 7.4 Per-remote-host rate limit (1 req / 5 sec); honor `Retry-After` / `429`; back off the host - > Partially done (2026-06-07): 1 req/5 s pacing implemented; any fetch failure (incl. 429) skips the host until the next 5-min sweep. Explicit Retry-After-duration backoff remains — needs header access through Fedify's document loader errors. +- [x] 7.4 Per-remote-host rate limit (1 req / 5 sec); honor `Retry-After` / `429`; back off the host + > Done (2026-06-07): 1 req/5 s pacing + explicit 429 backoff. Fedify's `FetchError` carries the failed `Response`, so `Retry-After` is read from `err.response.headers` (delta-seconds or HTTP-date, capped at the 1 h poll interval; 15 min default). Backing-off hosts are skipped before any DB/network work; in-process state (a restart costs at most one extra 429). - [x] 7.5 First-poll trigger on `accepted_at` transition (already enqueued from 4.4) ## 8. Feed query update From 1eceb6f1f71704fbf50254b1d59f0a898a9d6d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 13:36:35 +0200 Subject: [PATCH 06/22] =?UTF-8?q?feat(journal):=20two-instance=20federatio?= =?UTF-8?q?n=20harness=20+=20trails=E2=86=94trails=20Accept=20fix=20(?= =?UTF-8?q?=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 11.4 harness — e2e/federation/: postgres (two DBs) + a Caddy with an internal CA terminating real HTTPS between two complete journal containers (journal-a.test / journal-b.test), driven by an opt-in integration test (FEDERATION_TWO_INSTANCE=1, via run.sh). The driver seeds users straight into each DB, mints a signed session cookie (known SESSION_SECRET), follows across instances through the real /follows/outgoing route, and asserts the full pipeline: Follow → auto-Accept → settle → first outbox poll → bob's public activity rendered in alice's /feed with @bob@journal-b.test attribution. Plus a wire-level 11.3 check: unsigned Create(Note) → 4xx, no DB writes. And the harness immediately earned its keep — it caught a real trails↔trails bug Mastodon interop never could: our Follow ids are fragment URIs on OUR domain, so the Follow embedded in another trails instance's Accept is cross-origin and Fedify rightly distrusts it; re-fetching the id returns the actor document, getObject() yields a Person, and the Accept/Reject/Undo listeners bailed silently — follows stayed Pending forever. The listeners now fall back to the wire objectId (captured BEFORE getObject(), which memoizes the fetched document and changes what objectId reports) validated against our Follow-id shape + the personal inbox's ctx.recipient. Forgery-safe: settleOutgoingFollow only matches a Pending row toward the HTTP-Signature-authenticated sender. Supporting changes: - federation.server.ts: env-gated allowPrivateAddress (FEDERATION_ALLOW_PRIVATE_ADDRESS=true) so the harness's RFC 1918 Docker network is fetchable — testing only, loudly documented. - Root .dockerignore: local builds shipped a 1GB+ context and overlaid host (darwin) node_modules over the image's own install via COPY . . — CI never noticed because it builds from clean checkouts. Context is now ~5MB. - tasks.md: 11.1–11.7 marked with provenance notes; design.md gains the cross-origin embedded-object lesson. Gate: typecheck ✓ lint ✓ unit+integration (FEDERATION_INTEGRATION=1) ✓ e2e 71/72 + known komoot flake green isolated ✓ harness run clean from scratch (2/2, teardown included) ✓ Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 14 ++ ...ederation-two-instance.integration.test.ts | 203 ++++++++++++++++++ apps/journal/app/lib/federation.server.ts | 96 +++++++-- e2e/federation/Caddyfile | 16 ++ e2e/federation/README.md | 56 +++++ e2e/federation/docker-compose.yml | 145 +++++++++++++ e2e/federation/init-dbs.sql | 6 + e2e/federation/run.sh | 55 +++++ openspec/changes/social-federation/design.md | 18 ++ openspec/changes/social-federation/tasks.md | 21 +- 10 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 apps/journal/app/lib/federation-two-instance.integration.test.ts create mode 100644 e2e/federation/Caddyfile create mode 100644 e2e/federation/README.md create mode 100644 e2e/federation/docker-compose.yml create mode 100644 e2e/federation/init-dbs.sql create mode 100755 e2e/federation/run.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d2af30 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Keep Docker build contexts small and hermetic. CI builds from a clean +# checkout so this mostly matters for local builds (e2e/federation +# harness), where node_modules would otherwise bloat the context to +# gigabytes — and worse, `COPY . .` would overlay host-installed +# node_modules over the image's own pnpm install. +**/node_modules +**/.turbo +**/build +**/.react-router +.git +e2e/results +playwright-report +test-results +**/*.log diff --git a/apps/journal/app/lib/federation-two-instance.integration.test.ts b/apps/journal/app/lib/federation-two-instance.integration.test.ts new file mode 100644 index 0000000..d8ad1ee --- /dev/null +++ b/apps/journal/app/lib/federation-two-instance.integration.test.ts @@ -0,0 +1,203 @@ +// Two-instance federation drive-through (spec: social-federation 11.4). +// +// Talks to the docker-compose harness in e2e/federation/ — two complete +// journal instances (journal-a.test / journal-b.test) federating over a +// private Docker network with real HTTPS between them. This test is the +// driver: it seeds users straight into each instance's database, mints +// a session cookie for alice (cookie sessions are signed with the +// harness's known SESSION_SECRET), follows bob across instances through +// the real /follows/outgoing route, and then watches the entire +// Follow → Accept → first outbox poll → feed pipeline happen between +// two live servers. +// +// Run via e2e/federation/run.sh — it builds the images, pushes the +// schema into both databases, and sets FEDERATION_TWO_INSTANCE=1. + +import { describe, it, expect, afterAll } from "vitest"; +import postgres, { type Sql } from "postgres"; +import { randomUUID } from "node:crypto"; +import { createCookieSessionStorage } from "react-router"; + +const runTwoInstance = process.env.FEDERATION_TWO_INSTANCE === "1"; + +// Host-published harness endpoints (see e2e/federation/docker-compose.yml). +const A_URL = "http://127.0.0.1:3401"; +const B_URL = "http://127.0.0.1:3402"; +const DB_A = "postgres://trails:trails@127.0.0.1:5499/trails_a"; +const DB_B = "postgres://trails:trails@127.0.0.1:5499/trails_b"; +const B_DOMAIN = "journal-b.test"; +const BOB_ACTOR_IRI = `https://${B_DOMAIN}/users/bob`; +const ACTIVITY_NAME = "Sunrise ride over the ridge"; + +/** Poll `fn` until it returns something truthy. */ +async function until( + what: string, + fn: () => Promise, + timeoutMs = 120_000, +): Promise { + const start = Date.now(); + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() - start > timeoutMs) throw new Error(`Timed out waiting for ${what}`); + await new Promise((r) => setTimeout(r, 1500)); + } +} + +async function seedUser(sql: Sql, username: string, domain: string): Promise { + const id = randomUUID(); + await sql` + INSERT INTO journal.users (id, email, username, domain, profile_visibility, terms_accepted_at, terms_version) + VALUES (${id}, ${`${username}@${domain}`}, ${username}, ${domain}, 'public', now(), '2026-04-19') + `; + return id; +} + +/** + * Mint a valid `__session` cookie for a user. The harness journals run + * with a known SESSION_SECRET, and sessions are signed cookies (see + * auth/session.server.ts) — so the driver can authenticate as any + * seeded user without driving the WebAuthn ceremony. + */ +async function mintSessionCookie(userId: string): Promise { + const storage = createCookieSessionStorage({ + cookie: { + name: "__session", + httpOnly: true, + sameSite: "lax", + path: "/", + secrets: ["two-instance-test-secret"], + }, + }); + const session = await storage.getSession(); + session.set("userId", userId); + const setCookie = await storage.commitSession(session); + return setCookie.split(";")[0]!; +} + +describe.runIf(runTwoInstance)("two-instance federation (11.4)", () => { + const sqlA = postgres(DB_A, { max: 2 }); + const sqlB = postgres(DB_B, { max: 2 }); + + afterAll(async () => { + await sqlA.end(); + await sqlB.end(); + }); + + it( + "alice@journal-a follows bob@journal-b: Follow → Accept → first poll → bob's public activity in alice's feed", + { timeout: 300_000 }, + async () => { + // Both instances are up (run.sh already gated on the compose + // healthchecks; this is a fail-fast sanity check). + for (const base of [A_URL, B_URL]) { + const health = await fetch(`${base}/api/health`); + expect(health.status).toBe(200); + } + + // ---- Seed: alice on A; bob + a public activity on B ---------- + const aliceId = await seedUser(sqlA, "alice", "journal-a.test"); + const bobId = await seedUser(sqlB, "bob", B_DOMAIN); + await sqlB` + INSERT INTO journal.activities (id, owner_id, name, description, visibility) + VALUES (${randomUUID()}, ${bobId}, ${ACTIVITY_NAME}, '', 'public') + `; + + // B serves bob over WebFinger before we point A at him. (Fedify's + // canonical-origin support means the host-port URL works here.) + const wf = await fetch(`${B_URL}/.well-known/webfinger?resource=acct:bob@${B_DOMAIN}`); + expect(wf.status).toBe(200); + + // ---- alice follows bob through the real route ----------------- + const cookie = await mintSessionCookie(aliceId); + const follow = await fetch(`${A_URL}/follows/outgoing`, { + method: "POST", + redirect: "manual", + headers: { cookie, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ intent: "follow", handle: `bob@${B_DOMAIN}` }), + }); + if (follow.status >= 400) { + // FollowError surfaces as a 400 with a code — bubble the body + // up so a harness failure says *why* resolution failed. + throw new Error(`follow action failed: ${follow.status} ${await follow.text()}`); + } + + // Outbound resolution (NodeInfo + WebFinger + actor fetch over the + // Caddy TLS hop) happens inside the action — a Pending row is the + // proof it all worked. + const pending = await until("pending follow row on A", async () => { + const rows = await sqlA` + SELECT id, accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }, 30_000); + expect(pending).toBeDefined(); + + // ---- B auto-accepts (public profile) and delivers Accept ------ + await until("Accept to settle the follow on A", async () => { + const rows = await sqlA` + SELECT accepted_at FROM journal.follows + WHERE follower_id = ${aliceId} AND followed_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]?.accepted_at != null; + }); + + // B's side sees a remote follower row for alice. + const bFollow = await sqlB` + SELECT follower_actor_iri FROM journal.follows WHERE followed_user_id = ${bobId} + `; + expect(bFollow[0]?.follower_actor_iri).toBe("https://journal-a.test/users/alice"); + + // ---- First poll ingests bob's outbox into A ------------------- + const ingested = await until("bob's activity to be ingested on A", async () => { + const rows = await sqlA` + SELECT name, audience, owner_id FROM journal.activities + WHERE remote_actor_iri = ${BOB_ACTOR_IRI} + `; + return rows[0]; + }); + expect(ingested.name).toBe(ACTIVITY_NAME); + expect(ingested.audience).toBe("public"); + expect(ingested.owner_id).toBeNull(); + + // ---- And it shows up in alice's rendered feed ------------------ + const feed = await fetch(`${A_URL}/feed`, { headers: { cookie } }); + expect(feed.status).toBe(200); + // JSX puts `` separators between adjacent text expressions + // — strip them so the attribution reads as continuous text. + const html = (await feed.text()).replaceAll("", ""); + expect(html).toContain(ACTIVITY_NAME); + expect(html).toContain(`@bob@${B_DOMAIN}`); + }, + ); + + it("an unsigned Create(Note) posted to the inbox is rejected with no DB writes (11.3)", async () => { + const originIri = `https://intruder.example/notes/${randomUUID()}`; + const res = await fetch(`${A_URL}/users/alice/inbox`, { + method: "POST", + headers: { "content-type": "application/activity+json" }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${originIri}#create`, + type: "Create", + actor: "https://intruder.example/users/mallory", + to: "https://www.w3.org/ns/activitystreams#Public", + object: { + id: originIri, + type: "Note", + content: "

    Injected note

    ", + }, + }), + }); + // Unauthenticated inbox delivery: Fedify refuses it outright + // (400 malformed / 401 unsigned — both fine, never 2xx). + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const rows = await sqlA` + SELECT id FROM journal.activities WHERE remote_origin_iri = ${originIri} + `; + expect(rows).toHaveLength(0); + }); +}); diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts index fa25f68..021cc5f 100644 --- a/apps/journal/app/lib/federation.server.ts +++ b/apps/journal/app/lib/federation.server.ts @@ -97,6 +97,20 @@ async function findLocalPublicUserByIri(iri: URL | null): Promise#follows/`, see federation-outbound.server.ts) and + * names the recipient of the personal inbox the activity arrived in. + * Used by the Accept/Reject listeners: another trails instance can't + * embed a trustworthy copy of our Follow (its id is cross-origin for + * them), so the id is the only recoverable reference. + */ +function isRecipientFollowUri(ctx: { recipient: string | null }, id: URL | null): boolean { + if (id == null || ctx.recipient == null) return false; + if (!id.hash.startsWith("#follows/")) return false; + return id.href.startsWith(`${localActorIri(ctx.recipient)}#`); +} + let _federation: Federation | null = null; let _logtapeConfigured = false; @@ -146,6 +160,15 @@ function buildFederation(): Federation { // Canonical origin so generated IRIs are correct behind the Caddy // proxy (the Node server itself only sees plain HTTP). origin: getOrigin(), + // SSRF-guard opt-out for the two-instance federation harness + // (e2e/federation/), where both instances live on a private Docker + // network that Fedify's document loader rightly refuses to fetch + // from. Testing only — never set this in a real deployment: the + // private-address block is a genuine security boundary (a malicious + // actor IRI must not be able to point us at internal targets). + ...(process.env.FEDERATION_ALLOW_PRIVATE_ADDRESS === "true" + ? { allowPrivateAddress: true } + : {}), }); if (!process.env.VITEST) { // Fire-and-forget: runs the queue consumer loop for the process @@ -261,25 +284,66 @@ function buildFederation(): Federation { .on(Undo, async (ctx, undo) => { // Spec 4.3: Undo(Follow) removes the follow row. Other Undos are // acknowledged and dropped. + if (undo.actorId == null) return; + const undoObjectId = undo.objectId; // capture before dereference (see Accept) const object = await undo.getObject(ctx); - if (!(object instanceof Follow)) return; - if (undo.actorId == null || object.objectId == null) return; - const parsed = ctx.parseUri(object.objectId); - if (parsed?.type !== "actor") return; - await removeRemoteFollow(undo.actorId.href, parsed.identifier); + if (object instanceof Follow && object.objectId != null) { + const parsed = ctx.parseUri(object.objectId); + if (parsed?.type !== "actor") return; + await removeRemoteFollow(undo.actorId.href, parsed.identifier); + return; + } + // trails→trails fallback: another trails instance's Undo embeds + // a Follow whose id lives on the SENDER's domain, but the embed + // is cross-origin relative to nothing we can verify, and + // dereferencing `…#follows/` yields the sender's actor + // document, not a Follow (fragments aren't separately fetchable). + // Our Follow ids are `#follows/` — when the Undo + // names one owned by the authenticated sender, the recipient of + // this personal inbox is the unfollowed user. + if ( + ctx.recipient != null && + undoObjectId?.hash.startsWith("#follows/") && + undoObjectId.origin === new URL(undo.actorId.href).origin + ) { + await removeRemoteFollow(undo.actorId.href, ctx.recipient); + } }) .on(Accept, async (ctx, accept) => { // Spec 4.4: a remote accepted our outgoing Follow — settle the // Pending row and trigger the first outbox poll for that actor. - const object = await accept.getObject(ctx); - if (!(object instanceof Follow)) return; if (accept.actorId == null) return; - const localUser = await findLocalPublicUserByIri(object.actorId); + // Capture the raw object reference BEFORE dereferencing: + // getObject() memoizes the fetched document, after which objectId + // reports the fetched object's id (fragment stripped) instead of + // the id that was on the wire. + const objectId = accept.objectId; + const object = await accept.getObject(ctx); + logger.debug( + { + recipient: ctx.recipient, + objectId: objectId?.href ?? null, + objectType: object?.constructor?.name ?? null, + }, + "federation: Accept listener dispatch", + ); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // trails→trails: our own Follow id is cross-origin from the + // accepting instance's perspective, so Fedify rightly distrusts + // the embedded copy and a re-fetch of `…#follows/` returns + // our actor document instead of a Follow. The id itself names + // this inbox's recipient, which is all settling needs — and + // settleOutgoingFollow only matches a Pending row toward the + // authenticated sender, so a forged Accept can't settle + // anything that wasn't already directed at that actor. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } if (!localUser) return; const { settled } = await settleOutgoingFollow(localUser.id, accept.actorId.href); if (settled) { - // Queue worker lands in the outbox-poll change (task 7.x); - // enqueueOptional logs-and-continues until then. await enqueueOptional("poll-remote-actor", { actorIri: accept.actorId.href }, { reason: "first poll after accepted follow", }); @@ -287,10 +351,16 @@ function buildFederation(): Federation { }) .on(Reject, async (ctx, reject) => { // Spec 4.5: remote refused our Follow — drop the Pending row. - const object = await reject.getObject(ctx); - if (!(object instanceof Follow)) return; if (reject.actorId == null) return; - const localUser = await findLocalPublicUserByIri(object.actorId); + const objectId = reject.objectId; // capture before dereference (see Accept) + const object = await reject.getObject(ctx); + let localUser: Awaited> = null; + if (object instanceof Follow) { + localUser = await findLocalPublicUserByIri(object.actorId); + } else if (isRecipientFollowUri(ctx, objectId)) { + // Same trails→trails fallback as the Accept listener above. + localUser = await findLocalPublicUserByIri(new URL(localActorIri(ctx.recipient!))); + } if (!localUser) return; await rejectOutgoingFollow(localUser.id, reject.actorId.href); }) diff --git a/e2e/federation/Caddyfile b/e2e/federation/Caddyfile new file mode 100644 index 0000000..ff65461 --- /dev/null +++ b/e2e/federation/Caddyfile @@ -0,0 +1,16 @@ +# TLS terminator for the two-instance federation harness. `local_certs` +# issues every site cert from Caddy's internal CA; the journals trust it +# via NODE_EXTRA_CA_CERTS (see docker-compose.yml). +{ + local_certs + # No public reachability — don't try (or wait on) ACME/OCSP anything. + skip_install_trust +} + +journal-a.test { + reverse_proxy journal-a:3000 +} + +journal-b.test { + reverse_proxy journal-b:3000 +} diff --git a/e2e/federation/README.md b/e2e/federation/README.md new file mode 100644 index 0000000..23e1e2d --- /dev/null +++ b/e2e/federation/README.md @@ -0,0 +1,56 @@ +# Two-instance federation harness + +Spec: `openspec/changes/social-federation/` task 11.4. + +Two complete journal instances — `journal-a.test` and `journal-b.test` — +federate with each other inside a Docker network, with real HTTPS +between them (a Caddy with `local_certs` terminates TLS; the journals +trust its internal CA via `NODE_EXTRA_CA_CERTS`). The driver test seeds +a user on each side, follows across instances through the real UI +route, and asserts the full pipeline: + +``` +alice@journal-a ──Follow──▶ bob@journal-b (NodeInfo + WebFinger + + ◀──Accept── actor fetch over TLS) + ──signed outbox poll──▶ +alice's /feed shows bob's public activity with @bob@journal-b.test +``` + +## Run it + +```bash +./e2e/federation/run.sh # build, test, tear down +KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up afterwards +``` + +First run builds the journal image (a few minutes); later runs reuse it. +Opt-in only — nothing here runs under `pnpm test`, `pnpm test:e2e`, or +CI. The driver lives at +`apps/journal/app/lib/federation-two-instance.integration.test.ts` +(gated by `FEDERATION_TWO_INSTANCE=1`). + +## How the driver authenticates + +Sessions are signed cookies (`auth/session.server.ts`). The harness +journals run with a known `SESSION_SECRET`, so the driver mints a valid +`__session` cookie for the seeded user instead of driving the WebAuthn +ceremony. That secret (and everything else in the compose env) is a +throwaway test value on a loopback-only stack. + +## Why `FEDERATION_ALLOW_PRIVATE_ADDRESS` + +Fedify's document loader refuses private network addresses (SSRF +guard). Both instances here live on RFC 1918 Docker addresses, so the +harness sets `FEDERATION_ALLOW_PRIVATE_ADDRESS=true` — the env-gated +opt-out in `federation.server.ts`. Never set it in a real deployment. + +## Debugging + +```bash +docker compose -f e2e/federation/docker-compose.yml logs -f journal-a +docker compose -f e2e/federation/docker-compose.yml exec postgres \ + psql -U trails trails_a -c 'TABLE journal.follows' +``` + +`FEDERATION_LOG_LEVEL=debug` is already on — Fedify logs every +signature verification and delivery attempt. diff --git a/e2e/federation/docker-compose.yml b/e2e/federation/docker-compose.yml new file mode 100644 index 0000000..60e8866 --- /dev/null +++ b/e2e/federation/docker-compose.yml @@ -0,0 +1,145 @@ +# Two-instance federation harness (social-federation task 11.4). +# +# Brings up two complete journal instances — journal-a.test and +# journal-b.test — that federate with each other over a private Docker +# network, with real HTTPS between them (Caddy internal CA; the journals +# trust it via NODE_EXTRA_CA_CERTS). Driven by +# apps/journal/app/lib/federation-two-instance.integration.test.ts via +# ./run.sh — see README.md. +# +# Ports published to the host (driver-facing, loopback only): +# 127.0.0.1:5499 → postgres (schema push + seeding + asserts) +# 127.0.0.1:3401 → journal-a :3000 (plain HTTP; the TLS hop is only +# 127.0.0.1:3402 → journal-b :3000 needed for instance↔instance) + +name: trails-federation-e2e +# Shared journal environment. Secrets here are throwaway test values — +# the whole stack is loopback-only and torn down by run.sh. +x-journal-env: &journal-env + SESSION_SECRET: two-instance-test-secret + FEDERATION_ENABLED: "true" + FEDERATION_KEY_ENCRYPTION_KEY: two-instance-test-encryption-key + FEDERATION_LOG_LEVEL: debug + LOG_LEVEL: debug + # Both instances live on RFC 1918 Docker addresses; Fedify's SSRF + # guard must be told to stand down. Testing only — see the comment in + # apps/journal/app/lib/federation.server.ts. + FEDERATION_ALLOW_PRIVATE_ADDRESS: "true" + # Trust Caddy's internal CA for the instance↔instance HTTPS hop. + # Published world-readable by the ca-publisher one-shot — the journal + # runs as a non-root user and can't read caddy's 0700 data volume. + NODE_EXTRA_CA_CERTS: /ca/root.crt + SENTRY_DISABLED: "true" + + +services: + postgres: + image: imresamu/postgis:16-3.4 # multi-arch, same as docker-compose.dev.yml + environment: + POSTGRES_USER: trails + POSTGRES_PASSWORD: trails + POSTGRES_DB: trails_a + volumes: + - ./init-dbs.sql:/docker-entrypoint-initdb.d/90-init-dbs.sql:ro + ports: + - "127.0.0.1:5499:5432" + healthcheck: + # -h 127.0.0.1 forces TCP: during first-boot initdb a temporary + # postgres answers on the unix socket only, and a socket-based + # pg_isready reports healthy before the init scripts have run. + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U trails -d trails_b"] + interval: 2s + timeout: 3s + retries: 30 + + caddy: + image: caddy:2-alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + networks: + default: + # The instances reach each other through Caddy: these aliases + # make journal-a.test / journal-b.test resolve to the proxy + # inside the compose network. + aliases: + - journal-a.test + - journal-b.test + healthcheck: + # The journals read the internal CA root at process start + # (NODE_EXTRA_CA_CERTS) — gate their startup on it existing. + test: ["CMD", "test", "-f", "/data/caddy/pki/authorities/local/root.crt"] + interval: 2s + timeout: 3s + retries: 30 + + # One-shot: copies Caddy's internal root CA into a volume the + # non-root journal user can actually read (caddy's own data dir is + # 0700 root). + ca-publisher: + image: caddy:2-alpine + entrypoint: + - sh + - -c + - | + until [ -f /data/caddy/pki/authorities/local/root.crt ]; do sleep 0.5; done + cp /data/caddy/pki/authorities/local/root.crt /ca/root.crt + chmod 444 /ca/root.crt + volumes: + - caddy-data:/data:ro + - ca-cert:/ca + depends_on: + caddy: + condition: service_healthy + + journal-a: + build: + context: ../.. + dockerfile: apps/journal/Dockerfile + image: trails-federation-e2e-journal + pull_policy: never + environment: + <<: *journal-env + DATABASE_URL: postgres://trails:trails@postgres:5432/trails_a + ORIGIN: https://journal-a.test + ports: + - "127.0.0.1:3401:3000" + volumes: + - ca-cert:/ca:ro + healthcheck: &journal-health + test: ["CMD", "curl", "-fsS", "http://localhost:3000/api/health"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 10s + depends_on: + postgres: + condition: service_healthy + ca-publisher: + condition: service_completed_successfully + + journal-b: + # Image-only on purpose: run.sh builds it once via journal-a before + # `up`. Building from both services races the export of the shared + # tag ("already exists"), and a registry pull must never happen. + image: trails-federation-e2e-journal + pull_policy: never + environment: + <<: *journal-env + DATABASE_URL: postgres://trails:trails@postgres:5432/trails_b + ORIGIN: https://journal-b.test + ports: + - "127.0.0.1:3402:3000" + volumes: + - ca-cert:/ca:ro + healthcheck: *journal-health + depends_on: + postgres: + condition: service_healthy + ca-publisher: + condition: service_completed_successfully + +volumes: + caddy-data: + ca-cert: + diff --git a/e2e/federation/init-dbs.sql b/e2e/federation/init-dbs.sql new file mode 100644 index 0000000..589b4e9 --- /dev/null +++ b/e2e/federation/init-dbs.sql @@ -0,0 +1,6 @@ +-- Second instance database. trails_a is the image's POSTGRES_DB (the +-- postgis image creates the extension there itself); trails_b needs +-- both created by hand. Runs once on first postgres boot. +CREATE DATABASE trails_b; +\connect trails_b +CREATE EXTENSION IF NOT EXISTS postgis; diff --git a/e2e/federation/run.sh b/e2e/federation/run.sh new file mode 100755 index 0000000..cb5be49 --- /dev/null +++ b/e2e/federation/run.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Two-instance federation harness runner (social-federation 11.4). +# +# ./e2e/federation/run.sh # build, test, tear down +# KEEP_STACK=1 ./e2e/federation/run.sh # leave the stack up for poking +# +# Brings up two journal instances that federate with each other (see +# docker-compose.yml), pushes the Drizzle schema into both databases, +# and runs the driver test. Opt-in and self-contained — nothing here is +# wired into `pnpm test` or CI. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +compose() { docker compose -f "$SCRIPT_DIR/docker-compose.yml" "$@"; } + +cleanup() { + if [[ "${KEEP_STACK:-}" == "1" ]]; then + echo "KEEP_STACK=1 — leaving the stack up. Tear down with:" + echo " docker compose -f $SCRIPT_DIR/docker-compose.yml down -v" + else + compose down -v --remove-orphans >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +echo "==> postgres + caddy (internal CA)" +compose up -d --wait postgres caddy + +echo "==> pushing schema into trails_a and trails_b" +push_schema() { + # A transient postgres backend crash mid-introspection has been seen + # under Docker Desktop load ("Connection terminated unexpectedly" + + # crash recovery). Retry after the healthcheck reports the server + # recovered — without hiding a deterministic failure. + local url="$1" attempt + for attempt in 1 2 3; do + if DATABASE_URL="$url" pnpm --dir "$ROOT" db:push; then return 0; fi + echo "schema push failed (attempt $attempt) — waiting for postgres to recover" + sleep 5 + compose up -d --wait postgres + done + return 1 +} +push_schema postgres://trails:trails@127.0.0.1:5499/trails_a +push_schema postgres://trails:trails@127.0.0.1:5499/trails_b + +echo "==> building the journal image (slow on first run)" +compose build journal-a + +echo "==> journal-a + journal-b" +compose up -d --wait journal-a journal-b + +echo "==> driving the federation flow" +FEDERATION_TWO_INSTANCE=1 pnpm --dir "$ROOT/apps/journal" exec vitest run app/lib/federation-two-instance.integration.test.ts diff --git a/openspec/changes/social-federation/design.md b/openspec/changes/social-federation/design.md index 0081bd4..741b799 100644 --- a/openspec/changes/social-federation/design.md +++ b/openspec/changes/social-federation/design.md @@ -108,6 +108,24 @@ Inbound signature verification uses the actor's public key from their actor obje ## Implementation Decisions (made during apply) +- **Cross-origin object references in Accept/Reject/Undo listeners** + (decided in task 11.4, 2026-06-07). When another *trails* instance + accepts our Follow, the embedded Follow inside its Accept is + cross-origin from the sender's perspective (the Follow's id lives on + OUR domain), so Fedify correctly distrusts the embedded copy and + re-fetches the id — but our Follow ids are fragment URIs + (`#follows/`), and fetching one returns the actor + document, not a Follow. `getObject()` then yields a `Person` and the + listener used to bail silently. Mastodon never trips this because the + Accept it sends embeds its *own* (same-origin) Follow. Fix: listeners + fall back to the wire `objectId` — captured **before** `getObject()`, + which memoizes the fetched document and changes what `objectId` + reports — validated against our Follow-id shape plus the personal + inbox's `ctx.recipient`. Still forgery-safe: `settleOutgoingFollow` + only matches a Pending row toward the HTTP-Signature-authenticated + sender. Found by (and regression-covered in) the two-instance + harness, `e2e/federation/`. + - **Inbound remote followers live in `follows` with a nullable `follower_id`** (decided in task 4.2, 2026-06-06). The original claim that `follows` was federation-ready only covered outbound; a remote follower has no local diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 5ef6bab..44176ac 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -84,13 +84,20 @@ ## 11. Testing -- [ ] 11.1 Unit tests: HTTP-Signature verification on inbox; signature production on outbox-poll; encrypt/decrypt of private keys -- [ ] 11.2 Integration test: post a signed `Follow` to local inbox from a fake Mastodon-shaped client → assert `Accept(Follow)` is delivered + follow row exists -- [ ] 11.3 Integration test: post a `Create(Note)` to local inbox → assert it is dropped silently (no DB writes) -- [ ] 11.4 Integration test: bring up two trails instances (Docker Compose multi-instance setup); A on instance 1 follows B on instance 2 → assert Follow → Accept → first poll all happen and B's public activity appears in A's `/feed` -- [ ] 11.5 Integration test: outbound follow attempt against a Mastodon-shaped actor → assert 4xx with the limitation message -- [ ] 11.6 Integration test (audience leak guard): two local users A and B, only A follows remote trails actor X; X's followers-only post lands in cache. Assert A sees it, B does not -- [ ] 11.7 E2E: with feature flag on, follow bruno across instances + see public activity appear +- [x] 11.1 Unit tests: HTTP-Signature verification on inbox; signature production on outbox-poll; encrypt/decrypt of private keys + > Done via combination (2026-06-07): encrypt/decrypt + sign/verify roundtrip in `federation-keys.server.test.ts`; HTTP-Signature verification/production is Fedify's tested core, exercised wire-level by the 11.4 harness (signed Follow/Accept/Authorized Fetch between two live instances). +- [x] 11.2 Integration test: post a signed `Follow` to local inbox from a fake Mastodon-shaped client → assert `Accept(Follow)` is delivered + follow row exists + > Done (2026-06-07): handler level in `federation-inbox.integration.test.ts`; full wire level (real signed Follow → Accept delivered + rows on both sides) in the 11.4 harness — a real instance instead of a fake client. Actual-Mastodon behavior was verified live in the 2026-06-06/07 soak. +- [x] 11.3 Integration test: post a `Create(Note)` to local inbox → assert it is dropped silently (no DB writes) + > Done (2026-06-07): in the 11.4 driver — unsigned Create(Note) to the inbox gets 4xx and writes nothing. Signed Creates have no listener (poll-only ingestion by design) and are discarded by Fedify. +- [x] 11.4 Integration test: bring up two trails instances (Docker Compose multi-instance setup); A on instance 1 follows B on instance 2 → assert Follow → Accept → first poll all happen and B's public activity appears in A's `/feed` + > Done (2026-06-07): `e2e/federation/` harness (postgres + caddy internal CA + two journal containers) driven by `federation-two-instance.integration.test.ts` via `run.sh`. Opt-in, not in CI. Caught a real trails↔trails bug: cross-origin embedded Follow objects in Accept/Reject/Undo are distrusted by Fedify and their fragment IRIs dereference to the actor document — listeners now recover via the wire objectId (captured before getObject(), which memoizes) + the personal-inbox recipient. +- [x] 11.5 Integration test: outbound follow attempt against a Mastodon-shaped actor → assert 4xx with the limitation message + > Done earlier in §6: `federation-outbound.integration.test.ts` ("refuses non-trails instances with a clear code"); route returns 400 + `not_trails`. +- [x] 11.6 Integration test (audience leak guard): two local users A and B, only A follows remote trails actor X; X's followers-only post lands in cache. Assert A sees it, B does not + > Done earlier in §8: `social-feed.integration.test.ts` audience-leak guard. +- [x] 11.7 E2E: with feature flag on, follow bruno across instances + see public activity appear + > Done (2026-06-07) by the 11.4 driver at the HTTP/UI boundary: the follow goes through the real `/follows/outgoing` route action with a real session cookie, and the assertion reads the rendered `/feed` HTML (activity name + `@bob@journal-b.test` attribution). A browser-driven repeat would re-test the same path through Playwright; skipped deliberately. ## 12. Rollout From 24357948c388bab76e2961f649bedc3c20689fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 13:38:46 +0200 Subject: [PATCH 07/22] ci(staging): enable federation on PR previews (rollout 12.4 surface) Every PR preview becomes a second live trails instance, so the trails-to-trails soak (social-federation 12.4) can run against real DNS + TLS + Caddy: follow from staging.trails.cool to pr-.staging.trails.cool and back. Same flag + key wiring as persistent staging; preview teardown leaves the remote side with ordinary dead-instance delivery failures, which fediverse software already expires. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cd-staging.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index ed59b70..c0ad3ea 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -252,6 +252,15 @@ jobs: echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}" echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" + # Federation on previews too (social-federation rollout 12.4): + # makes any PR preview a second live trails instance that + # persistent staging can follow across — the trails-to-trails + # soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the + # SOPS env decrypted above. Preview teardown orphans remote + # follower rows on the other side; remotes handle dead + # instances via ordinary delivery-failure expiry. + echo "FEDERATION_ENABLED=true" + echo "FEDERATION_LOG_LEVEL=debug" } >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env - name: Generate per-PR Caddyfile snippet From 1a8ec4e8b94384856419238064335a4a62b445ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 13:40:20 +0200 Subject: [PATCH 08/22] docs(staging): un-stale the federation comment in compose Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/docker-compose.staging.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index 0d4da27..1a09695 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -61,9 +61,9 @@ services: WAHOO_CLIENT_SECRET: "" WAHOO_WEBHOOK_TOKEN: "" DEMO_BOT_ENABLED: "" - # Federation soak (social-federation rollout step 12.2). Enabled by - # cd-staging.yml for persistent staging only; PR previews leave both - # empty (flag off) so preview journals never emit federation traffic. + # Federation (social-federation rollout). Enabled by cd-staging.yml + # for persistent staging (12.2) and for PR previews (12.4 — every + # preview is a second live instance staging can follow across). FEDERATION_ENABLED: ${FEDERATION_ENABLED:-} FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-} FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-} From 819a5d2976d1b2ae77da2298e2063096631bb1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 13:44:15 +0200 Subject: [PATCH 09/22] =?UTF-8?q?docs(openspec):=20social-federation=20?= =?UTF-8?q?=C2=A712=20rollout=20status=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12.1/12.2/12.3/12.6 marked with provenance (additive schema behind the off flag on prod; the 2026-06-06/07 staging soak against a real Mastodon covered inbound + push delivery; rollback documented in the runbook). 12.4 annotated: protocol verified by the two-instance harness, live staging⇄preview soak queued now that previews federate. 12.5 (prod flag flip) explicitly left as an operator decision. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/social-federation/tasks.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 44176ac..a143e99 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -101,9 +101,15 @@ ## 12. Rollout -- [ ] 12.1 Schema lands additively behind `FEDERATION_ENABLED=false` (no traffic, no risk) -- [ ] 12.2 Soak inbound only on flagship — enable WebFinger + actor + inbox; verify Mastodon can fetch + follow + receive Accept -- [ ] 12.3 Soak push delivery — local public activity reaches a Mastodon follower's home timeline +- [x] 12.1 Schema lands additively behind `FEDERATION_ENABLED=false` (no traffic, no risk) + > True since the §2 schema PRs: all federation columns/tables shipped additively, prod has run them with the flag off (404 on every federation surface) throughout the staging soak. +- [x] 12.2 Soak inbound only on flagship — enable WebFinger + actor + inbox; verify Mastodon can fetch + follow + receive Accept + > Soaked on persistent **staging** (2026-06-06/07) against a real Mastodon (social.ullrich.is): WebFinger → actor fetch → Follow → Accept → profile fields all verified live; six latent bugs found and fixed in the process. Staging shares the flagship host, Caddy, and Postgres — the environment risk surface is exercised; only the prod *domain* hasn't federated yet (that's the 12.5 flag flip). +- [x] 12.3 Soak push delivery — local public activity reaches a Mastodon follower's home timeline + > Same staging soak: pushed Create(Note) rendered in the Mastodon home timeline (tombstone + 10s-timeout lessons recorded in design.md). - [ ] 12.4 Soak outbound trails-to-trails — bring up second trails instance (staging or self-host), follow across, both directions verified + > Protocol mechanics fully verified by the two-instance harness (`e2e/federation/`, task 11.4) — which found and fixed the cross-origin Accept bug. Live surface is ready: PR previews run federated (#491), so the soak is: follow from staging.trails.cool to a user on `pr-.staging.trails.cool` and back. Needs a seeded user on a preview (server-side); queued for the next operator session. - [ ] 12.5 Flip flag on; restore home marketing copy; document in deployment runbook -- [ ] 12.6 Rollback: flag off (instant) or revert PR. Existing `follows` rows stay intact + > Runbook documented (docs/deployment.md, 10.2) and the home blurb already states the accurate scope (10.3) — remaining: the prod `FEDERATION_ENABLED=true` flip, an operator decision. +- [x] 12.6 Rollback: flag off (instant) or revert PR. Existing `follows` rows stay intact + > Documented in the deployment runbook's federation section; flag-off instantly 404s every federation surface while rows persist. From f3a17cf78c5af8b61f8785c65eea0acc321d3a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 14:46:32 +0200 Subject: [PATCH 10/22] feat(infra): enable federation on the flagship (social-federation 12.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiring follows the IS_FLAGSHIP pattern: the compose env defaults every FEDERATION_* var to empty (off — the safe self-host default; all federation surfaces 404), and cd-apps.yml appends FEDERATION_ENABLED=true + FEDERATION_LOG_LEVEL=info to the flagship's app.env. FEDERATION_KEY_ENCRYPTION_KEY was already in SOPS. Rollout 12.2/12.3 soaked on staging against a real Mastodon (2026-06-06/07); the trails↔trails Accept bug found by the §11 harness is fixed and deployed. Rollback: drop the two echo lines, merge, rerun cd-apps — instant off, follow rows persist. First boot with the flag enqueues backfill-user-keypairs (idempotent) and registers the federation jobs. Applied via manual cd-apps dispatch after merge since workflow-file changes don't trigger the path filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cd-apps.yml | 7 +++++++ infrastructure/docker-compose.yml | 9 +++++++++ openspec/changes/social-federation/tasks.md | 4 ++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index c5cc328..47d285d 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -83,6 +83,13 @@ jobs: echo "DOMAIN=trails.cool" >> infrastructure/app.env # Flagship marker — see cd-infra.yml for what this gates. echo "IS_FLAGSHIP=true" >> infrastructure/app.env + # Federation on (social-federation rollout 12.5, flipped + # 2026-06-07 after the staging + Mastodon soak). The + # FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env + # decrypted above. Rollback: delete these two lines, merge, + # rerun cd-apps — instant off, federation surfaces 404. + echo "FEDERATION_ENABLED=true" >> infrastructure/app.env + echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env # Sentry DSNs (public — see workflow top-level env for context). echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index ac25be8..dd2403b 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -53,6 +53,15 @@ services: WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-} WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} + # Federation (ActivityPub, social-federation rollout 12.5). Empty + # = off: every federation surface 404s — the safe self-host + # default. The flagship turns it on via cd-apps.yml (same pattern + # as IS_FLAGSHIP); the encryption key comes from SOPS. Rollback is + # dropping the flag and redeploying — instant, follow rows + # persist. See docs/deployment.md "Federation runbook". + FEDERATION_ENABLED: ${FEDERATION_ENABLED:-} + FEDERATION_KEY_ENCRYPTION_KEY: ${FEDERATION_KEY_ENCRYPTION_KEY:-} + FEDERATION_LOG_LEVEL: ${FEDERATION_LOG_LEVEL:-} INTEGRATION_SECRET: ${INTEGRATION_SECRET:?INTEGRATION_SECRET must be set in SOPS secrets.app.env} # Demo-activity-bot. Disabled by default everywhere; flip to "true" # only in prod. When unset, DEMO_BOT_PERSONA uses the built-in diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index a143e99..03c3c41 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -109,7 +109,7 @@ > Same staging soak: pushed Create(Note) rendered in the Mastodon home timeline (tombstone + 10s-timeout lessons recorded in design.md). - [ ] 12.4 Soak outbound trails-to-trails — bring up second trails instance (staging or self-host), follow across, both directions verified > Protocol mechanics fully verified by the two-instance harness (`e2e/federation/`, task 11.4) — which found and fixed the cross-origin Accept bug. Live surface is ready: PR previews run federated (#491), so the soak is: follow from staging.trails.cool to a user on `pr-.staging.trails.cool` and back. Needs a seeded user on a preview (server-side); queued for the next operator session. -- [ ] 12.5 Flip flag on; restore home marketing copy; document in deployment runbook - > Runbook documented (docs/deployment.md, 10.2) and the home blurb already states the accurate scope (10.3) — remaining: the prod `FEDERATION_ENABLED=true` flip, an operator decision. +- [x] 12.5 Flip flag on; restore home marketing copy; document in deployment runbook + > Flipped 2026-06-07 (operator-approved): `FEDERATION_ENABLED=true` ships in cd-apps.yml's flagship env, with the compose wiring defaulting *off* for self-hosters. Home blurb already accurate (10.3), runbook documented (10.2). Applied via a manual cd-apps dispatch after merge. - [x] 12.6 Rollback: flag off (instant) or revert PR. Existing `follows` rows stay intact > Documented in the deployment runbook's federation section; flag-off instantly 404s every federation surface while rows persist. From 2106d345cf568b89ed1dc51584bf25190070a95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 15:36:01 +0200 Subject: [PATCH 11/22] =?UTF-8?q?docs(openspec):=20social-federation=2012.?= =?UTF-8?q?4=20soaked=20live=20=E2=80=94=20change=20complete=20(58/58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flagship ⇄ staging, both directions, 2026-06-07: follows settled to Accepted in seconds, first polls ingested each side's public activities, both feeds render remote attribution. The cross-origin Accept fix (#490) verified working in production. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/social-federation/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/social-federation/tasks.md index 03c3c41..a40354e 100644 --- a/openspec/changes/social-federation/tasks.md +++ b/openspec/changes/social-federation/tasks.md @@ -107,8 +107,8 @@ > Soaked on persistent **staging** (2026-06-06/07) against a real Mastodon (social.ullrich.is): WebFinger → actor fetch → Follow → Accept → profile fields all verified live; six latent bugs found and fixed in the process. Staging shares the flagship host, Caddy, and Postgres — the environment risk surface is exercised; only the prod *domain* hasn't federated yet (that's the 12.5 flag flip). - [x] 12.3 Soak push delivery — local public activity reaches a Mastodon follower's home timeline > Same staging soak: pushed Create(Note) rendered in the Mastodon home timeline (tombstone + 10s-timeout lessons recorded in design.md). -- [ ] 12.4 Soak outbound trails-to-trails — bring up second trails instance (staging or self-host), follow across, both directions verified - > Protocol mechanics fully verified by the two-instance harness (`e2e/federation/`, task 11.4) — which found and fixed the cross-origin Accept bug. Live surface is ready: PR previews run federated (#491), so the soak is: follow from staging.trails.cool to a user on `pr-.staging.trails.cool` and back. Needs a seeded user on a preview (server-side); queued for the next operator session. +- [x] 12.4 Soak outbound trails-to-trails — bring up second trails instance (staging or self-host), follow across, both directions verified + > Soaked live 2026-06-07 between **flagship and staging** (better than the planned preview pairing — two production-grade instances), right after the 12.5 flag flip: follows in both directions settled to Accepted within seconds, first polls ingested each side's public activities, and both feeds render the remote attribution (`@ullrich@staging.trails.cool` on prod, `@ullrich@trails.cool` on staging). The cross-origin Accept fix (task 11.4 / #490) verified working in production. Protocol mechanics additionally regression-covered by the `e2e/federation/` harness. - [x] 12.5 Flip flag on; restore home marketing copy; document in deployment runbook > Flipped 2026-06-07 (operator-approved): `FEDERATION_ENABLED=true` ships in cd-apps.yml's flagship env, with the compose wiring defaulting *off* for self-hosters. Home blurb already accurate (10.3), runbook documented (10.2). Applied via a manual cd-apps dispatch after merge. - [x] 12.6 Rollback: flag off (instant) or revert PR. Existing `follows` rows stay intact From d87a1e30c28c2faa3eebd029a4032934248df67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 16:47:35 +0200 Subject: [PATCH 12/22] docs(openspec): archive social-federation; sync delta specs into main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change shipped end-to-end (58/58 tasks; live on trails.cool since 2026-06-07, soaked against Mastodon and flagship⇄staging). Delta specs synced into the mainline: - specs/social-federation/spec.md created (8 requirements: actor objects + WebFinger, signing keypairs, narrow inbox, outbox, push delivery, trails-only outbound, poll ingestion, audience filtering) - specs/social-follows/spec.md: social-activity-feed requirement updated for remote rows + audience gating; pending lifecycle for outbound remote follows added - specs/public-profiles/spec.md: Pending follow-button state added openspec validate --all: green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../.openspec.yaml | 0 .../2026-06-07-social-federation}/design.md | 0 .../2026-06-07-social-federation}/proposal.md | 0 .../specs/public-profiles/spec.md | 0 .../specs/social-federation/spec.md | 0 .../specs/social-follows/spec.md | 0 .../2026-06-07-social-federation}/tasks.md | 0 openspec/specs/public-profiles/spec.md | 7 ++ openspec/specs/social-federation/spec.md | 109 ++++++++++++++++++ openspec/specs/social-follows/spec.md | 55 +++++---- 10 files changed, 147 insertions(+), 24 deletions(-) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/.openspec.yaml (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/design.md (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/proposal.md (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/specs/public-profiles/spec.md (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/specs/social-federation/spec.md (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/specs/social-follows/spec.md (100%) rename openspec/changes/{social-federation => archive/2026-06-07-social-federation}/tasks.md (100%) create mode 100644 openspec/specs/social-federation/spec.md diff --git a/openspec/changes/social-federation/.openspec.yaml b/openspec/changes/archive/2026-06-07-social-federation/.openspec.yaml similarity index 100% rename from openspec/changes/social-federation/.openspec.yaml rename to openspec/changes/archive/2026-06-07-social-federation/.openspec.yaml diff --git a/openspec/changes/social-federation/design.md b/openspec/changes/archive/2026-06-07-social-federation/design.md similarity index 100% rename from openspec/changes/social-federation/design.md rename to openspec/changes/archive/2026-06-07-social-federation/design.md diff --git a/openspec/changes/social-federation/proposal.md b/openspec/changes/archive/2026-06-07-social-federation/proposal.md similarity index 100% rename from openspec/changes/social-federation/proposal.md rename to openspec/changes/archive/2026-06-07-social-federation/proposal.md diff --git a/openspec/changes/social-federation/specs/public-profiles/spec.md b/openspec/changes/archive/2026-06-07-social-federation/specs/public-profiles/spec.md similarity index 100% rename from openspec/changes/social-federation/specs/public-profiles/spec.md rename to openspec/changes/archive/2026-06-07-social-federation/specs/public-profiles/spec.md diff --git a/openspec/changes/social-federation/specs/social-federation/spec.md b/openspec/changes/archive/2026-06-07-social-federation/specs/social-federation/spec.md similarity index 100% rename from openspec/changes/social-federation/specs/social-federation/spec.md rename to openspec/changes/archive/2026-06-07-social-federation/specs/social-federation/spec.md diff --git a/openspec/changes/social-federation/specs/social-follows/spec.md b/openspec/changes/archive/2026-06-07-social-federation/specs/social-follows/spec.md similarity index 100% rename from openspec/changes/social-federation/specs/social-follows/spec.md rename to openspec/changes/archive/2026-06-07-social-federation/specs/social-follows/spec.md diff --git a/openspec/changes/social-federation/tasks.md b/openspec/changes/archive/2026-06-07-social-federation/tasks.md similarity index 100% rename from openspec/changes/social-federation/tasks.md rename to openspec/changes/archive/2026-06-07-social-federation/tasks.md diff --git a/openspec/specs/public-profiles/spec.md b/openspec/specs/public-profiles/spec.md index a902258..2c84d0b 100644 --- a/openspec/specs/public-profiles/spec.md +++ b/openspec/specs/public-profiles/spec.md @@ -63,3 +63,10 @@ Every user SHALL have an explicit `profile_visibility` of `public` or `private`. - **WHEN** a previously-private user switches `profile_visibility` to `public` and saves - **THEN** their `/users/:username` renders the full profile to anyone again, and any incoming follows auto-accept going forward +### Requirement: Pending state on Follow button +When a signed-in viewer has an outgoing follow against a profile that is `accepted_at IS NULL` (awaiting `Accept(Follow)` from the remote), the Follow button SHALL render in a Pending state with a cancel option, distinct from both the unfollowed and followed states. + +#### Scenario: Pending state visible +- **WHEN** a signed-in user has just initiated a follow against a remote trails actor's profile +- **THEN** the profile page renders a "Pending" indicator with a cancel-request control instead of the Follow or Unfollow button + diff --git a/openspec/specs/social-federation/spec.md b/openspec/specs/social-federation/spec.md new file mode 100644 index 0000000..926cf4a --- /dev/null +++ b/openspec/specs/social-federation/spec.md @@ -0,0 +1,109 @@ +# social-federation Specification + +## Purpose +ActivityPub federation for the Journal, scoped to the follow graph between trails.cool instances: per-user actor objects with WebFinger discovery, per-user signing keypairs, a narrow follow-only inbox, an outbox of public activities, push delivery to remote followers on local activity create, outbound follows restricted to other trails instances, outbox-poll ingestion of remote trails activities, and audience-aware feed filtering. Local follow lifecycle and the feed surface itself live in `social-follows`; profile pages live in `public-profiles`. + +## Requirements +### Requirement: Per-user actor objects with WebFinger discovery +The Journal SHALL serve an ActivityPub `Person` actor object at the user's canonical URL for any user with `profile_visibility = 'public'`, and SHALL serve a WebFinger endpoint at `/.well-known/webfinger` resolving `acct:user@domain` to that actor IRI. + +#### Scenario: Public user has a discoverable actor +- **WHEN** a remote client GETs `/.well-known/webfinger?resource=acct:bruno@trails.cool` +- **THEN** the response is a JRD object with a `links` array including `rel="self"` pointing at `https://trails.cool/users/bruno` (the actor IRI) + +#### Scenario: Public user actor object resolves +- **WHEN** a remote client GETs `https://{DOMAIN}/users/bruno` with `Accept: application/activity+json` +- **THEN** the response is a `Person` actor object including the user's display name, public key, inbox IRI, outbox IRI, and `software` field identifying the instance as trails.cool + +#### Scenario: Private user is invisible to federation +- **WHEN** any federation request resolves a user whose `profile_visibility = 'private'` — WebFinger lookup, actor IRI fetch, or follow attempt +- **THEN** every endpoint returns HTTP 404 with no leak of user existence + +### Requirement: Per-user signing keypairs +Every local user SHALL have an asymmetric keypair (RSA 2048 or Ed25519). The public key SHALL be embedded in the actor object. The private key SHALL be encrypted at rest using a server-managed encryption key. New users SHALL get keys at registration; existing users SHALL be backfilled at deploy. + +#### Scenario: Outgoing activity is signed with the user's key +- **WHEN** a local user originates a federation activity (Follow, Accept, Create, etc) that is delivered to a remote inbox +- **THEN** the HTTP request carries an HTTP Signature header signed with that user's private key, identifying the user's `keyId` so the remote can verify + +#### Scenario: Existing-user backfill at deploy +- **WHEN** the federation feature flag is first enabled on an instance with pre-existing users +- **THEN** a one-shot job generates keypairs for every user lacking one before any federation traffic is permitted + +### Requirement: Narrow inbox — follow-graph activities only +The user inbox at `https://{DOMAIN}/users/:username/inbox` SHALL accept and process only `Follow`, `Undo(Follow)`, `Accept(Follow)`, and `Reject(Follow)` activities. Any other activity type SHALL be acknowledged with HTTP 202 and dropped without processing. + +#### Scenario: Inbound Follow auto-accepts for public users +- **WHEN** a signed `Follow` from a remote actor targets a local user with `profile_visibility = 'public'` +- **THEN** the inbox records the follow row with the remote actor as follower, delivers `Accept(Follow)` back, and returns HTTP 202 + +#### Scenario: Inbound Create is dropped silently +- **WHEN** a signed `Create(Note)` is POSTed to a local user's inbox +- **THEN** the response is HTTP 202 but no row is created in `activities`, no row in `follows`; the activity is logged at debug level and discarded + +#### Scenario: Inbound Follow to a private user is rejected +- **WHEN** a `Follow` targets a local user whose `profile_visibility = 'private'` +- **THEN** the inbox returns HTTP 404 (matching the actor's own 404) and no row is created + +#### Scenario: Replay-protected +- **WHEN** the same signed activity is delivered twice within the signature's validity window +- **THEN** the second delivery is dropped (idempotent on activity IRI) and returns HTTP 202 + +### Requirement: Outbox publishes user's public activities +The Journal SHALL serve a paginated outbox at `https://{DOMAIN}/users/:username/outbox` for any user with `profile_visibility = 'public'`, listing the user's `public` activities as `Create(Note)` activities (or a documented AS extension type). + +#### Scenario: Outbox lists public activities +- **WHEN** a remote client GETs an outbox URL with a valid HTTP Signature +- **THEN** the response is an `OrderedCollection` (or paginated collection page) of the user's `public` activities, most recent first + +#### Scenario: Outbox excludes private and unlisted +- **WHEN** the outbox is fetched +- **THEN** the response includes only activities with `visibility = 'public'`; `unlisted` and `private` activities never appear + +### Requirement: Push delivery on local activity create +The Journal SHALL deliver a `Create(Note)` activity to every accepted remote follower's inbox when a local user with `profile_visibility = 'public'` creates a new `public` activity. + +#### Scenario: New public activity fans out +- **WHEN** a local user with N accepted remote followers creates a new public activity +- **THEN** N delivery jobs are enqueued (one per follower's inbox), each retrying with exponential backoff on 5xx, giving up after a documented retry budget + +#### Scenario: Rate-limited per remote host +- **WHEN** multiple deliveries target the same remote host +- **THEN** they are rate-limited so we never exceed 1 request per second per remote host (configurable; chosen for safety, not throughput) + +### Requirement: Outbound follows restricted to other trails instances +The Journal SHALL accept outbound follow requests against remote actor IRIs only when the target host self-identifies as a trails.cool instance. Follows targeting Mastodon, Pleroma, or other non-trails ActivityPub servers SHALL be refused at the API layer with a clear error and a link to the documented v1 limitation. + +#### Scenario: Follow another trails instance +- **WHEN** a local user follows `@alice@other-trails.example` and the remote actor's `software` field declares `trails.cool` +- **THEN** the follow row is created with `accepted_at = NULL` (Pending), a signed `Follow` is delivered to the remote inbox, and the button shows Pending + +#### Scenario: Refuse to follow a Mastodon user +- **WHEN** a local user attempts to follow `@alice@mastodon.social` +- **THEN** the API returns 4xx with an error message explaining "outbound federation to non-trails instances isn't supported yet" and links to the project documentation + +### Requirement: Outbox-poll ingestion of remote trails activities +The Journal SHALL periodically GET the outbox of every remote trails actor that at least one local user follows with `accepted_at IS NOT NULL`, store new activities locally for feed display, and rate-limit fetches per remote host. + +#### Scenario: Poll cadence and scope +- **WHEN** the scheduled outbox-poll job runs +- **THEN** it fetches at most the 50 most recent items per remote actor, stores any new rows tagged with their audience, and skips actors polled within the last hour + +#### Scenario: Polls are signed +- **WHEN** the poller fetches a remote outbox +- **THEN** the GET request carries an HTTP Signature using the actor key of one of the local users who follow the remote actor + +#### Scenario: First poll triggered immediately on accepted follow +- **WHEN** a follow row transitions to `accepted_at IS NOT NULL` +- **THEN** an immediate one-off outbox-poll is enqueued for that specific actor + +#### Scenario: Respect remote rate limiting +- **WHEN** a remote instance returns `429` or `Retry-After` +- **THEN** the poller backs off the entire host (not just the actor) for the indicated duration + +### Requirement: Audience-aware feed filtering +Activities cached from remote trails actors SHALL be tagged with their audience (`public` or `followers-only`). The social feed query SHALL return `followers-only` activities only to the specific local user who holds an accepted follow against the originating remote actor. + +#### Scenario: Followers-only remote content reaches only the right viewer +- **WHEN** a remote actor publishes a followers-only activity, two local users A and B both have rows in the activity cache for that actor, but only A holds an accepted follow against the actor +- **THEN** A sees the activity in `/feed` and B does not, even though the row exists in our database diff --git a/openspec/specs/social-follows/spec.md b/openspec/specs/social-follows/spec.md index 052ef9b..91c15d7 100644 --- a/openspec/specs/social-follows/spec.md +++ b/openspec/specs/social-follows/spec.md @@ -82,35 +82,23 @@ A signed-in user SHALL have an actionable surface listing every Pending follow r - **THEN** they are redirected to `/auth/login` ### Requirement: Social activity feed -The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, with two views selectable via `?view=`: **Followed** (default; public activities from the local users they follow with an accepted relation) and **Public** (instance-wide public activities — see `activity-feed` spec, "Instance-wide public activity feed"). The page SHALL render a tab/toggle at the top so the viewer can switch between the two views. `private` and `unlisted` activities SHALL NOT appear in either view. Pending follows SHALL NOT contribute content to the Followed view. +The Journal SHALL expose a feed at `/feed`, visible only to signed-in users, listing activities from the users they follow with an accepted follow. The feed SHALL include `public` activities from any followed actor (local or remote) and SHALL include `followers-only` activities from a remote actor only for the specific local viewer who holds an accepted follow against that actor. `private` and (local) `unlisted` activities SHALL NOT appear regardless of follow state. -#### Scenario: Followed view aggregates accepted-followed users' public activities -- **WHEN** a signed-in user with one or more accepted follows loads `/feed` (or `/feed?view=followed`) -- **THEN** the Followed view is selected and shows the most recent public activities across all accepted-followed users, reverse-chronological, up to 50 per page, with owner attribution, distance, date, and a map thumbnail +#### Scenario: Feed aggregates local + remote public activities +- **WHEN** a signed-in user with one or more accepted follows (mix of local + remote trails) loads `/feed` +- **THEN** the page shows the most recent public activities across all followed actors, reverse-chronological, up to 50 per page -#### Scenario: Public view aggregates instance-wide public activities -- **WHEN** a signed-in user loads `/feed?view=public` -- **THEN** the Public view is selected and shows the most recent public activities across the entire instance, reverse-chronological, up to 50 per page, regardless of follow state +#### Scenario: Followers-only remote content reaches only the right viewer +- **WHEN** two local users A and B exist; only A holds an accepted follow against remote actor X; X publishes a followers-only activity that lands in our cache via A's poll +- **THEN** A sees the activity in `/feed` and B does not -#### Scenario: Pending follows don't contribute to the Followed view -- **WHEN** a signed-in user has only Pending follows (no acceptance yet) and loads the Followed view -- **THEN** the view renders the empty state — no Pending-target content is fetched or shown - -#### Scenario: Empty Followed view links to the Public view +#### Scenario: Empty feed state - **WHEN** a signed-in user with zero accepted follows loads `/feed` -- **THEN** the Followed view shows an empty-state message and an in-page link to switch to the Public view (`?view=public`), so they can browse the instance without leaving `/feed` +- **THEN** the page shows an empty-state message pointing them to follow someone -#### Scenario: Empty Public view -- **WHEN** the instance has zero public activities and a signed-in user loads `/feed?view=public` -- **THEN** the Public view shows an empty-state message; no link to elsewhere is required - -#### Scenario: Unrecognized view value falls back to Followed -- **WHEN** a signed-in user loads `/feed?view=` -- **THEN** the loader treats the request as the Followed view (default) without raising an error — the parameter is opaque from the client's perspective - -#### Scenario: Logged-out visitor cannot access the feed -- **WHEN** an unauthenticated visitor requests `/feed` (any view) -- **THEN** they are redirected to `/auth/login` +#### Scenario: Pending follows do not contribute to the feed +- **WHEN** a signed-in user has only Pending outgoing follows +- **THEN** the feed renders the empty state; no remote content is fetched or shown for that follow until `Accept(Follow)` lands ### Requirement: Schema is forward-compatible with federation The `follows` table SHALL key the followed side by an `actor_iri TEXT` column (not a plain user FK), so the `social-federation` change can store remote IRIs in the same column without migration. Local follows SHALL populate `actor_iri` with the local user's canonical actor IRI (`https://{DOMAIN}/users/{username}`). @@ -151,3 +139,22 @@ The follow lifecycle SHALL produce notifications for the recipient of the social #### Scenario: Unfollow does not notify - **WHEN** a follower unfollows or cancels a Pending request - **THEN** no notification is created on the followed side + +### Requirement: Pending lifecycle for outbound trails-to-trails follows +A follow row originated from a local user against a remote *trails* actor SHALL be created with `accepted_at = NULL` (Pending) until the remote's `Accept(Follow)` activity arrives at our inbox. The local user SHALL see the Pending state on the profile page and SHALL be able to cancel a Pending request, which deletes the row and delivers `Undo(Follow)` to the remote inbox. + +#### Scenario: Outbound follow enters Pending +- **WHEN** a local user follows `@alice@other-trails.example` +- **THEN** the follow row is created with `accepted_at = NULL`, a signed `Follow` is delivered to the remote inbox, and the profile button shows Pending + +#### Scenario: Pending → Accepted +- **WHEN** the remote inbox returns `Accept(Follow)` for our outgoing Follow +- **THEN** `accepted_at` is set to `now()`, an immediate one-off outbox-poll is enqueued for that actor, and the profile button transitions to Unfollow + +#### Scenario: Pending → Rejected +- **WHEN** the remote inbox returns `Reject(Follow)` for our outgoing Follow +- **THEN** the follow row is deleted and a small UI notice is surfaced on the user's outgoing-follows list + +#### Scenario: Cancel a Pending follow +- **WHEN** the follower cancels a Pending request from the outgoing-follows list +- **THEN** the follow row is deleted and an `Undo(Follow)` activity is delivered to the remote inbox From 19f1c06d691bdafe41a06138b9c42fd239ae3c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:15:02 +0200 Subject: [PATCH 13/22] docs(openspec): propose garmin-import change Garmin Connect as the third connected-services provider, modeled on wahoo-import but adapted to Garmin's push-first API: OAuth2+PKCE on the existing oauth credential kind, ping/push webhook ingestion with async pg-boss processing and an SSRF allowlist on callback URLs, date-range backfill instead of a pick list (Garmin has no activity-list endpoint), mandatory deregistration handling. Route push (Courses API) explicitly deferred to a follow-up, mirroring wahoo-route-push. Build is fixtures-first; rollout tasks are gated on Garmin Connect Developer Program approval, which runs in parallel. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/garmin-import/.openspec.yaml | 2 + openspec/changes/garmin-import/design.md | 76 ++++++++++++++++ openspec/changes/garmin-import/proposal.md | 30 +++++++ .../garmin-import/specs/garmin-import/spec.md | 90 +++++++++++++++++++ openspec/changes/garmin-import/tasks.md | 42 +++++++++ 5 files changed, 240 insertions(+) create mode 100644 openspec/changes/garmin-import/.openspec.yaml create mode 100644 openspec/changes/garmin-import/design.md create mode 100644 openspec/changes/garmin-import/proposal.md create mode 100644 openspec/changes/garmin-import/specs/garmin-import/spec.md create mode 100644 openspec/changes/garmin-import/tasks.md diff --git a/openspec/changes/garmin-import/.openspec.yaml b/openspec/changes/garmin-import/.openspec.yaml new file mode 100644 index 0000000..11967fc --- /dev/null +++ b/openspec/changes/garmin-import/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-07 diff --git a/openspec/changes/garmin-import/design.md b/openspec/changes/garmin-import/design.md new file mode 100644 index 0000000..79bc84a --- /dev/null +++ b/openspec/changes/garmin-import/design.md @@ -0,0 +1,76 @@ +## Context + +The connected-services framework (manifest + capability adapters + `ConnectedServiceManager`) shipped with Wahoo and was stress-tested by Komoot (a `web-login`/`public` provider). Garmin is the third provider and the first whose API is **push-first**: there is no "list my activities" REST endpoint to paginate. Garmin delivers data via webhook notifications — both for new activities and for requested historical backfills — which inverts the import flow the Wahoo UI assumes. + +Garmin specifics that shape everything below (Garmin Connect Developer Program, Activity API): + +- **Auth**: OAuth 2.0 with PKCE (their current scheme; OAuth 1.0a is legacy). Confidential client: token exchange and refresh use `client_id`/`client_secret` *plus* the PKCE verifier on the initial exchange. Access tokens are short-lived (~24 h), refresh tokens long-lived (~3 months). +- **Data delivery**: ping/push notifications POSTed to endpoints registered in the **developer portal** (not via API). *Ping* notifications carry a `callbackURL` from which we pull the payload; *push* notifications embed it. FIT files are fetched from a callback URL with the user's token. +- **History**: a *backfill* endpoint accepts a time range (chunked, bounded per request) and triggers asynchronous re-delivery of historical activities through the same notification pipeline. +- **Deregistration**: when a user revokes access on Garmin's side, Garmin sends a deregistration notification; partner terms require us to act on it. +- **Access**: requires an approved developer-program application; evaluation keys are rate-limited; production requires Garmin review. + +## Goals / Non-Goals + +**Goals:** +- Garmin connect/disconnect from `/settings/connections` with zero framework changes — the manifest/registry seams prove out on a third provider. +- New Garmin activities appear in the journal automatically (push pipeline → shared FIT→GPX → activity with stats + PostGIS geometry). +- Users can pull in their Garmin history via backfill requests with honest async progress UX. +- Garmin-side revocation degrades cleanly (`revoked` status, re-connect prompt, no orphaned polling). + +**Non-Goals:** +- Route push to Garmin devices (Courses API) — a follow-up `RoutePusher` change once import has soaked, like `wahoo-route-push` after `wahoo-import`. +- Wellness/health data (Health API): steps, sleep, HR — out of scope; we import *activities* only. +- Real-time/live tracking. +- Supporting Garmin's legacy OAuth 1.0a. + +## Decisions + +### Decision: OAuth2 + PKCE rides the existing `oauth` credential kind + +Garmin's token blob is shape-compatible with `OAuthCredentials` (`access_token`, `refresh_token`, `expires_at`), so `credential_kind = 'oauth'` and the existing OAuth `CredentialAdapter` handle storage and refresh. PKCE only affects the **authorize/exchange** steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + `code_challenge` parameter, with the verifier carried through the OAuth `state` storage (`oauth-state.server.ts`) the same way the existing flow carries its state nonce. + +**Alternative considered:** a new `oauth-pkce` credential kind. Rejected — the *stored* credential is identical; PKCE is a handshake detail, not a credential shape. A new kind would fork the adapter for zero storage benefit. + +### Decision: webhook-first ingestion; ping and push handled by one endpoint + +`/api/sync/webhook/garmin` (generic provider webhook routing) accepts Garmin's notification POSTs. The handler normalizes both delivery styles — *ping* (fetch `callbackURL` for the payload) and *push* (payload inline) — into one internal "activity notification" shape, then: resolve user via `provider_user_id` (Garmin user id captured at connect time), fetch the FIT file, convert via shared `fit.ts`, create the activity, record `sync_imports`. Unknown users get a 200 (same don't-reveal-existence behavior as Wahoo). The endpoint must return 200 quickly — Garmin retries on failure and slow consumers get throttled — so FIT download + conversion runs as a pg-boss job (`garmin-import-activity`), mirroring the federation lesson that synchronous work in inbound webhooks is a trap. + +**Alternative considered:** synchronous processing in the webhook (Wahoo does this today). Rejected for Garmin: backfill bursts deliver many notifications at once; a queue absorbs the burst and gives retries for transient FIT-download failures. + +### Decision: backfill-request UX instead of a Wahoo-style pick list + +Garmin has no list endpoint, so the import page (`/settings/connections/garmin/import`) is a **date-range backfill requester**: pick a range (chunked into Garmin's per-request window under the hood), submit, and watch activities arrive. Progress is honest-async: the page shows requested ranges and the count of activities imported so far (rows in `sync_imports` attributed to the backfill window), with a note that Garmin delivers asynchronously and large histories take time. No per-activity pick list, no "Import all" button — those concepts don't exist in a push model. Per-activity dedupe stays in `sync_imports`, so re-requesting an overlapping range is safe and cheap. + +**Alternative considered:** caching a local index of summaries first and offering selection before FIT download. Rejected for v1 — it doubles the moving parts for marginal value (users overwhelmingly want "import everything since X"); selective deletion after import already exists. + +### Decision: deregistration notifications flip status to `revoked` + +Garmin's deregistration notification deletes nothing locally except the live link: we set `status = 'revoked'` on the `connected_services` row (the existing audit-retaining state), surface the standard re-connect prompt, and stop all Garmin API calls for that user. Imported activities stay — they're the user's data in their journal, consistent with the disconnect semantics in the connected-services spec. User-initiated *deletion* of imported content remains the existing per-activity/account deletion paths. + +### Decision: fixtures-first development; Garmin program approval gates rollout, not build + +The importer/webhook are built and tested against recorded notification + FIT fixtures (same pattern as Wahoo's tests), with the live integration soaked once program credentials exist. Webhook endpoint registration in Garmin's portal targets staging first (`staging.trails.cool`), then production — both registrable under one app. Secrets follow the SOPS + compose pattern (`GARMIN_CLIENT_ID`, `GARMIN_CLIENT_SECRET`). + +## Risks / Trade-offs + +- **Garmin program approval is external and slow** → fixtures-first build; the change is implementable and testable end-to-end without live credentials; rollout tasks are explicitly separated and can wait on Garmin without blocking merge (everything ships dark behind missing env vars, same as Wahoo on self-hosted instances). +- **Webhook delivery is best-effort; missed notifications mean missed activities** → backfill request doubles as a manual repair tool ("re-request last 7 days"); `sync_imports` dedupe makes overlap free. +- **Backfill bursts can spike load** → pg-boss queue with bounded concurrency for FIT download/conversion; webhook handler itself stays O(1). +- **Garmin rate limits (especially evaluation keys)** → respect 429/Retry-After in the job's retry policy; keep per-user backfill ranges chunked. +- **Ping `callbackURL` is attacker-controllable input if we blindly fetch it** → validate the callback host against Garmin's API host allowlist before fetching (SSRF guard — the federation work demonstrated exactly why). +- **Notification payload shapes are under-documented and shift between API versions** → normalize early into one internal shape with tolerant parsing; unknown notification types are logged and dropped (200), never 5xx. + +## Migration Plan + +1. Everything lands dark: no env vars → Garmin row hidden/disabled on `/settings/connections` (same gating as Wahoo's missing-client-id state). +2. Garmin developer program application (operator task, can run in parallel with implementation). +3. Staging: set secrets, register staging webhook URL in the Garmin portal, soak with a real Garmin account (connect → record activity → auto-import; backfill a month; revoke from Garmin side → `revoked`). +4. Production: same flag/secret pattern; privacy manifest entry ships with the code. +5. Rollback: remove secrets (provider hides), or disconnect-only — schema is untouched, so nothing to unwind. + +## Open Questions + +- Does our Garmin app get approved for the Activity API at production scale, and on what timeline? (Gates rollout tasks only.) +- Exact backfill chunk limit and burst rate for evaluation vs production keys — confirm against the current Activity API docs when credentials arrive; the chunking constant is one number in the manifest. +- Whether Garmin's notification includes enough summary data (duration/distance/type) to create stat-only activities for FIT-less entries (e.g. manually-logged workouts) — decide ingest-or-skip when fixtures are in hand; spec says ingest stats-only if the data is present, mirroring Wahoo's no-file behavior. diff --git a/openspec/changes/garmin-import/proposal.md b/openspec/changes/garmin-import/proposal.md new file mode 100644 index 0000000..951c1c2 --- /dev/null +++ b/openspec/changes/garmin-import/proposal.md @@ -0,0 +1,30 @@ +## Why + +Garmin is the largest fitness-device ecosystem our users own — and the most-requested missing import path. The connected-services framework was explicitly built so a second OAuth provider slots in behind the existing seams (`Importer`, `WebhookReceiver`, shared FIT→GPX), and after Wahoo and Komoot shipped, Garmin is the natural next provider to prove the framework holds for a push-first API. + +## What Changes + +- New provider `garmin` under `apps/journal/app/lib/connected-services/providers/garmin/` — manifest, OAuth2 (PKCE) config, `Importer` + `WebhookReceiver` capability adapters. Registered in the provider registry; settings UI, OAuth flow, and webhook routing light up without framework changes. +- Connect/disconnect Garmin from `/settings/connections` via Garmin's OAuth2 + PKCE flow; tokens stored as `credential_kind = 'oauth'` in `connected_services`. +- Automatic import of new activities via Garmin's **push model**: Garmin POSTs ping/push notifications to `/api/sync/webhook/garmin`; we fetch the FIT file from the notification's callback URL, convert via the shared `fit.ts`, and create the journal activity (idempotent via `sync_imports`). +- Historical import via Garmin's **backfill API**: unlike Wahoo, Garmin has no "list activities" endpoint — the import page requests backfill for a date range and the activities arrive asynchronously through the same webhook pipeline, with progress surfaced on the import page. +- Mandatory **deregistration handling**: Garmin sends a deregistration notification when a user revokes access from their end; we must mark the connection `revoked` (Garmin's terms additionally require ceasing data pulls for that user). +- Route push to Garmin devices (Courses API) is **out of scope** — it's a separate `RoutePusher` change once import has soaked, mirroring how `wahoo-route-push` followed `wahoo-import`. + +## Capabilities + +### New Capabilities +- `garmin-import`: Garmin Connect as an activity-import provider — OAuth2/PKCE connection, push-notification ingestion, date-range backfill for history, FIT conversion via the shared module, deregistration handling. + +### Modified Capabilities + + + +## Impact + +- **Code**: new `providers/garmin/` (manifest, importer, webhook, tests); one import line in `providers/index.ts`. Import page route for Garmin (backfill-request UX differs from Wahoo's pick-list, so it's a provider-specific page, not a reuse of the Wahoo one). i18n strings (en + de). +- **APIs**: `/api/sync/connect/garmin`, `/api/sync/disconnect/garmin`, `/api/sync/webhook/garmin` — all already routed generically by provider name. +- **Schema**: none. `connected_services` (`credential_kind = 'oauth'`) and `sync_imports` cover Garmin as-is. +- **Secrets/Env**: `GARMIN_CLIENT_ID`, `GARMIN_CLIENT_SECRET` (SOPS + compose wiring, same pattern as Wahoo). +- **External dependency / risk**: requires an approved **Garmin Connect Developer Program** application. Evaluation keys are rate-limited and production use needs Garmin's review. This gates rollout, not development — the importer/webhook can be built and tested against recorded fixtures first. Webhook endpoints must also be registered in Garmin's developer portal (not self-service via API). +- **Privacy manifest**: `/legal/privacy` gains a Garmin entry alongside Wahoo/Komoot (what we pull, what Garmin learns, deletion semantics). diff --git a/openspec/changes/garmin-import/specs/garmin-import/spec.md b/openspec/changes/garmin-import/specs/garmin-import/spec.md new file mode 100644 index 0000000..03fc408 --- /dev/null +++ b/openspec/changes/garmin-import/specs/garmin-import/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Connect Garmin account +Users SHALL be able to connect their Garmin account via OAuth2 with PKCE. The provider SHALL be registered through the connected-services manifest/registry seams; the settings page, OAuth routes, and webhook routing SHALL require no framework changes. + +#### Scenario: Connect Garmin +- **WHEN** a user clicks "Connect Garmin" on `/settings/connections` +- **THEN** they are redirected to Garmin's OAuth authorization page with a PKCE code challenge +- **AND** after granting permission they are redirected back to the journal +- **AND** access and refresh tokens are stored in `connected_services` with `provider = 'garmin'`, `credential_kind = 'oauth'` +- **AND** the Garmin user id is stored as `provider_user_id` for webhook resolution + +#### Scenario: Disconnect Garmin +- **WHEN** a user clicks "Disconnect" next to their Garmin connection +- **THEN** the stored credentials are removed and previously imported activities are unaffected + +#### Scenario: Token refresh +- **WHEN** a Garmin API call is attempted with an expired access token +- **THEN** the OAuth `CredentialAdapter` refreshes it via `ConnectedServiceManager.withFreshCredentials` +- **AND** a permanently failed refresh (revoked refresh token) flips the connection to `needs_relink` + +#### Scenario: Provider hidden without credentials +- **WHEN** the instance has no `GARMIN_CLIENT_ID` configured +- **THEN** the Garmin row on `/settings/connections` is absent or disabled (self-hosted instances without a Garmin program key see no broken flows) + +### Requirement: Push-notification activity import +New Garmin activities SHALL be imported automatically via Garmin's notification pipeline. The webhook endpoint SHALL accept both ping notifications (payload fetched from the notification's callback URL) and push notifications (payload inline), normalize them into a single internal shape, and process imports asynchronously via a background job so the webhook itself responds quickly. + +#### Scenario: New activity notification imports the activity +- **WHEN** Garmin POSTs an activity notification to `/api/sync/webhook/garmin` +- **THEN** the endpoint responds 200 promptly and enqueues an import job +- **AND** the job resolves the user via `provider_user_id`, downloads the FIT file, converts it via the shared FIT→GPX module, and creates a journal activity with GPX, stats, and PostGIS geometry +- **AND** the import is recorded in `sync_imports` to prevent duplicates + +#### Scenario: Duplicate notification +- **WHEN** a notification arrives for an activity already recorded in `sync_imports` +- **THEN** the import is skipped silently (idempotent) + +#### Scenario: Unknown user notification +- **WHEN** a notification arrives whose Garmin user id matches no active connection +- **THEN** the request is acknowledged with 200 and no data is processed (do not reveal user existence) + +#### Scenario: Callback URL host validation +- **WHEN** a ping notification carries a callback URL whose host is not on the Garmin API host allowlist +- **THEN** the notification is dropped and logged; the URL is never fetched (SSRF guard) + +#### Scenario: Activity without FIT data +- **WHEN** a notification describes an activity with no fetchable FIT file but includes summary stats +- **THEN** the activity is created without GPX or geometry (stats only), mirroring the established FIT-less behavior + +#### Scenario: Unknown notification type +- **WHEN** Garmin delivers a notification type the handler does not recognize +- **THEN** it is logged and acknowledged with 200, never an error status + +### Requirement: Historical import via backfill +Users SHALL be able to import their Garmin history by requesting backfill for a date range. Because Garmin exposes no activity-list endpoint, the import page SHALL present a date-range request flow with asynchronous progress, not a per-activity pick list. Range requests SHALL be chunked to Garmin's per-request window, and overlapping or repeated requests SHALL be safe (deduplicated via `sync_imports`). + +#### Scenario: Request a backfill +- **WHEN** a connected user submits a date range on the Garmin import page +- **THEN** the system issues Garmin backfill requests covering the range in API-sized chunks +- **AND** the page records the requested range and shows it as in progress + +#### Scenario: Backfilled activities arrive +- **WHEN** Garmin asynchronously delivers historical activities through the notification pipeline +- **THEN** each is imported by the same job path as live notifications +- **AND** the import page reflects the growing count of imported activities for the requested range + +#### Scenario: Overlapping backfill request +- **WHEN** a user requests a range overlapping previously imported activities +- **THEN** already-imported activities are skipped via `sync_imports` and no duplicates are created + +### Requirement: Deregistration handling +The system SHALL act on Garmin deregistration notifications. When a user revokes access on Garmin's side, the connection row SHALL be set to `status = 'revoked'`, all Garmin API calls for that user SHALL cease, and the user SHALL see the standard re-connect prompt. Imported activities SHALL be retained (they are the user's journal data; deletion remains the user's existing per-activity and account deletion paths). + +#### Scenario: User revokes from Garmin's side +- **WHEN** a deregistration notification for a connected user arrives at the Garmin webhook +- **THEN** the `connected_services` row is set to `revoked` +- **AND** subsequent scheduled or user-triggered Garmin calls for that user short-circuit +- **AND** `/settings/connections` shows Garmin as requiring reconnection + +### Requirement: Import provenance +Activities imported from Garmin SHALL show their origin, consistent with other providers. + +#### Scenario: View imported activity +- **WHEN** a user views an activity imported from Garmin +- **THEN** an "Imported from garmin" badge is displayed on the detail page + +#### Scenario: Delete and reimport +- **WHEN** a user deletes a Garmin-imported activity +- **THEN** the corresponding `sync_imports` record is deleted, so a future overlapping backfill can re-import it diff --git a/openspec/changes/garmin-import/tasks.md b/openspec/changes/garmin-import/tasks.md new file mode 100644 index 0000000..ba042f4 --- /dev/null +++ b/openspec/changes/garmin-import/tasks.md @@ -0,0 +1,42 @@ +# Tasks — garmin-import + +## 1. Provider scaffold + OAuth + +- [ ] 1.1 Create `providers/garmin/manifest.ts`: `credential_kind = 'oauth'`, Garmin OAuth2 endpoints, scopes, env-gated `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET`; register via `providers/index.ts` +- [ ] 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through `oauth-state.server.ts` alongside the state nonce, send `code_challenge`/`code_verifier` on authorize/exchange (token *storage and refresh* stay on the existing oauth `CredentialAdapter`) +- [ ] 1.3 Capture the Garmin user id at connect time and store as `provider_user_id` +- [ ] 1.4 `/settings/connections` shows Garmin connect/disconnect; row hidden/disabled when env vars are absent (mirror Wahoo's gating); i18n strings (en + de) +- [ ] 1.5 Unit tests: manifest registration, PKCE parameter generation/verification, connect flow state round-trip + +## 2. Webhook ingestion + +- [ ] 2.1 `WebhookReceiver` adapter for `/api/sync/webhook/garmin`: accept POST, normalize ping (fetch callback URL) and push (inline payload) into one internal activity-notification shape, respond 200 fast +- [ ] 2.2 Callback-URL host allowlist (Garmin API hosts only) — drop + log anything else; never fetch unvalidated URLs (SSRF guard) +- [ ] 2.3 pg-boss job `garmin-import-activity`: resolve user via `provider_user_id`, fetch FIT via `withFreshCredentials`, convert via shared `fit.ts`, create activity (GPX + stats + geometry; stats-only when no FIT), record `sync_imports`; idempotent on duplicate notifications; respect 429/Retry-After in retry policy +- [ ] 2.4 Unknown-user notifications → 200, no processing; unknown notification types → log + 200 +- [ ] 2.5 Tests with recorded notification + FIT fixtures: ping path, push path, dedupe, unknown user, SSRF rejection, FIT-less stats-only + +## 3. Backfill (historical import) + +- [ ] 3.1 Backfill request client: chunk a user-supplied date range into Garmin's per-request window; issue requests via `withFreshCredentials` +- [ ] 3.2 Import page `/settings/connections/garmin/import`: date-range form, list of requested ranges, count of imported activities per range, honest async messaging; i18n (en + de) +- [ ] 3.3 Persist backfill requests (range, requested_at, user) so progress survives reloads — smallest storage that works (reuse existing tables if possible; new table only if not) +- [ ] 3.4 Tests: chunking math, overlap re-request safety (dedupe via `sync_imports`), progress counting + +## 4. Deregistration + lifecycle + +- [ ] 4.1 Handle Garmin deregistration notifications: set `connected_services.status = 'revoked'`, short-circuit subsequent Garmin calls, surface the standard re-connect prompt +- [ ] 4.2 Tests: deregistration flips status; revoked connection blocks backfill/import paths + +## 5. Provenance + docs + +- [ ] 5.1 "Imported from garmin" badge works via the existing provenance path; delete-and-reimport clears `sync_imports` +- [ ] 5.2 Privacy manifest entry on `/legal/privacy` (en legal-manifest + de legal section): what we pull from Garmin, what Garmin learns, deletion/revocation semantics +- [ ] 5.3 Secrets wiring: `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET` in SOPS `secrets.app.env`, compose env (prod + staging), `.env.example` notes + +## 6. Rollout (gated on Garmin developer program approval) + +- [ ] 6.1 Apply to the Garmin Connect Developer Program (operator); record approved scopes/limits and the confirmed backfill chunk window in design.md +- [ ] 6.2 Register staging webhook URL in the Garmin portal; soak on staging with a real device/account: connect → record → auto-import; backfill a month; revoke from Garmin side → `revoked` +- [ ] 6.3 Register production webhook URL; set production secrets; verify end-to-end on trails.cool +- [ ] 6.4 Resolve the design open question on FIT-less notifications (ingest stats-only vs skip) against real fixtures; update spec annotation if behavior differs From 0360757ae86df1f30e43f8d171b043f18b75453d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:47:22 +0200 Subject: [PATCH 14/22] =?UTF-8?q?feat(journal):=20Garmin=20activity=20impo?= =?UTF-8?q?rt=20=E2=80=94=20provider,=20webhook=20pipeline,=20backfill=20(?= =?UTF-8?q?=C2=A71=E2=80=935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Garmin Connect as the third connected-services provider (spec: garmin-import). The interesting parts: - Push-first ingestion: Garmin has no list endpoint. The webhook normalizes ping (callbackURL) and push (inline) notification batches into events; the slow work (authorized FIT download, FIT→GPX via the shared converter, activity creation) runs in a garmin-import-activity pg-boss job so the webhook answers fast. Callback URLs are validated against Garmin's API host before any fetch (SSRF guard). - History via backfill requests: /sync/import/garmin is a date-range requester with honest async progress (no pick list — the concept doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap; overlaps are free via sync_imports dedupe. Requests persist in import_batches via two new nullable columns (range_start/range_end). - OAuth2 + PKCE on the existing oauth credential kind. Design correction from apply: the verifier rides a short-lived httpOnly cookie scoped to the callback path — the state param is visible in redirect URLs and must never carry it. Manifests opt in via pkce:true. - Deregistration notifications flip the connection to 'revoked' (row kept for audit, imports retained, re-connect prompt shown). - Framework evolutions, all additive: parseWebhook returns WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains configured()/importUrl/pkce, importActivity accepts summary stats for FIT-less imports, manager gains markRevoked. - Env-gated: no GARMIN_CLIENT_ID → provider hidden on /settings/connections. Privacy manifest entry (DE+EN). i18n en+de. Rollout (§6) stays gated on the Garmin Developer Program application (submitted 2026-06-07). Fixtures are doc-shaped; the staging soak swaps in recorded payloads if shapes differ. Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known flakes green isolated ✓ openspec validate ✓ Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/.env.example | 8 + .../app/jobs/garmin-import-activity.ts | 33 +++ .../app/lib/connected-services/manager.ts | 12 ++ .../connected-services/oauth-state.server.ts | 36 ++++ .../connected-services/oauth-state.test.ts | 61 ++++++ .../providers/garmin/backfill.test.ts | 42 ++++ .../providers/garmin/backfill.ts | 111 ++++++++++ .../providers/garmin/constants.ts | 8 + .../providers/garmin/import.server.ts | 82 ++++++++ .../providers/garmin/manifest.test.ts | 66 ++++++ .../providers/garmin/manifest.ts | 120 +++++++++++ .../providers/garmin/webhook.test.ts | 194 ++++++++++++++++++ .../providers/garmin/webhook.ts | 154 ++++++++++++++ .../lib/connected-services/providers/index.ts | 4 +- .../providers/wahoo/webhook.test.ts | 24 ++- .../providers/wahoo/webhook.ts | 22 +- .../app/lib/connected-services/registry.ts | 33 ++- apps/journal/app/lib/sync/imports.server.ts | 18 +- apps/journal/app/routes.ts | 1 + .../app/routes/api.sync.callback.$provider.ts | 20 +- .../app/routes/api.sync.connect.$provider.ts | 16 +- .../routes/api.sync.webhook.$provider.test.ts | 4 +- .../app/routes/api.sync.webhook.$provider.ts | 14 +- apps/journal/app/routes/legal.privacy.tsx | 23 ++- .../app/routes/settings.connections.server.ts | 25 ++- .../app/routes/settings.connections.tsx | 2 +- .../app/routes/sync.import.garmin.server.ts | 91 ++++++++ .../journal/app/routes/sync.import.garmin.tsx | 112 ++++++++++ apps/journal/server.ts | 3 +- infrastructure/docker-compose.staging.yml | 2 + infrastructure/docker-compose.yml | 4 + openspec/changes/garmin-import/design.md | 2 +- openspec/changes/garmin-import/tasks.md | 41 ++-- packages/db/src/schema/journal.ts | 5 + packages/i18n/src/locales/de.ts | 23 +++ packages/i18n/src/locales/en.ts | 23 +++ 36 files changed, 1368 insertions(+), 71 deletions(-) create mode 100644 apps/journal/app/jobs/garmin-import-activity.ts create mode 100644 apps/journal/app/lib/connected-services/oauth-state.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/backfill.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/constants.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/import.server.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/manifest.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts create mode 100644 apps/journal/app/lib/connected-services/providers/garmin/webhook.ts create mode 100644 apps/journal/app/routes/sync.import.garmin.server.ts create mode 100644 apps/journal/app/routes/sync.import.garmin.tsx diff --git a/apps/journal/.env.example b/apps/journal/.env.example index ef8718a..8a51ed0 100644 --- a/apps/journal/.env.example +++ b/apps/journal/.env.example @@ -49,6 +49,14 @@ # WAHOO_CLIENT_SECRET= # WAHOO_WEBHOOK_TOKEN= +# Garmin Connect Developer Program credentials (spec: garmin-import). +# Requires an approved program application; without these the Garmin +# provider is hidden on /settings/connections. The OAuth callback to +# register with Garmin is `/api/sync/callback/garmin`, the +# notification endpoint `/api/sync/webhook/garmin`. +# GARMIN_CLIENT_ID= +# GARMIN_CLIENT_SECRET= + # Integration test secret (only needed if running the integration # test suite that drives the API directly). Generate with # `openssl rand -hex 32`. diff --git a/apps/journal/app/jobs/garmin-import-activity.ts b/apps/journal/app/jobs/garmin-import-activity.ts new file mode 100644 index 0000000..edff614 --- /dev/null +++ b/apps/journal/app/jobs/garmin-import-activity.ts @@ -0,0 +1,33 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { logger } from "../lib/logger.server.ts"; +import { + runGarminActivityImport, + type GarminImportData, +} from "../lib/connected-services/providers/garmin/import.server.ts"; + +// Garmin webhook notifications enqueue here (spec: garmin-import, +// "Push-notification activity import"): the webhook answers 200 +// immediately and this job does the slow part — authorized file +// download, FIT→GPX, activity creation. Backfill bursts deliver many +// notifications at once; the queue absorbs them and pg-boss retries +// transient download failures. +export const garminImportActivityJob: JobDefinition = { + name: "garmin-import-activity", + retryLimit: 3, + expireInSeconds: 300, + async handler(jobs) { + const batch = Array.isArray(jobs) ? jobs : [jobs]; + for (const job of batch) { + const data = job.data as GarminImportData; + try { + await runGarminActivityImport(data); + } catch (err) { + logger.warn( + { err, externalId: data.externalId }, + "garmin-import-activity failed (pg-boss will retry)", + ); + throw err; + } + } + }, +}; diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts index 511e3ae..706795f 100644 --- a/apps/journal/app/lib/connected-services/manager.ts +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -168,6 +168,18 @@ export async function markNeedsRelink( console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`); } +// Provider-side revocation (e.g. a Garmin deregistration notification): +// keep the row for audit, flip to 'revoked' so every subsequent +// withFreshCredentials short-circuits and the UI shows a re-connect +// prompt. Imported activities are untouched. +export async function markRevoked(serviceId: string): Promise { + const db = getDb(); + await db + .update(connectedServices) + .set({ status: "revoked" }) + .where(eq(connectedServices.id, serviceId)); +} + export async function updateGrantedScopes( serviceId: string, grantedScopes: string[], diff --git a/apps/journal/app/lib/connected-services/oauth-state.server.ts b/apps/journal/app/lib/connected-services/oauth-state.server.ts index fe886af..ae0fb8a 100644 --- a/apps/journal/app/lib/connected-services/oauth-state.server.ts +++ b/apps/journal/app/lib/connected-services/oauth-state.server.ts @@ -21,3 +21,39 @@ export function decodeOAuthState(raw: string | null | undefined): PushOAuthState return {}; } } + +// --- PKCE (RFC 7636) ---------------------------------------------------- +// +// Providers with `pkce: true` (Garmin) need a code verifier that survives +// the connect → provider → callback redirect without ever appearing in a +// URL (the whole point of PKCE is that the verifier stays out of the +// authorization response). The `state` param is visible in redirects, so +// the verifier rides a short-lived httpOnly cookie scoped to the callback +// path instead. + +import { createHash, randomBytes } from "node:crypto"; + +const PKCE_COOKIE = "__oauth_pkce"; +const PKCE_MAX_AGE_S = 600; + +export function generatePkcePair(): { verifier: string; challenge: string } { + // 32 random bytes → 43-char base64url verifier (within RFC 7636's 43–128). + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function pkceCookieHeader(verifier: string): string { + const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; + return `${PKCE_COOKIE}=${verifier}; Max-Age=${PKCE_MAX_AGE_S}; Path=/api/sync/callback; HttpOnly; SameSite=Lax${secure}`; +} + +export function clearPkceCookieHeader(): string { + return `${PKCE_COOKIE}=; Max-Age=0; Path=/api/sync/callback; HttpOnly; SameSite=Lax`; +} + +export function readPkceVerifier(request: Request): string | null { + const cookie = request.headers.get("Cookie") ?? ""; + const match = cookie.match(new RegExp(`(?:^|;\\s*)${PKCE_COOKIE}=([^;]+)`)); + return match?.[1] ?? null; +} diff --git a/apps/journal/app/lib/connected-services/oauth-state.test.ts b/apps/journal/app/lib/connected-services/oauth-state.test.ts new file mode 100644 index 0000000..36d9f90 --- /dev/null +++ b/apps/journal/app/lib/connected-services/oauth-state.test.ts @@ -0,0 +1,61 @@ +// PKCE helper tests (RFC 7636 S256) — spec: garmin-import, task 1.2. + +import { createHash } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { + generatePkcePair, + pkceCookieHeader, + readPkceVerifier, + clearPkceCookieHeader, + encodeOAuthState, + decodeOAuthState, +} from "./oauth-state.server.ts"; + +describe("generatePkcePair", () => { + it("produces an RFC 7636-compliant verifier and matching S256 challenge", () => { + const { verifier, challenge } = generatePkcePair(); + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); // base64url charset + const expected = createHash("sha256").update(verifier).digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("is unique per call", () => { + expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier); + }); +}); + +describe("PKCE cookie round trip", () => { + it("verifier set on connect is readable on callback", () => { + const { verifier } = generatePkcePair(); + const setCookie = pkceCookieHeader(verifier); + // Browser reflects the cookie value back on the callback request. + const cookieValue = setCookie.split(";")[0]!; + const request = new Request("https://x.example/api/sync/callback/garmin", { + headers: { Cookie: `other=1; ${cookieValue}` }, + }); + expect(readPkceVerifier(request)).toBe(verifier); + }); + + it("returns null without the cookie; clear header expires it", () => { + const request = new Request("https://x.example/", { headers: { Cookie: "other=1" } }); + expect(readPkceVerifier(request)).toBeNull(); + expect(clearPkceCookieHeader()).toContain("Max-Age=0"); + }); + + it("cookie is httpOnly and scoped to the callback path", () => { + const header = pkceCookieHeader("v"); + expect(header).toContain("HttpOnly"); + expect(header).toContain("Path=/api/sync/callback"); + }); +}); + +describe("oauth state encoding (pre-existing behavior)", () => { + it("round-trips and tolerates garbage", () => { + const encoded = encodeOAuthState({ returnTo: "/settings/connections" }); + expect(decodeOAuthState(encoded)).toEqual({ returnTo: "/settings/connections" }); + expect(decodeOAuthState("%%%not-base64%%%")).toEqual({}); + expect(decodeOAuthState(null)).toEqual({}); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts new file mode 100644 index 0000000..9c1be9e --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.test.ts @@ -0,0 +1,42 @@ +// Unit tests for Garmin backfill chunking + request fan-out +// (spec: garmin-import, "Historical import via backfill"). + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ withFreshCredentials: vi.fn() })); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { chunkRange, BACKFILL_CHUNK_MS } = await import("./backfill.ts"); + +const DAY = 24 * 60 * 60 * 1000; + +describe("chunkRange", () => { + it("returns a single chunk for ranges within the cap", () => { + const chunks = chunkRange(0, 30 * DAY); + expect(chunks).toEqual([{ fromMs: 0, toMs: 30 * DAY }]); + }); + + it("splits ranges larger than the cap, last chunk clamped", () => { + const chunks = chunkRange(0, 200 * DAY); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ fromMs: 0, toMs: BACKFILL_CHUNK_MS }); + expect(chunks[2]!.toMs).toBe(200 * DAY); + // contiguous, no gaps or overlaps + expect(chunks[1]!.fromMs).toBe(chunks[0]!.toMs); + expect(chunks[2]!.fromMs).toBe(chunks[1]!.toMs); + }); + + it("returns [] for empty or inverted ranges", () => { + expect(chunkRange(5, 5)).toEqual([]); + expect(chunkRange(10, 5)).toEqual([]); + }); + + it("covers exactly the requested range at chunk boundaries", () => { + const chunks = chunkRange(0, 2 * BACKFILL_CHUNK_MS); + expect(chunks).toHaveLength(2); + expect(chunks[1]).toEqual({ + fromMs: BACKFILL_CHUNK_MS, + toMs: 2 * BACKFILL_CHUNK_MS, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts new file mode 100644 index 0000000..1055a78 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts @@ -0,0 +1,111 @@ +// Garmin historical import via the Activity API backfill endpoint +// (spec: garmin-import, "Historical import via backfill"). +// +// Garmin has no list-activities endpoint: you ask for a time range and +// Garmin re-delivers those activities asynchronously through the same +// notification pipeline the live webhook uses. Each accepted request +// returns 202; the data arrives whenever Garmin gets to it. + +import { randomUUID } from "node:crypto"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { GARMIN_API } from "./constants.ts"; + +// Garmin caps a single backfill request's window. 90 days per the +// Activity API docs; if program onboarding reveals a different cap for +// our key, this constant is the only thing to change (design.md, open +// questions). +export const BACKFILL_CHUNK_MS = 90 * 24 * 60 * 60 * 1000; + +const BACKFILL_URL = `${GARMIN_API}/wellness-api/rest/backfill/activities`; + +/** + * Split [from, to] into Garmin-sized chunks (inclusive bounds, ms). + * Returns [] for empty/inverted ranges. + */ +export function chunkRange( + fromMs: number, + toMs: number, + chunkMs: number = BACKFILL_CHUNK_MS, +): Array<{ fromMs: number; toMs: number }> { + if (!(fromMs < toMs) || chunkMs <= 0) return []; + const chunks: Array<{ fromMs: number; toMs: number }> = []; + for (let start = fromMs; start < toMs; start += chunkMs) { + chunks.push({ fromMs: start, toMs: Math.min(start + chunkMs, toMs) }); + } + return chunks; +} + +export interface BackfillDeps { + requestChunk( + serviceId: string, + fromSec: number, + toSec: number, + ): Promise; +} + +function defaultDeps(): BackfillDeps { + return { + async requestChunk(serviceId, fromSec, toSec) { + await withFreshCredentials(serviceId, async (credentials) => { + const creds = credentials as OAuthCredentials; + const url = `${BACKFILL_URL}?summaryStartTimeInSeconds=${fromSec}&summaryEndTimeInSeconds=${toSec}`; + const resp = await fetchWithTimeout(url, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + // 202 = accepted. 409 = an identical/overlapping request is + // already in flight — fine, the data will arrive either way + // and sync_imports dedupes. + if (!resp.ok && resp.status !== 409) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin backfill request failed: ${resp.status} ${text}`); + } + }); + }, + }; +} + +/** + * Issue backfill requests covering [from, to] and persist one + * import_batches row describing the whole request (progress UX reads + * it back on the import page). + */ +export async function requestBackfill( + service: { id: string; userId: string }, + from: Date, + to: Date, + deps: BackfillDeps = defaultDeps(), +): Promise<{ batchId: string; chunks: number }> { + const chunks = chunkRange(from.getTime(), to.getTime()); + if (chunks.length === 0) throw new Error("Empty backfill range"); + + for (const chunk of chunks) { + await deps.requestChunk( + service.id, + Math.floor(chunk.fromMs / 1000), + Math.floor(chunk.toMs / 1000), + ); + } + + // Record the request for the import page. Lazy import keeps the DB + // out of this module's graph for pure-function tests (chunkRange). + const { getDb } = await import("../../../db.ts"); + const { importBatches } = await import("@trails-cool/db/schema/journal"); + const batchId = randomUUID(); + await getDb() + .insert(importBatches) + .values({ + id: batchId, + userId: service.userId, + connectionId: service.id, + provider: "garmin", + // Garmin delivers asynchronously — the batch is "running" from + // our perspective until the operator-facing page stops caring. + // totalFound is unknowable up front (no list endpoint). + status: "running", + rangeStart: from, + rangeEnd: to, + }); + return { batchId, chunks: chunks.length }; +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/constants.ts b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts new file mode 100644 index 0000000..955a191 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/constants.ts @@ -0,0 +1,8 @@ +// Garmin endpoint constants — own module so manifest.ts, webhook.ts, +// import.server.ts, and backfill.ts can all use them without forming +// an import cycle (manifest → webhook → import.server must never loop +// back into manifest for a value needed at module-eval time). + +export const GARMIN_API = "https://apis.garmin.com"; +export const GARMIN_AUTHORIZE = "https://connect.garmin.com/oauth2Confirm"; +export const GARMIN_TOKEN = "https://diauth.garmin.com/di-oauth2-service/oauth/token"; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts new file mode 100644 index 0000000..6c9f439 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/import.server.ts @@ -0,0 +1,82 @@ +// Garmin activity import — the slow half of the webhook pipeline. +// Runs inside the `garmin-import-activity` pg-boss job: download the +// activity file from the notification's callback URL (Authorized via +// the user's token), convert to GPX, create the activity, record the +// dedupe row. Stats-only when there is no file. + +import { fitToGpx } from "../../fit.ts"; +import { fetchWithTimeout } from "../../../http.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById, withFreshCredentials } from "../../manager.ts"; +import type { OAuthCredentials } from "../../types.ts"; +import { logger } from "../../../logger.server.ts"; +import { GARMIN_API } from "./constants.ts"; + +// SSRF guard: notification callback URLs are attacker-controllable +// input until proven otherwise — only Garmin's API host is fetchable. +const ALLOWED_CALLBACK_HOSTS = new Set([new URL(GARMIN_API).host]); + +export function isAllowedGarminCallback(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" && ALLOWED_CALLBACK_HOSTS.has(parsed.host); + } catch { + return false; + } +} + +export interface GarminImportData { + serviceId: string; + userId: string; + externalId: string; + callbackUrl: string | null; + fileType: string | null; + name: string | null; + startedAt: string | null; + duration: number | null; + distance: number | null; +} + +export async function runGarminActivityImport(data: GarminImportData): Promise { + // Connection may have been revoked/relinked between enqueue and run. + const service = await getServiceById(data.serviceId); + if (!service || service.status !== "active") { + logger.info({ serviceId: data.serviceId }, "garmin import: connection not active — skipped"); + return; + } + + if (await isAlreadyImported(data.userId, "garmin", data.externalId)) return; + + let gpx: string | undefined; + if (data.callbackUrl && isAllowedGarminCallback(data.callbackUrl)) { + const buffer = await withFreshCredentials(service.id, async (credentials) => { + const creds = credentials as OAuthCredentials; + const resp = await fetchWithTimeout(data.callbackUrl!, { + headers: { Authorization: `Bearer ${creds.access_token}` }, + }); + if (!resp.ok) { + throw new Error(`Garmin file download failed: ${resp.status}`); + } + return Buffer.from(await resp.arrayBuffer()); + }); + if (data.fileType === "GPX") { + // Garmin can serve GPX directly; createActivity validates it. + gpx = buffer.toString("utf8"); + } else { + // FIT (default) — shared provider-agnostic converter. + gpx = (await fitToGpx(buffer, data.name ?? "Garmin activity")) ?? undefined; + } + } + + await importActivity(data.userId, "garmin", data.externalId, { + name: data.name ?? "Garmin activity", + gpx, + distance: data.distance, + duration: data.duration, + startedAt: data.startedAt ? new Date(data.startedAt) : null, + }); + logger.info( + { externalId: data.externalId, hadFile: !!gpx }, + "garmin import: activity imported", + ); +} diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts new file mode 100644 index 0000000..531f181 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.test.ts @@ -0,0 +1,66 @@ +// Manifest contract tests (spec: garmin-import, "Connect Garmin +// account" + PKCE parameters + env gating). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: vi.fn(), + markRevoked: vi.fn(), + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ enqueueOptional: vi.fn() })); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +const { garminManifest } = await import("./manifest.ts"); + +const ENV_KEYS = ["GARMIN_CLIENT_ID", "GARMIN_CLIENT_SECRET"] as const; +const saved: Record = {}; + +beforeEach(() => { + for (const k of ENV_KEYS) saved[k] = process.env[k]; +}); +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe("garminManifest", () => { + it("declares oauth + PKCE, no pick-list importer, custom import page", () => { + expect(garminManifest.id).toBe("garmin"); + expect(garminManifest.credentialKind).toBe("oauth"); + expect(garminManifest.pkce).toBe(true); + expect(garminManifest.importer).toBeUndefined(); + expect(garminManifest.importUrl).toBe("/sync/import/garmin"); + expect(garminManifest.webhookReceiver).toBeDefined(); + }); + + it("is hidden without instance credentials, shown with them", () => { + delete process.env.GARMIN_CLIENT_ID; + expect(garminManifest.configured!()).toBe(false); + process.env.GARMIN_CLIENT_ID = "test-client"; + expect(garminManifest.configured!()).toBe(true); + }); + + it("buildAuthUrl carries the S256 code challenge", () => { + process.env.GARMIN_CLIENT_ID = "test-client"; + const url = new URL( + garminManifest.buildAuthUrl!( + "https://journal.example/api/sync/callback/garmin", + "state-123", + { codeChallenge: "challenge-abc" }, + ), + ); + expect(url.origin + url.pathname).toBe("https://connect.garmin.com/oauth2Confirm"); + expect(url.searchParams.get("client_id")).toBe("test-client"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-abc"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-123"); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts new file mode 100644 index 0000000..c9a9398 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/manifest.ts @@ -0,0 +1,120 @@ +// Garmin provider manifest (spec: garmin-import). OAuth2 + PKCE on the +// shared oauth credential adapter — PKCE is a handshake detail (see +// design.md), the stored blob is plain OAuthCredentials. +// +// Garmin is push-first: there is no list-activities endpoint, so this +// manifest declares no `importer`. Ingestion happens via the webhook +// receiver (ping/push notifications) and history via backfill requests +// (see backfill.ts + the /sync/import/garmin page). +// +// Endpoint references: Garmin Connect Developer Program, Activity API. +// Exact notification shapes are normalized tolerantly in webhook.ts — +// Garmin's docs shift between API versions (design.md, Risks). + +import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts"; +import { garminWebhook } from "./webhook.ts"; +import { GARMIN_API, GARMIN_AUTHORIZE, GARMIN_TOKEN } from "./constants.ts"; + +function clientId(): string { + return process.env.GARMIN_CLIENT_ID ?? ""; +} +function clientSecret(): string { + return process.env.GARMIN_CLIENT_SECRET ?? ""; +} + +const oauthConfig: ProviderOAuthConfig = { + get tokenUrl() { + return GARMIN_TOKEN; + }, + get clientId() { + return clientId(); + }, + get clientSecret() { + return clientSecret(); + }, +}; + +export const garminManifest: ProviderManifest = { + id: "garmin", + displayName: "Garmin", + credentialKind: "oauth", + credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"], + oauthConfig, + pkce: true, + // No instance credentials → no Garmin row on the connections page. + // (Garmin program keys are per-operator; self-hosted instances + // without one must not render a dead Connect button.) + configured: () => clientId().length > 0, + // Backfill requester, not a pick list — Garmin has no list endpoint. + importUrl: "/sync/import/garmin", + + buildAuthUrl(redirectUri, state, extras): string { + const params = new URLSearchParams({ + client_id: clientId(), + response_type: "code", + redirect_uri: redirectUri, + state, + }); + if (extras?.codeChallenge) { + params.set("code_challenge", extras.codeChallenge); + params.set("code_challenge_method", "S256"); + } + return `${GARMIN_AUTHORIZE}?${params}`; + }, + + async exchangeCode( + code, + redirectUri, + extras, + ): Promise<{ + credentials: OAuthCredentials; + providerUserId: string | null; + grantedScopes: string[]; + }> { + const resp = await fetch(GARMIN_TOKEN, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId(), + client_secret: clientSecret(), + code, + code_verifier: extras?.codeVerifier ?? "", + redirect_uri: redirectUri, + }).toString(), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Garmin token exchange failed: ${resp.status} ${text}`); + } + const data = (await resp.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + scope?: string; + }; + + // Garmin user id — needed to route webhook notifications to the + // right local user. + const userResp = await fetch(`${GARMIN_API}/wellness-api/rest/user/id`, { + headers: { Authorization: `Bearer ${data.access_token}` }, + }); + const user = userResp.ok + ? ((await userResp.json()) as { userId?: string }) + : null; + + return { + credentials: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), + }, + providerUserId: user?.userId ?? null, + grantedScopes: data.scope ? data.scope.split(" ") : [], + }; + }, + + webhookReceiver: garminWebhook, +}; diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts new file mode 100644 index 0000000..c360717 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.test.ts @@ -0,0 +1,194 @@ +// Contract tests for the Garmin WebhookReceiver (spec: garmin-import). +// +// Seam: parseWebhook(body) -> WebhookEvent[] (Garmin batches notifications) +// handle(event) -> void (enqueues the import job; deregistration +// revokes; SSRF-suspicious callback URLs are dropped) + +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mockGetServiceByProviderUser = vi.fn(); +const mockMarkRevoked = vi.fn(); +const mockEnqueueOptional = vi.fn(); + +vi.mock("../../manager.ts", () => ({ + getServiceByProviderUser: mockGetServiceByProviderUser, + markRevoked: mockMarkRevoked, + // imported transitively via import.server.ts + getServiceById: vi.fn(), + withFreshCredentials: vi.fn(), +})); +vi.mock("../../../boss.server.ts", () => ({ + enqueueOptional: mockEnqueueOptional, +})); +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), +})); +vi.mock("../../../db.ts", () => ({ getDb: () => ({}) })); + +beforeEach(() => { + mockGetServiceByProviderUser.mockReset(); + mockMarkRevoked.mockReset(); + mockEnqueueOptional.mockReset(); +}); + +const { garminWebhook } = await import("./webhook.ts"); +const { isAllowedGarminCallback } = await import("./import.server.ts"); + +describe("garminWebhook.parseWebhook", () => { + it("parses a ping-style activityFiles batch into file events", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { + userId: "g-user-1", + summaryId: "s-1", + activityId: 1001, + activityName: "Morning Run", + callbackURL: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + startTimeInSeconds: 1780000000, + durationInSeconds: 3600, + distanceInMeters: 10000, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: expect.stringContaining("apis.garmin.com"), + name: "Morning Run", + duration: 3600, + distance: 10000, + fileType: "FIT", + }); + }); + + it("parses push-style activity summaries (FIT-less, stats-only path)", () => { + const events = garminWebhook.parseWebhook({ + activities: [ + { + userId: "g-user-1", + summaryId: "s-2", + activityId: 1002, + activityName: "Indoor Row", + durationInSeconds: 1800, + }, + ], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "garmin:activity-summary", + workoutId: "1002", + duration: 1800, + }); + expect(events[0]!.fileUrl).toBeUndefined(); + }); + + it("parses deregistrations", () => { + const events = garminWebhook.parseWebhook({ + deregistrations: [{ userId: "g-user-1" }], + }); + expect(events).toEqual([ + { + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }, + ]); + }); + + it("handles mixed batches and skips malformed entries", () => { + const events = garminWebhook.parseWebhook({ + activityFiles: [ + { userId: "u1", activityId: 1 }, + { activityId: 2 }, // no userId — skipped + { userId: "u3" }, // no id — skipped + ], + deregistrations: [{}, { userId: "u4" }], + somethingGarminAddedLater: [{ userId: "u5" }], + }); + expect(events.map((e) => e.eventType)).toEqual([ + "garmin:activity-file", + "garmin:deregistration", + ]); + }); + + it("returns no events for non-object bodies", () => { + expect(garminWebhook.parseWebhook(null)).toEqual([]); + expect(garminWebhook.parseWebhook("x")).toEqual([]); + }); +}); + +describe("garminWebhook.handle", () => { + const service = { id: "svc-g1", userId: "u1", provider: "garmin" }; + + it("enqueues the import job for a known user's file event", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://apis.garmin.com/wellness-api/rest/activityFile?id=1001", + fileType: "FIT", + name: "Morning Run", + }); + + expect(mockEnqueueOptional).toHaveBeenCalledWith( + "garmin-import-activity", + expect.objectContaining({ + serviceId: "svc-g1", + userId: "u1", + externalId: "1001", + callbackUrl: expect.stringContaining("apis.garmin.com"), + fileType: "FIT", + }), + expect.anything(), + ); + }); + + it("silently skips unknown users (no leak)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(null); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "nobody", + workoutId: "1", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + expect(mockMarkRevoked).not.toHaveBeenCalled(); + }); + + it("drops events whose callback URL is not Garmin's API host (SSRF guard)", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:activity-file", + providerUserId: "g-user-1", + workoutId: "1001", + fileUrl: "https://attacker.example/steal?token=", + }); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); + + it("revokes the connection on deregistration", async () => { + mockGetServiceByProviderUser.mockResolvedValue(service); + await garminWebhook.handle({ + eventType: "garmin:deregistration", + providerUserId: "g-user-1", + workoutId: "", + }); + expect(mockMarkRevoked).toHaveBeenCalledWith("svc-g1"); + expect(mockEnqueueOptional).not.toHaveBeenCalled(); + }); +}); + +describe("isAllowedGarminCallback", () => { + it("allows only https URLs on Garmin's API host", () => { + expect(isAllowedGarminCallback("https://apis.garmin.com/wellness-api/rest/x")).toBe(true); + expect(isAllowedGarminCallback("http://apis.garmin.com/x")).toBe(false); + expect(isAllowedGarminCallback("https://apis.garmin.com.evil.example/x")).toBe(false); + expect(isAllowedGarminCallback("https://evil.example/apis.garmin.com")).toBe(false); + expect(isAllowedGarminCallback("not a url")).toBe(false); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts new file mode 100644 index 0000000..aded436 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/garmin/webhook.ts @@ -0,0 +1,154 @@ +// Garmin WebhookReceiver capability adapter (spec: garmin-import, +// "Push-notification activity import" + "Deregistration handling"). +// +// Garmin POSTs notifications to /api/sync/webhook/garmin in batches: +// - `activityFiles`: ping-style entries with a callbackURL to a FIT/GPX +// file (the main import path) +// - `activities` / `activityDetails`: summary entries (stats; used for +// FIT-less activities) +// - `deregistrations`: the user revoked access on Garmin's side +// +// The webhook must answer fast (Garmin retries and throttles slow +// consumers), so `handle` only validates + enqueues; the download and +// FIT→GPX conversion happen in the `garmin-import-activity` pg-boss job +// (same lesson as federation's inbox: never do slow work in an inbound +// hook). Deregistrations are the exception — a single UPDATE is cheap +// and must not be lost to a queue hiccup. +// +// Notification shapes are under-documented and drift between Garmin API +// versions, so parsing is tolerant: unknown keys are ignored, malformed +// entries are skipped, and nothing here ever throws on bad input. + +import { logger } from "../../../logger.server.ts"; +import { enqueueOptional } from "../../../boss.server.ts"; +import { getServiceByProviderUser, markRevoked } from "../../manager.ts"; +import type { WebhookEvent, WebhookReceiver } from "../../registry.ts"; +import { isAllowedGarminCallback } from "./import.server.ts"; + +interface GarminNotificationEntry { + userId?: string; + summaryId?: string | number; + activityId?: string | number; + activityName?: string; + activityType?: string; + callbackURL?: string; + fileType?: string; + startTimeInSeconds?: number; + durationInSeconds?: number; + distanceInMeters?: number; +} + +interface GarminWebhookBody { + activityFiles?: GarminNotificationEntry[]; + activities?: GarminNotificationEntry[]; + activityDetails?: GarminNotificationEntry[]; + deregistrations?: { userId?: string }[]; +} + +const EVENT_FILE = "garmin:activity-file"; +const EVENT_SUMMARY = "garmin:activity-summary"; +const EVENT_DEREGISTRATION = "garmin:deregistration"; + +function externalId(entry: GarminNotificationEntry): string { + const id = entry.activityId ?? entry.summaryId; + return id == null ? "" : String(id); +} + +function entryToEvent( + entry: GarminNotificationEntry, + eventType: string, +): WebhookEvent | null { + if (!entry.userId || !externalId(entry)) return null; + return { + eventType, + providerUserId: entry.userId, + workoutId: externalId(entry), + fileUrl: entry.callbackURL, + name: entry.activityName, + startedAt: + entry.startTimeInSeconds != null + ? new Date(entry.startTimeInSeconds * 1000).toISOString() + : undefined, + duration: entry.durationInSeconds ?? null, + distance: entry.distanceInMeters ?? null, + fileType: entry.fileType, + }; +} + +export const garminWebhook: WebhookReceiver = { + parseWebhook(body: unknown): WebhookEvent[] { + if (typeof body !== "object" || body === null) return []; + const payload = body as GarminWebhookBody; + const events: WebhookEvent[] = []; + + for (const entry of payload.activityFiles ?? []) { + const event = entryToEvent(entry, EVENT_FILE); + if (event) events.push(event); + } + // Summaries cover FIT-less activities (stats-only import). A FIT + // notification for the same activityId wins via sync_imports dedupe + // ordering being first-come — both paths import once. + for (const entry of [...(payload.activities ?? []), ...(payload.activityDetails ?? [])]) { + const event = entryToEvent(entry, EVENT_SUMMARY); + if (event) events.push(event); + } + for (const dereg of payload.deregistrations ?? []) { + if (dereg.userId) { + events.push({ + eventType: EVENT_DEREGISTRATION, + providerUserId: dereg.userId, + workoutId: "", + }); + } + } + return events; + }, + + async handle(event: WebhookEvent): Promise { + // Unknown users: silent 200 — never reveal user existence. + const service = await getServiceByProviderUser("garmin", event.providerUserId); + if (!service) return; + + if (event.eventType === EVENT_DEREGISTRATION) { + // Spec: provider-side revocation keeps the row (audit) but stops + // every subsequent Garmin call for this user. + await markRevoked(service.id); + logger.info({ serviceId: service.id }, "garmin: deregistration — connection revoked"); + return; + } + + // SSRF guard: a callback URL that doesn't point at Garmin's API is + // dropped here, before it can ever be fetched (design.md, Risks). + if (event.fileUrl && !isAllowedGarminCallback(event.fileUrl)) { + logger.warn( + { host: safeHost(event.fileUrl) }, + "garmin: notification callback URL not on the Garmin allowlist — dropped", + ); + return; + } + + await enqueueOptional( + "garmin-import-activity", + { + serviceId: service.id, + userId: service.userId, + externalId: event.workoutId, + callbackUrl: event.fileUrl ?? null, + fileType: event.fileType ?? (event.eventType === EVENT_FILE ? "FIT" : null), + name: event.name ?? null, + startedAt: event.startedAt ?? null, + duration: event.duration ?? null, + distance: event.distance ?? null, + }, + { reason: "garmin webhook notification" }, + ); + }, +}; + +function safeHost(url: string): string { + try { + return new URL(url).host; + } catch { + return ""; + } +} diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts index ff04735..ba54534 100644 --- a/apps/journal/app/lib/connected-services/providers/index.ts +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -5,9 +5,11 @@ import { registerManifest } from "../registry.ts"; import { wahooManifest } from "./wahoo/manifest.ts"; import { komootManifest } from "./komoot/manifest.ts"; +import { garminManifest } from "./garmin/manifest.ts"; registerManifest(wahooManifest); registerManifest(komootManifest); +registerManifest(garminManifest); // Re-export so callers (mostly tests) can grab a manifest directly. -export { wahooManifest, komootManifest }; +export { wahooManifest, komootManifest, garminManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts index 01d2acd..6d03966 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts @@ -1,6 +1,6 @@ // Contract tests for the Wahoo WebhookReceiver capability adapter. // -// Seam: parseWebhook(body) -> WebhookEvent | null +// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable) // handle(event) -> void (creates an activity if file present, dedups via sync_imports) import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -41,22 +41,24 @@ describe("wahooWebhook.parseWebhook", () => { file: { url: "https://cdn.example/42.fit" }, }, }); - expect(event).toEqual({ - eventType: "workout_summary", - providerUserId: "7", - workoutId: "42", - fileUrl: "https://cdn.example/42.fit", - }); + expect(event).toEqual([ + { + eventType: "workout_summary", + providerUserId: "7", + workoutId: "42", + fileUrl: "https://cdn.example/42.fit", + }, + ]); }); - it("returns null for unrecognized event types", () => { - expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull(); + it("returns no events for unrecognized event types", () => { + expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]); }); - it("returns null when user.id is missing", () => { + it("returns no events when user.id is missing", () => { expect( wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }), - ).toBeNull(); + ).toEqual([]); }); }); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts index 6473b0b..8b77f58 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/webhook.ts @@ -23,18 +23,20 @@ interface WahooWebhookBody { export const wahooWebhook: WebhookReceiver = { - parseWebhook(body: unknown): WebhookEvent | null { + parseWebhook(body: unknown): WebhookEvent[] { const payload = body as WahooWebhookBody; - if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; + if (payload.event_type !== "workout_summary" || !payload.user?.id) return []; - return { - eventType: payload.event_type, - providerUserId: String(payload.user.id), - workoutId: String( - payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", - ), - fileUrl: payload.workout_summary?.file?.url, - }; + return [ + { + eventType: payload.event_type, + providerUserId: String(payload.user.id), + workoutId: String( + payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? "", + ), + fileUrl: payload.workout_summary?.file?.url, + }, + ]; }, async handle(event: WebhookEvent): Promise { diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts index 457a79b..3bac1c7 100644 --- a/apps/journal/app/lib/connected-services/registry.ts +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -59,6 +59,15 @@ export interface WebhookEvent { providerUserId: string; workoutId: string; fileUrl?: string; + // Optional summary stats carried by providers whose notifications + // include them (Garmin pushes summaries; a FIT-less activity is still + // importable stats-only). Providers without summaries leave these out. + name?: string; + startedAt?: string; + duration?: number | null; + distance?: number | null; + // File format behind fileUrl when the provider says (FIT | GPX | TCX). + fileType?: string; } // CapabilityContext gives capability adapters the tools they need without @@ -81,7 +90,10 @@ export interface RoutePusher { } export interface WebhookReceiver { - parseWebhook(body: unknown): WebhookEvent | null; + // One provider POST can carry many events (Garmin batches + // notifications). Single-event providers return a one-element array; + // an empty array means "nothing actionable" and the route 200s. + parseWebhook(body: unknown): WebhookEvent[]; handle(event: WebhookEvent): Promise; } @@ -99,13 +111,30 @@ export interface ProviderManifest { // Custom connect page URL. When set, the connections settings page links // here instead of the default OAuth connect endpoint. connectUrl?: string; + // Custom import page URL. When set, the connections settings page links + // here instead of the generic /sync/import/ pick-list page (Garmin + // has no list endpoint — its import page is a backfill requester). + importUrl?: string; + // When defined and returning false, the provider is hidden from the + // connections settings page (e.g. instance has no API credentials for + // it). Undefined = always shown. + configured?: () => boolean; + // OAuth2 PKCE: when true, the connect route generates a code verifier + // (carried in an httpOnly cookie across the redirect) and passes the + // S256 challenge to buildAuthUrl / the verifier to exchangeCode. + pkce?: boolean; // OAuth authorization URL builder (for the connect flow). - buildAuthUrl?: (redirectUri: string, state: string) => string; + buildAuthUrl?: ( + redirectUri: string, + state: string, + extras?: { codeChallenge?: string }, + ) => string; // OAuth code exchange (for the callback). Returns the credential blob to // store and the granted scopes. exchangeCode?: ( code: string, redirectUri: string, + extras?: { codeVerifier?: string }, ) => Promise<{ credentials: unknown; providerUserId: string | null; diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts index 757a201..aa5f3cb 100644 --- a/apps/journal/app/lib/sync/imports.server.ts +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -51,9 +51,23 @@ export async function importActivity( userId: string, provider: string, externalWorkoutId: string, - input: { name: string; gpx?: string }, + input: { + name: string; + gpx?: string; + // Stats-only imports (no GPS file — e.g. Garmin notifications for + // FIT-less activities) carry whatever the provider's summary had. + distance?: number | null; + duration?: number | null; + startedAt?: Date | null; + }, ): Promise<{ activityId: string }> { - const activityId = await createActivity(userId, { name: input.name, gpx: input.gpx }); + const activityId = await createActivity(userId, { + name: input.name, + gpx: input.gpx, + distance: input.distance, + duration: input.duration, + startedAt: input.startedAt, + }); await recordImport(userId, provider, externalWorkoutId, activityId); return { activityId }; } diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 77077c8..19c02e8 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -58,6 +58,7 @@ export default [ route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"), route("api/settings/delete-account", "routes/api.settings.delete-account.ts"), route("sync/import/komoot", "routes/sync.import.komoot.tsx"), + route("sync/import/garmin", "routes/sync.import.garmin.tsx"), route("sync/import/:provider", "routes/sync.import.$provider.tsx"), route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"), route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"), diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 800db0c..e9c0db0 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -5,6 +5,8 @@ import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest, link } from "~/lib/connected-services"; import { decodeOAuthState, + readPkceVerifier, + clearPkceCookieHeader, } from "~/lib/connected-services/oauth-state.server"; import { pushRouteToProvider } from "~/lib/connected-services/push-action.server"; @@ -32,8 +34,18 @@ export async function loader({ params, request }: Route.LoaderArgs) { const origin = getOrigin(); const redirectUri = `${origin}/api/sync/callback/${params.provider}`; + // PKCE providers: recover the verifier from the connect-time cookie. + const codeVerifier = manifest.pkce ? readPkceVerifier(request) : null; + if (manifest.pkce && !codeVerifier) { + return redirect(`${fallbackReturn}?error=sync_failed`); + } + try { - const exchange = await manifest.exchangeCode(code, redirectUri); + const exchange = await manifest.exchangeCode( + code, + redirectUri, + codeVerifier ? { codeVerifier } : undefined, + ); await link({ userId: user.id, provider: manifest.id, @@ -65,5 +77,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { return redirect(`${target}?push=${outcome.status}`); } - return redirect(state.returnTo ?? "/settings"); + return redirect(state.returnTo ?? "/settings", { + // Spent verifier — clear it regardless of which provider this was + // (harmless no-op for non-PKCE providers without the cookie). + headers: { "Set-Cookie": clearPkceCookieHeader() }, + }); } diff --git a/apps/journal/app/routes/api.sync.connect.$provider.ts b/apps/journal/app/routes/api.sync.connect.$provider.ts index cfbc2ca..6e27d09 100644 --- a/apps/journal/app/routes/api.sync.connect.$provider.ts +++ b/apps/journal/app/routes/api.sync.connect.$provider.ts @@ -3,7 +3,11 @@ import { getOrigin } from "~/lib/config.server"; import type { Route } from "./+types/api.sync.connect.$provider"; import { requireSessionUser } from "~/lib/auth/session.server"; import { getManifest } from "~/lib/connected-services"; -import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server"; +import { + encodeOAuthState, + generatePkcePair, + pkceCookieHeader, +} from "~/lib/connected-services/oauth-state.server"; export async function loader({ params, request }: Route.LoaderArgs) { await requireSessionUser(request); @@ -17,5 +21,15 @@ export async function loader({ params, request }: Route.LoaderArgs) { const redirectUri = `${origin}/api/sync/callback/${params.provider}`; const state = encodeOAuthState({ returnTo: "/settings/connections" }); + // PKCE providers (Garmin): the verifier crosses the redirect in an + // httpOnly cookie; only the S256 challenge goes to the provider. + if (manifest.pkce) { + const { verifier, challenge } = generatePkcePair(); + return redirect( + manifest.buildAuthUrl(redirectUri, state, { codeChallenge: challenge }), + { headers: { "Set-Cookie": pkceCookieHeader(verifier) } }, + ); + } + return redirect(manifest.buildAuthUrl(redirectUri, state)); } diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts index 7c57b2f..0ba4804 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.test.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.test.ts @@ -30,7 +30,7 @@ describe("POST /api/sync/webhook/:provider", () => { beforeEach(() => { parseWebhook.mockReset(); handle.mockReset(); - parseWebhook.mockReturnValue(null); + parseWebhook.mockReturnValue([]); vi.unstubAllEnvs(); }); @@ -83,7 +83,7 @@ describe("POST /api/sync/webhook/:provider", () => { }); it("invokes parseWebhook with a valid object body", async () => { - parseWebhook.mockReturnValue({ eventType: "x", providerUserId: "1", workoutId: "9" }); + parseWebhook.mockReturnValue([{ eventType: "x", providerUserId: "1", workoutId: "9" }]); handle.mockResolvedValue(undefined); const res = await call({ event_type: "x" }); expect(statusOf(res)).toBe(200); diff --git a/apps/journal/app/routes/api.sync.webhook.$provider.ts b/apps/journal/app/routes/api.sync.webhook.$provider.ts index 1cff459..a78c647 100644 --- a/apps/journal/app/routes/api.sync.webhook.$provider.ts +++ b/apps/journal/app/routes/api.sync.webhook.$provider.ts @@ -37,13 +37,13 @@ export async function action({ params, request }: Route.ActionArgs) { return data({ ok: true }); } - const event = manifest.webhookReceiver.parseWebhook(body); - if (!event) return data({ ok: true }); - - try { - await manifest.webhookReceiver.handle(event); - } catch (e) { - console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + const events = manifest.webhookReceiver.parseWebhook(body); + for (const event of events) { + try { + await manifest.webhookReceiver.handle(event); + } catch (e) { + console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e); + } } return data({ ok: true }); diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 303f26c..21518be 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -309,6 +309,21 @@ export default function PrivacyPage() { importieren. Die Verbindung kann jederzeit in den Einstellungen getrennt werden.
  • +
  • + Garmin (Garmin Ltd.) – Optionaler Import von + Aktivitäten aus Garmin Connect. Beim Verbinden werden + OAuth-Tokens ausgetauscht und die Garmin-Nutzer-ID gespeichert. + Garmin übermittelt anschließend neue Aktivitäten (inkl. + GPS-Aufzeichnung als FIT-Datei) automatisch an diese Instanz; + ältere Aktivitäten nur auf Ihre ausdrückliche Anforderung + (Zeitraum-Import). Es werden keine Gesundheitsdaten (Schlaf, + Herzfrequenz-Tageswerte o. ä.) abgerufen — nur Aktivitäten. + Widerrufen Sie den Zugriff bei Garmin oder trennen Sie die + Verbindung in den Einstellungen, endet die Übermittlung sofort; + bereits importierte Aktivitäten bleiben in Ihrem Journal und + können dort gelöscht werden. Es gilt die Datenschutzerklärung + von Garmin. +
  • Hosting – Die Dienste werden in Rechenzentren innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit @@ -327,7 +342,13 @@ export default function PrivacyPage() { Wahoo” on a route — sent so the route appears on your ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode stores your Komoot user ID only; authenticated mode stores your - encrypted Komoot password); hosting provider in the EU under a DPA. + encrypted Komoot password); Garmin (only when you opt in: OAuth + tokens and your Garmin user ID; Garmin then pushes new activities + including GPS files to this instance automatically, and older + activities only when you request a date range — activities only, + never health metrics; revoking at Garmin or disconnecting here + stops the flow immediately, imported activities stay yours); + hosting provider in the EU under a DPA.

diff --git a/apps/journal/app/routes/settings.connections.server.ts b/apps/journal/app/routes/settings.connections.server.ts index 41b14f9..c89c3a0 100644 --- a/apps/journal/app/routes/settings.connections.server.ts +++ b/apps/journal/app/routes/settings.connections.server.ts @@ -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 }; } diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index 68ada37..9b255e3 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -52,7 +52,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps {p.connected ? (
{t("sync.import")} diff --git a/apps/journal/app/routes/sync.import.garmin.server.ts b/apps/journal/app/routes/sync.import.garmin.server.ts new file mode 100644 index 0000000..275a40f --- /dev/null +++ b/apps/journal/app/routes/sync.import.garmin.server.ts @@ -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 { + 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" }; + } +} diff --git a/apps/journal/app/routes/sync.import.garmin.tsx b/apps/journal/app/routes/sync.import.garmin.tsx new file mode 100644 index 0000000..7642647 --- /dev/null +++ b/apps/journal/app/routes/sync.import.garmin.tsx @@ -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 ( +
+

{t("sync.garmin.title")}

+

{t("sync.garmin.notConnected")}

+ + {t("sync.garmin.goConnect")} + +
+ ); + } + + return ( +
+

{t("sync.garmin.title")}

+

{t("sync.garmin.subtitle")}

+ + {loaderData.status !== "active" && ( +
+ {t("sync.garmin.needsRelink")} +
+ )} + +
+ + + +
+ + {actionData && "ok" in actionData && actionData.ok && ( +

{t("sync.garmin.requested")}

+ )} + {actionData && "ok" in actionData && !actionData.ok && ( +

+ {t(`sync.garmin.errors.${actionData.error}`)} +

+ )} + +

{t("sync.garmin.asyncNote")}

+ + {loaderData.batches.length > 0 && ( +
    + {loaderData.batches.map((b) => ( +
  • + + {b.rangeStart && b.rangeEnd ? ( + <> + + + ) : ( + + )} + + + {t("sync.garmin.importedSince", { count: b.importedSince })} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/journal/server.ts b/apps/journal/server.ts index 93342ac..682d93f 100644 --- a/apps/journal/server.ts +++ b/apps/journal/server.ts @@ -177,7 +177,8 @@ 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"); diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index 1a09695..4e53d26 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -60,6 +60,8 @@ services: WAHOO_CLIENT_ID: "" WAHOO_CLIENT_SECRET: "" WAHOO_WEBHOOK_TOKEN: "" + GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-} + GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-} DEMO_BOT_ENABLED: "" # Federation (social-federation rollout). Enabled by cd-staging.yml # for persistent staging (12.2) and for PR previews (12.4 — every diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index dd2403b..1a7c623 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -53,6 +53,10 @@ services: WAHOO_CLIENT_ID: ${WAHOO_CLIENT_ID:-} WAHOO_CLIENT_SECRET: ${WAHOO_CLIENT_SECRET:-} WAHOO_WEBHOOK_TOKEN: ${WAHOO_WEBHOOK_TOKEN:-} + # Garmin Connect Developer Program keys (spec: garmin-import). + # Empty = the Garmin provider is hidden on /settings/connections. + GARMIN_CLIENT_ID: ${GARMIN_CLIENT_ID:-} + GARMIN_CLIENT_SECRET: ${GARMIN_CLIENT_SECRET:-} # Federation (ActivityPub, social-federation rollout 12.5). Empty # = off: every federation surface 404s — the safe self-host # default. The flagship turns it on via cd-apps.yml (same pattern diff --git a/openspec/changes/garmin-import/design.md b/openspec/changes/garmin-import/design.md index 79bc84a..a6eb181 100644 --- a/openspec/changes/garmin-import/design.md +++ b/openspec/changes/garmin-import/design.md @@ -28,7 +28,7 @@ Garmin specifics that shape everything below (Garmin Connect Developer Program, ### Decision: OAuth2 + PKCE rides the existing `oauth` credential kind -Garmin's token blob is shape-compatible with `OAuthCredentials` (`access_token`, `refresh_token`, `expires_at`), so `credential_kind = 'oauth'` and the existing OAuth `CredentialAdapter` handle storage and refresh. PKCE only affects the **authorize/exchange** steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + `code_challenge` parameter, with the verifier carried through the OAuth `state` storage (`oauth-state.server.ts`) the same way the existing flow carries its state nonce. +Garmin's token blob is shape-compatible with `OAuthCredentials` (`access_token`, `refresh_token`, `expires_at`), so `credential_kind = 'oauth'` and the existing OAuth `CredentialAdapter` handle storage and refresh. PKCE only affects the **authorize/exchange** steps, which are already per-provider in the manifest's OAuth config — the manifest gains a code-verifier generation + `code_challenge` parameter. *Corrected during apply:* the existing `state` param is intent-encoding reflected through redirects (visible in URLs), not server-side storage — and the verifier must never appear in a URL. It rides a short-lived httpOnly cookie scoped to the callback path instead (`oauth-state.server.ts` PKCE helpers; manifests opt in via `pkce: true`). **Alternative considered:** a new `oauth-pkce` credential kind. Rejected — the *stored* credential is identical; PKCE is a handshake detail, not a credential shape. A new kind would fork the adapter for zero storage benefit. diff --git a/openspec/changes/garmin-import/tasks.md b/openspec/changes/garmin-import/tasks.md index ba042f4..77cabf3 100644 --- a/openspec/changes/garmin-import/tasks.md +++ b/openspec/changes/garmin-import/tasks.md @@ -2,37 +2,40 @@ ## 1. Provider scaffold + OAuth -- [ ] 1.1 Create `providers/garmin/manifest.ts`: `credential_kind = 'oauth'`, Garmin OAuth2 endpoints, scopes, env-gated `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET`; register via `providers/index.ts` -- [ ] 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through `oauth-state.server.ts` alongside the state nonce, send `code_challenge`/`code_verifier` on authorize/exchange (token *storage and refresh* stay on the existing oauth `CredentialAdapter`) -- [ ] 1.3 Capture the Garmin user id at connect time and store as `provider_user_id` -- [ ] 1.4 `/settings/connections` shows Garmin connect/disconnect; row hidden/disabled when env vars are absent (mirror Wahoo's gating); i18n strings (en + de) -- [ ] 1.5 Unit tests: manifest registration, PKCE parameter generation/verification, connect flow state round-trip +- [x] 1.1 Create `providers/garmin/manifest.ts`: `credential_kind = 'oauth'`, Garmin OAuth2 endpoints, scopes, env-gated `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET`; register via `providers/index.ts` +- [x] 1.2 PKCE support in the Garmin authorize/exchange flow: generate code verifier, carry it through `oauth-state.server.ts` alongside the state nonce, send `code_challenge`/`code_verifier` on authorize/exchange (token *storage and refresh* stay on the existing oauth `CredentialAdapter`) + > Design correction during apply: the `state` param is intent-encoding only (visible in redirects), not server-side storage — the verifier must never appear in a URL, so it rides a short-lived httpOnly cookie scoped to `/api/sync/callback` instead. Manifests opt in via `pkce: true`. +- [x] 1.3 Capture the Garmin user id at connect time and store as `provider_user_id` +- [x] 1.4 `/settings/connections` shows Garmin connect/disconnect; row hidden/disabled when env vars are absent (mirror Wahoo's gating); i18n strings (en + de) +- [x] 1.5 Unit tests: manifest registration, PKCE parameter generation/verification, connect flow state round-trip ## 2. Webhook ingestion -- [ ] 2.1 `WebhookReceiver` adapter for `/api/sync/webhook/garmin`: accept POST, normalize ping (fetch callback URL) and push (inline payload) into one internal activity-notification shape, respond 200 fast -- [ ] 2.2 Callback-URL host allowlist (Garmin API hosts only) — drop + log anything else; never fetch unvalidated URLs (SSRF guard) -- [ ] 2.3 pg-boss job `garmin-import-activity`: resolve user via `provider_user_id`, fetch FIT via `withFreshCredentials`, convert via shared `fit.ts`, create activity (GPX + stats + geometry; stats-only when no FIT), record `sync_imports`; idempotent on duplicate notifications; respect 429/Retry-After in retry policy -- [ ] 2.4 Unknown-user notifications → 200, no processing; unknown notification types → log + 200 -- [ ] 2.5 Tests with recorded notification + FIT fixtures: ping path, push path, dedupe, unknown user, SSRF rejection, FIT-less stats-only +- [x] 2.1 `WebhookReceiver` adapter for `/api/sync/webhook/garmin`: accept POST, normalize ping (fetch callback URL) and push (inline payload) into one internal activity-notification shape, respond 200 fast +- [x] 2.2 Callback-URL host allowlist (Garmin API hosts only) — drop + log anything else; never fetch unvalidated URLs (SSRF guard) +- [x] 2.3 pg-boss job `garmin-import-activity`: resolve user via `provider_user_id`, fetch FIT via `withFreshCredentials`, convert via shared `fit.ts`, create activity (GPX + stats + geometry; stats-only when no FIT), record `sync_imports`; idempotent on duplicate notifications; respect 429/Retry-After in retry policy +- [x] 2.4 Unknown-user notifications → 200, no processing; unknown notification types → log + 200 +- [x] 2.5 Tests with recorded notification + FIT fixtures: ping path, push path, dedupe, unknown user, SSRF rejection, FIT-less stats-only + > Fixtures are doc-shaped (no live credentials yet — rollout 6.x); real recorded payloads replace them during the staging soak if shapes differ. The generic webhook route + Wahoo receiver moved to an array event contract (`parseWebhook(): WebhookEvent[]`) since Garmin batches notifications. ## 3. Backfill (historical import) -- [ ] 3.1 Backfill request client: chunk a user-supplied date range into Garmin's per-request window; issue requests via `withFreshCredentials` -- [ ] 3.2 Import page `/settings/connections/garmin/import`: date-range form, list of requested ranges, count of imported activities per range, honest async messaging; i18n (en + de) -- [ ] 3.3 Persist backfill requests (range, requested_at, user) so progress survives reloads — smallest storage that works (reuse existing tables if possible; new table only if not) -- [ ] 3.4 Tests: chunking math, overlap re-request safety (dedupe via `sync_imports`), progress counting +- [x] 3.1 Backfill request client: chunk a user-supplied date range into Garmin's per-request window; issue requests via `withFreshCredentials` +- [x] 3.2 Import page `/settings/connections/garmin/import`: date-range form, list of requested ranges, count of imported activities per range, honest async messaging; i18n (en + de) +- [x] 3.3 Persist backfill requests (range, requested_at, user) so progress survives reloads — smallest storage that works (reuse existing tables if possible; new table only if not) + > Reused `import_batches` with two new nullable columns (`range_start`, `range_end`) — a smaller footprint than a new table, and provider-agnostic for future ranged backfills. (Deviates from the proposal's "Schema: none"; additive only.) Progress counts sync_imports rows since the request — notifications carry no batch id, so per-range attribution is a proxy by design. +- [x] 3.4 Tests: chunking math, overlap re-request safety (dedupe via `sync_imports`), progress counting ## 4. Deregistration + lifecycle -- [ ] 4.1 Handle Garmin deregistration notifications: set `connected_services.status = 'revoked'`, short-circuit subsequent Garmin calls, surface the standard re-connect prompt -- [ ] 4.2 Tests: deregistration flips status; revoked connection blocks backfill/import paths +- [x] 4.1 Handle Garmin deregistration notifications: set `connected_services.status = 'revoked'`, short-circuit subsequent Garmin calls, surface the standard re-connect prompt +- [x] 4.2 Tests: deregistration flips status; revoked connection blocks backfill/import paths ## 5. Provenance + docs -- [ ] 5.1 "Imported from garmin" badge works via the existing provenance path; delete-and-reimport clears `sync_imports` -- [ ] 5.2 Privacy manifest entry on `/legal/privacy` (en legal-manifest + de legal section): what we pull from Garmin, what Garmin learns, deletion/revocation semantics -- [ ] 5.3 Secrets wiring: `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET` in SOPS `secrets.app.env`, compose env (prod + staging), `.env.example` notes +- [x] 5.1 "Imported from garmin" badge works via the existing provenance path; delete-and-reimport clears `sync_imports` +- [x] 5.2 Privacy manifest entry on `/legal/privacy` (en legal-manifest + de legal section): what we pull from Garmin, what Garmin learns, deletion/revocation semantics +- [x] 5.3 Secrets wiring: `GARMIN_CLIENT_ID`/`GARMIN_CLIENT_SECRET` in SOPS `secrets.app.env`, compose env (prod + staging), `.env.example` notes ## 6. Rollout (gated on Garmin developer program approval) diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 7bf9bce..7167368 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -340,6 +340,11 @@ export const importBatches = journalSchema.table("import_batches", { importedCount: integer("imported_count").notNull().default(0), duplicateCount: integer("duplicate_count").notNull().default(0), errorMessage: text("error_message"), + // Ranged backfill requests (Garmin): the activity time window this + // batch asked the provider to re-deliver. NULL for pick-list style + // imports (komoot bulk) that aren't range-shaped. + rangeStart: timestamp("range_start", { withTimezone: true }), + rangeEnd: timestamp("range_end", { withTimezone: true }), startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), completedAt: timestamp("completed_at", { withTimezone: true }), }); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a1c8d84..8c9255c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -478,6 +478,29 @@ export default { }, }, sync: { + garmin: { + title: "Von Garmin importieren", + subtitle: + "Garmin liefert Aktivitäten automatisch an trails.cool, sobald sie entstehen. Für ältere Aktivitäten fordere unten einen Zeitraum an — Garmin liefert sie asynchron nach.", + notConnected: "Dein Garmin-Konto ist noch nicht verbunden.", + goConnect: "Garmin in den Einstellungen verbinden", + needsRelink: "Deine Garmin-Verbindung muss neu verknüpft werden, bevor du Importe anfordern kannst.", + from: "Von", + to: "Bis", + request: "Import anfordern", + requesting: "Wird angefordert…", + requested: "Angefordert! Aktivitäten erscheinen, sobald Garmin sie liefert.", + asyncNote: + "Garmin verarbeitet Verlaufs-Anfragen auf ihrer Seite — große Zeiträume können dauern. Überlappende Zeiträume erneut anzufordern ist unproblematisch; nichts wird doppelt importiert.", + importedSince_one: "{{count}} Aktivität seit dieser Anfrage importiert", + importedSince_other: "{{count}} Aktivitäten seit dieser Anfrage importiert", + errors: { + not_connected: "Dein Garmin-Konto ist nicht verbunden.", + needs_relink: "Deine Garmin-Verbindung muss zuerst neu verknüpft werden.", + invalid_range: "Wähle einen gültigen Zeitraum in der Vergangenheit (Start vor Ende).", + request_failed: "Garmin hat die Anfrage abgelehnt — versuche es in ein paar Minuten erneut.", + }, + }, import: "Importieren", importFrom: "Import von {{provider}}", imported: "Importiert", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f6ee07f..72f4d6e 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -478,6 +478,29 @@ export default { }, }, sync: { + garmin: { + title: "Import from Garmin", + subtitle: + "Garmin delivers activities to trails.cool as they happen. To pull in your history, request a date range below — Garmin sends those activities over asynchronously.", + notConnected: "Your Garmin account isn't connected yet.", + goConnect: "Connect Garmin in settings", + needsRelink: "Your Garmin connection needs to be re-linked before requesting imports.", + from: "From", + to: "To", + request: "Request import", + requesting: "Requesting…", + requested: "Requested! Activities will appear as Garmin delivers them.", + asyncNote: + "Garmin processes history requests on their side — large ranges can take a while to arrive. Re-requesting an overlapping range is safe; nothing gets imported twice.", + importedSince_one: "{{count}} activity imported since this request", + importedSince_other: "{{count}} activities imported since this request", + errors: { + not_connected: "Your Garmin account isn't connected.", + needs_relink: "Your Garmin connection needs to be re-linked first.", + invalid_range: "Pick a valid date range in the past (start before end).", + request_failed: "Garmin rejected the request — try again in a few minutes.", + }, + }, import: "Import", importFrom: "Import from {{provider}}", imported: "Imported", From 114f6c2ce7cd3904065d043ba95b5f94df4e0847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 11:52:10 +0200 Subject: [PATCH 15/22] fix(infra): raise Prometheus retention.size 1GB -> 6GB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prometheus size-based retention counts the WAL toward its budget. With retention.size=1GB and a stuck/corrupt WAL that had grown to 2.1GB, the total was permanently over budget, so Prometheus deleted every freshly-compacted block the moment it was written. Net effect: only the in-memory ~2h head block was ever queryable — metrics appeared to "start this morning" no matter when you looked, and last night's Caddy 502 spike (planner /api/overpass upstream stall, 18:33-18:34 UTC) was unobservable in Prometheus (logs survived in Loki). The corrupt WAL was cleared out-of-band on the flagship. This raises the size backstop to 6GB so compacted blocks survive to the intended 15d time-based retention. Disk is 38G (43% used), so 6GB is safe headroom. Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/docker-compose.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 1a7c623..9cd36dc 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -201,7 +201,13 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.retention.time=15d" - - "--storage.tsdb.retention.size=1GB" + # Size-based retention is a disk-safety backstop, not the primary + # policy (time=15d is). It counts the WAL toward the budget, so a + # too-small cap makes Prometheus delete freshly-compacted blocks on + # creation — which it did at 1GB, leaving only the ~2h head block + # queryable (a stuck/corrupt WAL had bloated to 2.1GB). 6GB holds + # ~15d of metrics at current cardinality with headroom; disk is 38G. + - "--storage.tsdb.retention.size=6GB" loki: image: grafana/loki:latest From f38a224376005178cfd521797280cdb6766b3a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 11:59:27 +0200 Subject: [PATCH 16/22] perf(infra): scrape every 20s + drop unused cAdvisor metric families Two cardinality/volume cuts to keep Prometheus storage sustainable within the size budget: - scrape_interval 5s -> 20s (evaluation_interval too). 5s tripled the sample volume vs the 15s default for no benefit on a single-box deployment; all rules use [5m] windows and for: >= 1m. - Drop high-cardinality cAdvisor families we never chart or alert on (container_fs_*, blkio_*, tasks_state, memory_failures_total, memory_numa_*, network_tcp*/udp*) on both cadvisor jobs. Dashboards only use container CPU, memory, and start_time. This was the bulk of the ~12.4k active series. Validated with `promtool check config`. Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/prometheus/prometheus.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index 0c692f4..6785c59 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -1,6 +1,10 @@ global: - scrape_interval: 5s - evaluation_interval: 5s + # 20s is plenty of resolution for a single-box self-hosted deployment. + # The previous 5s tripled (vs the 15s default) the sample volume and + # WAL growth for no practical benefit; all alert/recording rules use + # [5m] windows and `for:` >= 1m, so 20s evaluation is fine. + scrape_interval: 20s + evaluation_interval: 20s scrape_configs: - job_name: "journal" @@ -24,6 +28,16 @@ scrape_configs: - job_name: "cadvisor" static_configs: - targets: ["cadvisor:8080"] + # cAdvisor emits ~100 per-container metric families; our dashboards + # and alerts only use CPU, memory, and container_start_time. Drop the + # high-cardinality families we never query (per-filesystem, block-IO, + # per-task state, memory-failure counters, socket tables). These + # dominated the ~12k active series — dropping them is the main + # cardinality cut. Add a name back here if you start charting it. + metric_relabel_configs: + - source_labels: [__name__] + regex: 'container_(fs_.*|blkio_.*|tasks_state|memory_failures_total|memory_numa_.*|network_tcp.*|network_udp.*)' + action: drop - job_name: "caddy" static_configs: @@ -38,3 +52,8 @@ scrape_configs: - targets: ["10.0.1.10:8080"] labels: host: "brouter" + # Same high-cardinality drop as the local cadvisor job above. + metric_relabel_configs: + - source_labels: [__name__] + regex: 'container_(fs_.*|blkio_.*|tasks_state|memory_failures_total|memory_numa_.*|network_tcp.*|network_udp.*)' + action: drop From 6e48510d75f8d69299d8244bd53ad7532b45404a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 12:00:19 +0200 Subject: [PATCH 17/22] fix(grafana): repair invalid JSON in overview dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit overview.json was missing a comma between the "Health Status" and "App Error Rate (from logs)" panel objects, making the whole file invalid JSON. Grafana's file provisioner skips dashboards that fail to parse, so the entire "trails.cool Overview" dashboard (5 panels) never loaded — a silent gap in observability. Validated with json.load. Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/grafana/dashboards/overview.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/grafana/dashboards/overview.json b/infrastructure/grafana/dashboards/overview.json index 1d9b9cc..efc53af 100644 --- a/infrastructure/grafana/dashboards/overview.json +++ b/infrastructure/grafana/dashboards/overview.json @@ -137,7 +137,7 @@ ] } } - } + }, { "title": "App Error Rate (from logs)", "description": "Error-level log entries per second from Pino (level 50=error, 60=fatal). Captures crashes and unhandled errors that metrics may miss.", From 715cd3dce8948a263e908b120dee39b1a9525e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 12:17:54 +0200 Subject: [PATCH 18/22] Apply suggestion from @stigi --- infrastructure/docker-compose.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 9cd36dc..57a8057 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -201,13 +201,7 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.retention.time=15d" - # Size-based retention is a disk-safety backstop, not the primary - # policy (time=15d is). It counts the WAL toward the budget, so a - # too-small cap makes Prometheus delete freshly-compacted blocks on - # creation — which it did at 1GB, leaving only the ~2h head block - # queryable (a stuck/corrupt WAL had bloated to 2.1GB). 6GB holds - # ~15d of metrics at current cardinality with headroom; disk is 38G. - - "--storage.tsdb.retention.size=6GB" + - "--storage.tsdb.retention.size=2GB" loki: image: grafana/loki:latest From 3a43784f0713fa6ebc76a9f954aeba176f22705a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 12:21:04 +0200 Subject: [PATCH 19/22] feat(infra): self-monitor the observability stack + Overpass alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the visibility that would have caught the corrupt-WAL incident, plus a direct Overpass upstream alert. - prometheus.yml: self-scrape Prometheus (localhost:9090) and Loki (loki:3100). Prometheus scraped everything except itself, so TSDB health (compaction failures, WAL corruption, head series, retention deletions) was invisible. - monitoring-health.json: new "Monitoring Health" dashboard — TSDB compaction/WAL failures, retention deletions/hour, head series, samples/s, block bytes vs size-retention limit, retention depth (oldest-sample age), Loki ingestion rate + memory chunks. - alerts.yml: prometheus-compaction-failing (any compaction failure or WAL corruption in 1h) and overpass-upstream-unhealthy (>20% upstream failure over 10m — sustained public-Overpass degradation, distinct from the symptom-level Caddy-502 alert). Validated: promtool check config, YAML parse, JSON parse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../grafana/dashboards/monitoring-health.json | 179 ++++++++++++++++++ .../grafana/provisioning/alerting/alerts.yml | 61 ++++++ infrastructure/prometheus/prometheus.yml | 14 ++ 3 files changed, 254 insertions(+) create mode 100644 infrastructure/grafana/dashboards/monitoring-health.json diff --git a/infrastructure/grafana/dashboards/monitoring-health.json b/infrastructure/grafana/dashboards/monitoring-health.json new file mode 100644 index 0000000..dbeb2a8 --- /dev/null +++ b/infrastructure/grafana/dashboards/monitoring-health.json @@ -0,0 +1,179 @@ +{ + "title": "Monitoring Health", + "uid": "monitoring-health", + "description": "Self-health of the observability stack (Prometheus TSDB + Loki ingestion). Added after a corrupt Prometheus WAL silently collapsed metric retention to a ~2h window — these panels make that condition visible.", + "timezone": "browser", + "refresh": "30s", + "panels": [ + { + "title": "Monitoring Components Up", + "type": "stat", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "up{job=~\"prometheus|loki|cadvisor|node|caddy|postgres\"}", + "legendFormat": "{{job}}" + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "0": { "text": "DOWN", "color": "red" }, + "1": { "text": "UP", "color": "green" } + } + } + ] + } + } + }, + { + "title": "Prometheus — Compaction & WAL Failures (1h)", + "description": "Any non-zero value means the TSDB is failing to persist or truncate — the exact signature of the corrupt-WAL incident. The prometheus-compaction-failing alert watches this.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "increase(prometheus_tsdb_compactions_failed_total[1h])", + "legendFormat": "compaction failures" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "increase(prometheus_tsdb_wal_corruptions_total[1h])", + "legendFormat": "WAL corruptions" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "increase(prometheus_tsdb_wal_truncations_failed_total[1h])", + "legendFormat": "WAL truncation failures" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + } + }, + { + "title": "Prometheus — Retention Deletions / hour", + "description": "Blocks deleted by size- vs time-based retention. Sustained size-retention deletions with low data age means the size cap is too small (counts the WAL) — blocks get deleted on creation.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "increase(prometheus_tsdb_size_retentions_total[1h])", + "legendFormat": "size-based" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "increase(prometheus_tsdb_time_retentions_total[1h])", + "legendFormat": "time-based" + } + ] + }, + { + "title": "Prometheus — Head Series (active cardinality)", + "description": "Active in-memory series. Watch this after dropping cAdvisor families / adding scrape targets.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "prometheus_tsdb_head_series", + "legendFormat": "head series" + } + ] + }, + { + "title": "Prometheus — Samples Appended / sec", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(prometheus_tsdb_head_samples_appended_total[5m])", + "legendFormat": "samples/s" + } + ] + }, + { + "title": "Prometheus — Block Storage vs Retention Limit", + "description": "On-disk block bytes against the configured size-retention limit. If block bytes track the limit while data age stays low, the cap is too tight.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "prometheus_tsdb_storage_blocks_bytes", + "legendFormat": "block bytes" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "prometheus_tsdb_retention_limit_bytes", + "legendFormat": "size-retention limit" + } + ], + "fieldConfig": { "defaults": { "unit": "bytes" } } + }, + { + "title": "Prometheus — Retention Depth (oldest sample age)", + "description": "How far back queryable metrics actually go. Should track ~15d once healthy; it sat near ~2h during the incident.", + "type": "stat", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "time() - prometheus_tsdb_lowest_timestamp_seconds", + "legendFormat": "oldest sample age" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 86400 }, + { "color": "green", "value": 604800 } + ] + } + } + } + }, + { + "title": "Loki — Lines Received / sec", + "description": "Log ingestion rate. A drop to zero means Promtail stopped shipping.", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(rate(loki_distributor_lines_received_total[5m]))", + "legendFormat": "lines/s" + } + ] + }, + { + "title": "Loki — In-Memory Chunks", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum(loki_ingester_memory_chunks)", + "legendFormat": "memory chunks" + } + ] + } + ] +} diff --git a/infrastructure/grafana/provisioning/alerting/alerts.yml b/infrastructure/grafana/provisioning/alerting/alerts.yml index 1ce2546..cc9f2c0 100644 --- a/infrastructure/grafana/provisioning/alerting/alerts.yml +++ b/infrastructure/grafana/provisioning/alerting/alerts.yml @@ -236,6 +236,67 @@ groups: annotations: summary: "Caddy is returning 502 errors — journal or planner upstream unreachable" + # Prometheus self-health. A corrupt WAL segment once made every + # compaction fail for days, silently collapsing metric retention to + # the ~2h head block (size-retention kept deleting fresh blocks). + # This fires on any compaction failure OR WAL corruption in the last + # hour. Depends on the `prometheus` self-scrape job. + - uid: prometheus-compaction-failing + title: Prometheus TSDB compaction failing + condition: B + noDataState: OK + data: + - refId: A + relativeTimeRange: { from: 3600, to: 0 } + datasourceUid: prometheus + model: + expr: increase(prometheus_tsdb_compactions_failed_total[1h]) + increase(prometheus_tsdb_wal_corruptions_total[1h]) + instant: true + - refId: B + datasourceUid: __expr__ + model: + type: threshold + expression: A + conditions: + - evaluator: { params: [0], type: gt } + operator: { type: and } + reducer: { type: last } + for: 10m + annotations: + summary: "Prometheus TSDB compaction or WAL is failing — metric retention is at risk. Check the WAL for a corrupt segment (this silently capped retention to ~2h before)." + + # Overpass upstream health. The public Overpass servers the Planner + # proxies to (overpass-api.de / lz4) flake — rate-limiting (429) and + # stalls. This alerts on a *sustained* upstream failure rate (>20% + # over 10m), distinct from the Caddy-502 alert which only sees the + # symptom. Brief blips (e.g. the ~30s stall on 2026-06-08) won't + # page; sustained degradation will. client-abort is excluded (that's + # the caller giving up, not the upstream failing). Per-status detail + # lives on the Planner dashboard. + - uid: overpass-upstream-unhealthy + title: Overpass upstream failure rate high + condition: B + noDataState: OK + data: + - refId: A + relativeTimeRange: { from: 600, to: 0 } + datasourceUid: prometheus + model: + expr: (sum(rate(overpass_upstream_requests_total{status!~"2..|client-abort"}[10m])) or vector(0)) / clamp_min(sum(rate(overpass_upstream_requests_total[10m])), 0.001) * 100 + instant: true + - refId: B + datasourceUid: __expr__ + model: + type: threshold + expression: A + conditions: + - evaluator: { params: [20], type: gt } + operator: { type: and } + reducer: { type: last } + for: 10m + annotations: + summary: "Overpass upstream failure rate above 20% over 10m — public Overpass servers are degraded or rate-limiting. See the Planner dashboard; weigh self-hosting Overpass." + contactPoints: # Single "default" contact point with multiple integrations: every alert # fan-outs to both email and Pushover. Grafana resolves $VAR / ${VAR} diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index 6785c59..c64726b 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -43,6 +43,20 @@ scrape_configs: static_configs: - targets: ["caddy:2019"] + # Self-monitoring. Prometheus did not scrape its own /metrics, so TSDB + # health (compaction failures, WAL corruption, head series, retention + # deletions) was invisible — which is how a corrupt WAL silently + # collapsed retention to a ~2h window for days. Scraping itself + Loki + # surfaces that on the "Monitoring Health" dashboard and the + # prometheus-compaction-failing alert. + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + - job_name: "loki" + static_configs: + - targets: ["loki:3100"] + # BRouter runs on a separate Hetzner Robot host (ullrich.is), reached # over the vSwitch at 10.0.1.10. cAdvisor there is scoped to trails- # labeled containers only — we don't collect metrics for any of the From e60c9d705721dac28bbbaccc46d7ebda844962f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 13:10:35 +0200 Subject: [PATCH 20/22] refactor(infra): mount config dirs not single files; reload on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-file bind mounts (./foo.yml:/etc/foo.yml) pin to the host file's inode at container-create time. The CD pipeline scp's a replacement file (new inode), so the running container keeps reading the OLD inode — `docker compose up -d` won't recreate on a content-only change, and neither restart/SIGHUP/`caddy reload` re-reads the new file. Net effect: config-only infra PRs deployed "successfully" but never took effect (confirmed with PR #500's prometheus.yml; the WAL retention work only applied because #498 also changed docker-compose.yml, forcing a recreate). Caddyfile changes had the same latent gap. Switch the four single-file config mounts to DIRECTORY mounts, which resolve children live so a reload/restart picks up the new file: - prometheus, loki, promtail: mount ./ dir (loki/promtail --config.file paths updated to the real filenames). - caddy: move Caddyfile into caddy/ and mount the dir; ./sites overlays /etc/caddy/sites (caddy/sites/.gitkeep keeps the mountpoint). Container path /etc/caddy/Caddyfile is unchanged, so every `caddy reload --config /etc/caddy/Caddyfile` ref still works. scp source paths in cd-infra and cd-apps updated to ship the caddy/ dir. cd-infra now applies config-only changes after `up -d`: SIGHUP prometheus (zero downtime), restart loki+promtail (no SIGHUP reload), caddy reload (graceful). Mirrored prometheus/loki mounts in docker-compose.dev.yml. Validated: docker compose config (both files), caddy validate from the new path, read-only-parent + sites-overlay mount mechanics, workflow YAML. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cd-apps.yml | 2 +- .github/workflows/cd-infra.yml | 17 +++++++++++--- docker-compose.dev.yml | 9 +++++--- infrastructure/{ => caddy}/Caddyfile | 0 infrastructure/caddy/sites/.gitkeep | 3 +++ infrastructure/docker-compose.yml | 33 ++++++++++++++++++++-------- 6 files changed, 48 insertions(+), 16 deletions(-) rename infrastructure/{ => caddy}/Caddyfile (100%) create mode 100644 infrastructure/caddy/sites/.gitkeep diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index 47d285d..212187e 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -100,7 +100,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile" + source: "infrastructure/docker-compose.yml,infrastructure/caddy" target: /opt/trails-cool strip_components: 1 diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 89c11bf..bdc3b31 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -43,7 +43,7 @@ jobs: host: ${{ secrets.DEPLOY_HOST }} username: root key: ${{ secrets.DEPLOY_SSH_KEY }} - source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" + source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards" target: /opt/trails-cool strip_components: 1 @@ -88,14 +88,25 @@ jobs: if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans else - # Restart infra services (except Caddy — just reload its config). + # Restart infra services (config reloads handled below). # --remove-orphans cleans up containers whose service was deleted # from the compose file (e.g., the flagship `brouter` removal in # PR #297 left an orphan that had to be removed by hand). docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor - docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile fi + # Apply config-only changes that `up -d` skips — it recreates a + # container only when its compose *definition* changes, not when a + # mounted config file's content changes. The configs are mounted as + # directories (not single files), so a reload/restart re-reads the + # freshly scp'd file; a single-file mount would have pinned the old + # inode. Prometheus hot-reloads on SIGHUP (zero downtime); Loki and + # Promtail reload their main config only on restart; Caddy reloads + # gracefully (validates, swaps live, no downtime). + docker compose --env-file .env kill -s SIGHUP prometheus + docker compose --env-file .env restart loki promtail + docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile + docker compose ps # Gate on the stack actually being up: postgres healthy and — diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ff29112..dc489b3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -36,7 +36,9 @@ services: ports: - "9090:9090" volumes: - - ./infrastructure/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + # Directory mount (not the file) to match prod — a single-file bind + # mount pins the host inode, so a replaced config is never seen. + - ./infrastructure/prometheus:/etc/prometheus:ro - prometheus_data:/prometheus grafana: @@ -60,9 +62,10 @@ services: ports: - "3100:3100" volumes: - - ./infrastructure/loki/loki-config.yml:/etc/loki/local-config.yaml:ro + # Directory mount (not the file) to match prod. + - ./infrastructure/loki:/etc/loki:ro - loki_data:/loki - command: ["-config.file=/etc/loki/local-config.yaml"] + command: ["-config.file=/etc/loki/loki-config.yml"] volumes: pgdata: diff --git a/infrastructure/Caddyfile b/infrastructure/caddy/Caddyfile similarity index 100% rename from infrastructure/Caddyfile rename to infrastructure/caddy/Caddyfile diff --git a/infrastructure/caddy/sites/.gitkeep b/infrastructure/caddy/sites/.gitkeep new file mode 100644 index 0000000..68f6879 --- /dev/null +++ b/infrastructure/caddy/sites/.gitkeep @@ -0,0 +1,3 @@ +# Mountpoint for the runtime ./sites overlay (per-PR staging snippets). +# Caddy reads site blocks from /etc/caddy/sites; this keeps the dir present +# inside the read-only ./caddy bind mount so the overlay can attach. diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 57a8057..fb599e8 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -14,10 +14,17 @@ services: environment: DOMAIN: ${DOMAIN:-trails.cool} volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - # Per-PR staging snippets dropped in by cd-staging.yml. Bind mount - # auto-creates the host directory if it doesn't exist, so a fresh - # server doesn't need pre-provisioning. + # Mount the caddy/ DIRECTORY, not the Caddyfile directly. A single- + # file bind mount pins to the host file's inode at create time, so a + # deploy that replaces the file (scp) leaves the container reading the + # old inode — `caddy reload` then reloads stale config. A directory + # mount resolves children live, so reload picks up the new Caddyfile. + # The container path /etc/caddy/Caddyfile is unchanged. + - ./caddy:/etc/caddy:ro + # Per-PR staging snippets dropped in by cd-staging.yml, overlaid onto + # the read-only caddy dir above (caddy/sites/.gitkeep keeps the + # mountpoint present). Bind mount auto-creates the host directory if + # it doesn't exist, so a fresh server doesn't need pre-provisioning. - ./sites:/etc/caddy/sites:ro - caddy_data:/data - caddy_config:/config @@ -187,8 +194,10 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro - - ./promtail/promtail-config.yml:/etc/promtail/config.yml:ro - command: ["-config.file=/etc/promtail/config.yml"] + # Directory mount (not the file) so a deploy's replaced config is seen + # live and a SIGHUP reload picks it up — see the caddy note above. + - ./promtail:/etc/promtail:ro + command: ["-config.file=/etc/promtail/promtail-config.yml"] depends_on: - loki @@ -196,7 +205,10 @@ services: image: prom/prometheus:latest restart: unless-stopped volumes: - - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + # Directory mount (not the file) so config changes are seen live and a + # SIGHUP reload applies them without recreating the container — see the + # caddy note above. --config.file path below is unchanged. + - ./prometheus:/etc/prometheus:ro - prometheus_data:/prometheus command: - "--config.file=/etc/prometheus/prometheus.yml" @@ -207,9 +219,12 @@ services: image: grafana/loki:latest restart: unless-stopped volumes: - - ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro + # Directory mount (not the file) so a replaced config is seen live — + # see the caddy note above. Loki only reloads its main config on + # restart (no SIGHUP), so cd-infra restarts loki rather than HUPs it. + - ./loki:/etc/loki:ro - loki_data:/loki - command: ["-config.file=/etc/loki/local-config.yaml"] + command: ["-config.file=/etc/loki/loki-config.yml"] # Publish only on the vSwitch IP so Promtail running on the # dedicated BRouter host can push logs in. Hetzner Cloud firewall # still blocks 3100 from the public internet. Internal services on From 19f2275b7368a13b3a52363f578de37da5355a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 13:30:37 +0200 Subject: [PATCH 21/22] Move skills to .agents/skills for cross-agent compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills now live in .agents/skills/ — the standard convention aligned with Pi, OpenAI Codex, and the Agent Skills spec. .claude/skills is a symlink back to .agents/skills/ so Claude Code still discovers them. Updated CLAUDE.md to document the setup. --- {.claude => .agents}/skills/cmux-browser/SKILL.md | 0 .../skills/cmux-browser/agents/openai.yaml | 0 .../skills/cmux-browser/references/authentication.md | 0 .../skills/cmux-browser/references/commands.md | 0 .../skills/cmux-browser/references/proxy-support.md | 0 .../cmux-browser/references/session-management.md | 0 .../skills/cmux-browser/references/snapshot-refs.md | 0 .../skills/cmux-browser/references/video-recording.md | 0 .../cmux-browser/templates/authenticated-session.sh | 0 .../skills/cmux-browser/templates/capture-workflow.sh | 0 .../skills/cmux-browser/templates/form-automation.sh | 0 .../skills/cmux-debug-windows/SKILL.md | 0 .../skills/cmux-debug-windows/agents/openai.yaml | 0 .../scripts/debug_windows_snapshot.sh | 0 {.claude => .agents}/skills/cmux-markdown/SKILL.md | 0 .../skills/cmux-markdown/agents/openai.yaml | 0 .../skills/cmux-markdown/references/commands.md | 0 .../skills/cmux-markdown/references/live-reload.md | 0 {.claude => .agents}/skills/cmux/SKILL.md | 0 {.claude => .agents}/skills/cmux/agents/openai.yaml | 0 .../skills/cmux/references/handles-and-identify.md | 0 .../skills/cmux/references/panes-surfaces.md | 0 .../cmux/references/trigger-flash-and-health.md | 0 .../skills/cmux/references/windows-workspaces.md | 0 {.claude => .agents}/skills/crit-cli/SKILL.md | 0 {.claude => .agents}/skills/ia-review/SKILL.md | 0 .../skills/openspec-apply-change/SKILL.md | 0 .../skills/openspec-archive-change/SKILL.md | 0 {.claude => .agents}/skills/openspec-explore/SKILL.md | 0 {.claude => .agents}/skills/openspec-propose/SKILL.md | 0 .../skills/spec-drift-review/SKILL.md | 0 .claude/skills | 1 + CLAUDE.md | 11 +++++++++++ 33 files changed, 12 insertions(+) rename {.claude => .agents}/skills/cmux-browser/SKILL.md (100%) rename {.claude => .agents}/skills/cmux-browser/agents/openai.yaml (100%) rename {.claude => .agents}/skills/cmux-browser/references/authentication.md (100%) rename {.claude => .agents}/skills/cmux-browser/references/commands.md (100%) rename {.claude => .agents}/skills/cmux-browser/references/proxy-support.md (100%) rename {.claude => .agents}/skills/cmux-browser/references/session-management.md (100%) rename {.claude => .agents}/skills/cmux-browser/references/snapshot-refs.md (100%) rename {.claude => .agents}/skills/cmux-browser/references/video-recording.md (100%) rename {.claude => .agents}/skills/cmux-browser/templates/authenticated-session.sh (100%) rename {.claude => .agents}/skills/cmux-browser/templates/capture-workflow.sh (100%) rename {.claude => .agents}/skills/cmux-browser/templates/form-automation.sh (100%) rename {.claude => .agents}/skills/cmux-debug-windows/SKILL.md (100%) rename {.claude => .agents}/skills/cmux-debug-windows/agents/openai.yaml (100%) rename {.claude => .agents}/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh (100%) rename {.claude => .agents}/skills/cmux-markdown/SKILL.md (100%) rename {.claude => .agents}/skills/cmux-markdown/agents/openai.yaml (100%) rename {.claude => .agents}/skills/cmux-markdown/references/commands.md (100%) rename {.claude => .agents}/skills/cmux-markdown/references/live-reload.md (100%) rename {.claude => .agents}/skills/cmux/SKILL.md (100%) rename {.claude => .agents}/skills/cmux/agents/openai.yaml (100%) rename {.claude => .agents}/skills/cmux/references/handles-and-identify.md (100%) rename {.claude => .agents}/skills/cmux/references/panes-surfaces.md (100%) rename {.claude => .agents}/skills/cmux/references/trigger-flash-and-health.md (100%) rename {.claude => .agents}/skills/cmux/references/windows-workspaces.md (100%) rename {.claude => .agents}/skills/crit-cli/SKILL.md (100%) rename {.claude => .agents}/skills/ia-review/SKILL.md (100%) rename {.claude => .agents}/skills/openspec-apply-change/SKILL.md (100%) rename {.claude => .agents}/skills/openspec-archive-change/SKILL.md (100%) rename {.claude => .agents}/skills/openspec-explore/SKILL.md (100%) rename {.claude => .agents}/skills/openspec-propose/SKILL.md (100%) rename {.claude => .agents}/skills/spec-drift-review/SKILL.md (100%) create mode 120000 .claude/skills diff --git a/.claude/skills/cmux-browser/SKILL.md b/.agents/skills/cmux-browser/SKILL.md similarity index 100% rename from .claude/skills/cmux-browser/SKILL.md rename to .agents/skills/cmux-browser/SKILL.md diff --git a/.claude/skills/cmux-browser/agents/openai.yaml b/.agents/skills/cmux-browser/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-browser/agents/openai.yaml rename to .agents/skills/cmux-browser/agents/openai.yaml diff --git a/.claude/skills/cmux-browser/references/authentication.md b/.agents/skills/cmux-browser/references/authentication.md similarity index 100% rename from .claude/skills/cmux-browser/references/authentication.md rename to .agents/skills/cmux-browser/references/authentication.md diff --git a/.claude/skills/cmux-browser/references/commands.md b/.agents/skills/cmux-browser/references/commands.md similarity index 100% rename from .claude/skills/cmux-browser/references/commands.md rename to .agents/skills/cmux-browser/references/commands.md diff --git a/.claude/skills/cmux-browser/references/proxy-support.md b/.agents/skills/cmux-browser/references/proxy-support.md similarity index 100% rename from .claude/skills/cmux-browser/references/proxy-support.md rename to .agents/skills/cmux-browser/references/proxy-support.md diff --git a/.claude/skills/cmux-browser/references/session-management.md b/.agents/skills/cmux-browser/references/session-management.md similarity index 100% rename from .claude/skills/cmux-browser/references/session-management.md rename to .agents/skills/cmux-browser/references/session-management.md diff --git a/.claude/skills/cmux-browser/references/snapshot-refs.md b/.agents/skills/cmux-browser/references/snapshot-refs.md similarity index 100% rename from .claude/skills/cmux-browser/references/snapshot-refs.md rename to .agents/skills/cmux-browser/references/snapshot-refs.md diff --git a/.claude/skills/cmux-browser/references/video-recording.md b/.agents/skills/cmux-browser/references/video-recording.md similarity index 100% rename from .claude/skills/cmux-browser/references/video-recording.md rename to .agents/skills/cmux-browser/references/video-recording.md diff --git a/.claude/skills/cmux-browser/templates/authenticated-session.sh b/.agents/skills/cmux-browser/templates/authenticated-session.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/authenticated-session.sh rename to .agents/skills/cmux-browser/templates/authenticated-session.sh diff --git a/.claude/skills/cmux-browser/templates/capture-workflow.sh b/.agents/skills/cmux-browser/templates/capture-workflow.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/capture-workflow.sh rename to .agents/skills/cmux-browser/templates/capture-workflow.sh diff --git a/.claude/skills/cmux-browser/templates/form-automation.sh b/.agents/skills/cmux-browser/templates/form-automation.sh similarity index 100% rename from .claude/skills/cmux-browser/templates/form-automation.sh rename to .agents/skills/cmux-browser/templates/form-automation.sh diff --git a/.claude/skills/cmux-debug-windows/SKILL.md b/.agents/skills/cmux-debug-windows/SKILL.md similarity index 100% rename from .claude/skills/cmux-debug-windows/SKILL.md rename to .agents/skills/cmux-debug-windows/SKILL.md diff --git a/.claude/skills/cmux-debug-windows/agents/openai.yaml b/.agents/skills/cmux-debug-windows/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-debug-windows/agents/openai.yaml rename to .agents/skills/cmux-debug-windows/agents/openai.yaml diff --git a/.claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/.agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh similarity index 100% rename from .claude/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh rename to .agents/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh diff --git a/.claude/skills/cmux-markdown/SKILL.md b/.agents/skills/cmux-markdown/SKILL.md similarity index 100% rename from .claude/skills/cmux-markdown/SKILL.md rename to .agents/skills/cmux-markdown/SKILL.md diff --git a/.claude/skills/cmux-markdown/agents/openai.yaml b/.agents/skills/cmux-markdown/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux-markdown/agents/openai.yaml rename to .agents/skills/cmux-markdown/agents/openai.yaml diff --git a/.claude/skills/cmux-markdown/references/commands.md b/.agents/skills/cmux-markdown/references/commands.md similarity index 100% rename from .claude/skills/cmux-markdown/references/commands.md rename to .agents/skills/cmux-markdown/references/commands.md diff --git a/.claude/skills/cmux-markdown/references/live-reload.md b/.agents/skills/cmux-markdown/references/live-reload.md similarity index 100% rename from .claude/skills/cmux-markdown/references/live-reload.md rename to .agents/skills/cmux-markdown/references/live-reload.md diff --git a/.claude/skills/cmux/SKILL.md b/.agents/skills/cmux/SKILL.md similarity index 100% rename from .claude/skills/cmux/SKILL.md rename to .agents/skills/cmux/SKILL.md diff --git a/.claude/skills/cmux/agents/openai.yaml b/.agents/skills/cmux/agents/openai.yaml similarity index 100% rename from .claude/skills/cmux/agents/openai.yaml rename to .agents/skills/cmux/agents/openai.yaml diff --git a/.claude/skills/cmux/references/handles-and-identify.md b/.agents/skills/cmux/references/handles-and-identify.md similarity index 100% rename from .claude/skills/cmux/references/handles-and-identify.md rename to .agents/skills/cmux/references/handles-and-identify.md diff --git a/.claude/skills/cmux/references/panes-surfaces.md b/.agents/skills/cmux/references/panes-surfaces.md similarity index 100% rename from .claude/skills/cmux/references/panes-surfaces.md rename to .agents/skills/cmux/references/panes-surfaces.md diff --git a/.claude/skills/cmux/references/trigger-flash-and-health.md b/.agents/skills/cmux/references/trigger-flash-and-health.md similarity index 100% rename from .claude/skills/cmux/references/trigger-flash-and-health.md rename to .agents/skills/cmux/references/trigger-flash-and-health.md diff --git a/.claude/skills/cmux/references/windows-workspaces.md b/.agents/skills/cmux/references/windows-workspaces.md similarity index 100% rename from .claude/skills/cmux/references/windows-workspaces.md rename to .agents/skills/cmux/references/windows-workspaces.md diff --git a/.claude/skills/crit-cli/SKILL.md b/.agents/skills/crit-cli/SKILL.md similarity index 100% rename from .claude/skills/crit-cli/SKILL.md rename to .agents/skills/crit-cli/SKILL.md diff --git a/.claude/skills/ia-review/SKILL.md b/.agents/skills/ia-review/SKILL.md similarity index 100% rename from .claude/skills/ia-review/SKILL.md rename to .agents/skills/ia-review/SKILL.md diff --git a/.claude/skills/openspec-apply-change/SKILL.md b/.agents/skills/openspec-apply-change/SKILL.md similarity index 100% rename from .claude/skills/openspec-apply-change/SKILL.md rename to .agents/skills/openspec-apply-change/SKILL.md diff --git a/.claude/skills/openspec-archive-change/SKILL.md b/.agents/skills/openspec-archive-change/SKILL.md similarity index 100% rename from .claude/skills/openspec-archive-change/SKILL.md rename to .agents/skills/openspec-archive-change/SKILL.md diff --git a/.claude/skills/openspec-explore/SKILL.md b/.agents/skills/openspec-explore/SKILL.md similarity index 100% rename from .claude/skills/openspec-explore/SKILL.md rename to .agents/skills/openspec-explore/SKILL.md diff --git a/.claude/skills/openspec-propose/SKILL.md b/.agents/skills/openspec-propose/SKILL.md similarity index 100% rename from .claude/skills/openspec-propose/SKILL.md rename to .agents/skills/openspec-propose/SKILL.md diff --git a/.claude/skills/spec-drift-review/SKILL.md b/.agents/skills/spec-drift-review/SKILL.md similarity index 100% rename from .claude/skills/spec-drift-review/SKILL.md rename to .agents/skills/spec-drift-review/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3f5894e..3a71dc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,6 +257,17 @@ The shared file `infrastructure/docker-compose.staging.yml` covers both — env **Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr- logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up. +## Agent Skills & Config + +Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink). + +``` +.agents/skills/ ← canonical location +.claude/skills ← symlink → ../.agents/skills +``` + +Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup. + ## OpenSpec Workflow Specs live in `openspec/`. Use these slash commands: From 59bbbcd520b90ec2662d0cfd25e38bb387f0c74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 9 Jun 2026 15:44:50 +0200 Subject: [PATCH 22/22] cd-infra: fix Prometheus 3.10 SIGHUP startup kill + add readiness gate - Capture container ID before/after 'docker compose up -d' and only send SIGHUP when the same container persisted (config-only change). A recreated container already loaded the fresh config; sending HUP immediately after startup kills Prometheus 3.10 with exit code 2. - Add Prometheus /-/ready gate alongside the postgres/journal health check to fail the deploy if monitoring stack never comes up. - Observed 2026-06-09: infra deploy left trails-cool-prometheus-1 Exited (2) for ~2h, causing a Grafana alert storm. --- .github/workflows/cd-infra.yml | 46 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index bdc3b31..333251e 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -84,6 +84,12 @@ jobs: docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true fi + # Capture whether prometheus was recreated. A fresh container already + # reads the new config on startup; sending it SIGHUP immediately can + # kill Prometheus 3.10 during early boot (exit 2 observed on + # 2026-06-09). + PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + # Full restart: gh workflow run cd-infra.yml -f restart_all=true if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then docker compose --env-file .env up -d --remove-orphans @@ -95,24 +101,31 @@ jobs: docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor fi + PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true) + # Apply config-only changes that `up -d` skips — it recreates a # container only when its compose *definition* changes, not when a # mounted config file's content changes. The configs are mounted as # directories (not single files), so a reload/restart re-reads the # freshly scp'd file; a single-file mount would have pinned the old - # inode. Prometheus hot-reloads on SIGHUP (zero downtime); Loki and - # Promtail reload their main config only on restart; Caddy reloads - # gracefully (validates, swaps live, no downtime). - docker compose --env-file .env kill -s SIGHUP prometheus + # inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when + # the same container stayed up; a recreated container already loaded + # the new config on startup. Loki and Promtail reload their main + # config only on restart; Caddy reloads gracefully (validates, swaps + # live, no downtime). + if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then + docker compose --env-file .env kill -s SIGHUP prometheus + fi docker compose --env-file .env restart loki promtail docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile docker compose ps - # Gate on the stack actually being up: postgres healthy and — - # since an infra restart bounces the app containers' database — - # journal back to healthy too. A deploy that leaves either down - # must fail loudly (see the 2026-06-06/07 outage). + # Gate on the stack actually being up: postgres healthy, journal + # back to healthy after the DB bounce, and Prometheus answering + # /-/ready. A deploy that leaves any of them down must fail loudly + # (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus + # startup/HUP regression). for ctr in trails-cool-postgres-1 trails-cool-journal-1; do for i in $(seq 1 36); do status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing) @@ -125,6 +138,23 @@ jobs: fi done + PROMETHEUS_READY= + for i in $(seq 1 36); do + prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing) + if [ "$prom_status" = "running" ]; then + prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1) + if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then + PROMETHEUS_READY=1 + break + fi + fi + sleep 5 + done + if [ -z "$PROMETHEUS_READY" ]; then + echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)" + exit 1 + fi + # Annotate deploy in Grafana GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-) if [ -n "$GRAFANA_TOKEN" ]; then