diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml
index 7b87971..ed59b70 100644
--- a/.github/workflows/cd-staging.yml
+++ b/.github/workflows/cd-staging.yml
@@ -195,6 +195,13 @@ jobs:
# via cd-infra) are live. Idempotent.
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
+ # Reclaim superseded image layers. cd-apps prunes after its own
+ # deploys, but a day of staging/preview deploys while cd-apps is
+ # red can fill the disk on its own (2026-06-07: 100% full,
+ # postgres down). The 1h filter avoids racing layers another
+ # in-flight deploy just pulled.
+ docker image prune -af --filter "until=1h" || true
+
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
# ── PR preview deploy ────────────────────────────────────────────────
@@ -356,6 +363,10 @@ jobs:
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
+ # Same disk-hygiene prune as the persistent staging deploy —
+ # preview pushes are the highest-volume image source.
+ docker image prune -af --filter "until=1h" || true
+
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
# Find any prior preview comment so we can update it in place rather
diff --git a/.github/workflows/disk-maintenance.yml b/.github/workflows/disk-maintenance.yml
new file mode 100644
index 0000000..5113a0c
--- /dev/null
+++ b/.github/workflows/disk-maintenance.yml
@@ -0,0 +1,52 @@
+name: Disk Maintenance
+
+# Daily safety net for flagship disk usage. The deploy workflows prune
+# superseded image layers after their own runs, but that protection
+# disappears exactly when it's needed most: a deploy that fails early
+# never reaches its prune step, while other workflows keep pulling
+# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
+# deploys, disk 100% full, postgres down on a Saturday morning).
+#
+# Also doubles as a redundant alert channel: the run FAILS when the
+# disk is still above the threshold after pruning, so a scheduled-run
+# failure email lands even if the Grafana disk alert drowns in other
+# noise (which is what happened during the incident).
+
+on:
+ schedule:
+ # Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
+ - cron: "30 4 * * *"
+ workflow_dispatch: {}
+
+concurrency:
+ group: disk-maintenance
+ cancel-in-progress: false
+
+jobs:
+ prune-flagship:
+ name: Prune unused images (flagship)
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Prune and check disk headroom
+ uses: appleboy/ssh-action@v1
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: root
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ script: |
+ set -euo pipefail
+ echo "before: $(df -h / | tail -1)"
+ # 12h filter: never touch layers a same-day deploy may still
+ # be assembling; running containers' images are never pruned.
+ docker image prune -af --filter "until=12h"
+ echo "after: $(df -h / | tail -1)"
+
+ PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
+ if [ "$PCT" -ge 85 ]; then
+ echo "Disk still at ${PCT}% after pruning — needs a human."
+ echo "Largest docker consumers:"
+ docker system df
+ exit 1
+ fi
+ echo "Disk at ${PCT}% — healthy."
diff --git a/apps/journal/app/components/CollectionPage.tsx b/apps/journal/app/components/CollectionPage.tsx
index 08c8bab..2632b44 100644
--- a/apps/journal/app/components/CollectionPage.tsx
+++ b/apps/journal/app/components/CollectionPage.tsx
@@ -4,6 +4,9 @@ interface Entry {
username: string;
displayName: string | null;
domain: string;
+ /** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
+ profileUrl: string;
+ remote: boolean;
}
interface Props {
@@ -42,9 +45,10 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
) : (
{entries.map((entry) => (
- -
+
-
diff --git a/apps/journal/app/lib/federation-inbox.integration.test.ts b/apps/journal/app/lib/federation-inbox.integration.test.ts
index 313c0b4..9adcbee 100644
--- a/apps/journal/app/lib/federation-inbox.integration.test.ts
+++ b/apps/journal/app/lib/federation-inbox.integration.test.ts
@@ -64,6 +64,23 @@ describe.runIf(runIntegration)("federation inbox (integration)", () => {
expect(rows[0]!.acceptedAt).not.toBeNull();
});
+ it("remote followers appear in the followers list (count/list consistency)", async () => {
+ const username = `fed-list-${Date.now()}`;
+ const userId = await makeUser({ username });
+ await recordRemoteFollow(REMOTE_ACTOR, username);
+
+ const { listFollowers, countFollowers } = await import("./follow.server.ts");
+ const [entries, total] = await Promise.all([listFollowers(userId), countFollowers(userId)]);
+ expect(total).toBe(1);
+ expect(entries).toHaveLength(1);
+ const entry = entries[0]!;
+ expect(entry.remote).toBe(true);
+ expect(entry.profileUrl).toBe(REMOTE_ACTOR);
+ // No remote_actors cache row in this test → identity parsed from the IRI.
+ expect(entry.username).toBe("alice");
+ expect(entry.domain).toBe("other-trails.example");
+ });
+
it("inbound Follow is idempotent — replays don't double-insert", async () => {
const username = `fed-replay-${Date.now()}`;
const userId = await makeUser({ username });
diff --git a/apps/journal/app/lib/federation.server.test.ts b/apps/journal/app/lib/federation.server.test.ts
index f00aa39..294ab08 100644
--- a/apps/journal/app/lib/federation.server.test.ts
+++ b/apps/journal/app/lib/federation.server.test.ts
@@ -95,12 +95,18 @@ describe("actor object", () => {
expect(actor.preferredUsername).toBe("bruno");
expect(actor.name).toBe("Bruno");
expect(actor.summary).toBe("Riding bikes");
- // Profile-metadata field Mastodon renders ("this is a trails profile")
- const attachments = Array.isArray(actor.attachment) ? actor.attachment : [actor.attachment];
- const field = attachments.find((a: { type: string }) => a?.type === "PropertyValue");
- expect(field?.name).toBe("🥾 trails.cool");
- expect(field?.value).toContain('href="http://localhost:3000/users/bruno"');
- expect(field?.value).toContain('rel="me"');
+ // Profile-metadata fields Mastodon renders ("this is a trails
+ // profile"). MUST serialize as a JSON array — Mastodon's parser
+ // silently ignores a bare attachment object, which is what Fedify
+ // compacts single-element arrays into (hence two fields).
+ expect(Array.isArray(actor.attachment)).toBe(true);
+ const fields = actor.attachment.filter((a: { type: string }) => a?.type === "PropertyValue");
+ expect(fields.length).toBeGreaterThanOrEqual(2);
+ const trails = fields.find((f: { name: string }) => f.name === "🥾 trails.cool");
+ expect(trails?.value).toContain('href="http://localhost:3000/users/bruno"');
+ expect(trails?.value).toContain('rel="me"');
+ const instance = fields.find((f: { name: string }) => f.name === "Instance");
+ expect(instance?.value).toContain("localhost:3000");
});
it("404s the actor for a private user", async () => {
diff --git a/apps/journal/app/lib/federation.server.ts b/apps/journal/app/lib/federation.server.ts
index 6cb971e..8622653 100644
--- a/apps/journal/app/lib/federation.server.ts
+++ b/apps/journal/app/lib/federation.server.ts
@@ -180,11 +180,19 @@ function buildFederation(): Federation {
// metadata table — a human-visible "this is a trails profile"
// marker. (The machine-readable marker is NodeInfo; this is
// flair.) Mastodon strips most HTML in values but keeps links.
+ // NOTE: Mastodon's parser requires `attachment` to be a JSON
+ // *array* and silently ignores a bare object — and Fedify
+ // compacts single-element arrays to bare objects. Keep at least
+ // two fields here so the array survives serialization.
attachments: [
new PropertyValue({
name: "🥾 trails.cool",
value: `${getOrigin().replace(/^https?:\/\//, "")}/users/${identifier}`,
}),
+ new PropertyValue({
+ name: "Instance",
+ value: `${getOrigin().replace(/^https?:\/\//, "")}`,
+ }),
],
});
})
diff --git a/apps/journal/app/lib/follow.server.ts b/apps/journal/app/lib/follow.server.ts
index f1254ae..0105e5d 100644
--- a/apps/journal/app/lib/follow.server.ts
+++ b/apps/journal/app/lib/follow.server.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, and, count, desc, isNull, isNotNull } from "drizzle-orm";
import { getDb } from "./db.ts";
-import { users, follows } from "@trails-cool/db/schema/journal";
+import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import { localActorIri } from "./actor-iri.ts";
import { createNotification } from "./notifications.server.ts";
import { logger } from "./logger.server.ts";
@@ -283,35 +283,89 @@ export interface CollectionEntry {
username: string;
displayName: string | null;
domain: string;
+ /** Where the entry's profile lives: local path or remote actor URL. */
+ profileUrl: string;
+ remote: boolean;
}
const COLLECTION_PAGE_SIZE = 50;
+/**
+ * Best-effort identity for a remote actor we may or may not have
+ * cached: prefer the remote_actors row, fall back to parsing the IRI
+ * (canonical fediverse shape `https://host/users/name`).
+ */
+function remoteEntry(
+ actorIri: string,
+ cached: { username: string | null; displayName: string | null; domain: string | null },
+): CollectionEntry {
+ let host = "";
+ let lastSegment: string;
+ try {
+ const url = new URL(actorIri);
+ host = url.host;
+ lastSegment = url.pathname.split("/").filter(Boolean).pop() ?? "";
+ } catch {
+ // Malformed IRI in the DB — display it raw rather than hide the row.
+ lastSegment = actorIri;
+ }
+ return {
+ username: cached.username ?? lastSegment,
+ displayName: cached.displayName,
+ domain: cached.domain ?? host,
+ profileUrl: actorIri,
+ remote: true,
+ };
+}
+
/**
* Paginated list of accepted followers of `userId`. Newest acceptance first.
- * Pending requests are excluded — they live in the Requests tab on
- * /notifications.
+ * Includes remote (federated) followers — `follower_actor_iri` rows — so the
+ * list matches countFollowers; display data comes from the remote_actors
+ * cache when present, IRI parsing otherwise. Pending requests are excluded —
+ * they live in the Requests tab on /notifications.
*/
export async function listFollowers(userId: string, page: number = 1): Promise {
const db = getDb();
const offset = (Math.max(1, page) - 1) * COLLECTION_PAGE_SIZE;
const rows = await db
.select({
- username: users.username,
- displayName: users.displayName,
- domain: users.domain,
+ localUsername: users.username,
+ localDisplayName: users.displayName,
+ localDomain: users.domain,
+ followerActorIri: follows.followerActorIri,
+ remoteUsername: remoteActors.username,
+ remoteDisplayName: remoteActors.displayName,
+ remoteDomain: remoteActors.domain,
})
.from(follows)
- .innerJoin(users, eq(follows.followerId, users.id))
+ .leftJoin(users, eq(follows.followerId, users.id))
+ .leftJoin(remoteActors, eq(follows.followerActorIri, remoteActors.actorIri))
.where(and(eq(follows.followedUserId, userId), isNotNull(follows.acceptedAt)))
.orderBy(desc(follows.acceptedAt))
.limit(COLLECTION_PAGE_SIZE)
.offset(offset);
- return rows;
+ return rows.map((r) =>
+ r.localUsername !== null
+ ? {
+ username: r.localUsername,
+ displayName: r.localDisplayName,
+ domain: r.localDomain ?? "",
+ profileUrl: `/users/${r.localUsername}`,
+ remote: false,
+ }
+ : remoteEntry(r.followerActorIri ?? "", {
+ username: r.remoteUsername,
+ displayName: r.remoteDisplayName,
+ domain: r.remoteDomain,
+ }),
+ );
}
/**
* Paginated list of accepted follows from `userId`. Newest acceptance first.
+ * Includes remote (trails-to-trails) targets — rows whose followed side is
+ * only an actor IRI — for the same count/list consistency as listFollowers.
* Pending outgoing follows (against private/locked targets) are excluded.
*/
export async function listFollowing(userId: string, page: number = 1): Promise {
@@ -319,15 +373,34 @@ export async function listFollowing(userId: string, page: number = 1): Promise
+ r.localUsername !== null
+ ? {
+ username: r.localUsername,
+ displayName: r.localDisplayName,
+ domain: r.localDomain ?? "",
+ profileUrl: `/users/${r.localUsername}`,
+ remote: false,
+ }
+ : remoteEntry(r.followedActorIri, {
+ username: r.remoteUsername,
+ displayName: r.remoteDisplayName,
+ domain: r.remoteDomain,
+ }),
+ );
}