Commit Graph
440 Commits
Author SHA1 Message Date
Jannis Braun a9bf5aeb53 fix(dm): include federatedId in POST /api/dm idempotent existing-DM response
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.
2026-04-25 00:59:16 +02:00
Jannis Braun 618056659e fix(federation): persist remote instance_name from /peer/initiate handshake response
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'.
2026-04-25 00:48:42 +02:00
Jannis Braun 18d6b0acfa fix(federation): persist remote instance_name when ensurePeered/performHandshake succeeds
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.
2026-04-25 00:45:06 +02:00
Jannis Braun eba16e16a3 fix(federation): include own instanceName in /peer/accept response body
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.
2026-04-25 00:42:11 +02:00
Jannis Braun 7fe9476e57 fix(federation): persist peer instance_name on /peer/accept activation paths
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).
2026-04-25 00:35:42 +02:00
Jannis Braun ab5e7c8839 refactor(server): delete baseline + heal functions now unreachable under single-baseline history
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.
2026-04-24 23:33:35 +02:00
Jannis Braun 707d3217f1 refactor(server): squash drizzle migrations 0000-0004 into single baseline
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.
2026-04-24 23:26:20 +02:00
Jannis Braun 2dbd2b9b9f test(server): add undeliverable:[] to processRelayEvents mock (#18)
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.
2026-04-24 21:24:18 +02:00
Jannis Braun 26a4925032 feat(server): reclassify undeliverable targeted-peer as no_recipient failure (#18)
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.
2026-04-24 21:11:11 +02:00
Jannis Braun 7d2137b6d4 feat(server): sendCallRelay surfaces undeliverable messageIds (#18)
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.
2026-04-24 21:05:28 +02:00
Jannis Braun 7533d8ca14 feat(server): Path A connection gate + undeliverable on zero ringee (#18)
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.
2026-04-24 21:01:19 +02:00
Jannis Braun 792634c2cf feat(server): Path B zero-match → undeliverable ack (#18)
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.
2026-04-24 19:34:34 +02:00
Jannis Braun 0057cb4d42 refactor(server): thread undeliverable collector through processRelayEvents (#18)
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.
2026-04-24 19:17:27 +02:00
Jannis Braun 5c94bc7669 refactor(server): remove unused admin_reset PeerDeactivationReason
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.
2026-04-24 01:07:47 +02:00
Jannis Braun 314df6c5a1 feat(server): 30s federated-call sentinel worker (TDD) 2026-04-24 00:49:04 +02:00
Jannis Braun 71445c5f27 feat(server): wire onPeerDeactivated at admin revoke/reset sites
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.
2026-04-24 00:45:02 +02:00
Jannis Braun 8e6639648e feat(server): wire onPeerDeactivated on performHandshake 403 rejection 2026-04-24 00:43:32 +02:00
Jannis Braun 743fdcac97 feat(server): wire onPeerDeactivated at federationWorker peer-deactivation sites 2026-04-24 00:42:20 +02:00
Jannis Braun 0a8949fbfb feat(server): onPeerDeactivated utility mirrors onPeerActivated (TDD) 2026-04-24 00:40:42 +02:00
Jannis Braun 3b61380a1e feat(server): ConnectionManager.evictFederatedCallsForHost (TDD) 2026-04-24 00:37:44 +02:00
Jannis Braun 1e59e7012c fix(server): scope accept-rollback terminal to the acceptor only
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.
2026-04-23 23:38:31 +02:00
Jannis Braun f6252b8ce1 feat(server): fan dm_call_end out on host ring timeout 2026-04-23 23:19:42 +02:00
Jannis Braun 6cd7728f3e feat(server): fanOutCallEvent returns failures; surface to host-side caller 2026-04-23 23:14:50 +02:00
Jannis Braun 3cb80d110d feat(server): aggregate Path-1 call fan-out failures and surface to originator 2026-04-23 23:12:55 +02:00
Jannis Braun 13241345de feat(server): surface dm_call_end relay failure to originator (TDD) 2026-04-23 23:11:20 +02:00
Jannis Braun 5170d316ba feat(server): surface dm_call_reject relay failure to rejector (TDD) 2026-04-23 23:10:18 +02:00
Jannis Braun 07c5b0e7de feat(server): surface dm_call_accept relay failure to acceptor (TDD) 2026-04-23 23:09:18 +02:00
Jannis Braun 06e1ed92e7 refactor(server): add buildFailureFromResult + CallFanoutFailure types 2026-04-23 23:07:07 +02:00
Jannis Braun 06c538b013 fix(server): rebuild empty drifted tables to heal pre-rename column drift
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.
2026-04-23 03:19:56 +02:00
Jannis Braun e703e29f8a fix(server): heal 0000-baseline schema drift after baselining existing installs
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.
2026-04-23 03:00:30 +02:00
Jannis Braun 6ff983b46c fix(federation): treat duplicate rejection as terminal in outbox worker
Duplicate rejection means the peer already has the message (e.g.,
delivered earlier via outbox AND pulled via sync in the same
window). Retrying will fail identically forever until TTL expires.

Before this patch: duplicate-rejected outbox entries were retained
with attempts++ and exponential backoff, creating log noise and
outbox bloat for up to 30 days.

After: duplicate-rejected entityIds join the terminal set alongside
accepted ones and are deleted from the outbox. Logged at info level
('outbox entry removed (terminal)') to distinguish from warn-level
transient-rejection retries.

Other rejection reasons (attribution_mismatch, processing_error,
etc.) stay on the retry path; some may also be terminal but are
deferred until observed accumulating.
2026-04-23 00:10:34 +02:00
Jannis Braun 15e42a7cc1 fix(federation): per-event fault isolation in syncPeerMutationLog (#25)
Replace the batch-level processRelayEvents call with a per-event
loop wrapped in try/catch. On exception: log event type, messageId,
timestamp, peer origin, and the error message; continue to the next
event.

Previously, a single poison-pill event (e.g., UNIQUE conflict from
a malformed relay payload) would throw, be caught by the outer
try/catch, and block lastSyncedAt from advancing — causing every
future activation to retry the same broken window indefinitely.

The final 'replayed N events' log line now reports '(K skipped due
to errors)' when K > 0, surfacing the count to operators. Individual
event failures are logged via console.error with enough context to
debug or replay manually.

Trade-off documented in docs/systems/federation.md: forward progress
of the sync pipeline takes priority over strict at-least-once
delivery. An event that fails to process is lost to the receiver
unless replayed manually.
2026-04-22 01:45:14 +02:00
Jannis Braun 911c7e3479 fix(federation): ensurePeered must not auto-heal needs_attention peers
Discovered during live verification of #10b Scenario 3: the switch
in ensurePeered had no case for needs_attention, so it fell through
to performHandshake. Because the /peer/accept idempotent-200-no-update
safeguard covers needs_attention on the inbound side, the remote
returned 200 without writing the new secret, and performHandshake
transitioned the local peer to 'active' on the 200 response — auto-
healing a state that requires admin intervention.

Affected paths: sendCallRelay non-blocking warm-up (used by typing
relay); any future caller of ensurePeered on a needs_attention peer.
Not affected: resolvePendingPeers (already filters on status='pending').

Fix: explicit case 'needs_attention' returning { status: 'rejected',
error }. Caller observes the rejection and does not advance state.
2026-04-22 01:27:07 +02:00
Jannis Braun 3fe7500ff0 feat(federation): /sync serializers for 5 new event types
Adds contextType='profile' query branch. Extends the DM-pass
mutation_type IN-clause to include dm_close, dm_reopen,
read_state_update, file_rejected (events with no associated
dm_messages row). Builds channelFederatedIdMap for O(1)
federatedId resolution. Adds serializer branches for all five
new event types (dm_close/dm_reopen, read_state_update,
file_rejected, profile_update) that emit FederationRelayEvent
objects compatible with the existing inbound processors.

Inbound processors (processDmCloseEvent, processDmReopenEvent,
processReadStateUpdateEvent, processProfileUpdateEvent,
processFileRejectedEvent) already exist; sync replay feeds
events through processRelayEvents without any new receive-side
code.
2026-04-22 00:53:46 +02:00
Jannis Braun a23e02339e feat(federation): capture dm_close/reopen/read_state/profile/file_rejected in mutation log
Four event types previously bypassed appendMutationLog, making
them unrecoverable via /api/federation/sync after peer inactivity:
  - queueDmCloseRelay (dm_close, dm_reopen)
  - queueReadStateRelay (read_state_update)
  - handleSizeRejection in federationWorker (file_rejected)
  - profile PATCH route (profile_update) — two call sites,
    one appendMutationLog per profile change (not per target origin)

The /api/federation/sync response builder is extended to
serialize these event types in the next task.
2026-04-22 00:48:49 +02:00
Jannis Braun 250596c0f6 feat(federation): wire onPeerActivated into 8 transition sites
Every code location that sets federation_peers.status='active'
now invokes onPeerActivated(peerId, reason). HTTP handler sites
use fire-and-forget (.catch(log)) so the response isn't blocked
by sync-pull pagination. The worker-internal health-check site
awaits the handler since the tick is already async.

Sites: /peer/initiate, /peer/accept (4 branches), /approval-
requests/:id/approve, health check recovery, ensurePeered/
performHandshake.
2026-04-22 00:39:58 +02:00
Jannis Braun 57d7ca66d3 fix(federation): explicit per-status handling in queueOutboxEvent
Replaces the silent UNIQUE-swallow placeholder branch. Each peer
status has an explicit branch:
  active/pending/unreachable: race-catch — re-fetch peer row and
    enqueue via matchedPeers. Previously skipped silently, losing
    real-time delivery under asymmetric failure.
  awaiting_approval/needs_attention/rejected/revoked: drop with
    logged reason. Mutation log still captures; sync-pull on
    activation replays.
  default: exhaustiveness check (no 'as never' cast) — TypeScript
    enforces that every status value is handled explicitly.
2026-04-22 00:34:43 +02:00
Jannis Braun ae035eba9b fix(federation): remove dead processRelayEvents import
Missed in 02a1ed7. The new sync-pull path in federationPeerActivation.ts
uses a dynamic import of processRelayEvents from routes/federation.js;
the static import in federationWorker.ts is no longer used after
runInitialSyncForNewPeers deletion.
2026-04-22 00:30:54 +02:00
Jannis Braun 02a1ed73f4 refactor(federation): replace runInitialSyncForNewPeers with startupBootstrapSync
The per-peer sync body is now syncPeerMutationLog (in the new
peer-activation module), invoked via onPeerActivated. The startup
path scans for status='active' AND lastSyncedAt=0 and calls the
unified handler for each — same trigger condition as before, unified
code path with runtime transitions.
2026-04-22 00:28:18 +02:00
Jannis Braun cca2245cdf feat(federation): implement onPeerActivated with dedup
Two-invariant handler: resetOutboxBackoff + syncPeerMutationLog.
In-flight map keyed by peerId coalesces concurrent activations —
a second call for a peer whose activation is still running shares
the same promise. Errors are swallowed and logged — the handler
never throws so fire-and-forget callers at HTTP handler sites
are safe.
2026-04-22 00:24:57 +02:00
Jannis Braun 39c43032e8 fix(federation): address Task 3 review — pagination test + polish
- Add pagination-advance test: verifies since=checkpoint on second
  iteration within a pass, and each pass re-seeds since from
  peer.lastSyncedAt (not carried from prior pass).
- Eliminate four peer! non-null assertions by capturing the narrowed
  value in activePeer after the guard.
- Tighten bodyObj type from Record<string, unknown> to a local
  SyncRequestBody type alias.
- Drop the no-op federationRelayEnabled UPDATE in test beforeEach
  (default is already 1 per baseline migration).
2026-04-22 00:21:34 +02:00
Jannis Braun 5c1b42938e feat(federation): implement syncPeerMutationLog
Three-pass pull-sync (dm, friend, profile) from peer's
/api/federation/sync endpoint, paginated. Seeds sinceTimestamp from
peer.lastSyncedAt so a recovered peer pulls only the delta. Updates
lastSyncedAt to Date.now() on full success; leaves it untouched on
transient failure so the next activation retries the same window.
Replaces the body of the soon-to-be-removed runInitialSyncForNewPeers.
2026-04-22 00:15:48 +02:00
Jannis Braun fd37e0c604 feat(federation): implement resetOutboxBackoff
Unconditionally resets nextRetryAt=now and attempts=0 for all
outbox entries of the given peer. No WHERE filter on nextRetryAt —
resetting attempts=0 on already-eligible rows is the correctness fix:
without it, a previously-failed entry keeps stale attempts, and its
next failure uses BACKOFF_SCHEDULE_MS[attempts] (5min to 24h) on a
peer that just recovered.
2026-04-22 00:10:27 +02:00
Jannis Braun 14a96efa58 feat(federation): scaffold peer-activation recovery module
Empty stubs for onPeerActivated, resetOutboxBackoff, syncPeerMutationLog,
and startupBootstrapSync. Functions are filled in by subsequent tasks
following TDD cycles.
2026-04-22 00:06:17 +02:00
Jannis Braun 531104fecc fix(federation): handle 202 in admin /peer/initiate handshake
/peer/initiate checked `response.ok` to decide whether to activate the
local peer. `response.ok` is true for the full 2xx range, so a remote
that returned 202 (queued for admin approval — autoAcceptPeering off
on their side) caused the local peer to flip to `active` while the
remote had us `awaiting_approval`. The split only self-healed when
the remote admin approved and pushed us an `awaiting_approval → active`
override via the peer_approval_requests inbound path.

The auto-peer flow in federationPeering.ts:performHandshake already
had the correct 202 branch: set local status to awaiting_approval,
broadcast federation_peers_changed, surface a pending outcome. Mirror
it here:

- Check response.status === 202 BEFORE the !response.ok branch so the
  fall-through can't reach the activation code.
- Transition local peer to awaiting_approval (not active).
- Broadcast federation_peers_changed so other admin tabs refresh.
- Return 202 with the sanitized peer so the client observes the
  queued state distinctly from both success and failure.

Also added the missing federation_peers_changed broadcast on the
activation (200) path for parity with every other peer-state-change
site in the codebase — it was a pre-existing drift that would leave
sibling admin tabs stale after an initiate. Pattern-aligned with
federationPeering.ts:160 and the rest of routes/federation.ts.

Docs: expanded Phase 1 bullets in docs/systems/federation.md to cover
the 200 / 202 / other non-2xx / network-error branches explicitly and
reference the mirrored auto-peer branch.

Verified: pnpm -r typecheck clean (shared + server), vitest 70/70
pass.

Closes #21 from S2S DM unification backlog.
2026-04-21 22:41:44 +02:00
Jannis Braun 95c9666213 build(server): drop stale project reference to @backspace/shared
The three typecheck failures in ws/events.ts for DmCallUndeliverable{Failure,Reason}
and 'dm_call_undeliverable' looked like missing exports from @backspace/shared,
but the types are fully defined and exported in packages/shared/src/types.ts
(lines 361, 367, 429). The real cause was architectural.

packages/server/tsconfig.json declared:

    "references": [{ "path": "../shared" }]

TypeScript project references make the dependent project's typecheck consume
the referenced project's *build output* (dist/*.d.ts), not its sources. The
shared package's dist is gitignored, is not rebuilt by any script before
`pnpm -r typecheck` or `pnpm --filter @backspace/server typecheck`, and the
referenced-project-stale failure mode surfaces as TS6305 in the server, or
(when the dist is present but older) as "no exported member" for types
that were added after the last shared build. The #16 merge (which added
DmCallUndeliverableFailure/Reason and the dm_call_undeliverable event
variant) landed the types in src but the local dist was never refreshed,
so server's typecheck started failing against the stale .d.ts.

packages/web/tsconfig.json already resolved @backspace/shared via
`moduleResolution: "bundler"` + the package.json `exports` field, which
points directly at ./src/types.ts. That path has no build-ordering
dependency, never goes stale, and already typecheck-passes cleanly.

Fix: remove the server-side project reference so server matches web's
bundler-style resolution. Server still emits its own dist on `tsc` (its
rootDir confines emission to its own src/); the runtime already reads
TS directly via tsx, so nothing in the dev or prod run path changes.
The Dockerfile/root build scripts that build shared explicitly are also
unaffected.

Verified: `pnpm -r typecheck` passes for shared and server; standalone
`pnpm exec tsc --noEmit` in packages/web passes; `pnpm build` completes
all three packages.

Fixes #24 (pre-existing typecheck failure on main).
2026-04-21 22:27:51 +02:00
Jannis Braun 010aa7f7ca fix(schema): normalize federation_peers.consecutive_failures to NOT NULL
The column was `integer DEFAULT 0` (nullable) since the initial schema.
Counters should not be nullable — the semantics are a count, not an
optional measurement. `consecutive_auth_failures` (added later) was
correctly declared NOT NULL; tightening `consecutive_failures` to match
removes the drift and eliminates the "|null" burden everywhere the value
is read.

SQLite does not support in-place ALTER … SET NOT NULL, so drizzle-kit
cannot auto-generate this. The manual migration uses the standard
SQLite recreate pattern (new table + INSERT SELECT + DROP + RENAME +
recreate index) under `PRAGMA defer_foreign_keys = ON` so the existing
federation_outbox → federation_peers FK survives the swap. The COPY
step coalesces any hypothetical NULL to 0 defensively; live probes on
both test instances (nova, orbit) showed zero NULL rows so no
actual backfill is required.

Verified by applying the full migration chain against a copy of the VM's
live DB: column ends as `notnull=1 dflt=0`, the peer row is preserved,
the unique index on origin is recreated, NULL inserts are rejected, and
`PRAGMA foreign_key_check` reports no violations.

Server `SanitizedPeer.consecutiveFailures` tightened to `number` to
match the new drizzle inference and the shared `FederationPeer` shape.

Follow-up #22 from S2S DM unification backlog.
2026-04-21 22:21:06 +02:00
Jannis Braun 0d74d1d112 perf(federation-worker): tighten health-check cadence to 15 min
HEALTH_CHECK_INTERVAL_MS was 1 h, but ROTATION_GRACE_PERIOD_MS is 15 min.
Phase skew between two peers' health-check ticks could stretch rotation
finalization desync up to ~1 h, during which signatures from the already-
finalized side verify against the other side's primary-only secret (grace
has expired; verifyPeerSignature stops trying the pending secret). With
AUTH_FAILURE_THRESHOLD = 5 and the existing backoff schedule, this
occasionally tripped legitimate rotations into needs_attention.

Setting the interval to 15 min (= ROTATION_GRACE_PERIOD_MS) guarantees a
finalization tick fires within one grace window on each side, so the
cross-verification window where one peer signs with NEW while the other
still treats NEW as pending cannot outlast the grace period.

Per-tick cost is negligible for the worker's steady state: the only
network fetches are per-active-peer /peer/rotate calls when the 90-day
rotation interval hits (rare) and per-unreachable-peer /instance/info
health pings (bounded by outage count). Going lower than 15 min would
reduce the residual desync but increase tick overhead with diminishing
returns; 15 min is the grace-period-aligned value that the original spec
("runs hourly") deviated from without justification.

Follow-up #20 from S2S DM unification backlog; reduces #19 false-positive
rate (outbox auth-failure transition) on legitimate rotations.
2026-04-21 22:16:09 +02:00
Jannis Braun 0c2864a3d2 feat(federation): add POST /api/federation/peers/:id/reset
Admin-only endpoint for recovering from needs_attention. Deletes the
local peer row; FK cascade removes queued outbox entries. Gated to
peers in needs_attention to prevent accidental resets of healthy
peerings (use /peers/:id for revoke on active peers).

Also extends the Task 8.5 test mock of '../db/index.js' to re-export
`schema`. federation.ts imports `schema` from the re-export alongside
`getDb`; the previous mock only exposed `getDb`, causing the route
handler to blow up with 500s before reaching any assertion. This is
a scaffolding fix — no test assertions were changed.
2026-04-21 20:56:26 +02:00
Jannis Braun 54f32657e5 test(federation): add route-level tests for POST /peers/:id/reset
Four cases from the spec's testing strategy: 404 on missing peer, 400 on
wrong status, 403 for non-admin, and successful delete including FK
cascade of queued outbox entries. Introduces a minimal Fastify-inject
harness for route testing — previously the codebase had only pure-function
unit tests under utils/.

Tests intentionally FAIL at this commit — Task 8 will add the handler and
close the loop.
2026-04-21 20:52:14 +02:00