Commit Graph
1186 Commits
Author SHA1 Message Date
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 855410f88c feat(shared): add SpaceInviteRequest / SpaceInviteResponse / SpaceInviteSystemPayload 2026-04-29 21:34:07 +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 04c66c4bcf feat(federation): add optional type field to FederationRelayEvent.message 2026-04-29 21:29:01 +02:00
Jannis Braun d31a9cf579 refactor(web): RegistrationPanel — glass-bubble modal buttons + clearer Revoke copy 2026-04-29 03:01:47 +02:00
Jannis Braun f0ac847d9e refactor(web): RegistrationPanel sibling parity — Edit/Reinstate redesign + Redemptions header 2026-04-29 02:51:44 +02:00
Jannis Braun a52bab4c16 refactor(web): CreateInviteModal — icon-badge header + uppercase section labels + dominant submit 2026-04-29 02:47:44 +02:00
Jannis Braun 526043a3b4 feat(web): RegistrationPanel sort + status filter via FilterDropdown 2026-04-29 02:44:32 +02:00
Jannis Braun b91bc89c58 refactor(web): RegistrationPanel InviteRow → collapsed/expanded model 2026-04-29 02:40:33 +02:00
Jannis Braun 3fdf501620 refactor(web): RegistrationPanel tab strip restyle + count badges 2026-04-29 02:36:52 +02:00
Jannis Braun 33547038f4 feat(invites): expose lastRedeemedAt on InviteLinkSummary 2026-04-29 02:33:49 +02:00
Jannis Braun a7c321a9d4 feat(web): show federated-registration warning banner in Connections add-instance form 2026-04-29 01:49:32 +02:00
Jannis Braun 641d4efcb9 fix(web): RegisterPage invite-fallback validation, IIFE preference, open-registration silence 2026-04-29 01:46:48 +02:00
Jannis Braun 0dae217787 feat(web): RegisterPage invite-token URL pickup + manual entry + closed-state UX 2026-04-29 01:38:52 +02:00
Jannis Braun 5b51980278 fix(web): make ConfirmDialog Escape capture-phase to prevent leaking to ancestor modals 2026-04-29 01:26:38 +02:00
Jannis Braun bde4d829ef feat(web): RegistrationPanel Edit/Revoke/Reinstate/Delete + Redemptions 2026-04-29 01:21:14 +02:00
Jannis Braun ba5c84fc6b fix(web): add Custom… expiry preset to CreateInviteModal per spec §4.2 2026-04-29 01:13:22 +02:00
Jannis Braun 234ec8e70d feat(web): RegistrationPanel Create-invite modal 2026-04-29 01:06:48 +02:00
Jannis Braun 42d1be54fa fix(web): guard RegistrationPanel invite-list against stale-fetch race 2026-04-29 00:57:57 +02:00
Jannis Braun dcd3daf390 feat(web): RegistrationPanel invite-list with Active/Archived tabs 2026-04-29 00:53:39 +02:00
Jannis Braun 7dd6bd6263 refactor(web): move registrationOpen toggle out of GeneralPanel 2026-04-29 00:44:35 +02:00
Jannis Braun cad6c50bff feat(web): RegistrationPanel scaffold with both registration toggles 2026-04-29 00:40:04 +02:00
Jannis Braun 7edfd8ae26 refactor(web): rename invites.patch → invites.update for codebase consistency
Other namespaces use 'update' (users.update, spaces.update, channels.update,
roles.update); the new 'invites.patch' breaks the pattern. Renamed before
Tasks 17-19 import it. Plan updated to match.
2026-04-28 21:11:43 +02:00
Jannis Braun 50756b3e65 feat(web): add invites + checkInvite to api client 2026-04-28 21:08:12 +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 e984ce910c refactor(shared): rename PatchInviteRequest → UpdateInviteRequest + JSDoc
Matches existing codebase convention (UpdateChannelRequest,
UpdateSpaceRequest, etc.). Adds JSDoc on InviteStatus and
InviteLinkSummary.url / createdByUsername fields with server-side
semantics. Plan updated for downstream task naming consistency.
2026-04-28 19:50:05 +02:00
Jannis Braun 884b3d9fe9 feat(shared): add invite link types + federatedRegistrationOpen 2026-04-28 19:45:25 +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 82ae487765 feat(sounds): add 'Play sound for every message' toggle to Voice settings 2026-04-28 14:50:41 +02:00
Jannis Braun 081937a030 fix(sounds): drop singleton AudioManager from SoundController effect deps 2026-04-28 14:49:03 +02:00