Commit Graph
224 Commits
Author SHA1 Message Date
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 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 6c68b4b574 feat(federation): buildRelayPayload forwards system-typed messages 2026-04-29 21:30:01 +02:00
Jannis Braun 33547038f4 feat(invites): expose lastRedeemedAt on InviteLinkSummary 2026-04-29 02:33:49 +02:00
Jannis Braun b775e3bcc9 refactor(invites): typed InviteUnavailableReason + real rollback test
Quality-review polish on Task 8:

1. InviteUnavailableError gains a typed public readonly `reason` field
   (the union 'not found' | 'revoked' | 'expired' | 'exhausted'). Task 11
   route handler can switch on the discriminant to produce user-facing
   copy without parsing the message string.

2. The original "aborts transaction if insertUser throws" test was
   vacuous — the callback threw before any DB write, so SQLite ROLLBACK
   never fired and the post-conditions were trivially true. Replaced
   with two tests: one that explicitly validates the synchronous
   short-circuit (no DB work happens at all), and a second that writes
   a real users row inside the callback then throws AFTER the write,
   proving the SQLite ROLLBACK actually reverts the in-callback write.
2026-04-28 20:32:42 +02:00
Jannis Braun 95737ba405 feat(invites): redeemInvite (atomic txn) + deleteInvite 2026-04-28 20:26:57 +02:00
Jannis Braun b7557aa6fa test(invites): document empty-updates guard + Path A rollback test
Quality-review polish: a one-line comment over the empty-updates guard
in reinstateInvite explains why removing it would re-leak a confusing
Drizzle error. A new test verifies that when Path A (revoked->active)
fails its post-state check, the original token is preserved by the
SQLite transaction rollback (not replaced by the would-be new token).
2026-04-28 20:24:15 +02:00
Jannis Braun ee405cba7c feat(invites): reinstateInvite with revoked-vs-archived branching 2026-04-28 20:20:13 +02:00
Jannis Braun 45b2eabed3 refactor(invites): thread tx through resolveCreatorUsername + tighten test assertions
Inside patchInvite/revokeInvite txn bodies, all reads now go through
the tx proxy. Pre-Task-7 hygiene: locks in the consistent pattern that
reinstateInvite (Task 7) and redeemInvite (Task 8) will copy.

Createinvite test failures now pin to InviteValidationError, catching
regressions where the wrong error class would otherwise pass silently.
2026-04-28 20:17:41 +02:00
Jannis Braun 23338419df feat(invites): patchInvite + revokeInvite with txn re-derive + foldUsername helper
Both functions wrap their read-modify-write in a Drizzle better-sqlite3
db.transaction((tx) => ...) with in-txn re-fetch so concurrent admin
mutations are serialized by SQLite's writer lock.

- patchInvite: 404 on missing, 409 on revoked, 400 on maxUses < usedCount,
  allows expiresAt to be moved into the past (effective soft-shut).
- revokeInvite: 404 on missing, 409 on already-revoked (explicit reject,
  not silent no-op).

Also extracts foldUsername() to collapse the duplicated
(username, isDeleted) -> display string fold across resolveCreatorUsername,
listInvites, and listRedemptions (deferred refactor from Task 5 review).

Note: the plan's example used db.transaction(cb)() with an IIFE,
which is the raw better-sqlite3 signature. Drizzle's wrapper
returns the callback's return value directly, so we use the
(tx) => ... form consistent with the rest of the codebase
(userDeletion, federation, channels, etc.).

Tests: 36 invite-service tests pass (27 prior + 9 new).
Full server suite: 40 files / 311 tests pass.
2026-04-28 20:11:47 +02:00
Jannis Braun ce5d4c10c8 feat(invites): listInvites + listRedemptions with creator/current JOINs
Adds two query helpers to inviteService:

- listInvites(filter): single-query LEFT JOIN against users to surface
  createdByUsername, with status filtered in TS via the canonical
  inviteStatus() derivation. Avoids N+1 the spec calls out (§3.1).
  'archived' = expired | exhausted | revoked. Sort: createdAt DESC.

- listRedemptions(inviteId): LEFT JOIN against users via userId to
  expose currentUsername alongside the registrantUsername snapshot.
  Three null-handling branches per spec §3.1: live (username),
  tombstoned ('Deleted User', isDeleted=true), and hard-deleted
  (userId null, currentUsername null, isDeleted false).
  Sort: redeemedAt DESC.

Also fixes a mistitled DB-miss test in getInviteByToken: the original
'returns null when token not found' used a 24-char string that fails
the format regex *before* the DB lookup. Split into two tests covering
both the format-reject path and the well-formed-but-missing path.

40 files / 302 tests passing.
2026-04-28 20:04:18 +02:00
Jannis Braun daf2e958f7 feat(invites): createInvite + getInviteByToken with validation 2026-04-28 19:58:03 +02:00
Jannis Braun 967c62d488 feat(invites): inviteStatus derivation + token generator 2026-04-28 19:52:16 +02:00
Jannis Braun f17c46c77f fix(presence): reset stale users.status on boot; drop REST-login online write
users.status was only flipped back to offline by the WebSocket disconnect
path (5s grace timer in ConnectionManager). Process exits (deploy/crash/OOM)
lose those in-memory timers, freezing any non-offline row at its last value
and making the user appear permanently online to friends and space co-members.
Confirmed in production on the Pi instance: a user appeared online for ~3
days with no live socket.

Add resetStalePresenceOnBoot() in utils/presenceBoot.ts and call it from
index.ts after getDb()/seedDatabase() and before WebSocket route registration.
The reset is federation-safe: it only updates rows where home_instance IS
NULL (replicated stubs are projections of remote presence and must not be
stomped) and is_deleted = 0 (tombstoned users are excluded from broadcasts).

Also remove the redundant status='online' write from POST /api/auth/login.
A successful REST login does not imply a live socket; the WS auth handshake
is the single source of truth. Login alone could otherwise produce the same
stuck-online row when a client logs in and never establishes a WS.

Tests cover: locally-homed online/idle/dnd reset, replicated rows untouched,
tombstoned rows untouched, idempotence, mixed populations.

Updates docs/systems/activity-presence.md (Connect/Disconnect Flow, new Boot
Reset section) and docs/systems/auth.md (login no longer mutates status).
2026-04-27 00:35:36 +02:00
Jannis Braun b698ded47d fix(federation): harden processFriendRequestCreateEvent receiver-side
Two correctness/defense fixes plus regression tests in the existing
in-memory drizzle test file.

1. Reverse-direction idempotency. The sender-side path in social.ts
   checks BOTH directions of friend_requests and returns 409
   incoming_request_exists when an opposite-direction row exists. The
   receiver only matched from->to, so cross-fire (alice@A and bob@B both
   click "add friend" near-simultaneously) produced two opposite
   pending rows on each instance. The receiver now silent-accepts when
   either direction matches a pending row, mirroring the sender's
   both-direction check.

2. Self-target guard (defense-in-depth). Reject events whose
   from-identity equals to-identity (after normalizeOriginForCompare)
   with a new receiver-acknowledged 4xx code self_target_invalid.
   Sender's local cannot_friend_self should catch this, but the
   receiver does not trust upstream validation. Added to
   TERMINAL_REJECTION_REASONS so the standard rollback fires
   (mapped client-side to peer_rejected). Logged at console.warn.

Spec updates: social.md inbound contract now documents both-direction
idempotency and the self-target guard; federation.md and the
s2s-friend-add design spec list the new terminal rejection reason.
2026-04-27 00:07:52 +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 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 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 b687bfe721 feat(federation): schema for outbound peering gate (direction column, subscribers, notifications) 2026-04-26 21:02:27 +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 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 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 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 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 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 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 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 314df6c5a1 feat(server): 30s federated-call sentinel worker (TDD) 2026-04-24 00:49:04 +02:00
Jannis Braun 8e6639648e feat(server): wire onPeerDeactivated on performHandshake 403 rejection 2026-04-24 00:43:32 +02:00
Jannis Braun 743fdcac97 feat(server): wire onPeerDeactivated at federationWorker peer-deactivation sites 2026-04-24 00:42:20 +02:00
Jannis Braun 0a8949fbfb feat(server): onPeerDeactivated utility mirrors onPeerActivated (TDD) 2026-04-24 00:40:42 +02:00
Jannis Braun f6252b8ce1 feat(server): fan dm_call_end out on host ring timeout 2026-04-23 23:19:42 +02:00
Jannis Braun 6ff983b46c fix(federation): treat duplicate rejection as terminal in outbox worker
Duplicate rejection means the peer already has the message (e.g.,
delivered earlier via outbox AND pulled via sync in the same
window). Retrying will fail identically forever until TTL expires.

Before this patch: duplicate-rejected outbox entries were retained
with attempts++ and exponential backoff, creating log noise and
outbox bloat for up to 30 days.

After: duplicate-rejected entityIds join the terminal set alongside
accepted ones and are deleted from the outbox. Logged at info level
('outbox entry removed (terminal)') to distinguish from warn-level
transient-rejection retries.

Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; some may also be terminal but are
deferred until observed accumulating.
2026-04-23 00:10:34 +02:00
Jannis Braun 15e42a7cc1 fix(federation): per-event fault isolation in syncPeerMutationLog (#25)
Replace the batch-level processRelayEvents call with a per-event
loop wrapped in try/catch. On exception: log event type, messageId,
timestamp, peer origin, and the error message; continue to the next
event.

Previously, a single poison-pill event (e.g., UNIQUE conflict from
a malformed relay payload) would throw, be caught by the outer
try/catch, and block lastSyncedAt from advancing — causing every
future activation to retry the same broken window indefinitely.

The final 'replayed N events' log line now reports '(K skipped due
to errors)' when K > 0, surfacing the count to operators. Individual
event failures are logged via console.error with enough context to
debug or replay manually.

Trade-off documented in docs/systems/federation.md: forward progress
of the sync pipeline takes priority over strict at-least-once
delivery. An event that fails to process is lost to the receiver
unless replayed manually.
2026-04-22 01:45:14 +02:00