import crypto from 'node:crypto'; import type { SpawnedInstance } from './twoInstanceHarness.js'; export interface TestUser { id: string; username: string; password: string; token: string; origin: string; // origin where this token authenticates homeUserId?: string; // present for federated users โ€” the id on the user's home instance homeInstance?: string; // present for federated users } const nano = () => crypto.randomBytes(4).toString('hex'); /** * Register a native local user on `instance` via POST /api/auth/register. * Returns the user record + JWT. * * Endpoint contract (verified against packages/server/src/routes/auth.ts): * - Request body: { username, password, displayName?, avatarColor?, homeInstance?, homeUserId? } * - Response 201: { token, user: { id, ... } } */ export async function registerLocal(instance: SpawnedInstance, baseName = 't'): Promise { // Username validator (auth.ts): /^[a-z0-9_]+$/, length 3-32. Lowercase the // baseName and use underscore separator so callers can pass readable names // like "probe-local" without tripping the validator. const safeBase = baseName.toLowerCase().replace(/[^a-z0-9_]/g, '_'); const username = `${safeBase}_${nano()}`; const password = `pw_${crypto.randomBytes(8).toString('hex')}`; const res = await fetch(`${instance.origin}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, displayName: username }), }); if (!res.ok) { const txt = await res.text(); throw new Error(`registerLocal failed (${instance.origin}): ${res.status} ${txt}`); } const data = await res.json() as { user: { id: string }; token: string }; return { id: data.user.id, username, password, token: data.token, origin: instance.origin, }; } /** * Create a federated account: register `home` first (native), then create the * `username@homeDomain` mirror on `remote` via POST /api/auth/register with * homeInstance + homeUserId โ€” the production federated-account-creation path * documented in client-federation.md ยง1. Returns BOTH user records. * * Also seeds the home's user_federation_registry via PUT * /api/users/@me/federation-registry so the home's federation-identity * delete endpoint sees this remote in the user's registry. * * Registry PUT contract (verified against packages/server/src/routes/users.ts): * - Body: { registry: Array<{...}>, updatedAt: number } * NOTE: the route's array key is `registry`, NOT `entries` as the original * plan template suggested. The deviation has been applied here. * - Per-entry required: { origin: string, status: 'connected'|'disconnected'|'unreachable'|'auth_expired', addedAt: number } * - Per-entry optional: { label, username, remoteUserId, lastConnectedAt, disconnectedAt, errorMessage } * - LWW guard: updatedAt must be > stored federationRegistryUpdatedAt (else 409). */ export async function createFederatedUser( home: SpawnedInstance, remote: SpawnedInstance, baseName = 't', ): Promise<{ homeUser: TestUser; remoteUser: TestUser }> { const homeUser = await registerLocal(home, baseName); const federatedUsername = `${homeUser.username}@${home.domain}`; const res = await fetch(`${remote.origin}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: federatedUsername, password: homeUser.password, displayName: homeUser.username, homeInstance: home.domain, homeUserId: homeUser.id, }), }); if (!res.ok) { const txt = await res.text(); throw new Error(`createFederatedUser remote register failed: ${res.status} ${txt}`); } const data = await res.json() as { user: { id: string }; token: string }; const remoteUser: TestUser = { id: data.user.id, username: federatedUsername, password: homeUser.password, token: data.token, origin: remote.origin, homeUserId: homeUser.id, homeInstance: home.domain, }; // Seed the home user's federation registry. Body shape matches the route's // declared Body<{ registry, updatedAt }> โ€” see users.ts L591-L594. const registryUpdatedAt = Date.now(); const registryRes = await fetch(`${home.origin}/api/users/@me/federation-registry`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${homeUser.token}`, }, body: JSON.stringify({ updatedAt: registryUpdatedAt, registry: [ { origin: remote.origin, label: remote.domain, username: federatedUsername, remoteUserId: remoteUser.id, status: 'connected', addedAt: registryUpdatedAt, lastConnectedAt: registryUpdatedAt, }, ], }), }); if (!registryRes.ok) { const txt = await registryRes.text(); throw new Error(`registry sync failed: ${registryRes.status} ${txt}`); } return { homeUser, remoteUser }; }