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.
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.
Final-pass review found two doc misses:
(1) docs/systems/desktop.md had a SECOND 'Icon Generation' section
under Build System (separate from the Tray Icon section that Task 9
updated) that still described the deleted scripts/gen-icns.sh —
sips/iconutil pipeline using present tense. Replaced with an accurate
description of the new SVG-driven scripts/gen-icons.mjs pipeline,
including the dev-time Electron Resources cp note and links to the
spec and generator README.
(2) The icon-system spec's 'Files Deleted' table only listed
/icon.png and gen-icns.sh; tray-icon@2x.png was deleted in Task 8 but
not documented. Added with the Cocoa-template-only rationale.
Doc-only fix; no code changes.
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.
Three issues caught by the code review of scripts/gen-icons.mjs:
(1) Missing source SVG validation. Spec requires the script "refuses
to run if any source SVG is missing." Added an existsSync guard at the
top of main() that throws a targeted message rather than letting sharp
fail with a raw ENOENT.
(2) Summary table missing byte sizes. Spec says the summary prints
"each output, its size in pixels, its file size in bytes." trace() now
captures statSync().size; the printout has a size column with
human-readable formatting and a total at the end.
(3) writeMaskablePng's intermediate buffer used .png() without the
compressionLevel/palette options that every other render uses. Zero
functional impact (the buffer is only piped into composite, never
written to disk) but inconsistent and misleading. Aligned with the
rest of the script.
README: replaced 'git add -A' with explicit directory paths matching
the project's convention — the generator writes to a fixed set of
three directories and shouldn't accidentally stage unrelated
working-tree changes during a regen.
Plan updated to match the corrected code so Task 3's source-of-truth
stays consistent.
Single Node ESM script. Reads assets/brand/*.svg, writes the full
desktop + web icon set deterministically. Idempotent; safe to re-run.
Companion README documents the version-bump determinism caveat.
Three semantic copies from Artworks-Backspace/SVG/ — drives the new
icon generator. Element 1 → app-icon (full badge), Element 2 → mark
(bare gradient B, transparent), Element 4 → mark-mono-dark (solid
black for macOS menu-bar template). Element 3 and Element 5 stay in
the archive only.
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.
Replaces the stale Auto-Launch section with an accurate description of the
Tasks 3–7 implementation: OS-authoritative read model, platform-specific
applyLoginItemSettings() contract, Linux/AppImage-only conditional re-apply,
defence-in-depth hidden-launch detection, and the pure helpers in autoLaunch.ts.
Also updates Startup Sequence step 9 to reflect that unconditional re-apply is gone.
- 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.