Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/

feat(profile): add account reset and delete (#60)

* feat(profile): add wipeSifaData helper for profile reset/delete

* fix(profile): add pagination and batching to wipeSifaData

* feat(profile): add reset and delete account endpoints

* fix(profile): improve error handling in reset/delete endpoints

* style: fix prettier formatting

authored by

Guido X Jansen and committed by
GitHub
910c962e 00b24cbd

+261
+74
src/routes/profile-write.ts
··· 33 33 } from '../services/pds-writer.js'; 34 34 import { createAuthMiddleware, getAuthContext } from '../middleware/auth.js'; 35 35 import { sanitize, sanitizeOptional } from '../lib/sanitize.js'; 36 + import { wipeSifaData } from '../services/profile-wipe.js'; 37 + import { 38 + sessions as sessionsTable, 39 + oauthSessions as oauthSessionsTable, 40 + } from '../db/schema/index.js'; 36 41 37 42 const overrideSchema = z.object({ 38 43 headline: z.string().max(300).nullish(), ··· 944 949 945 950 return reply.status(200).send({ ok: true }); 946 951 }); 952 + 953 + // DELETE /api/profile/reset -- wipe all Sifa data from PDS and local DB (keep account) 954 + app.delete( 955 + '/api/profile/reset', 956 + { preHandler: requireAuth, config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, 957 + async (request, reply) => { 958 + const { did, session } = getAuthContext(request); 959 + 960 + try { 961 + await wipeSifaData(session, did, db); 962 + } catch (err) { 963 + app.log.error({ err, did }, 'Profile reset failed'); 964 + return reply 965 + .status(500) 966 + .send({ error: 'ResetFailed', message: 'Failed to reset profile data' }); 967 + } 968 + 969 + app.log.info({ did }, 'Profile reset completed'); 970 + return reply.status(200).send({ ok: true }); 971 + }, 972 + ); 973 + 974 + // DELETE /api/profile/account -- wipe all Sifa data, then logout and destroy session 975 + app.delete( 976 + '/api/profile/account', 977 + { preHandler: requireAuth, config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, 978 + async (request, reply) => { 979 + const { did, session } = getAuthContext(request); 980 + let handle: string | undefined; 981 + 982 + try { 983 + // Read handle before wiping so we can return it for redirect 984 + const [profileRow] = await db 985 + .select({ handle: profilesTable.handle }) 986 + .from(profilesTable) 987 + .where(eq(profilesTable.did, did)) 988 + .limit(1); 989 + handle = profileRow?.handle ?? undefined; 990 + 991 + await wipeSifaData(session, did, db); 992 + } catch (err) { 993 + app.log.error({ err, did }, 'Account deletion failed'); 994 + return reply 995 + .status(500) 996 + .send({ error: 'DeleteFailed', message: 'Failed to delete account' }); 997 + } 998 + 999 + // Logout: destroy session and revoke OAuth tokens 1000 + const sessionId = request.cookies?.session; 1001 + if (sessionId) { 1002 + await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId)); 1003 + } 1004 + await db 1005 + .delete(oauthSessionsTable) 1006 + .where(eq(oauthSessionsTable.did, did)) 1007 + .catch((err: unknown) => { 1008 + app.log.error({ err, did }, 'Failed to delete OAuth sessions during account deletion'); 1009 + }); 1010 + if (oauthClient) { 1011 + await oauthClient.revoke(did).catch((err: unknown) => { 1012 + app.log.warn({ err, did }, 'Failed to revoke OAuth token during account deletion'); 1013 + }); 1014 + } 1015 + reply.clearCookie('session', { path: '/' }); 1016 + 1017 + app.log.info({ did }, 'Account deletion completed'); 1018 + return reply.status(200).send({ ok: true, handle }); 1019 + }, 1020 + ); 947 1021 }
+68
src/services/profile-wipe.ts
··· 1 + import { Agent } from '@atproto/api'; 2 + import type { OAuthSession } from '@atproto/oauth-client'; 3 + import { eq } from 'drizzle-orm'; 4 + import { buildApplyWritesOp, writeToUserPds } from './pds-writer.js'; 5 + import type { ApplyWritesOp } from './pds-writer.js'; 6 + import type { Database } from '../db/index.js'; 7 + import { profiles, externalAccountVerifications } from '../db/schema/index.js'; 8 + 9 + const SIFA_COLLECTIONS = [ 10 + 'id.sifa.profile.self', 11 + 'id.sifa.profile.position', 12 + 'id.sifa.profile.education', 13 + 'id.sifa.profile.skill', 14 + 'id.sifa.profile.certification', 15 + 'id.sifa.profile.project', 16 + 'id.sifa.profile.volunteering', 17 + 'id.sifa.profile.publication', 18 + 'id.sifa.profile.course', 19 + 'id.sifa.profile.honor', 20 + 'id.sifa.profile.language', 21 + 'id.sifa.profile.externalAccount', 22 + ] as const; 23 + 24 + export { SIFA_COLLECTIONS }; 25 + 26 + export async function buildPdsDeleteOps( 27 + agent: Agent, 28 + did: string, 29 + collections: readonly string[], 30 + ): Promise<ApplyWritesOp[]> { 31 + const ops: ApplyWritesOp[] = []; 32 + for (const collection of collections) { 33 + let cursor: string | undefined; 34 + do { 35 + const existing = await agent.com.atproto.repo.listRecords({ 36 + repo: did, 37 + collection, 38 + limit: 100, 39 + cursor, 40 + }); 41 + for (const rec of existing.data.records) { 42 + const rkey = rec.uri.split('/').pop() ?? ''; 43 + if (rkey) ops.push(buildApplyWritesOp('delete', collection, rkey)); 44 + } 45 + cursor = existing.data.cursor; 46 + } while (cursor); 47 + } 48 + return ops; 49 + } 50 + 51 + export async function wipeSifaData( 52 + session: OAuthSession, 53 + did: string, 54 + db: Database, 55 + ): Promise<void> { 56 + const agent = new Agent(session); 57 + const ops = await buildPdsDeleteOps(agent, did, SIFA_COLLECTIONS); 58 + 59 + // applyWrites has a 200-op limit per call 60 + const BATCH_SIZE = 200; 61 + for (let i = 0; i < ops.length; i += BATCH_SIZE) { 62 + const batch = ops.slice(i, i + BATCH_SIZE); 63 + await writeToUserPds(session, did, batch); 64 + } 65 + 66 + await db.delete(profiles).where(eq(profiles.did, did)); 67 + await db.delete(externalAccountVerifications).where(eq(externalAccountVerifications.did, did)); 68 + }
+31
tests/routes/profile-reset.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + wipeSifaData, 4 + buildPdsDeleteOps, 5 + SIFA_COLLECTIONS, 6 + } from '../../src/services/profile-wipe.js'; 7 + 8 + describe('profile-wipe module', () => { 9 + it('exports wipeSifaData as a function', () => { 10 + expect(typeof wipeSifaData).toBe('function'); 11 + }); 12 + 13 + it('exports buildPdsDeleteOps as a function', () => { 14 + expect(typeof buildPdsDeleteOps).toBe('function'); 15 + }); 16 + 17 + it('exports SIFA_COLLECTIONS with all 12 lexicon collections', () => { 18 + expect(SIFA_COLLECTIONS).toHaveLength(12); 19 + expect(SIFA_COLLECTIONS).toContain('id.sifa.profile.self'); 20 + expect(SIFA_COLLECTIONS).toContain('id.sifa.profile.position'); 21 + expect(SIFA_COLLECTIONS).toContain('id.sifa.profile.skill'); 22 + expect(SIFA_COLLECTIONS).toContain('id.sifa.profile.externalAccount'); 23 + }); 24 + }); 25 + 26 + describe('reset and delete endpoint signatures', () => { 27 + it('wipeSifaData expects three parameters (session, did, db)', () => { 28 + // wipeSifaData(session, did, db) -> 3 params 29 + expect(wipeSifaData.length).toBe(3); 30 + }); 31 + });
+88
tests/services/profile-wipe.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { buildPdsDeleteOps } from '../../src/services/profile-wipe.js'; 3 + import type { Agent } from '@atproto/api'; 4 + 5 + function createMockAgent(recordsByCollection: Record<string, Array<{ uri: string }>>): Agent { 6 + return { 7 + com: { 8 + atproto: { 9 + repo: { 10 + listRecords: vi.fn(async ({ collection }: { collection: string }) => ({ 11 + data: { 12 + records: recordsByCollection[collection] ?? [], 13 + }, 14 + })), 15 + }, 16 + }, 17 + }, 18 + } as unknown as Agent; 19 + } 20 + 21 + describe('buildPdsDeleteOps', () => { 22 + it('returns delete ops for records returned by listRecords', async () => { 23 + const agent = createMockAgent({ 24 + 'id.sifa.profile.position': [ 25 + { uri: 'at://did:plc:abc/id.sifa.profile.position/3abc' }, 26 + { uri: 'at://did:plc:abc/id.sifa.profile.position/3def' }, 27 + ], 28 + }); 29 + 30 + const ops = await buildPdsDeleteOps(agent, 'did:plc:abc', ['id.sifa.profile.position']); 31 + 32 + expect(ops).toHaveLength(2); 33 + expect(ops[0]).toEqual({ 34 + $type: 'com.atproto.repo.applyWrites#delete', 35 + collection: 'id.sifa.profile.position', 36 + rkey: '3abc', 37 + }); 38 + expect(ops[1]).toEqual({ 39 + $type: 'com.atproto.repo.applyWrites#delete', 40 + collection: 'id.sifa.profile.position', 41 + rkey: '3def', 42 + }); 43 + }); 44 + 45 + it('returns empty array when no records exist', async () => { 46 + const agent = createMockAgent({}); 47 + 48 + const ops = await buildPdsDeleteOps(agent, 'did:plc:abc', [ 49 + 'id.sifa.profile.self', 50 + 'id.sifa.profile.skill', 51 + ]); 52 + 53 + expect(ops).toEqual([]); 54 + }); 55 + 56 + it('iterates all provided collections', async () => { 57 + const agent = createMockAgent({ 58 + 'id.sifa.profile.self': [{ uri: 'at://did:plc:abc/id.sifa.profile.self/self' }], 59 + 'id.sifa.profile.skill': [{ uri: 'at://did:plc:abc/id.sifa.profile.skill/ts' }], 60 + 'id.sifa.profile.education': [{ uri: 'at://did:plc:abc/id.sifa.profile.education/uni1' }], 61 + }); 62 + 63 + const collections = [ 64 + 'id.sifa.profile.self', 65 + 'id.sifa.profile.skill', 66 + 'id.sifa.profile.education', 67 + ]; 68 + 69 + const ops = await buildPdsDeleteOps(agent, 'did:plc:abc', collections); 70 + 71 + expect(ops).toHaveLength(3); 72 + 73 + const collectionsInOps = ops.map((op) => op.collection); 74 + expect(collectionsInOps).toContain('id.sifa.profile.self'); 75 + expect(collectionsInOps).toContain('id.sifa.profile.skill'); 76 + expect(collectionsInOps).toContain('id.sifa.profile.education'); 77 + 78 + const listRecords = agent.com.atproto.repo.listRecords as ReturnType<typeof vi.fn>; 79 + expect(listRecords).toHaveBeenCalledTimes(3); 80 + for (const col of collections) { 81 + expect(listRecords).toHaveBeenCalledWith({ 82 + repo: 'did:plc:abc', 83 + collection: col, 84 + limit: 100, 85 + }); 86 + } 87 + }); 88 + });