Commit Graph
342 Commits
Author SHA1 Message Date
Jannis Braun af94f12389 feat(federation): S2S verify-attach-proof endpoint — single-use, peer-domain-bound, signed response (re-attach spec §3.1) 2026-07-03 01:49:20 +02:00
Jannis Braun 1c962ded12 feat(auth): attach-proof mint endpoint for detached-account re-attach (re-attach spec §3.1) 2026-07-03 01:41:08 +02:00
Jannis Braun a0238eabc2 feat(federation): startup sweep removes dead-incarnation channels and self-homed stubs (dead-incarnation spec §3.4) 2026-07-03 01:05:26 +02:00
Jannis Braun 3591773a4c feat(federation): deleted flag on wire profile snapshots — tombstone markers never leave the instance (dead-incarnation spec §3.3) 2026-07-03 00:59:37 +02:00
Jannis Braun 5ffc7c565e feat(federation): sync endpoint scopes friend events to the requesting peer, pagination-safe (dead-incarnation spec §3.2) 2026-07-03 00:50:31 +02:00
Jannis Braun bd40058613 feat(federation): sync endpoint scopes DM channels to the requesting peer (dead-incarnation spec §3.2) 2026-07-03 00:42:02 +02:00
Jannis Braun 99c8361b75 feat(federation): receiver guard — never create replicated stubs homed at our own domain (dead-incarnation spec §3.1) 2026-07-03 00:37:27 +02:00
Jannis Braun 13d050c1bb fix(federation): close detached-account gaps from final review — presence/hydrate guards, ack re-detect clear, self-delete password (detach spec §4.3/§4.4/§4.6) 2026-07-02 19:34:32 +02:00
Jannis Braun 42ad5e141d feat(federation): server-side acknowledge for reset events (acknowledged_at + admin endpoint) (detach spec §4.6) 2026-07-02 18:59:33 +02:00
Jannis Braun 7e1e32de69 feat(federation): detached accounts get local profile+password self-service; self-view flag (detach spec §4.4, §4.7) 2026-07-02 18:54:37 +02:00
Jannis Braun ea66ec5dbd test(federation): pin registration 409 + suffixed stub creation against detached accounts (detach spec §4.3.5)
Also add the positive companion assertion (folded in from a Task 3 review
Minor): findFederatedUser tier-2 STILL returns a NON-detached (orphaned=0)
same-name federated row, locking that the eq(federation_home_orphaned, 0)
clause discriminates on the flag alone and never over-filters legitimate
replicated identities.

All three behaviors pass against the shipped Task 1-3 code; no product-code
change was required.
2026-07-02 18:47:29 +02:00
Jannis Braun 68be2e26b1 feat(federation): S2S surfaces exclude detached accounts — tier-2, profile_update, identity delete (detach spec §4.3) 2026-07-02 18:39:54 +02:00
Jannis Braun 79c1138813 feat(federation): detached accounts keep local-password login; self-heal permanently disabled (detach spec §4.1) 2026-07-02 18:28:55 +02:00
Jannis Braun 3ebdd048bd feat(dm): read-only guard blocks message create/edit/delete in a Deleted-User 1-on-1 (S3) 2026-07-02 15:47:18 +02:00
Jannis Braun 6926bdb8f8 fix(federation): /peer/initiate handles needs_attention/awaiting_approval/rejected rows instead of 500ing 2026-07-02 13:29:46 +02:00
Jannis Braun cd28c0336c refactor(federation): store verified epoch as peer baseline; drop redundant assertion 2026-07-02 13:07:35 +02:00
Jannis Braun c6f0e6f25d fix(federation): initiator handles 409 + verifies handshake before activating (BUG-1b/BUG-2) 2026-07-02 12:56:26 +02:00
Jannis Braun edcb4cb72a fix(federation): /peer/accept returns honest 409 instead of false 200 for existing peer (BUG-1a) 2026-07-02 12:40:16 +02:00
Jannis Braun 085670cff3 fix(federation): handshake sourceOrigin honors PUBLIC_ORIGIN (align with S2S auth origin) 2026-07-02 12:31:02 +02:00
Jannis Braun df1e457971 feat(federation): add needs_attention_reason 'repeer_incomplete' (BUG-2 prep) 2026-07-02 11:46:10 +02:00
Jannis Braun 6de14b281b fix(federation): friend-add returns graceful 503 instead of 500 on peer lookup failure (BUG-3)
lookupRemoteUser now maps peer HTTP failures (403/5xx, malformed body) to a
structured {ok:false,reason:'unreachable'} instead of throwing, and the
federated friend-add wraps the call in try/catch as defense-in-depth. A
desynced/unreachable peer no longer surfaces as a raw 500 on a user action.
README.md left unstaged.
2026-07-02 11:23:08 +02:00
Jannis Braun 43d1dad1d7 fix(federation): carry error body on HttpError so Reset-cleanup owns-spaces copy reaches the UI
Also narrow SanitizedPeer.needsAttentionReason to the shared union.
2026-07-02 02:19:13 +02:00
Jannis Braun 290cd606c0 feat(federation): GET /reset-events admin endpoint + types 2026-07-02 01:47:58 +02:00
Jannis Braun ee52ff0c7b feat(federation): expose needsAttentionReason on peer API 2026-07-02 01:43:12 +02:00
Jannis Braun e0a0d92fe7 feat(federation): login self-heal epoch guard (fetchPeerEpoch, fail-closed) 2026-07-02 01:33:42 +02:00
Jannis Braun 9b945ba5b7 feat(federation): freeze login for reset-orphaned federated accounts 2026-07-02 01:27:47 +02:00
Jannis Braun 7d8c9c9d8d feat(federation): peer_reset_pending guard during limbo window 2026-07-01 22:32:40 +02:00
Jannis Braun 7ef1ded116 test(federation): lock reset admissibility for peer_reset_detected peers 2026-07-01 22:22:43 +02:00
Jannis Braun 45e1c88bdc feat(federation): reset detection (markPeerReset) via handshake + probe 2026-07-01 22:09:43 +02:00
Jannis Braun 3b1a0b64a3 feat(federation): relay envelope populates peer epoch baseline 2026-07-01 21:58:03 +02:00
Jannis Braun bf74aa8bb2 feat(federation): signed /api/federation/epoch endpoint + caller 2026-07-01 21:43:02 +02:00
Jannis Braun 538519fcd2 feat(federation): exchange + store peer epoch on handshake 2026-07-01 21:35:20 +02:00
Jannis Braun 7acf48d0a4 feat(federation): shared epoch types + getInstanceId() 2026-07-01 21:19:25 +02:00
Jannis Braun f481e1fe9e license: relicense to AGPL-3.0-only with commercial dual-license
- LICENSE -> verbatim GNU AGPL-3.0; add LICENSE-COMMERCIAL.md + SECURITY.md
- CLA -> exclusive-license grant (contributors keep copyright); add README
  anti-rugpull covenant + relicense record
- NOTICE / README / CONTRIBUTING / CLAUDE.md / package.json x5 updated;
  contact routed through GitHub (no email placeholders)
- AGPL section 13 source offer: operator-configurable BACKSPACE_SOURCE_URL +
  build-injected commit; sourceCodeUrl+commit on /api/instance/info;
  SourceCodeLink on login/register/settings/desktop; docs + .env.example updated
2026-07-01 16:38:22 +02:00
Jannis Braun 209aef7e9d fix(uploads): graceful fallback for browser-unplayable video (HEVC .mov)
macOS screen recordings are HEVC inside a .mov container, which Chromium,
Firefox and stock Electron can't decode. The file uploaded fine and a
server-side ffmpeg poster was generated, but inline <video> playback failed
silently — stuck at 0:00 with no error, since AttachmentRenderer had no error
handling. Root cause: the system had no concept of web-playability.

Server detects, client degrades:
- mediaPlayable.ts: classifyVideoPlayable(mimetype, codec) — tri-state
  (false = known-undecodable e.g. HEVC/ProRes, true = web codec in web
  container, null = unknown/optimistic). Never widens `false` beyond codecs
  that fail everywhere, so ffmpeg-less instances keep prior behaviour.
- probeMediaMeta now captures the video codec_name; the upload finish hook
  stores the verdict in the new attachments.playable column (migration 0007).
- Flag propagated through every serializer: space messages, DMs, WS, and
  federation relay (outbound + inbound) — federation-compatible.
- VideoAttachment component: playable===false renders a download card (poster
  + "Can't play here — download" + name/duration/size) with no dead-player
  flash; otherwise plays inline with an onError fallback to the same card.

Specs updated: uploads.md, database.md, federation.md.
2026-06-30 17:38:11 +02:00
Jannis Braun 4ad83a7cc1 feat(federation): POST /peers/:id/recheck — manual reachability probe 2026-06-26 13:53:57 +02:00
Jannis Braun 00a2876e96 fix(channels): newly created channel sometimes hidden until space reopened
The sidebar's visibleChannels filter is keyed on the channelPermissions
Map. Creating a channel raced two state updates: the optimistic create
(added to channels with no permission entry) and the channel_created WS
event (the only thing that set the permission). When the optimistic add
won the race, the WS handler hit its dedup guard, skipped setChannels,
and set the permission by mutating the Map in place — no new reference,
so visibleChannels never recomputed and the channel stayed hidden until
loadSpace rebuilt the maps (i.e. leaving and returning to the space).

Centralize the logic in a new upsertChannel store action that replaces
channels and channelPermissions with fresh references, used by both the
create path and the channel_created handler. Also return the creator's
computed myPermissions (and isPrivate) from POST so the channel renders
immediately from the response, independent of WS timing.

Adds spaceStore.upsertChannel.test.ts covering the reference-identity
regression and the optimistic-reconcile path.
2026-06-25 12:34:11 +02:00
Jannis Braun 8dd76f3435 Public-release prep: ELv2 license, README/CLA/NOTICE, SSRF safeFetch, identifier genericization, export tooling 2026-06-22 16:04:03 +02:00
Jannis Braun ae61786335 feat(security): remove hardcoded admin/admin123 seed; first registered user is admin 2026-06-20 02:07:47 +02:00
Jannis Braun 3c7bb02901 fix(dm): ownership transfer divergence after back-and-forth — canonicalize ownerHomeInstance + normalize authority checks
Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.

Three compounding fixes:

1. Receiver authority checks now compare via `normalizeOriginForCompare` so
   legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
   site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
   creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
   `processOwnershipTransferEvent` receiver storage) routes through it. Full URL
   is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
   `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
   present and leaves existing values untouched otherwise (legacy-server safe).
   Without this, `getOwnerInstanceForDm` returned the previous owner's home
   after a successful WS broadcast, routing the next owner-only op to the wrong
   instance.

Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.

Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
2026-05-10 22:38:03 +02:00
Jannis Braun b6842c5590 fix(dm): owner-only group DM ops accept federated target identification
Transferring ownership or kicking a member surfaced "Target user is not a
member of this DM channel" whenever the target was a federated user.

Root cause: the client passed `canonical.id` from `useCanonicalUserView`,
which returns the user's HOME id when the home view is in the userViews
cache. After owner-routing the request to the owner instance, that
instance's `dm_members.userId` (its own local replicated id) never
matched the home id, so `isDmMember` returned false. The same failure
mode applied across any cross-instance scenario where the
channel-serving instance and the owner-serving instance disagree on the
local replicated user id for the same federated user.

Fix: both endpoints now accept federated identification, mirroring the
existing pattern on `POST /api/dm/:id/members`:

- `POST /api/dm/:id/transfer` body: `{ newOwnerId? } | { homeUserId, homeInstance }`.
  Federated args win when both are supplied (strictly more specific).
- `DELETE /api/dm/:id/members/:targetUserId` reads optional
  `?homeInstance=<origin>` query; when present, the URL segment is
  treated as a homeUserId and resolved via `resolveOrCreateReplicatedUser`.

Client `api.dm.kickMember` and `api.dm.transferOwnership` gain an
optional `federated` argument; `DmRosterPanel` and `MobileGroupDmInfo`
pass it whenever the target has `homeUserId` + `homeInstance` populated.

Adds 5 server tests (3 transfer + 2 kick) covering federated targets,
the federated-wins-over-local precedence rule, and federated-non-member
rejection. Updates 2 client routing tests and 2 DmRosterPanel test
assertions for the new signature. Updates `docs/systems/dm-system.md`
and `docs/systems/api.md`.

Server: 965 tests pass (was 960). Web: 362 tests pass (was 360).
2026-05-10 22:09:14 +02:00
Jannis Braun 24240f24f4 fix(janitor): protect dm_channels.icon files from cleanup (owner + receiver)
getProfileReferencedFilenames() didn't include dm_channels.icon, so the
storage janitor deleted group DM icons within ~1 hour:

- Owner instance: PATCH /api/dm/:id leaves an attachments row with
  messageId=null and dmMessageId=null. After 1h, getUnlinkedAttachments
  flags it and cleanupStorage phase 2 deletes the file because the
  filename isn't in profileReferenced.

- Receiver instance: downloadProfileAsset writes the icon directly to
  uploadDir with no attachments row. cleanupStorage phase 1 treats it
  as orphaned and deletes it.

Fix: include dm_channels.icon (non-null, not soft-deleted, not http://)
in the profile-referenced set. Soft-deleted DMs are excluded so their
files still get reaped by cleanupSoftDeletedDmChannels. Absolute URLs
are skipped because they live on a remote instance.

Also mirror the avatar precedent at the PATCH endpoint by deleting the
new icon's standalone attachment row — the file is now protected via
dm_channels.icon, matching users.ts:473.
2026-05-10 21:18:58 +02:00
Jannis Braun 063ed2dd64 refactor(federation): extract normalizeIconForWire helper 2026-05-10 19:31:06 +02:00
Jannis Braun 04d7910077 feat(federation): bootstrap carries group name + icon + metadataUpdatedAt 2026-05-10 19:23:47 +02:00
Jannis Braun 7c8de29eff test(federation): lock kick authority behavior in member_remove receiver 2026-05-10 19:14:48 +02:00
Jannis Braun 78d2ab496d refactor(federation): drop dead export on downloadProfileAsset 2026-05-10 19:10:56 +02:00
Jannis Braun 240df49d1d feat(federation): processGroupMetadataUpdateEvent with receiver hardening 2026-05-10 19:04:00 +02:00
Jannis Braun 7f8d650cab refactor(server): extract transferGroupDmOwnership helper + wrap in transaction 2026-05-10 18:56:01 +02:00
Jannis Braun 35c720429e feat(server): POST /api/dm/:id/transfer — manual ownership transfer 2026-05-10 18:46:41 +02:00
Jannis Braun ce11fd12b5 refactor(server): extract evictUserFromDmVoiceRoom helper + drop redundant optional chains 2026-05-10 18:42:25 +02:00