diff --git a/server/.env.example b/server/.env.example index f91cf6e..27c6117 100644 --- a/server/.env.example +++ b/server/.env.example @@ -76,6 +76,13 @@ CLICKHOUSE_PROTOCOL=http HMA_SERVICE_URL=http://localhost:9876 # Scylla Cluster Details +# Set to "false" to run Coop without a Scylla cluster. This disables the two +# Scylla-backed features — Item Investigation (item/user history views) and +# User Strikes (repeat-offender strike counts) — which then no-op: reads return +# empty (strike counts read as 0) and writes are dropped. When "false" (or +# "0"/"no"), the SCYLLA_* connection settings below are not required. +# Defaults to enabled. +ITEM_INVESTIGATION_AND_STRIKES_ENABLED=true SCYLLA_USERNAME=cassandra SCYLLA_PASSWORD=cassandra SCYLLA_HOSTS='127.0.0.1:9042' diff --git a/server/iocContainer/index.ts b/server/iocContainer/index.ts index 91ac142..9154918 100644 --- a/server/iocContainer/index.ts +++ b/server/iocContainer/index.ts @@ -59,6 +59,9 @@ import makeRuleEvaluator, { type RuleEvaluator, } from '../rule_engine/RuleEvaluator.js'; import { Scylla } from '../scylla/index.js'; +import NoOpScylla, { + itemInvestigationAndStrikesEnabled, +} from '../scylla/noOpScylla.js'; import { makeActionStatisticsService, type ActionStatisticsService, @@ -772,6 +775,9 @@ export default async function getBottle() { executionContext, ); }, + itemInvestigationAndStrikesEnabled( + process.env.ITEM_INVESTIGATION_AND_STRIKES_ENABLED, + ), ), ); @@ -783,6 +789,23 @@ export default async function getBottle() { // keyspace aware and it's very annoying and likely error prone to be // switching keyspaces with `USE KEYSPACE` all the time. bottle.factory('Scylla', () => { + // Scylla backs the item-investigation and user-strike features. Operators + // who don't need those (and don't want to run a Scylla cluster) can set + // `ITEM_INVESTIGATION_AND_STRIKES_ENABLED=false` to swap in a no-op that + // drops writes and returns empty reads, so no `SCYLLA_*` connection env + // vars are required. Defaults to enabled to preserve existing behaviour. + if ( + !itemInvestigationAndStrikesEnabled( + process.env.ITEM_INVESTIGATION_AND_STRIKES_ENABLED, + ) + ) { + // eslint-disable-next-line no-restricted-syntax + logJson( + 'scylla.disabled ITEM_INVESTIGATION_AND_STRIKES_ENABLED=false; using no-op Scylla', + ); + return new NoOpScylla(); + } + const contactPoints = safeGetEnvVar('SCYLLA_HOSTS') .split(',') .map((it) => it.trim()) diff --git a/server/scylla/noOpScylla.test.ts b/server/scylla/noOpScylla.test.ts new file mode 100644 index 0000000..bbf1c40 --- /dev/null +++ b/server/scylla/noOpScylla.test.ts @@ -0,0 +1,78 @@ +import NoOpScylla, { + itemInvestigationAndStrikesEnabled, +} from './noOpScylla.js'; +import Scylla from './scylla.js'; + +/** + * Tests for the Scylla-disabled path used when + * `ITEM_INVESTIGATION_AND_STRIKES_ENABLED=false`. + * + * Two things are covered: + * 1. The behavioural contract of {@link NoOpScylla} (drops writes, empty reads, + * connect/close resolve). + * 2. The exact flag-parsing predicate (`itemInvestigationAndStrikesEnabled`) + * used by the `Scylla` DI factory in `iocContainer` to decide + * enabled-vs-disabled. Imported directly (not mirrored) so the + * default-enabled (upstream-preserving) behaviour is guarded by a test. + */ + +describe('ITEM_INVESTIGATION_AND_STRIKES_ENABLED gate predicate', () => { + test('defaults to enabled when unset (preserves upstream behaviour)', () => { + expect(itemInvestigationAndStrikesEnabled(undefined)).toBe(true); + expect(itemInvestigationAndStrikesEnabled('')).toBe(true); + }); + + test('is disabled only for explicit falsey values', () => { + for (const v of ['false', 'FALSE', ' false ', '0', 'no', 'No']) { + expect(itemInvestigationAndStrikesEnabled(v)).toBe(false); + } + }); + + test('stays enabled for truthy / unrelated values', () => { + for (const v of ['true', 'TRUE', '1', 'yes', 'anything']) { + expect(itemInvestigationAndStrikesEnabled(v)).toBe(true); + } + }); +}); + +describe('NoOpScylla', () => { + // A minimal DB shape for the generic parameter. + type TestDB = { widgets: { id: number; name: string } }; + const noop = new NoOpScylla(); + + test('is a Scylla so it satisfies every consumer unchanged', () => { + expect(noop).toBeInstanceOf(Scylla); + }); + + test('connect() and close() resolve (eager callers proceed)', async () => { + await expect(noop.connect()).resolves.toBeUndefined(); + await expect(noop.close()).resolves.toBeUndefined(); + }); + + test('insert() resolves and drops the write', async () => { + await expect( + noop.insert({ into: 'widgets', row: { id: 1, name: 'a' } }), + ).resolves.toBeDefined(); + }); + + test('select() returns an empty result set', async () => { + await expect( + noop.select({ from: 'widgets', select: '*' }), + ).resolves.toEqual([]); + }); + + test('selectStream() yields nothing', async () => { + const rows = await (async () => { + const collected = []; + for await (const row of noop.selectStream({ + from: 'widgets', + select: '*', + })) { + // eslint-disable-next-line functional/immutable-data + collected.push(row); + } + return collected; + })(); + expect(rows).toEqual([]); + }); +}); diff --git a/server/scylla/noOpScylla.ts b/server/scylla/noOpScylla.ts new file mode 100644 index 0000000..99ab71f --- /dev/null +++ b/server/scylla/noOpScylla.ts @@ -0,0 +1,104 @@ +import { type CqlSelectOptions, type DBDefinition } from './cqlUtils.js'; +import Scylla from './scylla.js'; + +/** + * Parses the `ITEM_INVESTIGATION_AND_STRIKES_ENABLED` feature flag from its raw + * string value (i.e. `process.env.ITEM_INVESTIGATION_AND_STRIKES_ENABLED`). + * + * Shared by the `Scylla` DI factory in `iocContainer` (to decide whether to + * return a real Scylla or a {@link NoOpScylla}) and by the unit tests. Defaults + * to enabled when unset/empty so existing deployments are unaffected; only the + * explicit falsey values `false`/`0`/`no` (case/whitespace-insensitive) disable + * the Scylla-backed features. + * + * Kept as a pure function of its argument (it does not read `process.env` + * itself) so callers own where the value comes from and tests stay independent + * of the ambient environment. + */ +export function itemInvestigationAndStrikesEnabled( + raw: string | undefined, +): boolean { + return !['false', '0', 'no'].includes((raw ?? 'true').trim().toLowerCase()); +} + +/** + * A no-op implementation of {@link Scylla} used when the Scylla-backed features + * (item investigation and user strikes) are disabled via + * `ITEM_INVESTIGATION_AND_STRIKES_ENABLED=false`. + * + * Scylla has no managed offering on some deployment platforms, and some + * operators do not need the features that depend on it. Rather than gate the + * ~100+ call sites that touch Scylla, we gate at the single dependency-injection + * chokepoint (the `Scylla` factory in `iocContainer`) and return this no-op. + * + * Behaviour when disabled: + * - `connect()` / `close()` resolve immediately (so the item-processing worker's + * eager `await scylla.connect()` succeeds without a real cluster). + * - `insert()` resolves and drops the write. + * - `select()` returns an empty result set. + * - `selectStream()` yields nothing. + * + * This keeps every consumer compiling and running unchanged; they simply observe + * empty data (e.g. user strike counts read as 0) and their writes are discarded. + */ +export default class NoOpScylla extends Scylla { + constructor() { + // The base class only stores the client and never touches it once all + // query methods are overridden below, so a null client cast is safe here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + super(null as any); + } + + /** No cluster to connect to; resolve so eager callers proceed. */ + async connect(): Promise { + return undefined; + } + + /** Nothing to shut down. */ + async close(): Promise { + return undefined; + } + + /** Drop the write. */ + override async insert( + _opts: { + [K in RelationName]: { + into: RelationName; + row: DB[K]; + ttlInSeconds?: number; + }; + }[RelationName], + ): Promise['insert']>>> { + // There is no cluster to write to; return a minimal empty result set. The + // real driver returns a full `ResultSet`, but no-op consumers never read + // the result, so a minimal shape is sufficient here. + // @ts-expect-error - minimal stand-in for the driver's ResultSet; unused by callers + return { rows: [], rowLength: 0 }; + } + + /** Return no rows. */ + override async select< + RelationName extends keyof DB & string, + Cols extends keyof DB[RelationName] & string = keyof DB[RelationName] & + string, + >( + _opts: CqlSelectOptions, + ): Promise<{ [K in Cols]: DB[RelationName][K] }[]> { + return []; + } + + /** Yield nothing. */ + override selectStream< + RelationName extends keyof DB & string, + Cols extends keyof DB[RelationName] & string = keyof DB[RelationName] & + string, + >( + _opts: CqlSelectOptions, + ): AsyncIterableIterator<{ [K in Cols]: DB[RelationName][K] }> { + type Selection = { [K in Cols]: DB[RelationName][K] }; + async function* empty(): AsyncIterableIterator { + // Intentionally yields nothing. + } + return empty(); + } +} diff --git a/server/services/userStrikeService/userStrikeService.ts b/server/services/userStrikeService/userStrikeService.ts index 5307abd..49e687e 100644 --- a/server/services/userStrikeService/userStrikeService.ts +++ b/server/services/userStrikeService/userStrikeService.ts @@ -33,6 +33,7 @@ export class UserStrikeService { private readonly getUserStrikeTTLinDays: Dependencies['getUserStrikeTTLInDaysEventuallyConsistent'], private readonly actionExecutionsAdapter: IActionExecutionsAdapter, private readonly publishActions: Dependencies['ActionPublisher']['publishActions'], + private readonly enabled: boolean = true, ) { this.scylla = scylla; this.moderationConfigService = moderationConfigService; @@ -97,6 +98,10 @@ export class UserStrikeService { actorEmail?: string; }, ) { + if (!this.enabled) { + return; + } + const targetUser = getUserFromActionTargetItem(executionContext.targetItem); const mostSeverePolicy = this.findMostSeverePolicyViolationFromActions(triggeredActions);