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.
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.
Renders space invite system messages in DMs as embed-style cards on the chat
surface. Three render states: snapshot-only on mount (Join enabled, loading
dot), live-confirmed (memberCount refreshed from preview), revoked (gray-out
+ glass-pill indicator). Join targets the space's home origin via
joinByCode(code, spaceInstanceOrigin || undefined) — the three-way
federation correctness rule. Re-exports getApiForOrigin from api/client to
expose the cross-store resolver under a natural import surface.
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.
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.
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.
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.
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".
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.
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.
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.
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).
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.
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.
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.
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.
Wire broadcastStreamWatch (LiveKit data-channel ping) to all three
explicit user-action sites in StreamTile: "Stop Watching" context-menu
item, "Watch Stream" context-menu item, and the handleWatch button
callback. Broadcast is intentionally confined to click sites only —
not wired in voiceStore actions — to prevent noisy stream_user_left
sounds on automatic teardown paths in useLiveKit.ts.
vitest+jsdom puts test code in a different realm than Node's TextEncoder,
so `toBeInstanceOf(Uint8Array)` rejects the encoder's return value even
though it is structurally a Uint8Array. `Object.prototype.toString.call`
checks the @@toStringTag tag, which is realm-safe.
Strip the desktop package description to "Backspace" so Windows Task
Manager shows the bare product name instead of the long tagline.
Unify the web meta description and PWA manifest description on a single
positioning line that names Discord and TeamSpeak as the comparison
targets — improves link-preview copy and SEO surface.
Voice & Video tab no longer fires getUserMedia on mount. Uses
navigator.permissions.query({name:'camera'}) for state detection,
which is passive (no LED activation). Preview only opens when the
user explicitly clicks the dormant tile or the prompt-state CTA.
In-call mode unchanged (attaches existing LK track, no extra LED).
Spec and plan updated to reflect the corrected design — the
"auto-start preview, no Test Camera toggle" rationale was wrong;
macOS holds the camera LED on for ~2s after release, so any
incidental getUserMedia call (probe, transient mount) flashes the
LED in the user's face even when they aren't on the Voice & Video
panel. Privacy-correct UX: never light the LED without an explicit
user action.
Two-mode preview: in-call attaches to the LiveKit local camera track
(no double-capture); pre-call uses getUserMedia. Modes transition
reactively on isCameraOn changes.
Empty labels fall back to 'Camera N'; duplicate labels are
disambiguated with ' (1)', ' (2)' suffixes by enumeration order.
'No cameras detected' subline appears when enumeration is empty.
LocalTrackPublished registers a one-shot onended on the camera track's
MediaStreamTrack. The handler:
- skips when consumeIntentionalCameraOff() flag is set (user-initiated)
- re-probes getUserMedia to distinguish NotAllowedError (permission
revoked) from NotFoundError (disconnected) from other errors
- tears down camera state via the unified path
Also reset _intentionalCameraOff in voiceActions if setCameraEnabled(false)
rejects, so a failed disable doesn't poison the next genuine unplug.
Uses room.switchActiveDevice('videoinput', id) to swap the underlying
MediaStreamTrack without re-publishing. Compares against the published
track's actual getSettings().deviceId so null→explicit-same-device is a
no-op. Rolls back on failure with a 'Could not switch camera' toast.
- Voice-bar and mobile camera buttons now use the canonical handler
(fixes mobile no-op and voice-bar wrong-preset bugs)
- Remove dead useLiveKit.toggleCamera
- Add _intentionalCameraOff flag with mark/consume helpers
Two bugs in handleScroll's loadMoreMessages flow surfaced after the
smooth-scroll race fix.
(1) isLoadingMore stuck across channels. setIsLoadingMore(true) → await
loadMoreMessages → setIsLoadingMore(false) was unguarded. If the user
switched channels during the await, the new channel inherited the flag (same
component instance, same useState slot) and rendered the pagination skeleton
even with no load in flight. Cleared only when the original await resolved
or the component remounted (e.g., navigating to Friends and back).
(2) Wrong-channel scroll restore. The post-await rAF set
container.scrollTop = container.scrollHeight - prevScrollHeight against the
new channel's container with the old channel's prevScrollHeight, yanking
the new channel to a wrong position.
Fix:
- try/finally around the await so setIsLoadingMore(false) always runs.
- currentChannelIdRef tracks the live channelId; capture requestChannelId at
load start and compare both before scheduling the rAF and inside the rAF
callback (the 16ms frame gap is enough for a switch).
- Belt-and-suspenders: setIsLoadingMore(false) in the channel-switch effect
covers the case where the await never resolves (network hang). Without it,
a stuck await would leave the new channel inheriting the flag indefinitely.
No request cancellation — out of scope; AbortController plumbing through
chatStore is a bigger refactor and the channelId guard already silently
drops stale results.
Spec updated. Smooth-scroll fix from the previous commit untouched.
Smooth scrolls toward the bottom (new-message arrival in Effect A and the
Jump-to-Present click) animate scrollTop over many frames. Each intermediate
handleScroll measurement saw a large distanceFromBottom and flipped
isAtBottomRef to false, closing the Effect B/C gates. Lazy media (avatars,
embeds, Spotify thumbs) finishing mid-animation grew scrollHeight while the
gate was closed, so the smooth scroll landed at its originally-computed
target — leaving the user above the new bottom by ~the height of what loaded.
Fix: typed smoothScrollIntentRef ('bottom' | 'message' | null) with an 800ms
deadline. handleScroll suppresses the at-bottom flip while intent is 'bottom'
and the user hasn't wheeled past the 5000px nearBottom threshold. Effect D
fires a final defensive instant pin via native scrollend (Chrome 114+,
Safari 18+) or a setTimeout(800) fallback. 'message' intent (jump-to-message
from search) does NOT suppress — the gate flips honestly so the user is left
at the targeted message.
Verified live on nova.ddns.net Orbit → general: Jump-to-Present
lands flush at bottom; new Spotify-link messages stay at bottom as embeds
arrive via WS. docs/systems/message-list.md updated.
Iterating-by-paint-over wasn't working. Going back to first
principles: the brand IS the badge — full Element 1 mass, mark with
its own internal padding, dark frame around it. That's what reads as
"Backspace" on every other surface (dock, taskbar, favicon, apple-
touch). The sidebar home tile should look the same.
The earlier "ugly grey" complaint about Element 1 in the sidebar
came down to one specific colour mismatch: the source SVG's
#1d1d1b (warm near-black) sat next to the sidebar's #1a1a23 (cool
near-black) and read as an off-grey rectangle. Fix: in the generator,
do a string-replace `#1d1d1b → #000000` on app-icon.svg before
rendering logo.png only — every other output (.icns, .ico, favicons,
PWA) keeps the original brand `#1d1d1b`. Pure black against the
sidebar's #1a1a23 reads as a deliberately darker tile, not a hue
mismatch.
Reverts the SpaceSidebar lavender-tile workaround: with the badge
filling the tile via object-cover, the button's overflow-hidden +
rounded-[20px → 13px] morph already animates the badge cleanly.
backgroundStyle for type === 'dm' returns undefined again; deps trim
back to what they were.
Determinism gate verified — only logo.png changed (other 21 outputs
byte-identical across two regen runs).
Translucent white over the cool-dark sidebar reads as muddy grey;
that's the same root cause as the earlier #1d1d1b warm-grey complaint.
Any unsaturated tint loses against the dark sidebar bg.
Switching the home tile to accent-lavender (RGB 196,181,253 — the
endpoint of the mark's gradient) breaks the grey because saturation
defeats the grey-on-grey muddiness. Lavender also (a) complements
the gradient mark sitting on top, (b) parallels Discord's brand-
coloured home button pattern, and (c) gives the sidebar a clear
semantic colour hierarchy: action = mint, home = lavender (brand),
spaces = user-defined.
Three states escalate by alpha (0.08 / 0.16 / 0.28) so corner-morph
and fill-brightness both signal interaction.