diff --git a/packages/server/src/routes/federation.peeringNotifications.test.ts b/packages/server/src/routes/federation.peeringNotifications.test.ts new file mode 100644 index 00000000..9938de33 --- /dev/null +++ b/packages/server/src/routes/federation.peeringNotifications.test.ts @@ -0,0 +1,526 @@ +import { describe, it, expect, beforeEach, afterEach, 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'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +// The userId set onto request by the mocked authenticate preHandler. Tests +// override this per-case to simulate different authenticated users. +let currentUserId = 'alice'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + }, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = currentUserId; + }, + requireAdmin: async () => {}, +})); + +vi.mock('../utils/federationAuth.js', async () => { + const actual = await vi.importActual('../utils/federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-generated-secret', + }; +}); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + }, +})); + +vi.mock('../utils/federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 0, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +interface SeedNotif { + id: string; + userId: string; + kind: 'approved' | 'denied' | 'expired'; + peerOrigin: string; + triggerReason: string; + triggerTarget: string; + createdAt: number; + readAt: number | null; +} + +function seedNotification(n: SeedNotif): void { + testDb.insert(schema.peerApprovalNotifications).values({ + id: n.id, + userId: n.userId, + kind: n.kind, + peerOrigin: n.peerOrigin, + triggerReason: n.triggerReason, + triggerTarget: n.triggerTarget, + createdAt: n.createdAt, + readAt: n.readAt, + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +describe('GET /api/federation/peering-notifications', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 22: GET → user's rows ordered DESC by createdAt. + it("returns the user's notifications ordered DESC by createdAt", async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-old', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://orbit.example', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'notif-new', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://other.example', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: t0 + 1_000, + readAt: null, + }); + seedNotification({ + id: 'notif-mid', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://third.example', + triggerReason: 'direct_message', + triggerTarget: 'pal@third.example', + createdAt: t0 + 500, + readAt: t0 + 800, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + notifications: Array<{ + id: string; + userId: string; + kind: string; + peerOrigin: string; + triggerReason: string; + triggerTarget: string; + createdAt: number; + readAt: number | null; + }>; + }; + + expect(body.notifications).toHaveLength(3); + expect(body.notifications.map(n => n.id)).toEqual(['notif-new', 'notif-mid', 'notif-old']); + + // Shape check on the newest row. + expect(body.notifications[0]).toEqual({ + id: 'notif-new', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://other.example', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: t0 + 1_000, + readAt: null, + }); + }); + + // Test 23: GET ?unread=1 → only readAt IS NULL rows. + it('filters to only unread notifications when ?unread=1', async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-unread-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'notif-read', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: t0 + 100, + readAt: t0 + 200, + }); + seedNotification({ + id: 'notif-unread-2', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: t0 + 300, + readAt: null, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications?unread=1', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { notifications: Array<{ id: string; readAt: number | null }> }; + expect(body.notifications).toHaveLength(2); + // DESC ordering: unread-2 (newer) before unread-1 (older). + expect(body.notifications.map(n => n.id)).toEqual(['notif-unread-2', 'notif-unread-1']); + for (const n of body.notifications) { + expect(n.readAt).toBeNull(); + } + }); + + // Test 26 (cross-user): GET shows only the requesting user's notifications. + it('does NOT return other users\' notifications', async () => { + seedNotification({ + id: 'alice-notif', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: 1, + readAt: null, + }); + seedNotification({ + id: 'bob-notif', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 2, + readAt: null, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/federation/peering-notifications', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { notifications: Array<{ id: string; userId: string }> }; + expect(body.notifications).toHaveLength(1); + expect(body.notifications[0]!.id).toBe('alice-notif'); + expect(body.notifications[0]!.userId).toBe('alice'); + }); +}); + +describe('POST /api/federation/peering-notifications/:id/read', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 24: POST :id/read → sets readAt. + it('marks the notification as read by setting readAt', async () => { + const t0 = 1_700_000_000_000; + seedNotification({ + id: 'notif-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + + const before = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/notif-1/read', + }); + const after = Date.now(); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true }); + + const row = testDb.select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'notif-1')) + .get(); + expect(row).toBeDefined(); + expect(row!.readAt).not.toBeNull(); + expect(row!.readAt!).toBeGreaterThanOrEqual(before); + expect(row!.readAt!).toBeLessThanOrEqual(after); + }); + + // Test 26: POST :id/read on another user's notif → 403. + it("returns 403 when the notification belongs to another user; row UNTOUCHED", async () => { + seedNotification({ + id: 'bob-notif', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 1, + readAt: null, + }); + + // currentUserId is alice; row belongs to bob. + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/bob-notif/read', + }); + + expect(response.statusCode).toBe(403); + expect((response.json() as { error: string }).error).toBe('forbidden'); + + const row = testDb.select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'bob-notif')) + .get(); + expect(row!.readAt).toBeNull(); + }); + + // Test 26: POST :id/read on non-existent → 404. + it('returns 404 when the notification does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/does-not-exist/read', + }); + + expect(response.statusCode).toBe(404); + expect((response.json() as { error: string }).error).toBe('notification_not_found'); + }); +}); + +describe('POST /api/federation/peering-notifications/read-all', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + currentUserId = 'alice'; + app = await buildApp(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Test 25: POST read-all → marks all unread as read; returns count. + // Already-read rows are NOT touched (their readAt is preserved). + it('marks the user\'s unread rows as read; preserves already-read readAt; returns affected count', async () => { + const t0 = 1_700_000_000_000; + const ALREADY_READ_AT = t0 + 50; + + seedNotification({ + id: 'unread-1', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: t0, + readAt: null, + }); + seedNotification({ + id: 'unread-2', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: t0 + 100, + readAt: null, + }); + seedNotification({ + id: 'already-read', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: t0 + 200, + readAt: ALREADY_READ_AT, + }); + + const before = Date.now(); + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/read-all', + }); + const after = Date.now(); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, count: 2 }); + + const u1 = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'unread-1')).get(); + const u2 = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'unread-2')).get(); + const ar = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'already-read')).get(); + + expect(u1!.readAt).not.toBeNull(); + expect(u1!.readAt!).toBeGreaterThanOrEqual(before); + expect(u1!.readAt!).toBeLessThanOrEqual(after); + expect(u2!.readAt).not.toBeNull(); + expect(u2!.readAt!).toBeGreaterThanOrEqual(before); + expect(u2!.readAt!).toBeLessThanOrEqual(after); + + // already-read row's readAt is preserved (unchanged). + expect(ar!.readAt).toBe(ALREADY_READ_AT); + }); + + // Test 26: POST read-all only marks the requesting user's rows. + it('does NOT touch other users\' notifications', async () => { + seedNotification({ + id: 'alice-unread', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: 1, + readAt: null, + }); + seedNotification({ + id: 'bob-unread', + userId: 'bob', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'friend_add', + triggerTarget: 'b@b.example', + createdAt: 2, + readAt: null, + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/federation/peering-notifications/read-all', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ success: true, count: 1 }); + + const aliceRow = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'alice-unread')).get(); + const bobRow = testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'bob-unread')).get(); + + expect(aliceRow!.readAt).not.toBeNull(); + expect(bobRow!.readAt).toBeNull(); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index c26fe11c..8acf51d2 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -1852,6 +1852,92 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── GET /api/federation/peering-notifications ───────────────────────────── + // User-facing: list the requesting user's terminal-state peering + // notifications (kind='approved'|'denied'|'expired'). Optional ?unread=1 + // filter narrows to rows where readAt IS NULL. Ordered DESC by createdAt + // (newest first). + app.get<{ Querystring: { unread?: string } }>( + '/api/federation/peering-notifications', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const userId = request.userId; + const unread = request.query?.unread === '1'; + + const whereClause = unread + ? and( + eq(schema.peerApprovalNotifications.userId, userId), + isNull(schema.peerApprovalNotifications.readAt), + ) + : eq(schema.peerApprovalNotifications.userId, userId); + + const notifications = db + .select() + .from(schema.peerApprovalNotifications) + .where(whereClause) + .orderBy(desc(schema.peerApprovalNotifications.createdAt)) + .all(); + + return reply.send({ notifications }); + }, + ); + + // ─── POST /api/federation/peering-notifications/:id/read ─────────────────── + // User-facing: mark a single peering notification as read. Authorization: + // notification.userId must match request.userId. + app.post<{ Params: { id: string } }>( + '/api/federation/peering-notifications/:id/read', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const { id } = request.params; + const userId = request.userId; + + const notif = db + .select() + .from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, id)) + .get(); + if (!notif) { + return reply.code(404).send({ error: 'notification_not_found', statusCode: 404 }); + } + if (notif.userId !== userId) { + return reply.code(403).send({ error: 'forbidden', statusCode: 403 }); + } + + db.update(schema.peerApprovalNotifications) + .set({ readAt: Date.now() }) + .where(eq(schema.peerApprovalNotifications.id, id)) + .run(); + return reply.send({ success: true }); + }, + ); + + // ─── POST /api/federation/peering-notifications/read-all ─────────────────── + // User-facing: mark all the requesting user's unread peering notifications + // as read. Already-read rows are NOT touched (their readAt is preserved). + // Returns the count of rows affected for UI feedback. + app.post( + '/api/federation/peering-notifications/read-all', + { preHandler: [authenticate] }, + async (request, reply) => { + const db = getDb(); + const userId = request.userId; + const result = db + .update(schema.peerApprovalNotifications) + .set({ readAt: Date.now() }) + .where( + and( + eq(schema.peerApprovalNotifications.userId, userId), + isNull(schema.peerApprovalNotifications.readAt), + ), + ) + .run(); + return reply.send({ success: true, count: result.changes }); + }, + ); + // ─── POST /api/federation/peers/:id/rotate ────────────────────────────────── // Admin-only: trigger immediate secret rotation for a peer. app.post<{ Params: { id: string } }>( diff --git a/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts new file mode 100644 index 00000000..a8e9488f --- /dev/null +++ b/packages/server/src/utils/storageJanitor.peeringExpiry.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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 './snowflake.js'; + +setWorkerId(2); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.example', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + uploadDir: '/tmp/backspace-test-uploads', + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + displayName: username, + createdAt: Date.now(), + }).run(); +} + +describe('cleanupExpiredApprovalRequests', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedUser('alice', 'alice'); + seedUser('bob', 'bob'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Janitor expiry test A: outbound row with subscribers + past expiresAt → + // janitor writes kind='expired' notifications to each subscriber, cascade-deletes parent. + it('outbound expired row: fans out kind=expired notifications to each subscriber, then cascade-deletes parent', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-out', + origin: 'https://orbit.example', + direction: 'outbound', + instanceName: 'Orbit', + hmacSecret: null, + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-alice', + requestId: 'req-out', + userId: 'alice', + triggerReason: 'friend_add', + triggerTarget: 'someone@orbit.example', + createdAt: past - 1000, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-bob', + requestId: 'req-out', + userId: 'bob', + triggerReason: 'space_join', + triggerTarget: 'invite-xyz', + createdAt: past - 500, + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const before = Date.now(); + const cleaned = cleanupExpiredApprovalRequests(); + const after = Date.now(); + + expect(cleaned).toBe(1); + + // Parent deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-out')).get()).toBeUndefined(); + + // Subscribers cascade-deleted. + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, 'req-out')).all()).toHaveLength(0); + + // Each subscriber received a kind='expired' notification preserving their + // trigger_reason / trigger_target. + const allNotifs = testDb.select().from(schema.peerApprovalNotifications).all(); + expect(allNotifs).toHaveLength(2); + + const aliceNotif = allNotifs.find(n => n.userId === 'alice'); + const bobNotif = allNotifs.find(n => n.userId === 'bob'); + + expect(aliceNotif).toBeDefined(); + expect(aliceNotif!.kind).toBe('expired'); + expect(aliceNotif!.peerOrigin).toBe('https://orbit.example'); + expect(aliceNotif!.triggerReason).toBe('friend_add'); + expect(aliceNotif!.triggerTarget).toBe('someone@orbit.example'); + expect(aliceNotif!.readAt).toBeNull(); + expect(aliceNotif!.createdAt).toBeGreaterThanOrEqual(before); + expect(aliceNotif!.createdAt).toBeLessThanOrEqual(after); + + expect(bobNotif).toBeDefined(); + expect(bobNotif!.kind).toBe('expired'); + expect(bobNotif!.peerOrigin).toBe('https://orbit.example'); + expect(bobNotif!.triggerReason).toBe('space_join'); + expect(bobNotif!.triggerTarget).toBe('invite-xyz'); + expect(bobNotif!.readAt).toBeNull(); + }); + + // Janitor expiry test B: inbound row with past expiresAt → janitor deletes + // it without writing any notifications. + it('inbound expired row: deletes outright with NO notifications written', async () => { + const past = Date.now() - 1_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-in', + origin: 'https://orbit.example', + direction: 'inbound', + instanceName: 'Orbit', + hmacSecret: 'shared-secret', + requestedAt: past - 1000, + expiresAt: past, + approvalToken: null, + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(1); + + // Parent deleted. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-in')).get()).toBeUndefined(); + + // No notifications written for inbound. + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); + + // Mixed: not-yet-expired rows are not touched, regardless of direction. + it('does not touch rows whose expiresAt is in the future', async () => { + const future = Date.now() + 60_000; + testDb.insert(schema.peerApprovalRequests).values({ + id: 'req-future-out', + origin: 'https://a.example', + direction: 'outbound', + instanceName: null, + hmacSecret: null, + requestedAt: Date.now(), + expiresAt: future, + approvalToken: null, + }).run(); + testDb.insert(schema.peerApprovalSubscribers).values({ + id: 'sub-future', + requestId: 'req-future-out', + userId: 'alice', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: Date.now(), + }).run(); + + const { cleanupExpiredApprovalRequests } = await import('./storageJanitor.js'); + const cleaned = cleanupExpiredApprovalRequests(); + + expect(cleaned).toBe(0); + + // Row still present. + expect(testDb.select().from(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, 'req-future-out')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.id, 'sub-future')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications).all()).toHaveLength(0); + }); +}); + +describe('cleanupReadPeeringNotifications', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedUser('alice', 'alice'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + // Cleanup test: read notifications older than 30 days are deleted; unread + // and recent-read notifications are NOT deleted. + it('deletes only read-AND-old notifications; unread and recent-read survive', async () => { + const now = Date.now(); + const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; + const cutoffMargin = 60_000; + + // (a) Read & older than 30 days → DELETE + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'old-read', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://a.example', + triggerReason: 'friend_add', + triggerTarget: 'a@a.example', + createdAt: now - THIRTY_DAYS - 5 * 60_000, + readAt: now - THIRTY_DAYS - cutoffMargin, + }).run(); + + // (b) Read & recent (< 30 days old) → KEEP + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'recent-read', + userId: 'alice', + kind: 'denied', + peerOrigin: 'https://b.example', + triggerReason: 'space_join', + triggerTarget: 'invite-y', + createdAt: now - 5 * 60_000, + readAt: now - 60_000, + }).run(); + + // (c) Unread (regardless of age) → KEEP + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'old-unread', + userId: 'alice', + kind: 'expired', + peerOrigin: 'https://c.example', + triggerReason: 'direct_message', + triggerTarget: 'c@c.example', + createdAt: now - THIRTY_DAYS - 10 * 60_000, + readAt: null, + }).run(); + testDb.insert(schema.peerApprovalNotifications).values({ + id: 'fresh-unread', + userId: 'alice', + kind: 'approved', + peerOrigin: 'https://d.example', + triggerReason: 'friend_add', + triggerTarget: 'd@d.example', + createdAt: now - 60_000, + readAt: null, + }).run(); + + const { cleanupReadPeeringNotifications } = await import('./storageJanitor.js'); + const deleted = cleanupReadPeeringNotifications(); + + expect(deleted).toBe(1); + + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'old-read')).get()).toBeUndefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'recent-read')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'old-unread')).get()).toBeDefined(); + expect(testDb.select().from(schema.peerApprovalNotifications) + .where(eq(schema.peerApprovalNotifications.id, 'fresh-unread')).get()).toBeDefined(); + }); +}); diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index 73f0ebd2..78f1a562 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -4,6 +4,7 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte } from 'drizzle-orm'; import { config } from '../config.js'; import { getDb, getRawDb, schema } from '../db/index.js'; import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js'; +import { generateSnowflake } from './snowflake.js'; import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared'; const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']); @@ -433,65 +434,92 @@ export function cleanupFederationFileQueue(): number { /** * Expire peer approval requests older than their expiresAt timestamp. - * For each expired request, attempt to send a signed denial notification - * to the requesting instance before deleting. If notification fails, - * leave the record for the next janitor cycle. + * + * Outbound rows: before deletion, fan out kind='expired' notifications to + * each subscriber so the original requester(s) can see the terminal state on + * next page load. Subscriber rows cascade-delete via FK when the parent row + * is removed. + * + * Inbound rows: deleted outright. Both sides expire independently (their + * own janitor will fan out kind='expired' notifications to their subscribers + * on their schedule); no cross-instance network notification is required. + * + * No WebSocket broadcast is emitted on expiry — offline users see the + * notification on next GET /api/federation/peering-notifications, matching + * the persistence guarantee of the notifications table. */ -export async function cleanupExpiredApprovalRequests(): Promise { +export function cleanupExpiredApprovalRequests(): number { const db = getDb(); - const expired = db + const expiredRequests = db .select() .from(schema.peerApprovalRequests) .where(lte(schema.peerApprovalRequests.expiresAt, Date.now())) .all(); - if (expired.length === 0) return 0; + if (expiredRequests.length === 0) return 0; - const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js'); - const ourOrigin = getOurOrigin(); - let cleaned = 0; - - for (const req of expired) { - // Outbound rows have no hmac_secret and no remote /peer/denied endpoint; - // their expiry handling (subscriber notifications) lands in Task 9. - // Until then, skip outbound rows here so this loop only processes inbound expirations. - if (!req.hmacSecret) { - continue; - } - - const denialBody = JSON.stringify({ - origin: ourOrigin, - reason: 'expired' as const, - message: 'Request expired — no response from admin within 30 days', - }); - - const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin); - - let sent = false; - try { - const response = await fetch(`${req.origin}/api/federation/peer/denied`, { - method: 'POST', - headers, - body: denialBody, - signal: AbortSignal.timeout(10_000), - }); - sent = response.ok; - } catch { - // Network error — will retry next cycle - } - - if (sent) { - // No rejected peer record for expiry — origin can re-request - db.delete(schema.peerApprovalRequests) - .where(eq(schema.peerApprovalRequests.id, req.id)) - .run(); - cleaned++; - } else { - console.warn(`[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`); + const now = Date.now(); + let deletedCount = 0; + for (const req of expiredRequests) { + if (req.direction === 'outbound') { + // Fan out kind='expired' notifications to subscribers before delete. + const subs = db + .select() + .from(schema.peerApprovalSubscribers) + .where(eq(schema.peerApprovalSubscribers.requestId, req.id)) + .all(); + for (const sub of subs) { + db.insert(schema.peerApprovalNotifications) + .values({ + id: generateSnowflake(), + userId: sub.userId, + kind: 'expired', + peerOrigin: req.origin, + triggerReason: sub.triggerReason, + triggerTarget: sub.triggerTarget, + createdAt: now, + readAt: null, + }) + .run(); + } } + // Cascade-delete the parent (subscribers go via FK cascade for outbound; + // inbound has no subscribers to clear). + db.delete(schema.peerApprovalRequests) + .where(eq(schema.peerApprovalRequests.id, req.id)) + .run(); + deletedCount++; } - return cleaned; + if (deletedCount > 0) { + console.log(`[storage-janitor] Deleted ${deletedCount} expired peer_approval_requests rows`); + } + + return deletedCount; +} + +/** + * Delete read peering notifications older than 30 days. Unread notifications + * are never auto-cleaned — the user must explicitly read or dismiss them. + */ +const NOTIFICATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export function cleanupReadPeeringNotifications(): number { + const db = getDb(); + const cutoff = Date.now() - NOTIFICATION_RETENTION_MS; + const result = db + .delete(schema.peerApprovalNotifications) + .where( + and( + isNotNull(schema.peerApprovalNotifications.readAt), + lte(schema.peerApprovalNotifications.readAt, cutoff), + ), + ) + .run(); + if (result.changes > 0) { + console.log(`[storage-janitor] Deleted ${result.changes} read peering notifications older than 30 days`); + } + return result.changes; } /** @@ -629,14 +657,18 @@ export function runFederationJanitor(): void { ); } - // Async: expire approval requests (sends network notifications) - cleanupExpiredApprovalRequests().then((approvalExpired) => { + // Expire approval requests (outbound rows fan out kind='expired' + // notifications to subscribers before delete; inbound rows are deleted + // outright). Then sweep read peering notifications older than 30 days. + try { + const approvalExpired = cleanupExpiredApprovalRequests(); if (approvalExpired > 0) { console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`); } - }).catch((err) => { + cleanupReadPeeringNotifications(); + } catch (err) { console.error('[storage-janitor] Approval request expiry error:', err); - }); + } } catch (err) { console.error('[storage-janitor] Federation GC sweep error:', err); }