From d74ce32cd0ea68d86cce84a51b1d1ab1cf4c6f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 08:43:03 +0200 Subject: [PATCH 1/5] fix(journal): actor profile fields must serialize as a JSON array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mastodon's PropertyValue parser requires the actor's attachment to be a JSON *array* and silently ignores a bare object β€” and Fedify compacts single-element arrays to bare objects, so the πŸ₯Ύ trails.cool field shipped in #464 never rendered (verified: Mastodon stored fields = {} after a forced actor refresh on the soak instance). Ship two fields so the array survives serialization: the πŸ₯Ύ profile link (rel=me) plus an Instance link β€” the latter is genuinely useful for self-hosted instances anyway. Test now asserts the array shape explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/journal/app/lib/federation.server.test.ts | 18 ++++++++++++------ apps/journal/app/lib/federation.server.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) 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?:\/\//, "")}`, + }), ], }); }) From 5bf9358dc684d879beab2c4892484035904f7bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 08:48:27 +0200 Subject: [PATCH 2/5] fix(db): allow 'public' credential_kind in connected_services check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0002 migration's allowed list predates the Komoot public-profile connection mode (api.sync.komoot.verify.ts writes credentialKind 'public'). Against production data β€” which has such a connection β€” the drop-then-add CHECK failed at ATRewriteTable and blocked every cd-apps deploy since 2026-06-07 06:25 UTC. Also documents 'public' in the schema comment with a pointer to keep the two lists in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/migrations/0002_connected_services.sql | 6 +++++- packages/db/src/schema/journal.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/db/migrations/0002_connected_services.sql b/packages/db/migrations/0002_connected_services.sql index 5a02645..20ec004 100644 --- a/packages/db/migrations/0002_connected_services.sql +++ b/packages/db/migrations/0002_connected_services.sql @@ -65,11 +65,15 @@ BEGIN ALTER COLUMN status SET NOT NULL; -- 5. CHECK constraints. Drop-then-add for idempotency. + -- 'public' = credential-less public-profile connection (Komoot public + -- mode, api.sync.komoot.verify.ts). It was missing from the original + -- list, which made this migration fail against production data on + -- 2026-06-07 (a komoot public connection existed) and block cd-apps. ALTER TABLE journal.connected_services DROP CONSTRAINT IF EXISTS connected_services_credential_kind_check; ALTER TABLE journal.connected_services ADD CONSTRAINT connected_services_credential_kind_check - CHECK (credential_kind IN ('oauth', 'web-login', 'device')); + CHECK (credential_kind IN ('oauth', 'web-login', 'device', 'public')); ALTER TABLE journal.connected_services DROP CONSTRAINT IF EXISTS connected_services_status_check; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 546de41..3ed696e 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -224,6 +224,9 @@ export const oauthTokens = journalSchema.table("oauth_tokens", { // - 'oauth': { access_token, refresh_token, expires_at } // - 'web-login': { email, encrypted_password, session_jar } (Komoot, future) // - 'device': {} (Apple Health, future) +// - 'public': {} credential-less public-profile connection (Komoot public mode) +// Keep this list in sync with the CHECK constraint in +// packages/db/migrations/0002_connected_services.sql. // See docs/adr/0001 and CONTEXT.md (Connected Services). export const connectedServices = journalSchema.table( "connected_services", From 4e63f7631a75d3ccf8808c803b73a9bb1a7d3ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 09:13:44 +0200 Subject: [PATCH 3/5] fix(journal): show remote followers in follower/following lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit countFollowers counts every accepted follow row, but listFollowers inner-joined users on follower_id β€” so a federated follower (NULL follower_id, follower_actor_iri set) bumped the count while never appearing in the list. Observed live: profile said '1 follower', list below was empty. listFollowing had the same latent bug for outbound trails-to-trails follows. Both lists now left-join users + remote_actors: local entries link to the local profile as before; remote entries display cached actor data (IRI parsing as fallback) and link out to the remote profile. Integration test pins count/list consistency for a remote follower. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../journal/app/components/CollectionPage.tsx | 8 +- .../lib/federation-inbox.integration.test.ts | 17 ++++ apps/journal/app/lib/follow.server.ts | 99 ++++++++++++++++--- 3 files changed, 109 insertions(+), 15 deletions(-) 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) { ) : (