docs: update federation identity resolution and auth specs for FED-006 fix

- Document extractDomain, findFederatedUser, updated resolveOrCreateReplicatedUser
- Update origin normalization table to reflect bare domain storage
- Mark Known Issue #2 (duplicate user stubs) as resolved
- Document federated stub upgrade behavior in auth registration flow
This commit is contained in:
Jannis Braun
2026-03-31 04:55:51 +02:00
parent 373989aa44
commit c7bdfef038
2 changed files with 51 additions and 31 deletions
+20 -6
View File
@@ -145,12 +145,26 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi
1. Validate inputs (username format, password length) 1. Validate inputs (username format, password length)
2. Check registration is open 2. Check registration is open
3. Check username uniqueness (exact match on lowercased username) 3. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below).
4. Hash password (bcrypt, 12 rounds) 4. Check username uniqueness (exact match on lowercased username)
5. Generate Snowflake ID 5. Hash password (bcrypt, 12 rounds)
6. Insert user row with `status: 'online'`, admin flag if first user 6. Generate Snowflake ID
7. Sign JWT with `{ userId, username }` 7. Insert user row with `status: 'online'`, admin flag if first user
8. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`) 8. Sign JWT with `{ userId, username }`
9. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
### Federated Stub Upgrade
When a user registers with `homeInstance` set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using `findFederatedUser`. If found and the stub has `passwordHash = '!federation-replicated'` (not a real account), the stub is upgraded:
- `passwordHash` is set to the new bcrypt hash (enables login)
- `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`)
- `homeUserId` is backfilled if null
- Missing profile fields (`displayName`, `avatarColor`) are filled
The user's ID remains the same, preserving all existing FK references (DM memberships, messages, reactions, friendships). The user logs in and sees their full history. Returns HTTP 200 (not 201).
If the found user has a real password hash (already registered), the registration returns 409 and the client falls back to login.
### Username Availability Check ### Username Availability Check
+30 -24
View File
@@ -143,22 +143,32 @@ The 15-minute timestamp window prevents replaying old requests. However, there i
### Functions ### Functions
**`resolveLocalUser(homeUserId, db)`** -- `federation.ts:878` **`extractDomain(homeInstance)`** -- `federation.ts`
- Extracts bare domain from a homeInstance value (full URL or bare domain)
- `"https://nova.ddns.net"``"nova.ddns.net"`, `"nova.ddns.net"``"nova.ddns.net"`
- **Use when:** Normalizing homeInstance for comparison or storage
**`findFederatedUser(homeUserId, homeInstance, db, hints?)`** -- `federation.ts`
- Three-tier lookup: homeUserId match → domain + username hint match → not found
- Tier 1: delegates to `resolveLocalUser` (fast path)
- Tier 2: uses `extractDomain(homeInstance)` + `hints.username` to match stubs created by the auth registration path (which may have a different homeUserId)
- Side-effect-free — does not modify any records
- When multiple candidates match in tier 2, prefers real accounts over stubs, then most profile data
- **Use when:** Read-only lookup that needs to find users created by either auth or relay path
**`resolveLocalUser(homeUserId, db)`** -- `federation.ts`
- Read-only lookup. Returns `undefined` if not found. - Read-only lookup. Returns `undefined` if not found.
- Matches: `(users.homeUserId = homeUserId)` OR `(users.id = homeUserId AND homeInstance IS NULL)` - Matches: `(users.homeUserId = homeUserId)` OR `(users.id = homeUserId AND homeInstance IS NULL)`
- Excludes deleted users (`isDeleted = 0`) - Excludes deleted users (`isDeleted = 0`)
- When multiple candidates exist: prefers the one with `homeUserId` set (replicated stub) over a local ID match - When multiple candidates exist: prefers the one with `homeUserId` set (replicated stub) over a local ID match
- **Use when:** Optional lookups where null is acceptable (member_remove, reaction processing, friend_remove) - **Use when:** Optional lookups where null is acceptable (member_remove, reaction processing, friend_remove)
**`resolveOrCreateReplicatedUser(homeUserId, homeInstance, db)`** -- `federation.ts:912` **`resolveOrCreateReplicatedUser(homeUserId, homeInstance, db, hints?)`** -- `federation.ts`
- Calls `resolveLocalUser` first. If found, returns it. - Calls `findFederatedUser` first. If found, backfills `homeUserId` for future fast-path lookups and returns.
- If not found, creates a stub with: - Accepts optional `hints: { username?: string | null }` for tier-2 matching
- `username`: `{homeUserId}@{domain}` (domain extracted from homeInstance URL) - If not found, creates a stub with `homeInstance` normalized to bare domain via `extractDomain`
- `passwordHash`: `'!federation-replicated'`
- `homeInstance`: the full URL passed in
- `homeUserId`: the remote user's home ID
- Collision-safe: appends `_1`, `_2`, ..., `_10` suffix if username exists; after 10 attempts, uses `_<random hex>` - Collision-safe: appends `_1`, `_2`, ..., `_10` suffix if username exists; after 10 attempts, uses `_<random hex>`
- **Use when:** You MUST have a valid user ID (setting `ownerId`, inserting `dm_members`, creating messages) - **Use when:** You MUST have a valid user ID. Always pass `{ username: profile?.username }` when profile data is available.
**`hydrateReplicatedUserProfile(user, profile, db)`** -- `federation.ts:2041` **`hydrateReplicatedUserProfile(user, profile, db)`** -- `federation.ts:2041`
- Updates replicated stubs only (`homeInstance` must be set) - Updates replicated stubs only (`homeInstance` must be set)
@@ -177,16 +187,13 @@ Any code path that sets `ownerId`, creates a `dm_members` row, or inserts a mess
| Location | Format | Example | | Location | Format | Example |
|----------|--------|---------| |----------|--------|---------|
| `users.home_instance` | Bare domain OR full URL | `nova.ddns.net` or `https://nova.ddns.net` | | `users.home_instance` | Bare domain (normalized) | `nova.ddns.net` |
| `federation_peers.origin` | Full URL | `https://nova.ddns.net` | | `federation_peers.origin` | Full URL | `https://nova.ddns.net` |
| `getOurOrigin()` return | Full URL | `https://orbit.ddns.net` | | `getOurOrigin()` return | Full URL | `https://orbit.ddns.net` |
| `resolveOrCreateReplicatedUser` stores | Full URL (passed through) | `https://nova.ddns.net` | | `resolveOrCreateReplicatedUser` stores | Bare domain (normalized via `extractDomain`) | `nova.ddns.net` |
| Auth registration stores | Bare domain | `nova.ddns.net` | | Auth registration stores | Bare domain | `nova.ddns.net` |
The inconsistency exists because: Both user creation paths now store bare domain. A self-healing migration in `migrate.ts` normalizes any existing full-URL `homeInstance` values to bare domain on startup.
- `resolveOrCreateReplicatedUser` stores `homeInstance` as-is from the relay event (full URL)
- The auth registration path (`/api/auth/register` with `homeInstance` param) validates as bare domain only (regex: `/^[a-zA-Z0-9._-]+$/`)
- Relay event payloads populate `homeInstance` from `getOurOrigin()` (full URL) or from `user.homeInstance || getOurOrigin()` (which falls back to full URL)
**Normalization pattern used in code:** **Normalization pattern used in code:**
```typescript ```typescript
@@ -815,17 +822,16 @@ If the `federation_mutation_log` table exists but is empty, populates it with `c
- `getFriendEventTargets()` (`federationOutbox.ts:376-379`) -- compares `fromHomeInstance`/`toHomeInstance` against `getOurOrigin()` without normalization. When a local user's `homeInstance` is null, the fallback `domainOrigin` (full URL) is used, which works correctly. But when a user has `homeInstance` stored as a bare domain and that domain is **this instance** (e.g., an old replicated stub), the comparison `bareDomain !== fullUrl` evaluates to true, incorrectly including the local instance as a target, which then silently drops in `queueOutboxEvent`. - `getFriendEventTargets()` (`federationOutbox.ts:376-379`) -- compares `fromHomeInstance`/`toHomeInstance` against `getOurOrigin()` without normalization. When a local user's `homeInstance` is null, the fallback `domainOrigin` (full URL) is used, which works correctly. But when a user has `homeInstance` stored as a bare domain and that domain is **this instance** (e.g., an old replicated stub), the comparison `bareDomain !== fullUrl` evaluates to true, incorrectly including the local instance as a target, which then silently drops in `queueOutboxEvent`.
- `federationWorker.ts:424` -- `user.homeInstance === ourOrigin` in `handleSizeRejection`. Bare domain homeInstance won't match, potentially including a user in `affectedUserIds` who shouldn't be (minor). - `federationWorker.ts:424` -- `user.homeInstance === ourOrigin` in `handleSizeRejection`. Bare domain homeInstance won't match, potentially including a user in `affectedUserIds` who shouldn't be (minor).
### 2. Duplicate User Stubs ### 2. Duplicate User Stubs (RESOLVED)
The same remote user can have multiple replicated records. Code paths that call `resolveOrCreateReplicatedUser`: **Root cause:** Two independent code paths (auth registration and S2S relay) created user stubs with different identity key formats and different homeInstance formats.
- `processCreateEvent` (for each participant)
- `processMemberAddEvent` (bootstrap roster + incremental add + owner + addedBy)
- `processOwnershipTransferEvent` (new owner)
- `processFriendRequestCreateEvent` (sender)
- `processFriendRequestUpdateEvent` (recipient)
- `processFriendAddEvent` (both users)
`resolveLocalUser` (called first by `resolveOrCreateReplicatedUser`) matches on `homeUserId` OR `(id = homeUserId AND homeInstance IS NULL)`. If a user was created via auth registration (with `homeInstance` as bare domain) and later via relay (with `homeInstance` as full URL), `resolveLocalUser` may not find the first record if the IDs differ. The collision-safe username suffix ensures the insert succeeds, but now two stubs exist for the same person. **Fix (FED-006):**
- `findFederatedUser` provides a unified three-tier lookup that bridges both paths (homeUserId match → domain+username hint match → not found)
- `resolveOrCreateReplicatedUser` normalizes homeInstance to bare domain on write
- Auth registration upgrades existing stubs instead of creating duplicates (`auth.ts`)
- Self-healing migration in `migrate.ts` normalizes existing homeInstance values and merges duplicate pairs
- All `resolveOrCreateReplicatedUser` call sites pass `{ username: profile?.username }` hints for tier-2 matching
### 3. Silent Failures in queueOutboxEvent ### 3. Silent Failures in queueOutboxEvent