Commit Graph
1473 Commits
Author SHA1 Message Date
Jannis Braun 8cae4c0b7c chore(test): migrate federationPeering.test.ts to in-memory drizzle
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.
2026-04-26 23:42:08 +02:00
Jannis Braun ed5a6aa14c Merge branch 'feat/outbound-peering-gate' 2026-04-26 23:30:44 +02:00
Jannis Braun 6ba8b9256b fix(federation): broadcast peering_subscription_changed on terminal state
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)
2026-04-26 23:20:31 +02:00
Jannis Braun e35b44c05f docs(systems): outbound peering gate documentation across federation, db, api, ws, admin, client-federation
- federation.md: Outbound Peering Gate subsection (gate, intent contract,
  gate-but-don't-queue split, lifecycle invariant on onPeerActivated)
- database.md: peer_approval_requests direction + nullable hmac_secret +
  UNIQUE relaxation + CHECK; new peer_approval_subscribers + peer_approval_
  notifications tables
- api.md: /approval-requests direction-branched approve/deny + extended GET
  response; new peering-subscriptions and peering-notifications endpoints
- websocket.md: peering_subscription_changed, peering_notification_received
  events; federation_approval_request_received fires for outbound too
- admin.md: outbound row rendering with subscriber list
- client-federation.md: 'admin_required' status, peer_pending_local_admin
  error code, new Connections surfaces, federationStore slice

Spec §11 closes the Approve-button investigation finding.
2026-04-26 22:59:27 +02:00
Jannis Braun 4fd67fa7dd fix(web): map peer_pending_local_admin to user-facing message
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.
2026-04-26 22:43:52 +02:00
Jannis Braun cebbd5c859 feat(web): user-facing pending peering subscriptions and outcome notifications
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).
2026-04-26 22:42:53 +02:00
Jannis Braun eddf2254cc feat(web): render outbound peering requests with subscriber list in admin panel
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).
2026-04-26 22:35:16 +02:00
Jannis Braun 51da827089 feat(web): API client methods for peering subscriptions and notifications
- 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).
2026-04-26 22:32:36 +02:00
Jannis Braun e82ccbde62 fix(federation): tighten peering-notifications GET response to spec shape
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.
2026-04-26 22:27:43 +02:00
Jannis Braun 4064d822cd fix(janitor): restore inbound /peer/denied expiry notification
Task 9's first pass replaced inbound expiry's signed /peer/denied
POST with a plain delete, citing symmetric independent expiry as
the design. The spec at §4.10 explicitly said 'inbound row cleanup
unchanged' — that was scope creep, not a fix. Inbound expiry now
preserves the pre-branch behavior verbatim: signed POST to remote,
delete only on success, retry on failure. Outbound expiry's fanout
logic (the actual Task 9 scope) is unchanged.
2026-04-26 22:22:34 +02:00
Jannis Braun 86ac54b913 feat(federation): peering-notifications endpoints + janitor expiry fanout
- GET /api/federation/peering-notifications (?unread=1 filter)
- POST /api/federation/peering-notifications/:id/read (per-row mark-read)
- POST /api/federation/peering-notifications/read-all (bulk mark-read,
  preserves already-read readAt; returns affected count)
- janitor: outbound expired rows fan out kind='expired' notifications
  to each subscriber before cascade-deleting parent (replaces Task 1's
  scaffolding 'continue' guard); inbound expired rows are deleted outright
  (both sides expire independently — no cross-instance network call)
- janitor: 30-day cleanup pass for read notifications
  (cleanupReadPeeringNotifications); unread rows persist indefinitely
- cleanupExpiredApprovalRequests is now sync (no async network IO)

Notes:
- No WS broadcast on janitor expiry — offline users see notifications on
  next GET, matching the persistence guarantee of the notifications table.
- The previous /peer/denied network call on inbound expiry is removed;
  symmetric per-side expiry now handles termination on both sides.

Tests: 12 new (3 + 4 endpoint cases on 3 endpoints; 2 outbound/inbound
janitor expiry cases + 1 not-yet-expired guard + 1 retention sweep);
247 server tests passing total.
2026-04-26 22:18:44 +02:00
Jannis Braun 45ab0ea2c8 feat(federation): peering-subscriptions GET and DELETE endpoints
User-facing endpoints for the pending-peering-approvals UI surface.
GET lists the user's own pending subscriber rows joined to parent
peering requests. DELETE cancels a single subscription; if it was
the last subscriber, cascade-deletes the parent (admin queue updates
via federation_peers_changed). Authorization: subscriber.userId must
match request.userId.
2026-04-26 22:09:41 +02:00
Jannis Braun 42355ee889 feat(federation): direction-branched approve and deny for outbound queue
- /approve on outbound: generates HMAC, sends /peer/accept to remote.
  200 -> activate peer + onPeerActivated cleanup. 202 -> awaiting_approval,
  capture token, queue row + subscribers REMAIN. 4xx/5xx/network -> clean
  up peer row, leave queue for admin retry.
- /deny on outbound: fans out kind='denied' notifications, cascade-deletes
  parent + subscribers, broadcasts admin event. No remote network call.
- /approve and /deny on inbound: existing behavior preserved verbatim.
- GET /approval-requests: response includes direction; outbound rows
  carry subscribers[] (joined with users.username, possibly empty).
- Removes Task 1's temporary /deny scaffolding guard now that the
  direction-branched dispatcher handles outbound rows correctly.
- Updates docs/systems/federation.md to describe direction-branched flow.
2026-04-26 22:01:14 +02:00
Jannis Braun 7cc360eb11 feat(federation): fanout outbound subscribers on peer activation (any path)
The fanoutOutboundSubscribers helper is called from onPeerActivated,
the single point all peer activations flow through (queue approval,
/peer/initiate, autoAccept=1 remote, mutual-approval token verification).
Subscribers see kind='approved' notification, parent + subscriber rows
cascade-delete. Critical: cleanup triggers on status->active, not on
the local approve action — when the remote also gates, the peer goes
to awaiting_approval first and subscribers must remain queued.
2026-04-26 21:46:32 +02:00
Jannis Braun 4d4dc383d7 feat(federation): pass explicit intent at every ensurePeered call site
- social.ts friend-add: user_action, with 409 peer_pending_local_admin
  when gate fires
- /peer/ensure: user_action, surfaces peeringStatus: 'admin_required'
- sendCallRelay (typing warm-up + call relay): system intent
- federationWorker resolvePendingPeers: system intent (defensive — gate
  is unreachable from here since pending rows already exist)
- CallRelayFailureReason: peer_admin_required added (mapped to
  peer_transient_failure on the user-facing event surface, since system
  intent should never legitimately surface admin_required)
- Test files: thread intent arg through racePeering and ensurePeered
  calls (positional shift from racePeering signature change)
- outboundGate.test.ts: tighten noUncheckedIndexedAccess access via
  non-null assertions after toHaveLength()
- docs/systems/social.md: peer_pending_local_admin error code documented
2026-04-26 21:37:19 +02:00
Jannis Braun ef80e3b416 test(federation): outbound gate behavior across autoAccept settings and intents 2026-04-26 21:28:06 +02:00
Jannis Braun 6ee8663e9e fix(federation): narrow trust-guard query to inbound rows only
The pre-existing trust-guard's query filtered peer_approval_requests by
origin only. After Task 3 added outbound rows to the same table, the
guard started matching the user's own queued outbound row on retry,
returning 'rejected' with a misleading 'admin must resolve pending
approval' copy instead of the intended 'admin_required'. The variable
name (pendingInbound) and comment block already described the intent
as inbound-only — the query just didn't match. Adding direction='inbound'
to the where clause restores the intended behavior.
2026-04-26 21:23:55 +02:00
Jannis Braun 50bc510e8d feat(federation): outbound gate in ensurePeered with required intent argument
- New 'admin_required' EnsurePeeredResult variant.
- Required intent argument (no optional default) prevents future
  user-initiated callers from silently getting system behavior.
- Gate runs only when no peer row exists; toggling autoAccept later
  does not retroactively gate established peer rows.
- queueOutboundApproval helper upserts parent + subscriber rows and
  broadcasts to admins + user.
- Schema enum-narrows direction ('inbound' | 'outbound') and
  notifications.kind ('approved' | 'denied' | 'expired') at the column
  level; drizzle generate confirmed no migration delta.
- Existing call sites now fail typecheck — fixed in Task 5.
2026-04-26 21:18:43 +02:00
Jannis Braun 94c291c472 refactor(shared): rename TriggerReason → PeeringTriggerReason, pin subscribers semantics
- Symmetric with PeeringNotificationKind at module scope; avoids
  future collision with unrelated trigger systems.
- subscribers field doc now explicit: undefined for inbound, present
  (possibly []) for outbound. Pins the response contract before Task 7
  implements the GET endpoint response shape.
2026-04-26 21:14:51 +02:00
Jannis Braun 55af76f8b4 feat(shared): types for outbound peering gate (intent, subscriptions, notifications) 2026-04-26 21:10:32 +02:00
Jannis Braun 57462e69df docs(schema): comment CHECK invariant on peer_approval_requests
drizzle's snapshot does not encode SQL-level CHECK constraints. Without
this comment, a future migration that recreates the table for unrelated
reasons would silently drop the (direction='inbound' → hmac_secret
NOT NULL) invariant.
2026-04-26 21:07:08 +02:00
Jannis Braun b687bfe721 feat(federation): schema for outbound peering gate (direction column, subscribers, notifications) 2026-04-26 21:02:27 +02:00
Jannis Braun c65ff245bd Merge branch 'feat/peer-approval-token'
Closes the receiver-side trust-bypass class in /peer/accept's
awaiting_approval branch via single-use cryptographic approval tokens.
Builds on the cheap fix (4533e36) that landed earlier today.

Schema: nullable approval_token columns on federation_peers and
peer_approval_requests. Wire format: optional approvalToken field on
/peer/accept request body and 202 response body. Receiver verifies
token before promoting awaiting_approval → active. /approve forwards
the stored token in its outbound /peer/accept; performHandshake,
/peer/initiate, and /approve all capture the returned token from 202.

Backward compat: legacy peers (no token) fall through the existing
autoAccept gate — autoAccept=0 queues, autoAccept=1 promotes (no
regression vs prior behavior). Existing active peers untouched.

Live verification on nova + orbit (autoAccept=0 scenario):
- Bypass attempt without token → 202 (queued), peer row untouched.
- Positive case with matching token → 200, secret rotated, token cleared,
  stale approval-request deleted.

Test counts: 289 → 307 (+18 across 4 new test files).
Spec: internal notes
Plan: internal notes
2026-04-26 12:12:43 +02:00
Jannis Braun 94294c64b9 docs(systems): document approval token mechanism
federation.md: new 'Approval Token Verification' subsection covering
issuance (queue path generates token, returns in 202), storage on
initiator (federation_peers.approval_token), forwarding from /approve,
verification on receiver's awaiting_approval branch, single-use lifecycle,
backward compatibility, and threat-model boundary (sender-side outbound
gating tracked separately).

database.md: approval_token column documented on both federation_peers
and peer_approval_requests with cross-references to federation.md.

api.md: /peer/accept request + 202 response now show optional
approvalToken field with pointer to the federation spec.
2026-04-26 11:53:54 +02:00
Jannis Braun fa7a5f831d feat(federation): /approve forwards token + /approve & /peer/initiate capture on 202 + 200 clear
Three changes to keep the outbound /peer/accept call sites consistent
with the new approval-token mechanism:

1. /approve outbound body now forwards approvalToken from the queued
   peer_approval_requests row when present. Receiver's awaiting_approval
   branch verifies it and promotes mutual approval. Legacy null-token
   rows omit the field; receiver falls through autoAccept gate. Spec §3.7.

2. /approve and /peer/initiate 202 paths now capture the approvalToken
   returned by the remote and store it on the local federation_peers row.
   Without this, the symmetric autoAccept=0 mutual-approval flow could
   not verify on the eventual return /peer/accept. Spec §3.7.

3. /approve and /peer/initiate 200 paths now include approvalToken=null
   in the activation UPDATE — single-use lifecycle hygiene per §3.2.

Test coverage: +6 tests (4 in approveOutbound, 2 in peerInitiateOutbound).
Total: 301 → 307. Web tsc clean.
2026-04-26 11:52:04 +02:00
Jannis Braun 058eb992e4 feat(federation): verify approval token before awaiting_approval → active
Receiver-side defense — closes the trust-bypass class the cheap fix
(4533e36) cannot cover. The /peer/accept handler's awaiting_approval
branch now requires an approvalToken matching the one stored on the
local peer row before promoting to active. Without a match:
- autoAccept=0 falls through to queueApprovalRequest (no bypass; new
  approval-request queued, existing awaiting_approval row untouched).
- autoAccept=1 falls back to permissive promotion (no regression vs
  prior behavior, since autoAccept=1 would accept any inbound regardless).

Successful match also deletes any stale approval-request row for the
origin to prevent debris accumulation from prior bypass attempts.

Refactor: queueing path extracted to a top-level queueApprovalRequest()
helper so both the no-existing-peer case and the awaiting_approval
mismatch fallback share one implementation. Helper generates a fresh
single-use token on every call.

Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason
to distinguish the autoAccept=1 fallback path from the verified path
in onPeerActivated audit logs.

Spec §3.5, §3.6.

Test counts: 289 → 301 (+12).
2026-04-26 11:48:33 +02:00
Jannis Braun 9e078c44ba feat(federation): performHandshake captures + clears approval token
- 202 response: parse approvalToken from body and store on the local
  federation_peers row alongside status='awaiting_approval'. Legacy
  receivers that omit the field result in null stored token, handled
  gracefully by the verification logic landing in subsequent commits.
- 200 response: clear approvalToken in the same UPDATE that sets
  status='active' (single-use consumption per spec §3.2).

Test coverage: 4 tests in federationPeering.approvalToken.test.ts covering
JSON token, missing token (legacy), non-JSON body, and 200 clear after
prior token storage.
2026-04-26 11:45:16 +02:00
Jannis Braun 2d8ecd0131 feat(federation): add approval_token columns to peer schema
Nullable text columns on federation_peers and peer_approval_requests.
Existing rows degrade gracefully (NULL token) per spec §5; the verification
logic landing in subsequent commits routes legacy null-token state through
the existing autoAccept gate.
2026-04-26 11:44:06 +02:00
Jannis Braun 4533e36e51 Merge branch 'fix/peer-handshake-trust-bypass' 2026-04-26 00:16:10 +02:00
Jannis Braun 11c5a2bf06 fix(federation): refuse outbound handshake when inbound approval pending
Closes the auto-reconnect trust-bypass: any code path calling
ensurePeered(remote) on an instance with autoAcceptPeering=0 could
previously bypass the admin gate by initiating a fresh handshake to the
remote, which the remote then accepted against its existing
awaiting_approval row.

The trigger surfaced was stores/instanceStore.ts:1010 — the silent
.catch(() => {}) auto-reconnect that fires for any user with the
remote in their replicatedInstances (commonly: any admin). Anyone with
that profile reloading their session activated peering on both sides
without any admin approval action.

Surgical fix: ensurePeered now returns rejected when an unresolved
inbound peer_approval_requests row exists for the target origin. The
legitimate admin-approve flow (routes/federation.ts:1089) does not call
ensurePeered; it deletes the approval-request and does its own direct
fetch to /peer/accept, so this check does not block legitimate approvals.

The receiver-side trust assumption at routes/federation.ts:619-645
(awaiting_approval branch in /peer/accept) still has the same flaw
— an adversarial peer that knows the timing could re-handshake at the
right moment to flip the receiver to active. That deeper trust-model
rework is plan-grade work tracked at internal notes
2026-04-26-peer-handshake-trust-model.md.
2026-04-26 00:12:42 +02:00
Jannis Braun d5c9cd297e Merge branch 'feat/s2s-friend-add' 2026-04-26 00:01:26 +02:00
Jannis Braun e15661e1bc docs(systems): S2S friend-add — social/client-federation/federation/api
Reflects what shipped on feat/s2s-friend-add (T1-T22 verified live):
- social.md: rewrite §6 outbound friend_request_create flow (sender's
  home is now the queueing instance for native users); §8 sendFriendRequest
  collapsed to single home-API call; §12 drops ConnectInstanceModal
  trigger; new "Failure Handling" subsection covers rollback path;
  relayMessageId schema note added.
- client-federation.md: split paragraph clarifying friend/DM = S2S,
  spaces = client-federated; §1 clarifies federated accounts are now
  spaces-only; new API-client error contract subsection (err.message
  carries the code, not err.body — caught + fixed in fe969a7).
- federation.md: endpoints table + S2S User Lookup subsection;
  TERMINAL_REJECTION_REASONS + permanent-failure callback registry;
  ghost-row note (rollback errors are best-effort).
- api.md: POST /api/social/requests new error-code table; new
  federation lookup route entry.
2026-04-25 23:33:13 +02:00
Jannis Braun fe969a7d96 fix(web): friend-add error toasts surface raw codes — read err.message, not err.body
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.
2026-04-25 23:17:19 +02:00
Jannis Braun 4a939b743f refactor(web): UserProfileModal — toast on server errors, drop ConnectInstanceModal triggers
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.
2026-04-25 22:35:52 +02:00
Jannis Braun 7309f44de5 refactor(web): FriendsPage — toast on server errors, drop ConnectInstanceModal triggers
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.
2026-04-25 22:33:26 +02:00
Jannis Braun 9d3f75b33c feat(web): WS handlers for friend_request_sent + friend_request_relay_failed
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.
2026-04-25 22:29:45 +02:00
Jannis Braun b28bf6646d refactor(socialStore): collapse sendFriendRequest; delete federation error classes
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.
2026-04-25 22:26:58 +02:00
Jannis Braun 0677c21ab9 test(federation): buildFriendContextId determinism (cross-instance invariant)
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.
2026-04-25 22:24:04 +02:00
Jannis Braun bd70950de7 test(federation): receiver accepts home-queued friend_request_create 2026-04-25 22:23:04 +02:00
Jannis Braun d88d369a82 fix(social): friend_request_sent local broadcast must carry target profile
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.
2026-04-25 22:18:47 +02:00
Jannis Braun 1ad1fd1b0c feat(social): broadcast friend_request_sent on local request creation 2026-04-25 22:15:57 +02:00
Jannis Braun bf13e2a223 feat(social): federated branch — authority + self-friend + idempotency
Adds direction-aware idempotency and already-friends checks to
handleFederatedFriendRequest (after stub hydration, before transaction),
plus 6 tests covering authority defense, bare-host normalization, cannot_friend_self,
already_friends, same-direction idempotent 200, and opposite-direction 409.
2026-04-25 22:12:08 +02:00
Jannis Braun 72f4b170f6 test(social): federated branch peer-status + lookup-failure mappings 2026-04-25 22:09:42 +02:00
Jannis Braun 0574a10ff3 feat(social): federated branch for POST /api/social/requests (happy path)
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.
2026-04-25 22:04:36 +02:00
Jannis Braun 08872f1e08 feat(federation): rollbackFriendRequestCreate + side-effect registration 2026-04-25 21:51:04 +02:00
Jannis Braun 9e3417485c feat(federation-worker): treat receiver-ack 4xx reasons as terminal + invoke rollback 2026-04-25 21:46:28 +02:00
Jannis Braun be3eb5284e feat(federation): permanent-failure callback registry 2026-04-25 21:42:57 +02:00
Jannis Braun 03737c0955 feat(federation): resolveOriginFromHostname helper 2026-04-25 21:41:25 +02:00
Jannis Braun fbe2fb370b feat(federation): lookupRemoteUser outbound HMAC helper 2026-04-25 21:37:41 +02:00
Jannis Braun 03cef0e6b3 feat(federation): POST /api/federation/users/lookup endpoint 2026-04-25 21:32:49 +02:00