import type { DbOrTransaction } from "@atbb/db"; import { modActions, posts } from "@atbb/db"; import type { Logger } from "@atbb/logger"; import { and, eq, gt, isNull, or } from "drizzle-orm"; import { isProgrammingError } from "./errors.js"; /** * Encapsulates ban enforcement logic for the firehose indexer. * * Used by the Indexer to: * - Check ban status before indexing posts (fail closed) * - Soft-delete existing posts when a ban is applied * - Restore posts when a ban is lifted */ export class BanEnforcer { constructor(private db: DbOrTransaction, private logger: Logger) {} /** * Returns true if the DID has an active (non-expired) ban. * Fails closed: returns true if the DB query throws. */ async isBanned(did: string): Promise { try { const now = new Date(); const result = await this.db .select({ id: modActions.id }) .from(modActions) .where( and( eq(modActions.subjectDid, did), eq(modActions.action, "space.atbb.modAction.ban"), or(isNull(modActions.expiresAt), gt(modActions.expiresAt, now)) ) ) .limit(1); return result.length > 0; } catch (error) { if (isProgrammingError(error)) throw error; this.logger.error( "Failed to check ban status - denying indexing (fail closed)", { did, error: error instanceof Error ? error.message : String(error), } ); return true; // fail closed } } /** * Hides all posts for the given DID from public view. * Called when a ban mod action is indexed. * Uses bannedByMod column (not deleted) so user-initiated deletes are preserved. */ async applyBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise { try { await dbOrTx .update(posts) .set({ bannedByMod: true }) .where(eq(posts.did, subjectDid)); this.logger.info("Applied ban: hid all posts via bannedByMod", { subjectDid }); } catch (error) { this.logger.error("Failed to apply ban - posts may not be hidden", { subjectDid, error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Unhides all mod-hidden posts for the given DID. * Called when a ban mod action record is deleted (unban). * Only touches bannedByMod; user-initiated deletes (deleted=true) are preserved. */ async liftBan(subjectDid: string, dbOrTx: DbOrTransaction = this.db): Promise { try { await dbOrTx .update(posts) .set({ bannedByMod: false }) .where(eq(posts.did, subjectDid)); this.logger.info("Lifted ban: unhid all posts via bannedByMod", { subjectDid }); } catch (error) { this.logger.error("Failed to lift ban - posts may not be restored", { subjectDid, error: error instanceof Error ? error.message : String(error), }); throw error; } } }