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:
+20
-6
@@ -145,12 +145,26 @@ If `requestedAvatarColor` is provided and is in `AVATAR_COLORS`, use it. Otherwi
|
||||
|
||||
1. Validate inputs (username format, password length)
|
||||
2. Check registration is open
|
||||
3. Check username uniqueness (exact match on lowercased username)
|
||||
4. Hash password (bcrypt, 12 rounds)
|
||||
5. Generate Snowflake ID
|
||||
6. Insert user row with `status: 'online'`, admin flag if first user
|
||||
7. Sign JWT with `{ userId, username }`
|
||||
8. Return `{ token, user }` (user sanitized via `sanitizeUser(user, true)`)
|
||||
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. Check username uniqueness (exact match on lowercased username)
|
||||
5. Hash password (bcrypt, 12 rounds)
|
||||
6. Generate Snowflake ID
|
||||
7. Insert user row with `status: 'online'`, admin flag if first user
|
||||
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
|
||||
|
||||
|
||||
+31
-25
@@ -143,22 +143,32 @@ The 15-minute timestamp window prevents replaying old requests. However, there i
|
||||
|
||||
### 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.
|
||||
- Matches: `(users.homeUserId = homeUserId)` OR `(users.id = homeUserId AND homeInstance IS NULL)`
|
||||
- Excludes deleted users (`isDeleted = 0`)
|
||||
- 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)
|
||||
|
||||
**`resolveOrCreateReplicatedUser(homeUserId, homeInstance, db)`** -- `federation.ts:912`
|
||||
- Calls `resolveLocalUser` first. If found, returns it.
|
||||
- If not found, creates a stub with:
|
||||
- `username`: `{homeUserId}@{domain}` (domain extracted from homeInstance URL)
|
||||
- `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>`
|
||||
- **Use when:** You MUST have a valid user ID (setting `ownerId`, inserting `dm_members`, creating messages)
|
||||
**`resolveOrCreateReplicatedUser(homeUserId, homeInstance, db, hints?)`** -- `federation.ts`
|
||||
- Calls `findFederatedUser` first. If found, backfills `homeUserId` for future fast-path lookups and returns.
|
||||
- Accepts optional `hints: { username?: string | null }` for tier-2 matching
|
||||
- If not found, creates a stub with `homeInstance` normalized to bare domain via `extractDomain`
|
||||
- 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. Always pass `{ username: profile?.username }` when profile data is available.
|
||||
|
||||
**`hydrateReplicatedUserProfile(user, profile, db)`** -- `federation.ts:2041`
|
||||
- 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 |
|
||||
|----------|--------|---------|
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
The inconsistency exists because:
|
||||
- `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)
|
||||
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.
|
||||
|
||||
**Normalization pattern used in code:**
|
||||
```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`.
|
||||
- `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`:
|
||||
- `processCreateEvent` (for each participant)
|
||||
- `processMemberAddEvent` (bootstrap roster + incremental add + owner + addedBy)
|
||||
- `processOwnershipTransferEvent` (new owner)
|
||||
- `processFriendRequestCreateEvent` (sender)
|
||||
- `processFriendRequestUpdateEvent` (recipient)
|
||||
- `processFriendAddEvent` (both users)
|
||||
**Root cause:** Two independent code paths (auth registration and S2S relay) created user stubs with different identity key formats and different homeInstance formats.
|
||||
|
||||
`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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user