Commit Graph
1404 Commits
Author SHA1 Message Date
Jannis Braun bd70950de7 test(federation): receiver accepts home-queued friend_request_create 2026-04-25 22:23:04 +02:00
Jannis Braun d88d369a82 fix(social): friend_request_sent local broadcast must carry target profile
The local branch was reusing friendRequestPayload (built for
friend_request_received with user=sender) for the sender-side
friend_request_sent broadcast. Tab B on the sender would render
the sender's own avatar where the target's should appear.
Match the federated branch — sent broadcast carries target.
2026-04-25 22:18:47 +02:00
Jannis Braun 1ad1fd1b0c feat(social): broadcast friend_request_sent on local request creation 2026-04-25 22:15:57 +02:00
Jannis Braun bf13e2a223 feat(social): federated branch — authority + self-friend + idempotency
Adds direction-aware idempotency and already-friends checks to
handleFederatedFriendRequest (after stub hydration, before transaction),
plus 6 tests covering authority defense, bare-host normalization, cannot_friend_self,
already_friends, same-direction idempotent 200, and opposite-direction 409.
2026-04-25 22:12:08 +02:00
Jannis Braun 72f4b170f6 test(social): federated branch peer-status + lookup-failure mappings 2026-04-25 22:09:42 +02:00
Jannis Braun 0574a10ff3 feat(social): federated branch for POST /api/social/requests (happy path)
Refactors the POST handler into handleLocalFriendRequest + handleFederatedFriendRequest helpers. The federated branch resolves the target domain, ensures peering, looks up the remote user, creates/hydrates a replicated stub, and writes a transactional (friend_requests + mutation_log + outbox) event with relayMessageId set. Also exports hydrateReplicatedUserProfile from federation.ts and adds the T11 happy-path test.
2026-04-25 22:04:36 +02:00
Jannis Braun 08872f1e08 feat(federation): rollbackFriendRequestCreate + side-effect registration 2026-04-25 21:51:04 +02:00
Jannis Braun 9e3417485c feat(federation-worker): treat receiver-ack 4xx reasons as terminal + invoke rollback 2026-04-25 21:46:28 +02:00
Jannis Braun be3eb5284e feat(federation): permanent-failure callback registry 2026-04-25 21:42:57 +02:00
Jannis Braun 03737c0955 feat(federation): resolveOriginFromHostname helper 2026-04-25 21:41:25 +02:00
Jannis Braun fbe2fb370b feat(federation): lookupRemoteUser outbound HMAC helper 2026-04-25 21:37:41 +02:00
Jannis Braun 03cef0e6b3 feat(federation): POST /api/federation/users/lookup endpoint 2026-04-25 21:32:49 +02:00
Jannis Braun 069a1525ea feat(federation): per-peer rate limiter for user lookup (60/min) 2026-04-25 21:28:44 +02:00
Jannis Braun bd9657c5d2 fix(db): commit drizzle meta journal/snapshot for 0001 migration
The T3 commit (e4086fe) generated the SQL migration but missed the
drizzle meta files that track migration state. Without these, future
db:generate runs would re-emit or skew migration ordering.
2026-04-25 21:22:56 +02:00
Jannis Braun e4086fecff feat(db): add relay_message_id column to friend_requests
Nullable indexed column tracking the entityId of the originating relay
event for federated friend requests. Used by the rollback hook in
federationRollback.ts to locate and delete rows when a federated
friend_request_create is permanently rejected (spec §5).
2026-04-25 21:22:25 +02:00
Jannis Braun 1a9dc547c4 feat(shared): types for federation user lookup + WS events
Add FederationUserLookupRequest, FederationUserLookupProfile, and
FederationUserLookupResponse for the peer lookup endpoint contract.
Add friend_request_sent and friend_request_relay_failed ServerEvent
variants alongside existing friend_request_* cases.
2026-04-25 21:19:40 +02:00
Jannis Braun 1b98b052a1 chore(federation): T1 review polish — top-of-file import + undefined test case 2026-04-25 21:18:11 +02:00
Jannis Braun 1b63ca538e feat(federation): normalizeOriginForCompare helper 2026-04-25 21:14:58 +02:00
Jannis Braun 92a8a6a727 chore(friends): tighten Direct-Add gate consistency
Per code-review: directAddDisplay now reads directAt === -1
instead of re-deriving includes('@'); add a one-line comment on
showDirectAdd so the predicate's intent is obvious at first read.
2026-04-25 19:34:29 +02:00
Jannis Braun da98d78489 feat(friends): always-visible Direct-Add row with resolved-form display
The Send-Friend-Request action row in the Add Friend tab previously
appeared only when the typed query contained a non-edge @, leaving
no way to fire a blind request for a bare local handle. Widen the
gate to allow non-empty bare handles, keep the malformed @ shapes
(@, @bob, bob@) hidden. When the typed query has no @, display the
resolved form <query>@<window.location.host> so the user sees which
instance the request will hit. Submission string is unchanged.

Updates FriendsPage.test.tsx: inverts the now-stale 'does not show
Direct Add row for plain usernames' test into the new positive
assertion, and adds a separate test for the malformed @ shapes.
2026-04-25 19:32:03 +02:00
Jannis Braun 781e293cf7 chore(social-client): drop redundant comment, harden defineProperty
Per code-review:
- Drop the inline comment in sendFriendRequest; the commit message
  for the prior commit already covers the why and CLAUDE.md prefers
  no comments when the code is self-explanatory.
- Add writable: true to the window.location defineProperty in the
  test so re-firing beforeEach across jsdom version drift is safe.
2026-04-25 19:29:06 +02:00
Jannis Braun fba1f0b87d fix(social-client): lowercase parsed @domain in sendFriendRequest
Hostnames are case-insensitive (RFC 4343), and both right-hand
sides of the routing comparisons (window.location.host and
URL.host) are already canonical lowercase. The user-typed domain
substring was compared with strict ===, so ORBIT.ddns.net
failed to match an existing connected peer and popped a spurious
Connect Instance modal. Normalize at parse time.
2026-04-25 19:20:07 +02:00
Jannis Braun b5b48e407e test(social): note the shared-state harness contract
Per code-review suggestion: add a brief comment explaining why
module-level sqlite/testDb/app reassignment works (mock getter
closes over the current binding) and what would break it
(top-level it, .concurrent describe). Prevents a future foot-gun.
2026-04-25 19:17:50 +02:00
Jannis Braun 5f5590a3c9 fix(social): normalize username lookup on POST /api/social/requests
Registration canonicalizes usernames to lowercase (auth.ts:32),
but the friend-request endpoint compared with strict eq() against
raw user input, so 'Bob' returned 404 even when 'bob' existed.
Trim and lowercase before lookup, matching the rest of the auth
boundary. Empty-after-trim now returns 400 (was: 404).

Tests appended to social.test.ts as a second describe block
sharing the harness from Task 1.
2026-04-25 19:14:31 +02:00
Jannis Braun e99e0e7862 fix(social): tighten comment on federated-stub filter
Drop the file-path reference per code-review suggestion (paths in
comments rot); keep the load-bearing why — substring of the domain
matches every stub because stubs are stored as <homeUserId>@<domain>.
2026-04-25 19:12:40 +02:00
Jannis Braun b37d3bfa29 fix(social): apply discover-equivalent filters to /api/social/search
Tombstoned users, replicated federated stubs, and users with
discoverable=0 were all surfacing in Add Friend search results.
Add the three WHERE filters that /api/social/discover already
applies. Federated users continue to be surfaced via the
client-side cross-instance fan-out in socialStore.searchUsers.

New tests: social.test.ts covers all five filter cases plus
existing self-exclusion and displayName-match behaviours.
2026-04-25 18:42:05 +02:00
Jannis Braun d659637930 docs(message-list): note smooth-scroll exclusion; point sentinel comment at subsystem doc
Reviewer caught two small gaps after Task 3:
- Effect A's smooth-scroll path on new messages is intentionally NOT
  instrumented with the sentinel (the animation lands asynchronously
  across frames; no intermediate scrollTop is worth pinning to). The
  doc now records this so the reader's intuition matches the code.
- The sentinel-branch comment in MessageList.tsx pointed at "spec §2",
  which is the planning doc rather than the durable subsystem spec.
  Pointed at docs/systems/message-list.md instead.
2026-04-25 13:11:05 +02:00
Jannis Braun d18845acb5 fix(web): close handleScroll race that disabled auto-bottom on cold-cache image load
Tracks the post-clamp scrollTop of every programmatic scroll-to-bottom in
lastProgrammaticBottomScrollRef. handleScroll skips the at-bottom flip and
re-pins when the event's scrollTop matches the sentinel — i.e., the event
was queued by our own command and layout grew underneath. User scrolls
break the match (scrollTop changes) and flow through the normal path.

This complements the 2026-03-25 race-fix (which gated auxiliary effects on
isAtBottomRef) by also preventing handleScroll from flipping that ref to
false based on a post-growth distance measurement of our own scroll.
2026-04-25 13:02:01 +02:00
Jannis Braun 84ec8e04b7 fix(web): restore known-dimension reservation in ImageEmbed without letterbox fallback
Restores the dimension reservation reverted in dae6f2d, scoped to
the embed.width && embed.height case only. No fallback aspect-ratio
when dims are null — that was the source of the dark letterbox bars
on Tenor/Klipy GIFs and OG-less images that triggered the revert.

The server-side probe in embedResolver.ts:196 already populates dims
for all image-type embeds; this change makes the client honor them.
2026-04-25 12:53:11 +02:00
Jannis Braun f0d6bf2ed9 fix(federation): persist remote instance_name from approval-requests/:id/approve handshake response
Third initiator path that calls remote /peer/accept. Mirrors performHandshake (auto-peer) and /peer/initiate (admin-initiate) — same try/catch parse, same null-or-non-empty-string guard. Caught in final review of #33; same root cause as Bug #1, bundled rather than fragmented to a new backlog item.
2026-04-25 11:18:12 +02:00
Jannis Braun a9bf5aeb53 fix(dm): include federatedId in POST /api/dm idempotent existing-DM response
Fresh-create returned {id, ownerId, federatedId, createdAt, members, lastMessage}; the existing-DM path returned the same shape minus federatedId. Inconsistency was a footgun for any future feature reading federatedId from this response — fresh-create tests would pass while idempotent path would break. One-line addition to the result builder.
2026-04-25 00:59:16 +02:00
Jannis Braun 618056659e fix(federation): persist remote instance_name from /peer/initiate handshake response
Mirrors the previous performHandshake fix for the admin-initiated path. /peer/initiate now parses the remote's instanceName from the /peer/accept response body and writes it alongside status='active'.
2026-04-25 00:48:42 +02:00
Jannis Braun 18d6b0acfa fix(federation): persist remote instance_name when ensurePeered/performHandshake succeeds
Initiator side of the bidirectional handshake exchange. /peer/accept now returns instanceName in the response body (prior commit); performHandshake parses it and persists alongside status='active'. Tolerates missing field (older peers) and non-JSON bodies.
2026-04-25 00:45:06 +02:00
Jannis Braun eba16e16a3 fix(federation): include own instanceName in /peer/accept response body
Bidirectional handshake exchange. Today the responder learns the initiator's instance name from request body but the initiator never learns the responder's. Adding {instanceName} to the response body lets the initiator persist it on its side (next commit). Field is optional so older peers omitting it cause no ill effect.
2026-04-25 00:42:11 +02:00
Jannis Braun 7fe9476e57 fix(federation): persist peer instance_name on /peer/accept activation paths
Previously /peer/accept read body.instanceName only when queueing for admin approval. The four paths that mutate federation_peers (rejected→active override, awaiting_approval→active, pending→active, new-peer create) all wrote status='active' without persisting instance_name. Result: every peer established via direct handshake had instance_name = NULL forever. Anywhere peerLabel was rendered fell back to origin hostname.

Active/needs_attention idempotent early-return path deliberately left alone — same security posture that already refuses to overwrite hmac_secret from unauthenticated requests on already-active peers.

Existing live NULL rows are repaired post-deploy via manual UPDATE statements (see plan).
2026-04-25 00:35:42 +02:00
Jannis Braun ab5e7c8839 refactor(server): delete baseline + heal functions now unreachable under single-baseline history
baselineExistingInstall, healInitialSchemaDrift, and healRenamedColumns
existed only because of pre-squash intermediate states: dev DBs drifted
against drizzle-kit's assumed 0000 baseline, or carried pre-rename
columns from the old manual migration system. Under a single squashed
baseline there are no intermediate states to drift against, and these
functions are unreachable.

initDatabase() is now: pragmas -> drizzle() -> migrate() -> ensureDefaults().
ensureDefaults stays (idempotent startup seeding for settings row, worker
ID, first-admin promotion).

Refs backlog #31 Phase 2.
2026-04-24 23:33:35 +02:00
Jannis Braun 707d3217f1 refactor(server): squash drizzle migrations 0000-0004 into single baseline
Generated by pnpm db:generate against the current schema.ts — replaces
five historical migrations (0000_initial, 0001_clear_earthquake,
0002_peer_approval_requests, 0003_classy_loki, 0004_cooing_black_knight)
with one baseline that matches the schema shape all five produced together.

Pi + VM __drizzle_migrations rows will be rewritten per the Phase 2
surgery procedure before this deploys to either instance; DBs already
match the new baseline, no DDL runs. Phase 1 audit verified no still-present
bugs depend on any of the squashed migrations.

Refs backlog #31 Phase 2.
2026-04-24 23:26:20 +02:00
Jannis Braun 2dbd2b9b9f test(server): add undeliverable:[] to processRelayEvents mock (#18)
Follow-up from Task 2 code review. Keeps the test mock aligned with
the widened return type even though vi.mock doesn't structurally
typecheck the factory.
2026-04-24 21:24:18 +02:00
Jannis Braun b16ece93b8 feat(web): no_recipient toast copy arm (#18)
buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone."
for the single-failure terminal case; multi-failure + non-terminal paths
fall through to existing lines (which already fold the new reason in by
peer label). TDD — four new assertions.
2026-04-24 21:15:21 +02:00
Jannis Braun 26a4925032 feat(server): reclassify undeliverable targeted-peer as no_recipient failure (#18)
sendFederatedCallStart now treats a 200-with-undeliverable-messageId as
a peer-failure instead of unconditional success. Feeds the existing
failures[] array and terminal-determination machinery from #16.
New sendFederatedCallStartForTest export mirrors the existing
handleDm*ForTest pattern. TDD — three tests cover single-peer terminal
no_recipient, group-DM mixed delivered+undeliverable non-terminal, and
the happy-path (empty undeliverable → no event).

Also hardens sendCallRelay's response parse: validates undeliverable
is an Array and entries are well-shaped, logs protocol drift at warn/debug
rather than silently falling back to old-peer semantics.
2026-04-24 21:11:11 +02:00
Jannis Braun 7d2137b6d4 feat(server): sendCallRelay surfaces undeliverable messageIds (#18)
CallRelayResult success arm gains undeliverable: string[]. sendCallRelay
parses FederationRelayResponse.undeliverable (when present) and returns
the messageIds so sendFederatedCallStart can reclassify per-peer results.
Old peers that omit the field → empty array → today's behavior.
TDD — three tests cover old-peer, new-peer-with-undeliverable, and 5xx paths.
2026-04-24 21:05:28 +02:00
Jannis Braun 7533d8ca14 feat(server): Path A connection gate + undeliverable on zero ringee (#18)
processDmCallStartEvent Path A now skips offline local members (matching
Path B's pre-existing per-member check) and pushes undeliverable when no
member could be rung, instead of creating a stranded FederatedCallEntry.
TDD — two new tests cover zero-online and mixed-online cases.
2026-04-24 21:01:19 +02:00
Jannis Braun 792634c2cf feat(server): Path B zero-match → undeliverable ack (#18)
processDmCallStartEvent Path B no longer silently accepts when no local
participant is reachable. Pushes {messageId, reason: 'no_recipient'} to
the undeliverable ack bucket so the caller can surface fast-fail.
TDD — test asserts undeliverable push + no FederatedCallEntry.
2026-04-24 19:34:34 +02:00
Jannis Braun 0057cb4d42 refactor(server): thread undeliverable collector through processRelayEvents (#18)
Additive plumbing. No behavior change — every existing event-type path
continues to push to accepted/rejected only. Response serializes the new
bucket only when non-empty (byte-identical responses in the normal case).
Tasks 3-4 add actual undeliverable pushes for dm_call_start paths.
2026-04-24 19:17:27 +02:00
Jannis Braun 3988c5823a feat(shared): add no_recipient reason + undeliverable response field (#18)
Additive protocol extension. No consumers yet — follow-up commits wire
the new bucket into the relay endpoint, sendCallRelay, sendFederatedCallStart,
and the toast copy.
2026-04-24 19:12:54 +02:00
Jannis Braun 5c94bc7669 refactor(server): remove unused admin_reset PeerDeactivationReason
The admin reset endpoint (DELETE-pattern gated on peer.status !== 'needs_attention')
doesn't transition status — it deletes the row of an already-deactivated peer.
onPeerDeactivated already fired at the earlier needs_attention transition, so
the reset site correctly has no hook. The enum value was defensive-unused; per
project principles (no backwards-compat shims, no placeholders) drop it.
2026-04-24 01:07:47 +02:00
Jannis Braun 5e509cf3df feat(web): host_unreachable phase copy for dm_call_undeliverable (TDD) 2026-04-24 00:51:30 +02:00
Jannis Braun 314df6c5a1 feat(server): 30s federated-call sentinel worker (TDD) 2026-04-24 00:49:04 +02:00
Jannis Braun 71445c5f27 feat(server): wire onPeerDeactivated at admin revoke/reset sites
Admin-revoke endpoint (DELETE /api/federation/peers/:id): fires
onPeerDeactivated(id, 'admin_revoked') after the status write to
'revoked', evicting any in-flight federated calls for the now-revoked
peer.

Admin-reset endpoint (POST /api/federation/peers/:id/reset): hook
SKIPPED. The reset endpoint is guarded to only run when status is
already 'needs_attention' (active peers are rejected at the boundary
with a 400). Because the peer was already deactivated before reset is
called, onPeerDeactivated was already fired at the active→needs_attention
transition. The reset deletes the row entirely rather than writing a new
status; it does not represent a transition OUT OF active, so wiring it
here would be a semantic error — double-evicting an already-deactivated
peer.
2026-04-24 00:45:02 +02:00
Jannis Braun 8e6639648e feat(server): wire onPeerDeactivated on performHandshake 403 rejection 2026-04-24 00:43:32 +02:00