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.
The home/DM tile previously set backgroundStyle to undefined — the
button had no fill, so the existing rounded-[20px] → rounded-[13px]
corner-radius transition had nothing to morph and produced zero
hover/active feedback. The regression became visible after the icon
rebrand: the old logo.png shipped a #1d1d1b rounded-square that
filled the tile and gave it a de-facto background; the new bare-mark
logo is centred with transparent padding, exposing the missing
button bg.
Apply the same pattern as the existing 'action' tile: a translucent
white surface that brightens through three states (rest/hover/active)
so both the corner morph and the fill change are visible. Active
state (rgba 0.10) is the strongest because it signals 'you are on
the home page right now', complementing the existing pill indicator
on the left edge.
The previous Element-1 (full badge) approach produced a visible
warm-grey #1d1d1b square inside the cool-dark #1a1a23 sidebar — the
two near-blacks differ enough in hue to read as a foreign rectangle
in the slot.
The right answer (third time lucky): the SpaceSidebar's 40×40
rounded-[20px] tile already IS the dark squircle frame. logo.png now
composites the bare gradient mark at 75% on a transparent canvas:
the sidebar's tile bg shows through, the 25% padding keeps the mark
off the squircle edges and clear of the active-state ring, and there
is no foreign-bg colour conflict.
Refactored writeMaskablePng → writeCenteredMarkPng to share one code
path between the maskable PWA (opaque #1d1d1b background, 60% mark)
and the in-app logo (transparent background, 75% mark). Both use the
same composition pipeline; only canvas / scale / bg differ.
Only logo.png regenerated; other 21 outputs byte-identical
(determinism gate verified twice).
The SpaceSidebar's home tile is a 40×40 rounded-[20px] squircle with
object-cover. The original spec assumed the sidebar provides "the
colour-circle frame," so logo.png shipped as the bare gradient B on
transparent. In practice the tile is a transparent slot — the bare
mark touched the squircle edges (no internal padding), conflicted
with the active-state ring, and read as poorly-integrated.
Switching to the full badge (Element 1 / app-icon.svg): the badge's
own dark squircle bg fills the tile, its internal padding around the
mark keeps the gradient off the edges, and the result reads as a
clean Discord-style "home space" tile alongside the user's other
spaces.
Only logo.png regenerated; remaining 21 outputs byte-identical
(determinism gate verified). Spec output matrix and generator README
updated to reflect the corrected source mapping.
icon.png at repo root and gen-icns.sh are replaced by the SVG-driven
generator. tray-icon@2x.png is a Cocoa convention only — macOS uses
the template, not the colored variant. None have remaining consumers.
Three branches now: macOS template (.png + @2x, auto-recolour), Windows
multi-size .ico (DPI auto-pick), Linux 22x22 PNG. Drops the runtime
.resize({16,16}) — pre-rendered assets are at correct platform sizes;
runtime resize re-introduced bicubic blur. Defensive fallback to the
programmatic blurple circle is preserved but should not trigger in
practice now that templates ship populated.
build/icon.png and build/icon.icns are now committed pre-rendered
artefacts produced by scripts/gen-icons.mjs. The cp lines that seeded
gen-icns.sh and the gen-icns.sh invocation itself are no longer needed.
Preserves the macOS dock-icon override (cp into electron.icns) — that
is independent of how the icns is generated.
win.icon now references the hand-crafted multi-size .ico (16/24/32/48/
64/128/256) rather than letting electron-builder synthesise from a
single 1024 PNG — the latter was the cause of the 'Windows looks low
quality' complaint. linux.icon explicitly references build/icons/ dir
mode (was implicit before).
First run of pnpm gen-icons against the new artwork. All 22 outputs
generated deterministically (verified by hashing twice). Replaces the
dated raster set everywhere — desktop app icons, tray, web favicons,
PWA, in-app logo.
When autostart is disabled, Windows deletes the Run registry entry, so
deriveStartMinimizedFromArgs(undefined) was returning false and silently
resetting the user's startMinimized preference the next time autostart
was re-enabled. Both the get- and set-auto-launch-settings handlers now
fall back to the disk-cached value when no OS entry exists, matching the
existing macOS/Linux fallback pattern.
- AutoLaunchSettings: remove pre-await state writes; backend response is
now the sole writer to openAtLogin/startMinimized state, eliminating
the flicker caused when optimistic values diverged from OS truth
- Add busy flag across IPC round-trip; both Toggles receive disabled={busy}
- Start-minimized Toggle additionally receives disabled={!openAtLogin},
preventing interaction when it would have no effect
- Toggle.tsx: add optional disabled prop (forwarded to <button disabled>)
with cursor-not-allowed opacity-50 visual feedback; non-breaking for
all existing callers
macOS: add args:[--hidden] alongside openAsHidden for defence-in-depth detection on macOS 13+.
Windows: add enabled/path/args/name so re-enabling the toggle clears the StartupApproved\Run disable marker.
Linux: add deterministic name:'backspace' so the .desktop filename is stable across Electron/AppImage updates.
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.
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).
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.
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.
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.
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)
Without this, when the local admin gate fires on friend-add, the user
got a generic 'Could not send friend request' fallback. Now they see
'Your admin needs to approve federation with this instance' with a
pointer to Connections settings where Task 12's pending-approvals
section shows their queued request.
Two new inline sections in the user-facing federation/connections settings
panel: 'Recent peering outcomes' (terminal-state notifications with
Retry-for-approved + Dismiss) and 'Pending peering approvals' (active
subscriber rows the user is waiting on, with Cancel). New WS handlers for
peering_subscription_changed and peering_notification_received refresh the
lists in real-time and surface a transient toast for online users. Retry
deep-link for friend_add prefills the friend-add input with the original
target handle (other reasons get Dismiss only — the gate doesn't wire
those paths yet).
PendingApprovals now branches row rendering on direction. Inbound rows
render exactly as before. Outbound rows show '<instance> — N users
want us to peer' with an inline subscriber list (username + reason +
target). ConfirmDialog descriptions branch on direction so admins
see appropriate copy for outbound approve (initiate handshake on
behalf of N users) vs outbound deny (notify requesting users).
- Drops the web-local ApprovalRequest interface in favor of the
canonical shared type (Task 2 already established this; Task 10
reconciles the consumer side).
- Adds five new federation methods wrapping the REST endpoints
added in Tasks 8 and 9: peeringSubscriptions (GET), cancelPeering
Subscription (DELETE), peeringNotifications (GET +/- unreadOnly
filter), markPeeringNotificationRead (POST :id/read), markAll
PeeringNotificationsRead (POST read-all).
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.
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.
- 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.
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.
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.
- 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
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.
- 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.
- Symmetric with PeeringNotificationKind at module scope; avoids
future collision with unrelated trigger systems.
- subscribers field doc now explicit: undefined for inbound, present
(possibly []) for outbound. Pins the response contract before Task 7
implements the GET endpoint response shape.
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.
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.
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).
- 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.
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.
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.