Commit Graph
583 Commits
Author SHA1 Message Date
Jannis Braun 8cad964809 chore(test): cover processFriendRequestCreateEvent branches
Extends the existing happy-path test (one case) with 14 more covering
every branch of the receiver-side handler: missing payload, attribution
mismatch, tombstoned sender (silent accept), recipient_not_found
(including soft-deleted recipient), already-friends idempotency in both
column orderings, same-direction pending re-delivery idempotency,
declined-then-resent (status filter), reused replicated stub, profile
hydration with URL resolution, explicit and fallback createdAt, and
mixed-batch accept/reject isolation through processRelayEvents.
2026-04-26 23:58:45 +02:00
Jannis Braun 8cae4c0b7c chore(test): migrate federationPeering.test.ts to in-memory drizzle
The third describe block (`ensurePeered needs_attention handling`) was
the last federation test still using the legacy `vi.doMock` +
hand-crafted drizzle query-chain pattern. Convert it to the converged
in-memory better-sqlite3 + real drizzle pattern used by every other
federationPeering.* test (exemplar:
federationPeering.approvalToken.test.ts).

The first two describe blocks (`EnsurePeeredResult type`, `racePeering`)
did not use vi.doMock and remain byte-identical. The needs_attention
test's behavior is preserved 1:1: same `it(...)` description, same
assertions (status === 'rejected', error contains 'needs_attention',
fetch never called).

Module-level `vi.mock` calls now apply to the whole file, but the
type-only and racePeering tests don't exercise the mocked modules, so
there is no behavioral interaction.

No production code changes. No new test scenarios. No skipped/only/
commented blocks. All 352 server tests pass.
2026-04-26 23:42:08 +02:00
Jannis Braun 6ba8b9256b fix(federation): broadcast peering_subscription_changed on terminal state
Live verification caught a stale-UI bug: when admin denial / approval
fanout / janitor expiry cascaded subscriber rows away, only the
peering_notification_received WS event fired (which only refetches
the notification list). The user's pending-subscriptions section
stayed stale until manual refresh.

Each terminal-state path now also fires peering_subscription_changed
to affected users so their pending list refreshes alongside the new
notification. Fixed in:
- onPeerActivated.fanoutOutboundSubscribers (approval path)
- handleOutboundDeny (admin denial)
- cleanupExpiredApprovalRequests (janitor expiry; also adds the
  notification-received broadcast that was previously deferred to
  next-page-load only)
2026-04-26 23:20:31 +02:00
Jannis Braun e82ccbde62 fix(federation): tighten peering-notifications GET response to spec shape
db.select() returned every column including userId; spec §4.9
defined the response row WITHOUT userId. The leak is harmless today
(user queries their own rows) but expands the public API surface
beyond the spec, and would become part of the contract once Task 10
generates client types. Switching to explicit column projection.
2026-04-26 22:27:43 +02:00
Jannis Braun 4064d822cd fix(janitor): restore inbound /peer/denied expiry notification
Task 9's first pass replaced inbound expiry's signed /peer/denied
POST with a plain delete, citing symmetric independent expiry as
the design. The spec at §4.10 explicitly said 'inbound row cleanup
unchanged' — that was scope creep, not a fix. Inbound expiry now
preserves the pre-branch behavior verbatim: signed POST to remote,
delete only on success, retry on failure. Outbound expiry's fanout
logic (the actual Task 9 scope) is unchanged.
2026-04-26 22:22:34 +02:00
Jannis Braun 86ac54b913 feat(federation): peering-notifications endpoints + janitor expiry fanout
- GET /api/federation/peering-notifications (?unread=1 filter)
- POST /api/federation/peering-notifications/:id/read (per-row mark-read)
- POST /api/federation/peering-notifications/read-all (bulk mark-read,
  preserves already-read readAt; returns affected count)
- janitor: outbound expired rows fan out kind='expired' notifications
  to each subscriber before cascade-deleting parent (replaces Task 1's
  scaffolding 'continue' guard); inbound expired rows are deleted outright
  (both sides expire independently — no cross-instance network call)
- janitor: 30-day cleanup pass for read notifications
  (cleanupReadPeeringNotifications); unread rows persist indefinitely
- cleanupExpiredApprovalRequests is now sync (no async network IO)

Notes:
- No WS broadcast on janitor expiry — offline users see notifications on
  next GET, matching the persistence guarantee of the notifications table.
- The previous /peer/denied network call on inbound expiry is removed;
  symmetric per-side expiry now handles termination on both sides.

Tests: 12 new (3 + 4 endpoint cases on 3 endpoints; 2 outbound/inbound
janitor expiry cases + 1 not-yet-expired guard + 1 retention sweep);
247 server tests passing total.
2026-04-26 22:18:44 +02:00
Jannis Braun 45ab0ea2c8 feat(federation): peering-subscriptions GET and DELETE endpoints
User-facing endpoints for the pending-peering-approvals UI surface.
GET lists the user's own pending subscriber rows joined to parent
peering requests. DELETE cancels a single subscription; if it was
the last subscriber, cascade-deletes the parent (admin queue updates
via federation_peers_changed). Authorization: subscriber.userId must
match request.userId.
2026-04-26 22:09:41 +02:00
Jannis Braun 42355ee889 feat(federation): direction-branched approve and deny for outbound queue
- /approve on outbound: generates HMAC, sends /peer/accept to remote.
  200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval,
  capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean
  up peer row, leave queue for admin retry.
- /deny on outbound: fans out kind='denied' notifications, cascade-deletes
  parent + subscribers, broadcasts admin event. No remote network call.
- /approve and /deny on inbound: existing behavior preserved verbatim.
- GET /approval-requests: response includes direction; outbound rows
  carry subscribers[] (joined with users.username, possibly empty).
- Removes Task 1's temporary /deny scaffolding guard now that the
  direction-branched dispatcher handles outbound rows correctly.
- Updates docs/systems/federation.md to describe direction-branched flow.
2026-04-26 22:01:14 +02:00
Jannis Braun 7cc360eb11 feat(federation): fanout outbound subscribers on peer activation (any path)
The fanoutOutboundSubscribers helper is called from onPeerActivated,
the single point all peer activations flow through (queue approval,
/peer/initiate, autoAccept=1 remote, mutual-approval token verification).
Subscribers see kind='approved' notification, parent + subscriber rows
cascade-delete. Critical: cleanup triggers on status->active, not on
the local approve action — when the remote also gates, the peer goes
to awaiting_approval first and subscribers must remain queued.
2026-04-26 21:46:32 +02:00
Jannis Braun 4d4dc383d7 feat(federation): pass explicit intent at every ensurePeered call site
- social.ts friend-add: user_action, with 409 peer_pending_local_admin
  when gate fires
- /peer/ensure: user_action, surfaces peeringStatus: 'admin_required'
- sendCallRelay (typing warm-up + call relay): system intent
- federationWorker resolvePendingPeers: system intent (defensive — gate
  is unreachable from here since pending rows already exist)
- CallRelayFailureReason: peer_admin_required added (mapped to
  peer_transient_failure on the user-facing event surface, since system
  intent should never legitimately surface admin_required)
- Test files: thread intent arg through racePeering and ensurePeered
  calls (positional shift from racePeering signature change)
- outboundGate.test.ts: tighten noUncheckedIndexedAccess access via
  non-null assertions after toHaveLength()
- docs/systems/social.md: peer_pending_local_admin error code documented
2026-04-26 21:37:19 +02:00
Jannis Braun ef80e3b416 test(federation): outbound gate behavior across autoAccept settings and intents 2026-04-26 21:28:06 +02:00
Jannis Braun 6ee8663e9e fix(federation): narrow trust-guard query to inbound rows only
The pre-existing trust-guard's query filtered peer_approval_requests by
origin only. After Task 3 added outbound rows to the same table, the
guard started matching the user's own queued outbound row on retry,
returning 'rejected' with a misleading 'admin must resolve pending
approval' copy instead of the intended 'admin_required'. The variable
name (pendingInbound) and comment block already described the intent
as inbound-only — the query just didn't match. Adding direction='inbound'
to the where clause restores the intended behavior.
2026-04-26 21:23:55 +02:00
Jannis Braun 50bc510e8d feat(federation): outbound gate in ensurePeered with required intent argument
- New 'admin_required' EnsurePeeredResult variant.
- Required intent argument (no optional default) prevents future
  user-initiated callers from silently getting system behavior.
- Gate runs only when no peer row exists; toggling autoAccept later
  does not retroactively gate established peer rows.
- queueOutboundApproval helper upserts parent + subscriber rows and
  broadcasts to admins + user.
- Schema enum-narrows direction ('inbound' | 'outbound') and
  notifications.kind ('approved' | 'denied' | 'expired') at the column
  level; drizzle generate confirmed no migration delta.
- Existing call sites now fail typecheck — fixed in Task 5.
2026-04-26 21:18:43 +02:00
Jannis Braun 57462e69df docs(schema): comment CHECK invariant on peer_approval_requests
drizzle's snapshot does not encode SQL-level CHECK constraints. Without
this comment, a future migration that recreates the table for unrelated
reasons would silently drop the (direction='inbound' → hmac_secret
NOT NULL) invariant.
2026-04-26 21:07:08 +02:00
Jannis Braun b687bfe721 feat(federation): schema for outbound peering gate (direction column, subscribers, notifications) 2026-04-26 21:02:27 +02:00
Jannis Braun fa7a5f831d feat(federation): /approve forwards token + /approve & /peer/initiate capture on 202 + 200 clear
Three changes to keep the outbound /peer/accept call sites consistent
with the new approval-token mechanism:

1. /approve outbound body now forwards approvalToken from the queued
   peer_approval_requests row when present. Receiver's awaiting_approval
   branch verifies it and promotes mutual approval. Legacy null-token
   rows omit the field; receiver falls through autoAccept gate. Spec §3.7.

2. /approve and /peer/initiate 202 paths now capture the approvalToken
   returned by the remote and store it on the local federation_peers row.
   Without this, the symmetric autoAccept=0 mutual-approval flow could
   not verify on the eventual return /peer/accept. Spec §3.7.

3. /approve and /peer/initiate 200 paths now include approvalToken=null
   in the activation UPDATE — single-use lifecycle hygiene per §3.2.

Test coverage: +6 tests (4 in approveOutbound, 2 in peerInitiateOutbound).
Total: 301 → 307. Web tsc clean.
2026-04-26 11:52:04 +02:00
Jannis Braun 058eb992e4 feat(federation): verify approval token before awaiting_approval → active
Receiver-side defense — closes the trust-bypass class the cheap fix
(4533e36) cannot cover. The /peer/accept handler's awaiting_approval
branch now requires an approvalToken matching the one stored on the
local peer row before promoting to active. Without a match:
- autoAccept=0 falls through to queueApprovalRequest (no bypass; new
  approval-request queued, existing awaiting_approval row untouched).
- autoAccept=1 falls back to permissive promotion (no regression vs
  prior behavior, since autoAccept=1 would accept any inbound regardless).

Successful match also deletes any stale approval-request row for the
origin to prevent debris accumulation from prior bypass attempts.

Refactor: queueing path extracted to a top-level queueApprovalRequest()
helper so both the no-existing-peer case and the awaiting_approval
mismatch fallback share one implementation. Helper generates a fresh
single-use token on every call.

Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason
to distinguish the autoAccept=1 fallback path from the verified path
in onPeerActivated audit logs.

Spec §3.5, §3.6.

Test counts: 289 → 301 (+12).
2026-04-26 11:48:33 +02:00
Jannis Braun 9e078c44ba feat(federation): performHandshake captures + clears approval token
- 202 response: parse approvalToken from body and store on the local
  federation_peers row alongside status='awaiting_approval'. Legacy
  receivers that omit the field result in null stored token, handled
  gracefully by the verification logic landing in subsequent commits.
- 200 response: clear approvalToken in the same UPDATE that sets
  status='active' (single-use consumption per spec §3.2).

Test coverage: 4 tests in federationPeering.approvalToken.test.ts covering
JSON token, missing token (legacy), non-JSON body, and 200 clear after
prior token storage.
2026-04-26 11:45:16 +02:00
Jannis Braun 2d8ecd0131 feat(federation): add approval_token columns to peer schema
Nullable text columns on federation_peers and peer_approval_requests.
Existing rows degrade gracefully (NULL token) per spec §5; the verification
logic landing in subsequent commits routes legacy null-token state through
the existing autoAccept gate.
2026-04-26 11:44:06 +02:00
Jannis Braun 11c5a2bf06 fix(federation): refuse outbound handshake when inbound approval pending
Closes the auto-reconnect trust-bypass: any code path calling
ensurePeered(remote) on an instance with autoAcceptPeering=0 could
previously bypass the admin gate by initiating a fresh handshake to the
remote, which the remote then accepted against its existing
awaiting_approval row.

The trigger surfaced was stores/instanceStore.ts:1010 — the silent
.catch(() => {}) auto-reconnect that fires for any user with the
remote in their replicatedInstances (commonly: any admin). Anyone with
that profile reloading their session activated peering on both sides
without any admin approval action.

Surgical fix: ensurePeered now returns rejected when an unresolved
inbound peer_approval_requests row exists for the target origin. The
legitimate admin-approve flow (routes/federation.ts:1089) does not call
ensurePeered; it deletes the approval-request and does its own direct
fetch to /peer/accept, so this check does not block legitimate approvals.

The receiver-side trust assumption at routes/federation.ts:619-645
(awaiting_approval branch in /peer/accept) still has the same flaw
— an adversarial peer that knows the timing could re-handshake at the
right moment to flip the receiver to active. That deeper trust-model
rework is plan-grade work tracked at internal notes
2026-04-26-peer-handshake-trust-model.md.
2026-04-26 00:12:42 +02:00
Jannis Braun 0677c21ab9 test(federation): buildFriendContextId determinism (cross-instance invariant)
The friend contextId must be byte-identical on both peers for a given
canonical pair regardless of argument order. Initial-sync backfill keys
on contextId; if the two sides computed different values for the same
friendship, missed events would never reconcile.
2026-04-25 22:24:04 +02:00
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 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 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 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