Commit Graph
556 Commits
Author SHA1 Message Date
Jannis Braun 613424e1c7 feat(federation): queue S2S presence_update on auth/disconnect/status/activity changes
New FederationPresenceUpdatePayload + queuePresenceRelay() helper. Five WS
sites now project the native user's status (and optional activities) to all
active peers via the outbox: WS auth-success, finalizeDisconnect,
manual presence_update, activity_update, showActivity-toggle clear.

Outbox-only (no mutation-log entry) — presence is ephemeral; the upcoming
peer-activation hook re-emits a fresh snapshot so peers recovering from
unreachable converge without history replay. No-op for replicated users.
2026-05-05 16:01:28 +02:00
Jannis Braun bdbc90ebd2 feat(federation): backfill snowflake-named replicated-user stubs
Heals existing legacy stubs (created when resolveOrCreateReplicatedUser used
homeUserId@domain) by asking the peer for the canonical username via
lookupRemoteUserByHomeId and rewriting the local row. Idempotent and
collision-safe.

Wired into onPeerActivated (per-origin) so future peer flaps re-attempt for
stubs whose home was unreachable on a prior pass, and into a one-shot pass at
startupBootstrapSync for all currently-active peers (not just first-time
lastSyncedAt=0 peers).
2026-05-05 15:57:37 +02:00
Jannis Braun b2faf5afaa feat(federation): add /users/by-home-id reverse lookup for stub backfill
HMAC-authenticated, rate-limited (60/min/peer) endpoint that resolves a
homeUserId on this instance to its canonical username + profile snapshot.
Native non-deleted users only. Mirrors /users/lookup's auth shape.

Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side
helper. Used by the upcoming stub-backfill worker on peers that hold legacy
snowflake-named replicas of users now visible by their real handle.
2026-05-05 15:54:06 +02:00
Jannis Braun 097eb9a2ef feat(federation): preserve effective displayName across profile_update relay
FederationProfileUpdatePayload gains `username`: the home user's canonical
handle. Receiver applies displayName ?? username so stubs whose home user has
no displayName show the real handle instead of getting clobbered to null.
Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself
is immutable on the home instance, so the receiver does not rewrite the stub's
username column on profile_update.
2026-05-05 15:48:56 +02:00
Jannis Braun 1d353c994b fix(federation): create replicated-user stubs with realname@domain when hint provided
When friend_request_create / friend_add / DM relay carries a profile snapshot,
the canonical-username hint is now used as the stub's local-part. Stubs created
purely from S2S (no client-federation) now display the human-readable handle,
not the homeUserId snowflake. Falls back to the snowflake-id scheme only when
no hint is available.
2026-05-05 15:46:26 +02:00
Jannis Braun c7eb14d38e test(federation-identity): #18 unrelated federated user untouched 2026-05-04 00:32:48 +02:00
Jannis Braun dfd3c26922 test(federation-identity): #17 mixed-result fan-out 2026-05-04 00:32:40 +02:00
Jannis Braun ec37b241d9 test(federation-identity): #16 member_left WS broadcast on full delete 2026-05-04 00:29:24 +02:00
Jannis Braun d917ff5354 test(federation-identity): #15 rate limit 5/15min 2026-05-04 00:26:35 +02:00
Jannis Braun 8e3fa1d5aa test(federation-identity): #14 S2S idempotency 2026-05-04 00:23:51 +02:00
Jannis Braun 27b6a3f912 test(federation-identity): #13 attribution guard rejects mismatched origin 2026-05-04 00:23:02 +02:00
Jannis Braun 21b413f9bd test(harness): use DISABLE_RATE_LIMITS in spawned instances + rate-limit-enabled variant
spawnInstance now sets DISABLE_RATE_LIMITS=1 by default so unrelated tests
don't exhaust the shared 127.0.0.1 per-IP bucket. bootHomePlusRemotes/
bootTwoInstances accept an optional { enableRateLimits } that omits the env
for tests that need real enforcement, exposed via the explicit
bootTwoInstancesWithRateLimits() helper for Test #15 (rate-limit assertion).
2026-05-04 00:18:24 +02:00
Jannis Braun 22b01c35f5 feat(server): DISABLE_RATE_LIMITS env replaces NODE_ENV-based bypass
Overloading NODE_ENV='test' to silently disable rate limiting layered a
second meaning onto an env that already gates the test-only seed-peer
route. A dedicated DISABLE_RATE_LIMITS env (envBool semantics, matching
DISABLE_FEDERATION_WORKERS) makes intent explicit, defaults off in
production, and leaves room for tests that need to assert real rate-limit
behaviour to opt back in by simply not setting the var.
2026-05-04 00:17:10 +02:00
Jannis Braun f4c9ed880f test(federation-identity): #9 unreachable / #10 no_active_peer 2026-05-04 00:14:09 +02:00
Jannis Braun b5df3d5075 test(federation-identity): #7 #8 — owned-spaces 409 + transfer-and-retry
Also fixes per-IP rate limit exhaustion in test environments: @fastify/rate-limit
v9 has no skip(); use allowList(() => NODE_ENV==='test') which propagates to
per-route overrides via mergeParams Object.assign merge.
2026-05-04 00:13:14 +02:00
Jannis Braun 22daaafb63 test(federation-identity): #2 #4 #6 — all-remotes fan-out variants 2026-05-04 00:06:44 +02:00
Jannis Braun 3c0e024213 test(harness): document why peerInstances inserts two rows per direction
The original peerInstances comment framed the two-row pattern as a
band-aid for getOurOrigin's https://${DOMAIN} default. After
investigating a clean collapse to one row (PUBLIC_ORIGIN override on
each spawned instance), the deeper coupling surfaces:

  - extractDomain() strips port via new URL().hostname, so unique-port
    localhost instances all share hostname '127.0.0.1' and the
    receiver's attribution guard
        extractDomain(user.homeInstance) === extractDomain(fedHeaders.origin)
    becomes ambiguous in any multi-remote configuration.
  - The homeInstance validator regex /^[a-zA-Z0-9._-]+$/ in auth.ts
    rejects ':', so the port cannot be encoded into homeInstance to
    disambiguate.
  - Eliminating the second row would require a production refactor of
    extractDomain (port-preserving), the attribution check (decoupled
    from URL), or the homeInstance validator (allow ':') — all out of
    scope.

So the harness DELIBERATELY keeps DOMAIN as a per-instance human label
('home.test.local' / 'remoteN.test.local') for stable identity, and the
two peer rows per direction (transport URL + getOurOrigin URL) are
structural to localhost-port test reality, not a band-aid. Comment
rewritten to reflect this. PUBLIC_ORIGIN remains available in
production code for reverse-proxy / dev-without-TLS deployments.
2026-05-04 00:02:14 +02:00
Jannis Braun d55e85d2c5 feat(federation): PUBLIC_ORIGIN env override for getOurOrigin
Adds an explicit override for the federation transport URL returned by
getOurOrigin(). When unset, behaviour is unchanged (https://${DOMAIN} ->
http://localhost:${PORT} dev fallback). Intended for reverse-proxy /
dev-without-TLS deployments where the public origin must be advertised
explicitly (typically http://...) and differs from the bare DOMAIN
value used for federated identity.

Wired via config.publicOrigin (envOptional('PUBLIC_ORIGIN')) so the
override flows through the existing config layer rather than scattering
process.env reads. Trailing slash is stripped for symmetry with
peer.origin storage.

docs/systems/federation.md gets a "Public Origin Override" subsection
under §14 Background Workers documenting the resolution order.
2026-05-04 00:02:14 +02:00
Jannis Braun c5e3d36689 test(federation-identity): #3 soft / #5 full — DB cascade verification + setup fixture
Adds setupFullDeletionFixture (federated user joins remote space, authors 2
messages with reactions, opens 1-on-1 DM with a live observer). Tests #3 (soft
mode: tombstone + dm_members cleared, messages/reactions retained) and #5 (full
mode: tombstone + messages/reactions purged, surviving 1-on-1 DM channel).

Also fixes seedPeer to install both the URL form (outbound lookup on sender)
and the DOMAIN-claim form (inbound auth on receiver) — required because the
test harness's ephemeral http://127.0.0.1 origin and DOMAIN-derived
getOurOrigin() return different strings, while production has them coincide.
This was latent: test #1 (leave mode) skips S2S, so #3 was the first test to
actually exercise the S2S delete path and surfaced the dual-origin gap.
2026-05-03 23:46:20 +02:00
Jannis Braun 957cfd9094 test(federation-identity): #1 leave mode — registry cleaned, remote untouched, no S2S call
Also fix dbInspect.ts UserRow column aliases: SELECT * returns snake_case
columns (is_deleted, display_name, etc.) but UserRow expected camelCase.
Switch to explicit aliased SELECT so all callers get the documented interface.
2026-05-03 23:38:02 +02:00
Jannis Braun b62ffdf03b test(federation-identity): #11 invalid mode / #12 empty origins 2026-05-03 23:35:15 +02:00
Jannis Braun 2df346cf03 test(federation-identity): scaffold + harness boot smoke 2026-05-03 23:34:57 +02:00
Jannis Braun a2fadf7e93 test(harness): wsListener helper for capturing real WS events 2026-05-03 23:33:29 +02:00
Jannis Braun e06b5fec3c test(harness): testUsers helpers — registerLocal + createFederatedUser via production endpoints 2026-05-03 23:26:47 +02:00
Jannis Braun 5fe6e7f225 test(harness): hmacSign + seedSpaceWithStubOwner helpers 2026-05-03 23:21:53 +02:00
Jannis Braun e95d3ef065 test(harness): read-only DB inspector helpers 2026-05-03 23:20:59 +02:00
Jannis Braun db70064a5a test(harness): seedPeer helpers — peer pair + unreachable peer 2026-05-03 23:20:30 +02:00
Jannis Braun cd6c51f893 test(harness): N-remote child-process harness + log-tail helpers 2026-05-03 23:17:42 +02:00
Jannis Braun c25aecc81b chore: add test scripts for federation identity deletion suite 2026-05-03 23:07:37 +02:00
Jannis Braun 03165a3b7d feat(server): test-only seed-peer route gated by NODE_ENV+ENABLE_TEST_ROUTES 2026-05-03 22:59:46 +02:00
Jannis Braun 3dbf4dab5e feat(server): DISABLE_FEDERATION_WORKERS env gate for test isolation 2026-05-03 22:51:06 +02:00
Jannis Braun c0e71b1ded fix(federation): hydrate downloads replicated avatars locally + backfill stale URL rows
hydrateReplicatedUserProfile now calls downloadProfileAsset and stores bare local filenames, falling back to absolute URLs only on download failure. It also fills empty fields only — no longer clobbering local files written by processProfileUpdateEvent. Adds an idempotent startup backfill that converts existing http-prefixed avatar/banner rows on replicated users into local files, so federated profile pictures keep rendering when the home instance is offline.
2026-05-02 22:45:27 +02:00
Jannis Braun bc32eccc2d fix(server): CORS allows tus headers (federated uploads no longer blocked at preflight) 2026-05-02 20:42:02 +02:00
Jannis Braun 2f0940c30b feat(admin): manual cleanup of stale tus upload sessions + visibility
Adds an admin-driven sweep on top of the existing 24h auto-expire so
operators can see and reap abandoned `.tus/` sessions without waiting.

- storageJanitor: extract `walkTusDir(predicate)` helper, add
  `getStaleTusInfo` + `cleanupStaleTusSessions(thresholdMs, dryRun)`;
  refactor `cleanupTusStragglers` to delegate while preserving its
  janitor-tick `{ removed }` contract.
- StorageStats gains `staleTusSessions` + `staleTusSize` (fixed 1h
  display threshold).
- New `POST /api/admin/storage/cleanup-tus` route with
  `maxAgeHours` validation (positive finite number, default 1) and
  `dryRun` support; admin-gated.
- StoragePanel: 6th overview card "Stale Uploads" + new cleanup
  subsection mirroring the media-cleanup pattern (preview-then-clean
  with shared result panel styling).
- Tests: 8 new janitor tests covering empty dir, threshold filtering,
  dry-run vs live, oldest-mtime tracking, subdir skipping, and the
  override path on the existing straggler sweep. New
  `routes/admin.test.ts` covers auth/admin gates, validation (zero,
  negative, NaN), default `maxAgeHours`, dry-run vs live unlink.
- Docs: `uploads.md` §Janitor expanded to the full lifecycle (cancel
  DELETE, discard DELETE, auto-expire, straggler sweep, admin route);
  `admin.md` Storage Management updated with the new endpoint and
  StorageStats fields.
2026-05-02 18:44:19 +02:00
Jannis Braun 2be243336b feat: all profile uploads through transferStore; delete legacy POST /api/uploads
Migrates the remaining 5 profile/space upload sites (CreateSpace, AccountPanel
avatar+banner, OverviewPanel icon+banner) to transferStore.startUpload with
tray:false. Space sites pass _instanceOrigin so uploads route to the space's
home instance.

Removes upload/uploadWithProgress from api.uploads (and their private
uploadFile/uploadFileWithProgress helpers); api.uploads.url is preserved for
GET-path URL building. Deletes the server-side POST /api/uploads handler and
the now-unused @fastify/multipart plugin registration. GET /api/uploads/:filename
remains intact.
2026-05-02 16:46:29 +02:00
Jannis Braun 2e6dabd7ff feat(server): janitor sweeps for tus expired uploads and stragglers 2026-04-30 01:56:39 +02:00
Jannis Braun 974fbf759e feat(server): tus upload endpoint at /api/files with auth, ownership, size, finalize hooks 2026-04-30 01:50:03 +02:00
Jannis Braun 21022eaa73 feat(server): tus hook helpers (metadata parse, extension extract, ownership check) 2026-04-30 01:17:16 +02:00
Jannis Braun 9902130916 feat(server): config keys for tus upload directory and expiration 2026-04-30 01:11:34 +02:00
Jannis Braun 4477554f8a deps(server): add @tus/server and @tus/file-store 2026-04-30 01:08:26 +02:00
Jannis Braun 1ed70a90b1 fix(dm): render system messages in sidebar preview instead of raw JSON
DmLastMessagePreview lacked a `type` field, so the sidebar rendered
`lastMessage.content` verbatim — surfacing JSON like
`{"event":"space_invite",...}` for space invites and member-add events.

Adds `type` to the preview payload (populated server-side from
`dm_messages.type`) and routes all sidebar call sites through a single
`formatDmSidebarPreview` helper that renders human-readable text for
each system event and skips the group `Sender:` prefix on system rows.
2026-04-29 23:13:09 +02:00
Jannis Braun bc66ddc633 fix(server): canonicalize spaceInstanceOrigin before storing payload
Empty-string (local) origin is now stored as the absolute home origin
so relayed DM space-invite cards carry the correct value to remote
recipients instead of resolving against the wrong instance.
2026-04-29 22:35:34 +02:00
Jannis Braun 44e9a4234c fix(server): local-fast-path for invite snapshot — skip HTTP self-reach
POST /api/dm/space-invite was hanging 5s and returning invite_invalid
for any local-space invite. fetchSpaceInviteSnapshot was being called
against our own public domain from inside the backspace container, which
fails (Docker NAT loopback) and aborts on timeout.

Add getLocalInviteSnapshot — reads the snapshot directly from the DB —
and branch in dm.ts so local invites bypass the HTTP roundtrip entirely.
Cross-instance invites still go through fetchSpaceInviteSnapshot with
its existing SSRF guard.

Also refactor the GET /api/spaces/invite/:code/preview handler to use
the same helper, keeping the snapshot shape in one place.

Tests assert fetchSpaceInviteSnapshot is NOT called for the local case
(critical regression guard) and that the cross-instance path still hits
the HTTP fetch.
2026-04-29 22:29:44 +02:00
Jannis Braun 5481eb9e7e fix(server): SSRF guard on cross-instance invite preview fetch 2026-04-29 22:19:10 +02:00
Jannis Braun 7303c23b20 test(server): integration tests for POST /api/dm/space-invite 2026-04-29 21:43:25 +02:00
Jannis Braun 5cb0aa9d66 feat(server): POST /api/dm/space-invite endpoint with rate-limited per-friend invite
Adds POST /api/dm/space-invite which fetches a space-invite snapshot
server-to-server from the space's home instance, ensures a 1-on-1 DM
between the caller and a friend, and posts a type='system' message
carrying SpaceInviteSystemPayload. Snapshot is never trusted from the
client. Rate-limited 30/60s per caller. Federation relay queued when the
recipient is on a remote instance (system message type forwarded by
Tasks 1-3).

Adds an `ensureOneOnOneDmChannel` helper that mirrors the dedup-or-create
behavior of the existing POST /api/dm handler — including federatedId
computation and the dm_channel_created notification payload — without
modifying that handler. Duplication is intentional; consolidation is a
separate follow-up.
2026-04-29 21:37:44 +02:00
Jannis Braun 889dfe9b4a feat(server): add fetchSpaceInviteSnapshot helper for cross-instance preview fetch 2026-04-29 21:33:19 +02:00
Jannis Braun c4f84f8c68 test(federation): buildRelayPayload type-field forwarding 2026-04-29 21:31:56 +02:00
Jannis Braun a995bd4148 feat(federation): processCreateEvent inserts system-typed messages from relay 2026-04-29 21:30:51 +02:00
Jannis Braun 6c68b4b574 feat(federation): buildRelayPayload forwards system-typed messages 2026-04-29 21:30:01 +02:00