Skip to content

feat(federation): foundations, identity & join flow (Milestones 1–2)#173

Merged
krazyjakee merged 7 commits into
masterfrom
claude/compassionate-wright-803120
Jun 25, 2026
Merged

feat(federation): foundations, identity & join flow (Milestones 1–2)#173
krazyjakee merged 7 commits into
masterfrom
claude/compassionate-wright-803120

Conversation

@krazyjakee

Copy link
Copy Markdown
Contributor

Implements client-side federation for Milestone 1 (Foundations & Identity) and Milestone 2 (Spaces & Membership), against the server's peer-to-peer federation work in accordserver#38 (branch claude/federated-servers-xlydlj).

Closes #168, #153, #154, #155, #156, #157, #158, #169.

Why

The Accord server now speaks peer-to-peer federation — remote entities carry qualified IDs (<snowflake>@<domain>) and an origin field, spaces can be joined cross-server, and the home server fans member/channel/space/message changes out to replicas as ordinary gateway events. The SDK and UI had zero federation support, so nothing in the client could consume a federated space. This PR adds the plumbing and UI so a user can join a space homed on another Accord server and see its content render correctly and stay live.

What changed

SDK (packages/accordkit) — #168, #153

  • Qualified-ID helpers (utils/qualified_id.dart): localPart, domainOf, isRemoteId, qualify — mirroring the server's federation/mapping.rs.
  • Snowflake tolerance: decodeTimestampMs strips @domain so snowflake-derived timestamps still parse for qualified IDs.
  • origin field on user, member, message, space, channel, role, emoji (parse + round-trip), with isRemote/homeDomain convenience getters. Cache/equality keys keep the full qualified ID, so a remote 123@b.example never collides with a local 123.
  • client.federation.joinSpace(domain, spaceId)POST /federation/spaces/join.

Identity rendering — #154, #155

  • New shared RemoteOriginBadge (globe glyph + @domain) on the message author line, member list, profile popout, and space header.
  • Remote avatars resolve against the home server's CDN (https://<origin>/cdn, bare snowflake), with absolute-URL passthrough; local-only moderation (kick/ban/timeout/roles/nickname) is disabled for remote members.

Join flow — #156, #169

  • Reusable joinFederatedSpace helper that calls the SDK and hydrates the returned replica into the rail.
  • A "Federated" tab in the add-server dialog (spaceId@domain) and a daccord://federate/<spaceId>@<domain> deep link.

Live replica updates — #157, #158

No new event-handling code: the existing gateway handlers apply member join/leave, channel create/update/delete, and space metadata keyed by string IDs, so qualified replica content rides them unchanged once the space is in the list.

Testing

  • accordkit: dart analyze clean; full suite green incl. new qualified-ID, snowflake @domain, origin round-trip, and join-endpoint tests.
  • App: flutter analyze --no-fatal-infos → 0 errors; suite green incl. new member-display/remote-avatar and deep-link/address-parser tests.
  • ⚠️ The full two-server end-to-end path (peered servers) was not run in CI here — needs a federated server pair to exercise live.

Reviewer notes

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor Author

Review findings & fixes — commit efeed09

Reviewed across code quality, file size/structure, and test coverage. No 3D/visual rendering changes in this PR. Overall the implementation is solid and well-structured; findings are mostly low-to-medium severity.


Findings (ranked by severity)

🟡 MEDIUM — _parseFederate: misleading unused binding (fixed)

lib/features/server/utils/server_uri.dart

// Before
final host = _hostFromAuthority(authority);
if (host == null) return null;
return ParsedServerUrl(route: 'federate', spaceId: spaceId, domain: authority);

host is computed for validation but its value is never used — authority (the full host:port string) is intentionally passed as domain to preserve non-default ports, which is correct and matches the pattern in _parseInvite. However, a named binding that's only used for a null check looks like a bug (or like authority should have been replaced by host). Fixed to if (_hostFromAuthority(authority) == null) return null;.


🟡 MEDIUM — Missing test: server-relative remote avatar path (fixed)

test/features/member/member_display_federation_test.dart

The _userAvatarUrl function has three branches for the avatar value: bare hash, server-relative path (contains /), and absolute URL. Tests covered the bare-hash and absolute-URL branches for remote users but not the server-relative path (/cdn/avatars/custom/avatar.png), which goes through AccordCDN.resolvePath(avatar, cdnUrl: cdn) with the home CDN. Added.


🟡 MEDIUM — Missing test: remote member space-scoped avatar override (fixed)

test/features/member/member_display_federation_test.dart

accordMemberAvatarUrl has a separate code path for the per-space member.avatar override (hashed override resolved against /cdn/avatars/<hash>). When member.homeDomain is set this should resolve against the home CDN, but it wasn't tested. Added.


🟢 LOW — Missing test: lastIndexOf behaviour for qualified spaceId in address/deep-link (fixed)

test/features/spaces/federation_join_test.dart

Both parseFederatedAddress and _parseFederate use lastIndexOf('@'), which correctly handles a space ID that already embeds @domain (e.g. a cross-hop qualified id 42@olddomain@newdomain). This lastIndexOf invariant wasn't tested. Added tests for both the address parser and the deep-link parser.


Out of scope / deferred

  • joinFederatedSpace unit test: The function's happy path (post-join space hydration via Riverpod) is hard to unit-test without a full WidgetRef mock. The failure path (API error) is implicitly covered by the success/error typedef return. Acceptable trade-off.
  • AccordMessage.isRemote secondary condition (|| isRemoteId(authorId)): Redundant for JSON-parsed messages (since origin is already set from domainOf(authorId) in fromJson), but necessary for programmatically constructed messages. Intentional and correct.
  • autofocus: true on federated tab TextField: Will pop the keyboard when switching to the Federated tab on mobile. Likely intentional; can be revisited in UX polish pass.
  • Per-space federation toggle (Federation: per-space federation enable/disable toggle in space settings #166): Already noted as deferred in the PR description — the server route doesn't exist yet. No client change needed.

No issues found

  • File sizes: all changed files are well under 1000 lines.
  • Discord endpoints: none reintroduced.
  • accord_home.dart import of remote_origin_badge.dart: initially flagged as unused, but accord_home_channels.dart and accord_home_message_row.dart are part of 'accord_home.dart' and inherit its scope — the import is used by both part files. ✅

Generated by Claude Code

krazyjakee and others added 4 commits June 25, 2026 10:42
Implement client federation across Milestones 1 (Foundations & Identity)
and 2 (Spaces & Membership), against accordserver PR #38.

SDK (packages/accordkit) — #168, #153:
- Add qualified-ID helpers (localPart/domainOf/isRemoteId/qualify),
  mirroring the server's federation/mapping.rs.
- Tolerate qualified IDs in snowflake timestamp decoding.
- Add the optional `origin` (home domain) field to user, member,
  message, space, channel, role, and emoji, with isRemote/homeDomain
  convenience getters. Keep full qualified IDs as cache keys so a remote
  `123@b.example` never collides with a local `123`.
- Add `client.federation.joinSpace(domain, spaceId)`.

Identity rendering — #154, #155:
- New shared RemoteOriginBadge wired into the message author line, member
  list, profile popout, and space header.
- Resolve remote avatars against the home server's CDN; disable
  local-only moderation actions for remote members.

Join flow — #156, #169:
- joinFederatedSpace helper + a "Federated" tab in the add-server dialog
  and a daccord://federate/<spaceId>@<domain> deep link.

Live replica updates — #157, #158: carried by existing gateway handlers
(keyed by string IDs), which apply qualified replica content unchanged.

Tests: accordkit qualified-ID/snowflake/origin/endpoint coverage; app
helper + deep-link parser coverage. flutter analyze clean; SDK and app
suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess tests

- `_parseFederate`: inline `_hostFromAuthority` null-check so the
  validated-but-unused `host` binding doesn't mislead readers into
  thinking `authority` (full authority incl. port) should have been
  replaced by the stripped hostname in the result.

- Add two missing avatar-resolution tests (server-relative path for
  remote user, space-scoped bare-hash override for remote member) that
  exercised an untested branch of `_userAvatarUrl`/`accordMemberAvatarUrl`.

- Add `lastIndexOf` edge-case tests for `parseFederatedAddress` and the
  `daccord://federate/` deep link so qualified space IDs (containing an
  embedded `@domain`) are confirmed to split on the final `@`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHXe8ANc18KmzhtQyZ3Py9
Phase 3 of client federation: render and react to remote-homed messages
in shared spaces. M1–M2 already made every cache key on the full
qualified ID, so remote message create/edit/delete, reactions, and typing
flow through the existing gateway handlers unchanged. The gap this closes
is qualified-self recognition.

A remote home re-qualifies the local user's own actions to our domain
before fanning them back (`me` -> `me@a.example`), so a bare
`== currentUserId` stopped recognising them — self-notifying, self-chiming,
double-counting our own reactions, and showing "you are typing" on
ourselves on remote-homed channels/messages.

- accordkit: add `isSameUser(actorId, localId, {localDomain})` — matches a
  bare local id or our own id qualified to our home domain, while a remote
  actor that merely shares our bare snowflake stays distinct (#159).
- AccordServer.homeDomain + ref `watchHomeDomain()`: expose our federation
  home domain (the base URL host) to event handlers and the message pane.
- Event handler: route message author/mention, reaction ownership, and
  typing skip-self through qualified-aware self checks (#160#162).
- Message pane: own-message and mention highlighting recognise our
  qualified id on federated messages (#159).

Remote author identity, avatars (home CDN), and the origin badge already
resolve via the qualified author_id -> member/user cache -> replica-fetch
path and the M1–M2 RemoteOriginBadge.

Tests: isSameUser (self vs. colliding-snowflake); message-cache dedup/
update/delete keyed by qualified id; federated reaction aggregation
(remote actor not-self, own-echo merges). flutter analyze + suites run in
CI (no Dart toolchain in this environment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 of client federation: open and render direct messages with users
on other servers.

Open by qualified handle (#163):
- A qualified recipient (`<snowflake>@<domain>`) now routes through the
  single `recipient_id` field so the server takes its cross-server DM path
  (deterministic home + replica mirror); bare ids keep the recipients-list
  same-server form. Extracted as `dmCreateBody`.
- New "Message remote user" entry in the DM list opens a dialog that
  validates a qualified handle (`isValidRemoteHandle`) before opening.
- The member popout's existing "Direct Message" already passes a remote
  member's qualified id, so messaging a visible remote member just works.
- `openAccordDirectMessage` now surfaces the server's rejection reason
  (federation disabled, recipient not qualified, peer untrusted, blocked)
  instead of a generic failure.

Origin-aware rendering (#164):
- DM list tiles and the conversation header show the participant's
  home-domain avatar (resolved against their home CDN) and a
  RemoteOriginBadge for cross-server DMs. The server stores our own DM
  participant with a bare id, so the 1:1 "other" participant resolves
  cleanly in both the home and replica channel cases.
- Send/receive use the (qualified or bare) channel id unchanged.

Tests: dmCreateBody routing + isValidRemoteHandle validation; accordkit
cross-server DM channel parse (qualified channel id + remote recipient).
Remote avatar/origin helpers were already covered. flutter analyze +
suites run in CI (no Dart toolchain in this environment).

Known limitation (pre-existing, parity with local DMs): the lightweight DM
dialog isn't a live gateway view — incoming messages and reactions/typing
aren't shown there for any DM, local or federated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@krazyjakee krazyjakee force-pushed the claude/compassionate-wright-803120 branch from c2281a1 to b48def2 Compare June 25, 2026 09:44
krazyjakee and others added 3 commits June 25, 2026 11:18
Federated entities carry an `origin`/`@domain` minted by a peer server,
which the client turned directly into CDN URLs (and, for pasted server
URLs, the auth-bearing base URL). Harden that trust boundary:

- Add isValidHost / isLoopbackOrLinkLocalHost to accordkit.
- cdnBaseForDomain now rejects unsafe origins (invalid hostnames,
  smuggled userinfo/path, loopback and link-local incl. cloud metadata)
  so a malicious origin can't redirect avatar/emoji fetches to internal
  or attacker-controlled hosts.
- Reject off-home absolute avatar/emoji URLs from remote entities, and
  centralize emoji resolution in accordEmojiUrl.
- Tighten server URL/deep-link host validation to a strict allowlist so
  `host@evil.com`-style links can't aim the base URL at another host.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit follow-through focused on consistency, DRY, and clean organization:

- Add shared helpers: watchAccordClient(), list upsertById/removeById, and
  RestResult listOrLog/dataOrLog; adopt across controllers and views.
- Give AccordMemberAvatar an initialStyle param and route the remaining
  hand-rolled CircleAvatar sites (voice, forum, message row, etc.) through it.
- Extract the shared admin-tab body skeleton into AdminListScaffold.
- Move non-Riverpod helpers into the services/ convention: event handler ->
  events/services, sound + notification -> notifications/services,
  federation_join -> server/services.
- Rename the auth-state model accord_auth.dart -> accord_auth_state.dart to
  stop colliding with the AccordAuth repository.
- Enforce always_use_package_imports; fix the lone relative import and drop a
  dead commented-out class.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The space tile menu only offered "Leave server"/"Leave & delete data", both of
which hit the server over REST and so fail for a server you can no longer
reach, leaving no way to clear it from the rail. Add a "Remove server" entry
that calls AccordAuth.removeAccount() — purely local cleanup (disposes the
client, clears saved credentials, rail entry, tabs, unread state and cached
spaces), so it works even when the server is unreachable. Confirms first and
notes nothing is deleted server-side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@krazyjakee krazyjakee merged commit ef36b64 into master Jun 25, 2026
3 checks passed
@krazyjakee krazyjakee deleted the claude/compassionate-wright-803120 branch June 25, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Federation: accordkit SDK support for qualified IDs, origin, and the join trigger

2 participants