feat(federation): detached accounts get local profile+password self-service; self-view flag (detach spec §4.4, §4.7)
This commit is contained in:
+3
-1
@@ -47,7 +47,9 @@ GET /users/:id → { user }
|
|||||||
GET /users/:id/mutuals ?homeUserId= → { mutualFriends[], mutualSpaces[] }
|
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
|
## Spaces (`routes/spaces.ts`) — auth required
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -313,12 +313,13 @@ Trade-off: the separate authenticated call can fail independently of the login P
|
|||||||
| User type | `currentPassword` | Behavior |
|
| User type | `currentPassword` | Behavior |
|
||||||
|-----------|-------------------|----------|
|
|-----------|-------------------|----------|
|
||||||
| Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash |
|
| 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:**
|
**Steps:**
|
||||||
1. Validate `newPassword` is string, min 8 chars
|
1. Validate `newPassword` is string, min 8 chars
|
||||||
2. Load user from DB
|
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
|
4. Hash new password
|
||||||
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
|
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
|
||||||
6. Sign fresh JWT, return `{ token }`
|
6. Sign fresh JWT, return `{ token }`
|
||||||
|
|||||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
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<FastifyInstance> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,8 +77,9 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
// Federated users (replicas on this instance) don't need currentPassword —
|
// Federated users (replicas on this instance) don't need currentPassword —
|
||||||
// their home instance already verified the password change, and JWT auth
|
// their home instance already verified the password change, and JWT auth
|
||||||
// proves identity. The homeInstance field comes from the DB, not the request.
|
// proves identity. EXCEPTION: detached accounts (federation_home_orphaned=1)
|
||||||
if (!user.homeInstance) {
|
// 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
|
// Local users must provide current password
|
||||||
if (!currentPassword || typeof currentPassword !== 'string') {
|
if (!currentPassword || typeof currentPassword !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
|
||||||
@@ -195,9 +196,11 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write-protection: replicated users cannot update durable profile fields.
|
// Write-protection: replicated users cannot update durable profile fields —
|
||||||
// These are managed by the home instance via S2S relay.
|
// these are managed by the home instance via S2S relay. EXCEPTION: detached
|
||||||
if (preUpdateUser.homeInstance) {
|
// 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<string, unknown>)[f] !== undefined);
|
const hasDurableField = DURABLE_PROFILE_FIELDS.some(f => (request.body as Record<string, unknown>)[f] !== undefined);
|
||||||
if (hasDurableField) {
|
if (hasDurableField) {
|
||||||
return reply.code(403).send({ error: 'Profile fields are managed by your home instance', statusCode: 403 });
|
return reply.code(403).send({ error: 'Profile fields are managed by your home instance', statusCode: 403 });
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
|
|||||||
homeInstance: row.homeInstance ?? null,
|
homeInstance: row.homeInstance ?? null,
|
||||||
homeUserId: row.homeUserId ?? null,
|
homeUserId: row.homeUserId ?? null,
|
||||||
replicatedInstances,
|
replicatedInstances,
|
||||||
...(isSelf ? { showActivity: row.showActivity !== 0 } : {}),
|
...(isSelf
|
||||||
|
? {
|
||||||
|
showActivity: row.showActivity !== 0,
|
||||||
|
federationHomeOrphaned: row.federationHomeOrphaned === 1,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface User {
|
|||||||
homeUserId: string | null;
|
homeUserId: string | null;
|
||||||
replicatedInstances: ReplicatedInstance[];
|
replicatedInstances: ReplicatedInstance[];
|
||||||
showActivity?: boolean;
|
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 {
|
export interface ReplicatedInstance {
|
||||||
|
|||||||
Reference in New Issue
Block a user