From 7e1e32de69b2d156b8033b31dc0a0708b72d92c2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:54:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(federation):=20detached=20accounts=20get?= =?UTF-8?q?=20local=20profile+password=20self-service;=20self-view=20flag?= =?UTF-8?q?=20(detach=20spec=20=C2=A74.4,=20=C2=A74.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/systems/api.md | 4 +- docs/systems/auth.md | 5 +- .../routes/users.detachedSelfService.test.ts | 224 ++++++++++++++++++ packages/server/src/routes/users.ts | 13 +- packages/server/src/utils/sanitize.ts | 7 +- packages/shared/src/types.ts | 2 + 6 files changed, 246 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/routes/users.detachedSelfService.test.ts diff --git a/docs/systems/api.md b/docs/systems/api.md index e4145c49..e4fcb969 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -47,7 +47,9 @@ GET /users/:id → { user } GET /users/:id/mutuals ?homeUserId= → { mutualFriends[], mutualSpaces[] } ``` -**Write protection:** If the authenticated user is a replicated user (`homeInstance` is set), the following fields are rejected with 403: `displayName`, `avatar`, `banner`, `accentColor`, `avatarColor`, `bio`. These fields are managed by the home instance via S2S relay. +**Write protection:** If the authenticated user is a replicated user (`homeInstance` is set **and** `federationHomeOrphaned !== 1`), the following fields are rejected with 403: `displayName`, `avatar`, `banner`, `accentColor`, `avatarColor`, `bio`. These fields are managed by the home instance via S2S relay. **Exception — detached accounts** (`federationHomeOrphaned === 1`): a federated account whose home instance was reset/lost is a sovereign local account with no home managing its profile, so it edits these durable fields locally like a native user (detach design §4.4). Detached edits are NOT relayed (the S2S profile-relay path stays gated on `!homeInstance`). + +**Self-view flag:** `GET /users/@me`, the login response, and the WS `ready` payload all sanitize the row with `isSelf=true` and include `federationHomeOrphaned: boolean` (detach design §4.7) — self-view only; it is never exposed to other users and never on the deleted/tombstone branch. ## Spaces (`routes/spaces.ts`) — auth required ``` diff --git a/docs/systems/auth.md b/docs/systems/auth.md index 807e1784..8bdb01a4 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -313,12 +313,13 @@ Trade-off: the separate authenticated call can fail independently of the login P | User type | `currentPassword` | Behavior | |-----------|-------------------|----------| | Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash | -| Federated (`homeInstance` set) | Not required | JWT auth is sufficient (home instance already verified the change) | +| Federated (`homeInstance` set, `federationHomeOrphaned !== 1`) | Not required | JWT auth is sufficient (home instance already verified the change) | +| Detached (`homeInstance` set, `federationHomeOrphaned === 1`) | Required | Follows the **local** rule — the home is gone, so nothing external verified the change; the local hash is the sole authority (detach design §4.4) | **Steps:** 1. Validate `newPassword` is string, min 8 chars 2. Load user from DB -3. If local: require and verify `currentPassword` +3. If local **or detached** (`!homeInstance || federationHomeOrphaned === 1`): require and verify `currentPassword` 4. Hash new password 5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens 6. Sign fresh JWT, return `{ token }` diff --git a/packages/server/src/routes/users.detachedSelfService.test.ts b/packages/server/src/routes/users.detachedSelfService.test.ts new file mode 100644 index 00000000..39077fff --- /dev/null +++ b/packages/server/src/routes/users.detachedSelfService.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signJwt, hashPassword, verifyPassword } from '../utils/auth.js'; +import { sanitizeUser } from '../utils/sanitize.js'; + +setWorkerId(23); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +// Federation relay is disabled in these tests (no peers seeded) — but the PATCH +// handler's S2S block is also gated on `!homeInstance`, so detached/federated +// accounts never relay regardless. The ws layer is fully mocked. +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + setUserShowActivity: vi.fn(), + clearUserActivities: vi.fn(), + getUserStatus: vi.fn(() => 'online'), + forceDisconnectUser: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { userRoutes } = await import('./users.js'); + const f = Fastify({ logger: false }); + await f.register(userRoutes); + await f.ready(); + return f; +} + +// A REAL federated account whose home domain was reset and has been detached +// (federationHomeOrphaned = 1): sovereign local account, manages profile + +// password locally. +const DETACHED_ID = 'detached-1'; +const DETACHED_USERNAME = 'alice@orbit.test'; +const DETACHED_PASSWORD = 'correct-horse-battery'; + +// A plain replicated (non-detached) federated account — still write-protected +// and still gets the federated change-password bypass. +const FEDERATED_ID = 'federated-1'; +const FEDERATED_USERNAME = 'bob@orbit.test'; + +let detachedHash = ''; + +async function seedUsers(): Promise { + detachedHash = await hashPassword(DETACHED_PASSWORD); + testDb.insert(schema.users).values([ + { + id: DETACHED_ID, + username: DETACHED_USERNAME, + displayName: 'Alice', + passwordHash: detachedHash, + status: 'offline', + isAdmin: 0, + isDeleted: 0, + homeInstance: 'orbit.test', + homeUserId: 'old-home-uid', + federationHomeOrphaned: 1, + profileUpdatedAt: 1000, + createdAt: Date.now(), + }, + { + id: FEDERATED_ID, + username: FEDERATED_USERNAME, + displayName: 'Bob', + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: 0, + homeInstance: 'orbit.test', + homeUserId: 'bob-home-uid', + federationHomeOrphaned: 0, + profileUpdatedAt: 1000, + createdAt: Date.now(), + }, + ]).run(); +} + +function detachedToken(): string { + return signJwt({ userId: DETACHED_ID, username: DETACHED_USERNAME }); +} + +function federatedToken(): string { + return signJwt({ userId: FEDERATED_ID, username: FEDERATED_USERNAME }); +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + await seedUsers(); + app = await buildApp(); +}); + +describe('PATCH /api/users/@me — durable-field write-protection', () => { + it('detached account CAN edit durable profile fields', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/users/@me', + headers: { Authorization: `Bearer ${detachedToken()}` }, + payload: { displayName: 'New Name' }, + }); + + expect(res.statusCode).toBe(200); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get(); + expect(row?.displayName).toBe('New Name'); + }); + + it('non-detached federated account still CANNOT edit durable profile fields (403)', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/api/users/@me', + headers: { Authorization: `Bearer ${federatedToken()}` }, + payload: { displayName: 'Hijacked' }, + }); + + expect(res.statusCode).toBe(403); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get(); + expect(row?.displayName).toBe('Bob'); // unchanged + }); +}); + +describe('POST /api/users/@me/change-password — local rule for detached accounts', () => { + it('detached account change-password REQUIRES currentPassword (local rule)', async () => { + // No currentPassword → 400 + const missing = await app.inject({ + method: 'POST', + url: '/api/users/@me/change-password', + headers: { Authorization: `Bearer ${detachedToken()}` }, + payload: { newPassword: 'brand-new-password' }, + }); + expect(missing.statusCode).toBe(400); + + // Wrong currentPassword → 403 + const wrong = await app.inject({ + method: 'POST', + url: '/api/users/@me/change-password', + headers: { Authorization: `Bearer ${detachedToken()}` }, + payload: { currentPassword: 'not-the-password', newPassword: 'brand-new-password' }, + }); + expect(wrong.statusCode).toBe(403); + + // Correct currentPassword → 200 and hash actually rotates + const ok = await app.inject({ + method: 'POST', + url: '/api/users/@me/change-password', + headers: { Authorization: `Bearer ${detachedToken()}` }, + payload: { currentPassword: DETACHED_PASSWORD, newPassword: 'brand-new-password' }, + }); + expect(ok.statusCode).toBe(200); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get(); + expect(row?.passwordHash).not.toBe(detachedHash); + await expect(verifyPassword('brand-new-password', row!.passwordHash)).resolves.toBe(true); + }); + + it('non-detached federated account still gets the bypass (no currentPassword → 200)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/users/@me/change-password', + headers: { Authorization: `Bearer ${federatedToken()}` }, + payload: { newPassword: 'bob-new-password' }, + }); + expect(res.statusCode).toBe(200); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get(); + await expect(verifyPassword('bob-new-password', row!.passwordHash)).resolves.toBe(true); + }); +}); + +describe('sanitizeUser — federationHomeOrphaned is self-view only', () => { + it('exposes federationHomeOrphaned only on self-view', () => { + const detachedRow = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!; + + const self = sanitizeUser(detachedRow, true); + expect(self.federationHomeOrphaned).toBe(true); + + const other = sanitizeUser(detachedRow); + expect('federationHomeOrphaned' in other).toBe(false); + }); + + it('non-detached self-view reports federationHomeOrphaned false', () => { + const federatedRow = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get()!; + const self = sanitizeUser(federatedRow, true); + expect(self.federationHomeOrphaned).toBe(false); + }); + + it('tombstone (deleted) self-view never exposes federationHomeOrphaned', () => { + testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, DETACHED_ID)).run(); + const deletedRow = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!; + const self = sanitizeUser(deletedRow, true); + expect('federationHomeOrphaned' in self).toBe(false); + }); +}); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 0bb09395..812b32e0 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -77,8 +77,9 @@ export async function userRoutes(app: FastifyInstance): Promise { // Federated users (replicas on this instance) don't need currentPassword — // their home instance already verified the password change, and JWT auth - // proves identity. The homeInstance field comes from the DB, not the request. - if (!user.homeInstance) { + // proves identity. EXCEPTION: detached accounts (federation_home_orphaned=1) + // have no home verifying anything — they follow the LOCAL rule (detach spec §4.4). + if (!user.homeInstance || user.federationHomeOrphaned === 1) { // Local users must provide current password if (!currentPassword || typeof currentPassword !== 'string') { return reply.code(400).send({ error: 'Current password is required', statusCode: 400 }); @@ -195,9 +196,11 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } - // Write-protection: replicated users cannot update durable profile fields. - // These are managed by the home instance via S2S relay. - if (preUpdateUser.homeInstance) { + // Write-protection: replicated users cannot update durable profile fields — + // these are managed by the home instance via S2S relay. EXCEPTION: detached + // accounts (federation_home_orphaned = 1) have no home instance anymore and + // manage their profile locally (detach spec §4.4). + if (preUpdateUser.homeInstance && preUpdateUser.federationHomeOrphaned !== 1) { const hasDurableField = DURABLE_PROFILE_FIELDS.some(f => (request.body as Record)[f] !== undefined); if (hasDurableField) { return reply.code(403).send({ error: 'Profile fields are managed by your home instance', statusCode: 403 }); diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index a325479a..d4f65faa 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -54,6 +54,11 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal homeInstance: row.homeInstance ?? null, homeUserId: row.homeUserId ?? null, replicatedInstances, - ...(isSelf ? { showActivity: row.showActivity !== 0 } : {}), + ...(isSelf + ? { + showActivity: row.showActivity !== 0, + federationHomeOrphaned: row.federationHomeOrphaned === 1, + } + : {}), }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c86fc4d4..3f4b797f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -27,6 +27,8 @@ export interface User { homeUserId: string | null; replicatedInstances: ReplicatedInstance[]; showActivity?: boolean; + /** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */ + federationHomeOrphaned?: boolean; } export interface ReplicatedInstance {