The T19/T20 catch blocks looked for an `.body` property on thrown errors
to extract the structured error code. The shared API client (api/client.ts:298)
actually throws `new Error(body.error)` — the code lives in `err.message`,
and there's no `.body` attached.
Live E2E (T22 scenario 2) caught this: typing alice@orbit against an
awaiting_approval peer surfaced the raw code 'peer_pending_approval' as
the toast text instead of the human-readable mapServerErrorToMessage
output. Same defect would have hit every server-error toast on both
FriendsPage (AddFriend + UserDiscoverCard) and UserProfileModal.
Catch blocks now use err.message as both the code and the fallback text;
the inline comment points at the API client throw site so the contract
is documented at the consumer.
Same pattern as FriendsPage cleanup (T19): the friend-add catch no longer
branches on the deleted InstanceNotConnectedError / InstanceDisconnectedError;
the ConnectInstanceModal trigger is removed since the server handles
all routing/peering. Errors surface via toast using mapServerErrorToMessage.
This restores the web package to a compileable state.
Removes try/catch on the deleted InstanceNotConnectedError/Disconnected
classes (T17). Server now returns structured error codes; client maps
them to human-readable toasts via the new mapServerErrorToMessage helper.
The friend-add flow no longer triggers ConnectInstanceModal — the server
handles all routing/peering/lookup. The modal itself stays for Connections
settings and space-join flows.
Multi-tab sync: friend_request_sent appends the new outbound request to
socialStore (deduped by id+origin), no toast.
Async rollback: friend_request_relay_failed removes the row by id and
surfaces a warning toast with the target handle and reason. Wires the
client side of the rollback hook from T10.
Server now handles all parsing/routing/peering/lookup (T11-T14). Client
sends the trimmed username verbatim to /api/social/requests and surfaces
server errors via toast (added in T18-T20).
Note: FriendsPage.tsx and UserProfileModal.tsx will fail to compile
until T19 and T20 remove their now-dead try/catch blocks for the
deleted error classes. TypeScript catches it; pnpm dev will not start
until those tasks land.
The friend contextId must be byte-identical on both peers for a given
canonical pair regardless of argument order. Initial-sync backfill keys
on contextId; if the two sides computed different values for the same
friendship, missed events would never reconcile.
The local branch was reusing friendRequestPayload (built for
friend_request_received with user=sender) for the sender-side
friend_request_sent broadcast. Tab B on the sender would render
the sender's own avatar where the target's should appear.
Match the federated branch — sent broadcast carries target.
Refactors the POST handler into handleLocalFriendRequest + handleFederatedFriendRequest helpers. The federated branch resolves the target domain, ensures peering, looks up the remote user, creates/hydrates a replicated stub, and writes a transactional (friend_requests + mutation_log + outbox) event with relayMessageId set. Also exports hydrateReplicatedUserProfile from federation.ts and adds the T11 happy-path test.
The T3 commit (e4086fe) generated the SQL migration but missed the
drizzle meta files that track migration state. Without these, future
db:generate runs would re-emit or skew migration ordering.
Nullable indexed column tracking the entityId of the originating relay
event for federated friend requests. Used by the rollback hook in
federationRollback.ts to locate and delete rows when a federated
friend_request_create is permanently rejected (spec §5).
Sections 3 and 12 now describe the discover-equivalent filter set
applied to /api/social/search and the widened Direct-Add row
contract (always-visible for well-formed input, resolved-form
display, server-side username normalization).
Per code-review: directAddDisplay now reads directAt === -1
instead of re-deriving includes('@'); add a one-line comment on
showDirectAdd so the predicate's intent is obvious at first read.
The Send-Friend-Request action row in the Add Friend tab previously
appeared only when the typed query contained a non-edge @, leaving
no way to fire a blind request for a bare local handle. Widen the
gate to allow non-empty bare handles, keep the malformed @ shapes
(@, @bob, bob@) hidden. When the typed query has no @, display the
resolved form <query>@<window.location.host> so the user sees which
instance the request will hit. Submission string is unchanged.
Updates FriendsPage.test.tsx: inverts the now-stale 'does not show
Direct Add row for plain usernames' test into the new positive
assertion, and adds a separate test for the malformed @ shapes.
Per code-review:
- Drop the inline comment in sendFriendRequest; the commit message
for the prior commit already covers the why and CLAUDE.md prefers
no comments when the code is self-explanatory.
- Add writable: true to the window.location defineProperty in the
test so re-firing beforeEach across jsdom version drift is safe.
Hostnames are case-insensitive (RFC 4343), and both right-hand
sides of the routing comparisons (window.location.host and
URL.host) are already canonical lowercase. The user-typed domain
substring was compared with strict ===, so ORBIT.ddns.net
failed to match an existing connected peer and popped a spurious
Connect Instance modal. Normalize at parse time.
Per code-review suggestion: add a brief comment explaining why
module-level sqlite/testDb/app reassignment works (mock getter
closes over the current binding) and what would break it
(top-level it, .concurrent describe). Prevents a future foot-gun.
Registration canonicalizes usernames to lowercase (auth.ts:32),
but the friend-request endpoint compared with strict eq() against
raw user input, so 'Bob' returned 404 even when 'bob' existed.
Trim and lowercase before lookup, matching the rest of the auth
boundary. Empty-after-trim now returns 400 (was: 404).
Tests appended to social.test.ts as a second describe block
sharing the harness from Task 1.
Drop the file-path reference per code-review suggestion (paths in
comments rot); keep the load-bearing why — substring of the domain
matches every stub because stubs are stored as <homeUserId>@<domain>.
Tombstoned users, replicated federated stubs, and users with
discoverable=0 were all surfacing in Add Friend search results.
Add the three WHERE filters that /api/social/discover already
applies. Federated users continue to be surfaced via the
client-side cross-instance fan-out in socialStore.searchUsers.
New tests: social.test.ts covers all five filter cases plus
existing self-exclusion and displayName-match behaviours.
Final-review reviewer flagged two minor staleness items:
- embeds.md §10 ImageEmbed bullets still described the pre-Task-1
shape (no wrapper, no aspect-ratio). Replaced with the actual
current shape, with an explicit pointer to the Dimension
reservation contract section that explains why the dims-null
branch deliberately has no fallback.
- message-list.md said VideoEmbed uses "padding-bottom" without
noting the direct-video branch uses aspectRatio. Now describes
both branches explicitly.
No code changes; both are documentation-only touch-ups.
Reviewer caught two small gaps after Task 3:
- Effect A's smooth-scroll path on new messages is intentionally NOT
instrumented with the sentinel (the animation lands asynchronously
across frames; no intermediate scrollTop is worth pinning to). The
doc now records this so the reader's intuition matches the code.
- The sentinel-branch comment in MessageList.tsx pointed at "spec §2",
which is the planning doc rather than the durable subsystem spec.
Pointed at docs/systems/message-list.md instead.
Tracks the post-clamp scrollTop of every programmatic scroll-to-bottom in
lastProgrammaticBottomScrollRef. handleScroll skips the at-bottom flip and
re-pins when the event's scrollTop matches the sentinel — i.e., the event
was queued by our own command and layout grew underneath. User scrolls
break the match (scrollTop changes) and flow through the normal path.
This complements the 2026-03-25 race-fix (which gated auxiliary effects on
isAtBottomRef) by also preventing handleScroll from flipping that ref to
false based on a post-growth distance measurement of our own scroll.
Restores the dimension reservation reverted in dae6f2d, scoped to
the embed.width && embed.height case only. No fallback aspect-ratio
when dims are null — that was the source of the dark letterbox bars
on Tenor/Klipy GIFs and OG-less images that triggered the revert.
The server-side probe in embedResolver.ts:196 already populates dims
for all image-type embeds; this change makes the client honor them.
Third initiator path that calls remote /peer/accept. Mirrors performHandshake (auto-peer) and /peer/initiate (admin-initiate) — same try/catch parse, same null-or-non-empty-string guard. Caught in final review of #33; same root cause as Bug #1, bundled rather than fragmented to a new backlog item.
Fresh-create returned {id, ownerId, federatedId, createdAt, members, lastMessage}; the existing-DM path returned the same shape minus federatedId. Inconsistency was a footgun for any future feature reading federatedId from this response — fresh-create tests would pass while idempotent path would break. One-line addition to the result builder.
Mirrors the previous performHandshake fix for the admin-initiated path. /peer/initiate now parses the remote's instanceName from the /peer/accept response body and writes it alongside status='active'.
Initiator side of the bidirectional handshake exchange. /peer/accept now returns instanceName in the response body (prior commit); performHandshake parses it and persists alongside status='active'. Tolerates missing field (older peers) and non-JSON bodies.
Bidirectional handshake exchange. Today the responder learns the initiator's instance name from request body but the initiator never learns the responder's. Adding {instanceName} to the response body lets the initiator persist it on its side (next commit). Field is optional so older peers omitting it cause no ill effect.
Previously /peer/accept read body.instanceName only when queueing for admin approval. The four paths that mutate federation_peers (rejected→active override, awaiting_approval→active, pending→active, new-peer create) all wrote status='active' without persisting instance_name. Result: every peer established via direct handshake had instance_name = NULL forever. Anywhere peerLabel was rendered fell back to origin hostname.
Active/needs_attention idempotent early-return path deliberately left alone — same security posture that already refuses to overwrite hmac_secret from unauthenticated requests on already-active peers.
Existing live NULL rows are repaired post-deploy via manual UPDATE statements (see plan).
Squashes drizzle migrations 0000-0004 into a single baseline
(0000_lethal_wildside.sql) and deletes the three migration adapter
functions that existed only because of pre-squash intermediate states:
baselineExistingInstall, healInitialSchemaDrift, healRenamedColumns.
__drizzle_migrations surgery completed and verified live on Pi and VM
before this merge. Both instances boot cleanly against the single
baseline; cross-instance DM delivery verified.
Closes backlog #31 Phase 2.
Line 4 pointed at a stale `runMigrations()` name and omitted the drizzle-kit
generate step entirely. Replace with a description of the real workflow:
`pnpm db:generate` produces SQL from `schema.ts`, `initDatabase()` runs
`drizzle.migrate()` + `ensureDefaults()` on startup. Note the 2026-04-24
baseline squash for future readers.
Delete the "Migration flags (internal)" line — those flags belonged to
pre-drizzle data-fix migrations deleted wholesale in 3acaea2 (2026-04-09)
and are historical trivia with no present referent.
Refs backlog #31 Phase 2.
baselineExistingInstall, healInitialSchemaDrift, and healRenamedColumns
existed only because of pre-squash intermediate states: dev DBs drifted
against drizzle-kit's assumed 0000 baseline, or carried pre-rename
columns from the old manual migration system. Under a single squashed
baseline there are no intermediate states to drift against, and these
functions are unreachable.
initDatabase() is now: pragmas -> drizzle() -> migrate() -> ensureDefaults().
ensureDefaults stays (idempotent startup seeding for settings row, worker
ID, first-admin promotion).
Refs backlog #31 Phase 2.
Generated by pnpm db:generate against the current schema.ts — replaces
five historical migrations (0000_initial, 0001_clear_earthquake,
0002_peer_approval_requests, 0003_classy_loki, 0004_cooing_black_knight)
with one baseline that matches the schema shape all five produced together.
Pi + VM __drizzle_migrations rows will be rewritten per the Phase 2
surgery procedure before this deploys to either instance; DBs already
match the new baseline, no DDL runs. Phase 1 audit verified no still-present
bugs depend on any of the squashed migrations.
Refs backlog #31 Phase 2.
Harness: /tmp/scenario18-harness.mjs (WS-level assertion, pattern
matches #17's /tmp/call-test-harness.mjs).
- A (Pi→VM logged-out callee, Path A zero-ringee):
terminal dm_call_undeliverable reason=no_recipient peerOrigin=VM
elapsed 212ms (budget 2s, pre-fix 60s)
- B (VM→Pi logged-out callee, symmetric):
elapsed 155ms, peerOrigin=Pi
- C (Path B, DM deleted on VM):
relay hits Path B after DB delete, Bob offline,
elapsed 140ms, reason=no_recipient
- D (group DM with online member, non-regression):
VM accepted (Bob rung), no toast on caller
Bob's dm_call_incoming arrived in 144ms
All payloads correct: terminal:true, phase:'start', failures[0].reason
'no_recipient', correct peerOrigin. peerLabel empty because both
instances' federation_peers.instance_name is NULL — pre-existing state,
toast code already falls back to origin hostname (not a #18 concern).
Cannot merge from agent per plan Task 10 Step 7 — coordinator's call.
Follow-up from Task 2 code review. Keeps the test mock aligned with
the widened return type even though vi.mock doesn't structurally
typecheck the factory.
dm-system.md: fold the voice.md cross-reference into the paragraph so it
renders as part of the Cross-instance access explanation instead of an
orphaned bullet.
websocket.md: add 'host_unreachable' to the dm_call_undeliverable phase
union — stale since #32 was merged (docs drift noted in Task 8 review).
federation.md — new undeliverable bucket subsection with three-way
classification table, Path A/B semantics, and wire backward-compat note.
voice.md — no_recipient row in failure-surface table.
websocket.md — DmCallUndeliverableReason union updated to include no_recipient.
dm-system.md — cross-reference to voice.md for no_recipient reason.
buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone."
for the single-failure terminal case; multi-failure + non-terminal paths
fall through to existing lines (which already fold the new reason in by
peer label). TDD — four new assertions.
sendFederatedCallStart now treats a 200-with-undeliverable-messageId as
a peer-failure instead of unconditional success. Feeds the existing
failures[] array and terminal-determination machinery from #16.
New sendFederatedCallStartForTest export mirrors the existing
handleDm*ForTest pattern. TDD — three tests cover single-peer terminal
no_recipient, group-DM mixed delivered+undeliverable non-terminal, and
the happy-path (empty undeliverable → no event).
Also hardens sendCallRelay's response parse: validates undeliverable
is an Array and entries are well-shaped, logs protocol drift at warn/debug
rather than silently falling back to old-peer semantics.
CallRelayResult success arm gains undeliverable: string[]. sendCallRelay
parses FederationRelayResponse.undeliverable (when present) and returns
the messageIds so sendFederatedCallStart can reclassify per-peer results.
Old peers that omit the field → empty array → today's behavior.
TDD — three tests cover old-peer, new-peer-with-undeliverable, and 5xx paths.
processDmCallStartEvent Path A now skips offline local members (matching
Path B's pre-existing per-member check) and pushes undeliverable when no
member could be rung, instead of creating a stranded FederatedCallEntry.
TDD — two new tests cover zero-online and mixed-online cases.
processDmCallStartEvent Path B no longer silently accepts when no local
participant is reachable. Pushes {messageId, reason: 'no_recipient'} to
the undeliverable ack bucket so the caller can surface fast-fail.
TDD — test asserts undeliverable push + no FederatedCallEntry.
Additive plumbing. No behavior change — every existing event-type path
continues to push to accepted/rejected only. Response serializes the new
bucket only when non-empty (byte-identical responses in the normal case).
Tasks 3-4 add actual undeliverable pushes for dm_call_start paths.
Additive protocol extension. No consumers yet — follow-up commits wire
the new bucket into the relay endpoint, sendCallRelay, sendFederatedCallStart,
and the toast copy.
The admin reset endpoint (DELETE-pattern gated on peer.status !== 'needs_attention')
doesn't transition status — it deletes the row of an already-deactivated peer.
onPeerDeactivated already fired at the earlier needs_attention transition, so
the reset site correctly has no hook. The enum value was defensive-unused; per
project principles (no backwards-compat shims, no placeholders) drop it.
Admin-revoke endpoint (DELETE /api/federation/peers/:id): fires
onPeerDeactivated(id, 'admin_revoked') after the status write to
'revoked', evicting any in-flight federated calls for the now-revoked
peer.
Admin-reset endpoint (POST /api/federation/peers/:id/reset): hook
SKIPPED. The reset endpoint is guarded to only run when status is
already 'needs_attention' (active peers are rejected at the boundary
with a 400). Because the peer was already deactivated before reset is
called, onPeerDeactivated was already fired at the active→needs_attention
transition. The reset deletes the row entirely rather than writing a new
status; it does not represent a transition OUT OF active, so wiring it
here would be a semantic error — double-evicting an already-deactivated
peer.
Code-review catch: the Path-2 accept-rollback previously emitted
dm_call_undeliverable { terminal: true } via sendToFederatedCallUsers,
which broadcasts to every ringedUserIds entry. In a group DM this
would prematurely tear down non-accepting ringees whose own accept /
reject / timeout paths should govern their state. Switch to
sendToUser(acceptorId) so only the acting user gets the terminal
signal. Reorder the clearFederatedCall to happen before the emit so a
concurrent end-handler sees a cleared entry (clearFederatedCall is
idempotent). Spec updated, test extended to assert the scoping with a
two-ringee group-DM fixture.
Close backlog #30: second-pass heal that DROPs and rebuilds empty
drifted tables to reconcile pre-rename column drift on old pre-drizzle
dev DBs (companion to #29). `healInitialSchemaDrift` handles
addable-column drift; this handles the NOT-NULL-no-default case that
heal-by-ALTER can't resolve — e.g. federation_outbox's
`message_id`/`dm_channel_id` that were renamed to
`entity_id`/`context_id` in the pre-drizzle manual migrate.ts.
Rebuild target is the current-migration-state snapshot (derived from
__drizzle_migrations ↔ _journal.json by `when` timestamp), not the
latest on disk — rebuilding forward would duplicate-column with
pending ALTER TABLE ADD COLUMN statements drizzle is about to run.
Empty-table gate preserves data. Missing-column gate (not extra-only)
avoids touching unused leftover columns from pre-drizzle manual
migrations that no current code reads.
Verified against three simulated scenarios (fresh / pre-drizzle
drift / production-like) plus live boot on the actual affected dev
DB: server binds :3005 cleanly, no outbox worker SQLITE_ERROR ticks,
migrations complete silently. Server tests 110/110, web 131/131.
No migration files changed. Deployed instances unaffected. Skip
redeploy.
Companion to #29. `healInitialSchemaDrift` can only ADD columns, so it
skips NOT NULL-without-default columns like `federation_outbox.entity_id`
/ `context_id` — which on some old pre-drizzle dev DBs carry the
pre-rename names `message_id` / `dm_channel_id` instead. The tables
load but the outbox worker fails every tick with "no such column:
federation_outbox.context_id" once the server is up.
Adds healRenamedColumns() — a second pass that runs right after
`healInitialSchemaDrift`. For each table whose physical column set is
*missing* columns declared by the current-migration-state snapshot AND
which holds zero rows, it DROPs the table and rebuilds it from the
snapshot's JSON: columns, defaults, foreign keys, composite PKs,
unique constraints, indexes.
Key design decisions:
- **Target is the current-migration-state snapshot, not the latest on
disk.** The current state is determined by the highest
`__drizzle_migrations.created_at` matched against `_journal.json`'s
`when` timestamps (with backward walk for idx values that lack a
snapshot, like the hand-written 0002). Rebuilding to a *future*
snapshot would introduce columns that drizzle's migrator is about
to add via ALTER TABLE ADD COLUMN, causing duplicate-column errors.
Rebuilding to the *current* snapshot preserves the invariant that
drizzle's pending migrations can run cleanly afterwards.
- **Missing-column gate, not extra-column.** Extra columns alone don't
break anything at runtime (the ORM ignores them); they're leftover
from pre-drizzle manual migrations and might matter to the operator.
Missing columns DO break runtime queries, so only those trigger
rebuild.
- **Empty-table gate.** Non-empty tables log a warning and skip —
data preservation wins over heal, and this path should only ever
hit a pre-drizzle dev DB that never exercised the affected tables
in the first place.
- **Transactional rebuild.** DROP + CREATE + index reinstatement wrap
in a single `db.transaction()` so a partial rebuild rolls back.
Verified against three scenarios via in-memory simulation:
(A) fresh install — heal no-op, drizzle creates everything; (B) pre-
drizzle dev DB with fed_outbox/fed_mutation_log rename drift —
tables rebuilt to 0000 snapshot, drizzle then applies 0001–0004
successfully to reach the current target schema; (C) post-migration-
correct (production-like) — heal no-op, drizzle no-op, schema
unchanged. Live boot on my actual dev DB: migrations complete
silently, server binds :3005, no outbox worker errors. Server tests
110/110, web 131/131, typecheck clean.
No migration files changed. Deployed Pi+VM instances are unaffected
(their schema matches the snapshot exactly — heal won't touch
anything).
Closes backlog #30.
Two small items from the S2S DM unification backlog.
#28 — 4 stale JoinSpace test assertions (test-only fix)
Assertions from the old JoinServer component drifted when the file was
renamed (fc06e25) without being updated: placeholder text, submit
button now disables-when-empty (so the 'Invite code is required' error
path is unreachable from the rendered form), and joinByCode signature
gained a second `origin` argument. Updated each assertion to match
current behavior. No code change. 131/131 web tests now pass.
#29 — Local dev DB missing federation_peers.remote_max_upload_size
Root cause: baselineExistingInstall trusts that any pre-existing
install's schema matches 0000_initial. That breaks for dev DBs from
the pre-drizzle manual migrate.ts system that didn't ran every
idempotent ALTER — 0000 gets marked applied without its columns
actually existing, and a later migration that recreates the table
(0004_cooing_black_knight) crashes. New healInitialSchemaDrift()
walks 0000_snapshot.json, and for each existing table ADDs any
declared-but-missing columns before drizzle's migrate() runs.
Idempotent; production instances are no-op.
Remaining dev-DB drift flagged for a follow-up: federation_outbox and
federation_mutation_log retain pre-rename column names (message_id /
dm_channel_id) that the old manual system renamed to entity_id /
context_id. Both tables are empty on affected DBs, but the clean fix
requires DROP + RECREATE with index reinstatement, beyond #29's
scope. Outbox worker emits SQLITE_ERROR ticks post-boot on unfixed
dev DBs; separate item.
No migration files changed. Deployed instances are unaffected by
either commit (#28 test-only, #29 heals only DBs missing columns —
production DBs aren't missing any). Skip redeploy.
baselineExistingInstall marks 0000_initial as applied when it detects
pre-existing tables, on the assumption the install's schema matches the
0000 baseline. That assumption is false for dev DBs created under the
pre-drizzle manual migrate.ts system that skipped or never ran some of
its idempotent ALTER TABLE steps — for example the b9e4c65 migration
that added federation_peers.remote_max_upload_size. On such DBs, 0000
is marked done without the column actually existing, and a later
migration that recreates the table (0004_cooing_black_knight)
subsequently crashes with "no such column: remote_max_upload_size"
while building its __new_federation_peers SELECT.
Adds healInitialSchemaDrift(): walks every table in 0000_snapshot.json,
and for each table that already exists, ADDs any columns the snapshot
declares but the physical table is missing. Runs immediately after
baselining, before drizzle's migrate() — so later migrations find the
schema they expect. Columns that SQLite's ALTER TABLE ADD COLUMN can't
safely express (PRIMARY KEY; NOT NULL without a default) are skipped
with a warning rather than corrupting data.
Idempotent: on fresh installs and correctly-migrated DBs every column
is already present, so the loop is a no-op. Production Pi+VM instances
are unaffected.
Verification: local dev DB that previously crashed on 0004 now boots
cleanly — federation_peers gained remote_max_upload_size, nonce_supported,
pending_hmac_secret, secret_rotation_at, secret_rotated_at, and
auto_rotate_interval_days; __drizzle_migrations advanced from 4 to 5
entries; server binds :3005. 110/110 server tests + 131/131 web tests
still pass.
Not covered: a deeper drift on federation_outbox /
federation_mutation_log where the physical tables retain pre-rename
column names (message_id / dm_channel_id) instead of the current
entity_id / context_id. Heal skips those (NOT NULL without default)
and the outbox worker emits SQLITE_ERROR ticks post-boot. Both tables
are empty on affected dev DBs, but a clean fix requires DROP +
RECREATE with index reinstatement which is out of #29's stated scope
("column existing"). Flagged for a follow-up.
Closes backlog #29.
Four assertions drifted from the current JoinSpace modal, carried over
when the file was renamed from the old JoinServer component in fc06e25
without being updated:
- Placeholder was expanded to cover URL-form invite input
('e.g. abc123' → 'e.g. abc123 or https://instance.com/join/abc123').
- 'shows validation error when submitting empty code' asserted a code
path that no longer exists: the submit button is now disabled when
the trimmed input is empty (JoinSpace.tsx line 166), so clicking it
is a no-op and the 'Invite code is required' error from the parser
is unreachable from the rendered form. Replaced with an assertion
that the button is disabled while the input is empty — the actual
validation UX.
- joinByCode signature took on a second `origin` argument during the
S2S DM unification + federated-join work (spaceStore.ts line 69).
parseInviteInput returns { code, origin: undefined } for a bare
code, so the call is `joinByCode('my-invite-code', undefined)`.
Assertion updated to match exactly.
No code behavior change — tests now reflect actual behavior, which
was already correct and deployed. Closes backlog #28.
Close backlog #27: extract cross-store resolvers into a neutral utility
(packages/web/src/utils/crossStoreResolvers.ts) to break a TDZ cycle
between spaceStore and instanceStore. instanceStore's top-level
setResolver calls used to race with spaceStore's `let _getApiForOrigin`
declaration when the module graph was entered from instanceStore
(JoinSpaceModal → useInstanceStore), crashing with "Cannot access
'_getApiForOrigin' before initialization" and preventing
InviteModal.test.tsx and JoinSpace.test.tsx from loading.
Moves the three resolver lets + setters + pure getters + the
WS-populated user-ID cache into the utility; spaceStore re-exports the
public surface; instanceStore imports the setters directly from the
utility (re-exports do not resolve at module-init time under vite-ssr
in the cycle). authStore-using wrappers (resolveUserOrigin,
getLayoutHomeOrigin, getMyUserIdForOrigin) stay in spaceStore but
delegate to the utility.
Also adds the AudioManager mock to InviteModal/JoinSpace test files
(established pattern) so their suites can load.
Verification: typecheck clean, 127/131 web tests pass (up from 121/121;
+6 newly unlocked), server 90/90 unchanged. The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (placeholder
expanded, submit button now disable-when-empty) — unrelated to this
work, made visible only because the suite loads now.
Spec: docs/systems/client-federation.md updated.
Smoke test: vite dev bundle serves spaceStore + crossStoreResolvers
clean; full live E2E blocked by a pre-existing local DB-migration
error unrelated to this client-side refactor (reproduces on main).
instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.
Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).
spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.
Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.
Closes backlog #27.
Triage and fix of 12 pre-existing test failures on main (flagged by
the #10 DM origin failover merge, commit ff39ab0).
Two root causes:
1. Node 20+'s built-in localStorage/sessionStorage stub shadows jsdom's
working implementation because vitest's populateGlobal doesn't
overwrite globals outside its known allow-list. Any zustand persist
store threw "storage.setItem is not a function". Polyfilled with an
in-memory Storage in src/test/setup.ts. Resolves 11 of 12 failures
(10 keybindStore + 1 FriendsPage toast).
2. FriendsPage "Message button" DM test carried a stale two-arg
assertion that predated the 2026-04-01 federation refactor (commit
7f3ca4e) which dropped the second argument from addDmChannel.
Dropped the trailing '' so the assertion matches current behavior.
Hand-backs (not touched on this branch):
- InviteModal.test.tsx and JoinSpace.test.tsx still fail to LOAD (not
in the 12 tests but flagged by #10's merge note). After stubbing
AudioManager a second blocker surfaces: TDZ error on _getApiForOrigin
in spaceStore.ts:961, caused by a circular-import init order between
spaceStore and instanceStore (via socialStore → useWebSocket →
voiceStore). Federation-adjacent — filed as backlog item for a
structural fix.
Server: 90/90 unchanged. Web: 121/121 pass (was 109/121), 2 suite
loads still failing (tracked).
The DM "Message button" test asserted addDmChannel was called with two
arguments — the channel and an empty-string origin — but the assertion
has been stale since commit 7f3ca4e ("route DM creation to home instance
with federated identity", 2026-04-01). That refactor made FriendsPage
always route DM creation through the home api client and dropped the
second argument from the addDmChannel call because the remote friend's
instanceOrigin no longer applies — home-created DMs don't need a
channelOriginMap entry (lookups default to '' for missing keys; remote-
delivered DMs still get their origin tagged by useWebSocket).
The two-arg assertion was introduced on 2026-03-25 (commit 277b69a)
against an intermediate form of the code that was later rewritten. Drop
the trailing '' so the assertion matches the current, intentional
one-arg call.
Node 20+ ships a built-in localStorage/sessionStorage stub on globalThis
that throws "storage.setItem is not a function" unless Node is launched
with --localstorage-file=PATH. Vitest's jsdom env only overwrites a
fixed allow-list of globals, and neither storage is in that list — so
Node's broken stub shadows jsdom's working implementation and crashes
any code using zustand's persist middleware.
Override both globals in the test setup with an in-memory Storage
implementation. Resolves 10 keybindStore failures plus 1 FriendsPage
toast failure (all symptoms of the same root cause).
The remaining FriendsPage DM assertion failure is unrelated and is
left for follow-up triage as it touches federation DM-creation
behavior.
Client-side DM origin failover on WS disconnect (backlog #10).
When a remote instance's WebSocket drops mid-session, every DM pinned to
that origin is re-keyed to a connected sibling that mirrors the same
federated DM via S2S replication. Covers the channel-ID-per-origin
reality (each instance assigns its own local Snowflake; only federatedId
is shared) by keeping a `dmAlternatives: Map<federatedId, Map<origin,
localChannelId>>` on spaceStore, populated by every `ready` payload
regardless of dedup outcome. On transition, `rekeyDmChannel` atomically
renames the DM across spaceStore (dmChannels / channelOriginMap /
channelLastMessageIds / dmAlternatives), chatStore (messages / hasMore /
scrollPositions / channelAccessTimes / typingUsers / readStates /
unreadChannels / currentChannelId), and the URL (history.replaceState
when viewing the rekeyed DM). Triggers: setInstanceStatus on
connected→disconnected|error, and disconnectInstance / forceRemoveEntry
before removeInstanceSpaces. Voice state (activeDmCall / outgoingCall /
incomingCall) is intentionally not rewritten — LiveKit rooms can't
migrate across origins.
As an in-scope adjacent fix (§3.11 of the spec), `dm_message_created`
now consults `dmAlternatives` before its legacy 2-member-identity
fallback via a new `resolveDmChannelId(rawId)` helper. This closes a
pre-existing phantom-sidebar-entry bug for group DMs in multi-instance
sessions and handles post-failover routing when the reconnected
original origin's WS still addresses the DM by its old local id.
Live verification on Pi+VM (youruser@nova.ddns.net with Orbit.Backspace
as remote): (1) baseline — DM pinned to home, WS drop of remote is a
no-op, reconnect clean, no flap; (2) forced-rekey path — WebSocket
construction delayed on wss://nova.ddns.net via a client-side patch
so orbit's `ready` arrived first, pinning the Nova DM to orbit with
orbit's local id. Stopping the orbit container triggered
failoverDmOriginsFromDisconnected; URL auto-swapped from
`/channels/@me/<orbit-local-id>` to `/channels/@me/<nova-local-id>`
via history.replaceState, the chat view re-fetched from nova via the
new primary id, and subsequent message sends routed to nova. Restart
of orbit left the pin on nova — no re-home flap (§3.6). Group-DM
phantom fix covered by unit tests (9 in dmOriginFailover.test.ts plus
contract test); not exercised live because it requires concurrent
delivery from the non-primary origin's WS, which the forced-rekey
session didn't naturally produce.
Design: internal notes
Plan: internal notes
Pre-existing test failures on main (keybindStore, FriendsPage,
InviteModal, JoinSpace — 12 tests) are unchanged by this branch.
New subsection under client-federation.md's origin-aware routing section
covering dmAlternatives, failoverDmOriginsFromDisconnected, rekey flow,
trigger points, the intentional cache-flush trade-off, voice-out-of-scope,
no-re-home policy, and the WS routing contract. dm-system.md gets a
one-line cross-reference from the Client routing bullet.
Adds a new resolution step before the legacy 2-member-identity fallback:
if the event's dmChannelId is an alternate-origin local id for a DM
whose primary is in dmChannels, route the message to the primary via
resolveDmChannelId. Covers 1-on-1 AND group DMs uniformly — closes a
pre-existing phantom-sidebar-entry bug for group DMs in multi-instance
sessions and handles post-failover routing when the reconnected original
origin's WS still addresses the DM by its old local id.