Commit Graph
630 Commits
Author SHA1 Message Date
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
Jannis Braun 33547038f4 feat(invites): expose lastRedeemedAt on InviteLinkSummary 2026-04-29 02:33:49 +02:00
Jannis Braun dbcc399ebb test(settings): tighten 'preserves field when omitted' to actually prove preservation
The original test seeded the DB with the schema default (1) and asserted
the response was true after a partial PATCH. That passes both for
'untouched' and 'reset to default' — doesn't distinguish them. Now the
test toggles the DB column to false BEFORE the PATCH, then asserts the
false value survives both in the response AND in the DB row directly.
2026-04-28 21:06:05 +02:00
Jannis Braun 8d7ba33c21 feat(settings): expose federatedRegistrationOpen in /settings/instance + /instance/info
Surfaces the federatedRegistrationOpen flag (Task 1 schema column) on the
admin settings GET/PATCH endpoints and the public /api/instance/info
endpoint. Closes the 3 deferred TypeScript errors from Task 2 by
populating the now-required InstanceAdminSettings/InstanceInfoResponse
field.

Adds smoke tests (routes/instance.test.ts, routes/settings.test.ts) that
lock in the JSON contract the Connections UI (Task 21) and admin
RegistrationPanel (Task 15) consume, plus boolean-validation coverage
for the PATCH path. Updates docs/systems/admin.md with the new field in
both InstanceAdminSettings and the public info response schema.
2026-04-28 21:01:50 +02:00
Jannis Braun 87301bd4d2 test(auth): polish register handler — comment, test isolation, +1 coverage
Quality-review polish on Task 11:

1. One-line comment near the federatedRegistrationOpen default behavior
   noting that the missing-row case is unreachable post-migration but
   falls federation-closed defensively (asymmetric with registrationOpen
   which falls back to env config — by design).

2. The "federated gate blocks token registration" test now sets ONLY
   federatedRegistrationOpen=0, isolating the federated-gate-alone
   effect rather than a both-gates-closed compound.

3. New test: open registration + revoked token → 201 (silently ignored).
   Locks the spec §5.7 invariant "no validation when registration is open"
   against future "let's just validate it for safety" regressions.

4. auth.md prose explicitly notes that federated stub upgrade and new-
   account paths do NOT enter redeemInvite — surfacing the structural
   enforcement of spec §1.3 "tokens never unlock federated creation".
2026-04-28 20:58:16 +02:00
Jannis Braun 0559ea369b feat(auth): split registration gate by homeInstance + atomic invite redemption
The /api/auth/register handler now branches on homeInstance:

- Local path (no homeInstance): gated by registrationOpen. When closed, a
  valid inviteToken bypasses the gate and is consumed atomically inside
  redeemInvite()'s transaction (user insert + usedCount bump + redemption
  row all commit together, or all roll back). When open, inviteToken is
  silently ignored.

- Federated path (homeInstance set): gated by federatedRegistrationOpen.
  Token is ignored entirely on this path -- tokens never unlock federated
  creation. Closed → 403 with "Federated registration is closed".

InviteUnavailableError thrown by redeemInvite() (concurrent revoke,
last-slot race, expiry between check-invite and submit) is mapped to 403
"Invalid or expired invite". The in-txn re-derive closes the TOCTOU window.

9 new tests cover the toggle matrix from spec §5.6 + invite consumption
semantics + federated-gate independence + last-slot race rejection.

Updates docs/systems/auth.md: rewrites the Registration Gate section to
describe the three-path model (open / invite / federated), adds the toggle
matrix, adds an Invite Tokens subsection with the atomic-redemption shape,
notes that the federated stub upgrade is always gated by
federatedRegistrationOpen, never by an invite token.
2026-04-28 20:51:49 +02:00
Jannis Braun 1b76b8bf2a test(invites): tighten check-invite assertions to byte-identical responses
Per quality review: replace per-property assertions with toEqual()
object-equality on the invalid-response bodies. Locks the enumeration-
shield contract — revoked/unknown/malformed/missing must all return the
SAME body, not just bodies that happen to satisfy individual assertions.
2026-04-28 20:46:24 +02:00
Jannis Braun effb36c7f4 feat(auth): GET /api/auth/check-invite with collapsed enumeration shield 2026-04-28 20:42:46 +02:00
Jannis Braun ade5c0d998 test(invites): tighten requireAdmin mock to match real Fastify contract 2026-04-28 20:40:24 +02:00
Jannis Braun 0c9865ad90 feat(invites): /api/admin/invites CRUD endpoints 2026-04-28 20:36:09 +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 22788890f3 feat(db): add invite_links + invite_redemptions tables + federatedRegistrationOpen 2026-04-28 18:52:15 +02:00
Jannis Braun 76b6621d62 feat(storage): remove upload-size cap, add MB/GB unit toggle
Storage panel's max-upload-size input now accepts any positive integer
(bounded only by JS safe-integer ceiling) and offers an MB/GB unit
toggle. Multipart limit relaxed to MAX_SAFE_INTEGER — actual cap is
enforced per-request from the DB setting, not at the framework layer.
2026-04-28 18:37:16 +02:00
Jannis Braun 4fa2414933 fix(presence): drop status='online' from registration insert
Mirrors the REST-login change in f17c46c on the registration insert path.
A successful POST /api/auth/register does not by itself imply a live
WebSocket — the client may never connect (transient network, mobile
background, error path between the 201 and /ws), leaving a permanently
stuck-online row that no disconnect timer can clean up. The schema
default 'offline' is correct; ws/handler.ts flips it to 'online' on real
WS auth.

The federated-stub upgrade path in the same handler is unaffected: it
only updates passwordHash/username/homeUserId/displayName/avatarColor,
leaving the stub's pre-existing 'offline' status (set when the stub was
created via replication) untouched.

Updates docs/systems/auth.md step 7 to reflect the new behavior.
2026-04-27 09:35:34 +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 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