Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Fix] Make built-in MRT/NCMEC enqueue actions available to all orgs (#393)

* Make built-in MRT/NCMEC enqueue actions available to all orgs

* push migration for reals

authored by

Juan Mrad and committed by
GitHub
8b1bd55f f04c777f

+433 -14
+106
db/src/scripts/api-server-pg/2026.05.01T15.45.30.add_built_in_actions_for_existing_orgs.sql
··· 1 + -- Backfill per-org built-in actions for existing orgs. New orgs get them via 2 + -- ModerationConfigService.upsertBuiltInActions. Idempotent on (org_id, action_type). 3 + INSERT INTO public.actions ( 4 + id, 5 + name, 6 + description, 7 + org_id, 8 + action_type, 9 + callback_url, 10 + callback_url_headers, 11 + callback_url_body, 12 + penalty, 13 + apply_user_strikes, 14 + applies_to_all_items_of_kind, 15 + updated_at 16 + ) 17 + SELECT 18 + substr(md5(o.id || ':ENQUEUE_TO_MRT'), 1, 11), 19 + 'Enqueue Item to Manual Review', 20 + 'Sends the matched item directly to a manual review queue, routed by the org''s MRT routing rules.', 21 + o.id, 22 + 'ENQUEUE_TO_MRT'::public.action_type, 23 + NULL, 24 + NULL, 25 + NULL, 26 + 'NONE'::public.user_penalty_severity, 27 + false, 28 + ARRAY['CONTENT', 'USER', 'THREAD']::public.item_type_kind[], 29 + CURRENT_TIMESTAMP 30 + FROM public.orgs o 31 + WHERE NOT EXISTS ( 32 + SELECT 1 FROM public.actions a 33 + WHERE a.org_id = o.id 34 + AND a.action_type = 'ENQUEUE_TO_MRT' 35 + ) 36 + ON CONFLICT DO NOTHING; 37 + 38 + INSERT INTO public.actions ( 39 + id, 40 + name, 41 + description, 42 + org_id, 43 + action_type, 44 + callback_url, 45 + callback_url_headers, 46 + callback_url_body, 47 + penalty, 48 + apply_user_strikes, 49 + applies_to_all_items_of_kind, 50 + updated_at 51 + ) 52 + SELECT 53 + substr(md5(o.id || ':ENQUEUE_AUTHOR_TO_MRT'), 1, 11), 54 + 'Enqueue Author for Manual Review', 55 + 'Sends the author of the matched content to a manual review queue, with the matched item attached as context.', 56 + o.id, 57 + 'ENQUEUE_AUTHOR_TO_MRT'::public.action_type, 58 + NULL, 59 + NULL, 60 + NULL, 61 + 'NONE'::public.user_penalty_severity, 62 + false, 63 + ARRAY['CONTENT']::public.item_type_kind[], 64 + CURRENT_TIMESTAMP 65 + FROM public.orgs o 66 + WHERE NOT EXISTS ( 67 + SELECT 1 FROM public.actions a 68 + WHERE a.org_id = o.id 69 + AND a.action_type = 'ENQUEUE_AUTHOR_TO_MRT' 70 + ) 71 + ON CONFLICT DO NOTHING; 72 + 73 + INSERT INTO public.actions ( 74 + id, 75 + name, 76 + description, 77 + org_id, 78 + action_type, 79 + callback_url, 80 + callback_url_headers, 81 + callback_url_body, 82 + penalty, 83 + apply_user_strikes, 84 + applies_to_all_items_of_kind, 85 + updated_at 86 + ) 87 + SELECT 88 + substr(md5(o.id || ':ENQUEUE_TO_NCMEC'), 1, 11), 89 + 'Enqueue for NCMEC Review', 90 + 'Sends the user associated with the matched item to the NCMEC review flow, gathering their media for reporting.', 91 + o.id, 92 + 'ENQUEUE_TO_NCMEC'::public.action_type, 93 + NULL, 94 + NULL, 95 + NULL, 96 + 'NONE'::public.user_penalty_severity, 97 + false, 98 + ARRAY['CONTENT', 'USER']::public.item_type_kind[], 99 + CURRENT_TIMESTAMP 100 + FROM public.orgs o 101 + WHERE NOT EXISTS ( 102 + SELECT 1 FROM public.actions a 103 + WHERE a.org_id = o.id 104 + AND a.action_type = 'ENQUEUE_TO_NCMEC' 105 + ) 106 + ON CONFLICT DO NOTHING;
+1
server/bin/create-org-and-user.ts
··· 94 94 // Initialize org settings 95 95 await Promise.all([ 96 96 container.ModerationConfigService.createDefaultUserType(orgId), 97 + container.ModerationConfigService.upsertBuiltInActions(orgId), 97 98 container.OrgCreationLogger.logOrgCreated( 98 99 orgId, 99 100 argv.name,
+104
server/graphql/modules/org.resolver.test.ts
··· 1 + import { type Action } from '../../services/moderationConfigService/index.js'; 2 + import { resolveOrgActions } from './org.js'; 3 + 4 + function makeAction( 5 + id: string, 6 + actionType: Action['actionType'], 7 + orgId: string, 8 + ): Action { 9 + const base = { 10 + id, 11 + orgId, 12 + name: id, 13 + description: null, 14 + penalty: 'NONE' as const, 15 + applyUserStrikes: false, 16 + }; 17 + if (actionType === 'CUSTOM_ACTION') { 18 + return { 19 + ...base, 20 + actionType, 21 + callbackUrl: 'https://example.com', 22 + callbackUrlBody: null, 23 + callbackUrlHeaders: null, 24 + customMrtApiParams: null, 25 + }; 26 + } 27 + return { ...base, actionType }; 28 + } 29 + 30 + describe('Org resolvers', () => { 31 + describe('resolveOrgActions', () => { 32 + function makeContext(opts: { 33 + orgId: string; 34 + callerOrgId?: string; 35 + actionTypes: Array<{ id: string; actionType: Action['actionType'] }>; 36 + hasNCMECReportingEnabled: boolean; 37 + }) { 38 + const actions = opts.actionTypes.map((a) => 39 + makeAction(a.id, a.actionType, opts.orgId), 40 + ); 41 + const getActions = jest.fn(async () => actions); 42 + const hasNCMECReportingEnabled = jest.fn( 43 + async () => opts.hasNCMECReportingEnabled, 44 + ); 45 + const ctx = { 46 + getUser: () => ({ orgId: opts.callerOrgId ?? opts.orgId }), 47 + services: { 48 + ModerationConfigService: { getActions }, 49 + NcmecService: { hasNCMECReportingEnabled }, 50 + }, 51 + }; 52 + return { ctx, getActions, hasNCMECReportingEnabled }; 53 + } 54 + 55 + it('hides ENQUEUE_TO_NCMEC built-in when NCMEC reporting is disabled', async () => { 56 + const { ctx } = makeContext({ 57 + orgId: 'org-1', 58 + actionTypes: [ 59 + { id: 'a-custom', actionType: 'CUSTOM_ACTION' }, 60 + { id: 'a-mrt', actionType: 'ENQUEUE_TO_MRT' }, 61 + { id: 'a-author', actionType: 'ENQUEUE_AUTHOR_TO_MRT' }, 62 + { id: 'a-ncmec', actionType: 'ENQUEUE_TO_NCMEC' }, 63 + ], 64 + hasNCMECReportingEnabled: false, 65 + }); 66 + 67 + const result = await resolveOrgActions({ id: 'org-1' }, {}, ctx); 68 + expect(result.map((it) => it.id).sort()).toEqual([ 69 + 'a-author', 70 + 'a-custom', 71 + 'a-mrt', 72 + ]); 73 + }); 74 + 75 + it('returns ENQUEUE_TO_NCMEC built-in when NCMEC reporting is enabled', async () => { 76 + const { ctx } = makeContext({ 77 + orgId: 'org-1', 78 + actionTypes: [ 79 + { id: 'a-mrt', actionType: 'ENQUEUE_TO_MRT' }, 80 + { id: 'a-ncmec', actionType: 'ENQUEUE_TO_NCMEC' }, 81 + ], 82 + hasNCMECReportingEnabled: true, 83 + }); 84 + 85 + const result = await resolveOrgActions({ id: 'org-1' }, {}, ctx); 86 + expect(result.map((it) => it.id).sort()).toEqual(['a-mrt', 'a-ncmec']); 87 + }); 88 + 89 + it('rejects when caller org does not match the requested org (IDOR guard)', async () => { 90 + const { ctx, getActions, hasNCMECReportingEnabled } = makeContext({ 91 + orgId: 'org-1', 92 + callerOrgId: 'other-org', 93 + actionTypes: [], 94 + hasNCMECReportingEnabled: false, 95 + }); 96 + 97 + await expect(resolveOrgActions({ id: 'org-1' }, {}, ctx)).rejects.toThrow( 98 + 'User required.', 99 + ); 100 + expect(getActions).not.toHaveBeenCalled(); 101 + expect(hasNCMECReportingEnabled).not.toHaveBeenCalled(); 102 + }); 103 + }); 104 + });
+33 -9
server/graphql/modules/org.ts
··· 13 13 import { GraphQLError } from 'graphql'; 14 14 import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 15 15 import { forbiddenError, unauthenticatedError } from '../utils/errors.js'; 16 + import { type Context } from '../resolvers.js'; 16 17 17 18 const typeDefs = /* GraphQL */ ` 18 19 type Org { ··· 199 200 }, 200 201 }; 201 202 202 - const Org: GQLOrgResolvers = { 203 - async actions(org, _, context) { 204 - const user = context.getUser(); 205 - if (!user || user.orgId !== org.id) { 206 - throw unauthenticatedError('User required.'); 207 - } 208 - return context.services.ModerationConfigService.getActions({ 203 + // Narrowed to only the context fields this resolver actually uses, so tests 204 + // can build a minimal mock without casting. `Context` (the full resolver 205 + // context) is structurally assignable to this, so production usage is unchanged. 206 + type ResolveOrgActionsContext = { 207 + getUser: () => { orgId: string } | null | undefined; 208 + services: { 209 + ModerationConfigService: Pick<Context['services']['ModerationConfigService'], 'getActions'>; 210 + NcmecService: Pick<Context['services']['NcmecService'], 'hasNCMECReportingEnabled'>; 211 + }; 212 + }; 213 + 214 + export async function resolveOrgActions( 215 + org: { id: string }, 216 + _: unknown, 217 + context: ResolveOrgActionsContext, 218 + ) { 219 + const user = context.getUser(); 220 + if (!user || user.orgId !== org.id) { 221 + throw unauthenticatedError('User required.'); 222 + } 223 + const [actions, hasNcmecEnabled] = await Promise.all([ 224 + context.services.ModerationConfigService.getActions({ 209 225 orgId: org.id, 210 226 readFromReplica: true, 211 - }); 212 - }, 227 + }), 228 + context.services.NcmecService.hasNCMECReportingEnabled(org.id), 229 + ]); 230 + return hasNcmecEnabled 231 + ? actions 232 + : actions.filter((it) => it.actionType !== 'ENQUEUE_TO_NCMEC'); 233 + } 234 + 235 + const Org: GQLOrgResolvers = { 236 + actions: resolveOrgActions, 213 237 async contentTypes(org, _, context) { 214 238 const user = context.getUser(); 215 239 if (!user || user.orgId !== org.id) {
+101 -5
server/services/moderationConfigService/moderationConfigService.test.ts
··· 523 523 524 524 describe('Action-returning methods', () => { 525 525 describe('Creation methods', () => { 526 + describe('#upsertBuiltInActions', () => { 527 + it('seeds the three built-in (non-CUSTOM_ACTION) rows for the org', async () => { 528 + const all = await sutWithPrimary.getActions({ orgId: dummyOrgId }); 529 + const builtIns = all.filter( 530 + (it) => it.actionType !== 'CUSTOM_ACTION', 531 + ); 532 + const types = builtIns.map((it) => it.actionType).sort(); 533 + expect(types).toEqual( 534 + [ 535 + 'ENQUEUE_AUTHOR_TO_MRT', 536 + 'ENQUEUE_TO_MRT', 537 + 'ENQUEUE_TO_NCMEC', 538 + ].sort(), 539 + ); 540 + for (const action of builtIns) { 541 + expect(action.orgId).toBe(dummyOrgId); 542 + expect(action).not.toHaveProperty('callbackUrl'); 543 + } 544 + }); 545 + 546 + it('is idempotent: calling twice does not create duplicates', async () => { 547 + const before = await sutWithPrimary.getActions({ 548 + orgId: dummyOrgId, 549 + }); 550 + const beforeBuiltIns = before 551 + .filter((it) => it.actionType !== 'CUSTOM_ACTION') 552 + .map((it) => it.id) 553 + .sort(); 554 + await sutWithPrimary.upsertBuiltInActions(dummyOrgId); 555 + const after = await sutWithPrimary.getActions({ 556 + orgId: dummyOrgId, 557 + }); 558 + const afterBuiltIns = after 559 + .filter((it) => it.actionType !== 'CUSTOM_ACTION') 560 + .map((it) => it.id) 561 + .sort(); 562 + expect(afterBuiltIns).toEqual(beforeBuiltIns); 563 + }); 564 + 565 + it('built-ins surface for the appropriate item type kinds', async () => { 566 + const fresh = await createOrg( 567 + { 568 + KyselyPg: container.KyselyPg, 569 + ModerationConfigService: container.ModerationConfigService, 570 + ApiKeyService: container.ApiKeyService, 571 + }, 572 + uid(), 573 + ); 574 + try { 575 + const contentType = 576 + await sutWithPrimary.createContentType(fresh.org.id, { 577 + schema: dummySchema, 578 + description: null, 579 + name: faker.random.alphaNumeric(16), 580 + schemaFieldRoles: { displayName: 'fakeField' }, 581 + }); 582 + 583 + const forUser = await sutWithPrimary.getActionsForItemType({ 584 + orgId: fresh.org.id, 585 + itemTypeId: fresh.defaultUserItemType.id, 586 + itemTypeKind: 'USER', 587 + }); 588 + expect( 589 + forUser.map((it) => it.actionType).sort(), 590 + ).toEqual(['ENQUEUE_TO_MRT', 'ENQUEUE_TO_NCMEC'].sort()); 591 + 592 + const forContent = await sutWithPrimary.getActionsForItemType({ 593 + orgId: fresh.org.id, 594 + itemTypeId: contentType.id, 595 + itemTypeKind: 'CONTENT', 596 + }); 597 + expect( 598 + forContent.map((it) => it.actionType).sort(), 599 + ).toEqual( 600 + [ 601 + 'ENQUEUE_AUTHOR_TO_MRT', 602 + 'ENQUEUE_TO_MRT', 603 + 'ENQUEUE_TO_NCMEC', 604 + ].sort(), 605 + ); 606 + } finally { 607 + await fresh.cleanup(); 608 + } 609 + }); 610 + }); 611 + 526 612 describe('#createAction', () => { 527 613 it('should return and durably save the new action', async () => { 528 614 const saved = await sutWithPrimary.createAction(dummyOrgId, { ··· 573 659 574 660 it('should return all actions, properly formatted', async () => { 575 661 const res = await sutWithPrimary.getActions({ orgId: dummyOrgId }); 576 - expect(res).toHaveLength(createdActions.length); 577 - expect(res).toEqual(expect.arrayContaining(createdActions)); 662 + const customActions = res.filter( 663 + (it) => it.actionType === 'CUSTOM_ACTION', 664 + ); 665 + expect(customActions).toHaveLength(createdActions.length); 666 + expect(customActions).toEqual( 667 + expect.arrayContaining(createdActions), 668 + ); 578 669 }); 579 670 580 671 it('should round-trip a non-null customMrtApiParams value', async () => { ··· 1077 1168 readFromReplica: false, 1078 1169 }); 1079 1170 1080 - const ids = result.map((it) => it.id).sort(); 1081 - expect(ids).toEqual( 1171 + const customIds = result 1172 + .filter((it) => it.actionType === 'CUSTOM_ACTION') 1173 + .map((it) => it.id) 1174 + .sort(); 1175 + expect(customIds).toEqual( 1082 1176 [ 1083 1177 viaJunctionAction.id, 1084 1178 viaAppliesAllAction.id, ··· 1104 1198 itemTypeKind: 'CONTENT', 1105 1199 readFromReplica: false, 1106 1200 }); 1107 - expect(otherResult).toEqual([]); 1201 + expect( 1202 + otherResult.filter((it) => it.actionType === 'CUSTOM_ACTION'), 1203 + ).toEqual([]); 1108 1204 } finally { 1109 1205 await otherOrg.cleanup(); 1110 1206 }
+3
server/services/moderationConfigService/moderationConfigService.ts
··· 287 287 async deleteCustomAction(opts: { orgId: string; actionId: string }) { 288 288 return this.actionOps.deleteCustomAction(opts); 289 289 } 290 + async upsertBuiltInActions(orgId: string) { 291 + return this.actionOps.upsertBuiltInActions(orgId); 292 + } 290 293 291 294 async getActions(opts: { 292 295 orgId: string;
+81
server/services/moderationConfigService/modules/ActionOperations.ts
··· 26 26 } 27 27 } 28 28 29 + // Seeded once per org by upsertBuiltInActions; not creatable/editable via the 30 + // CRUD APIs, which are scoped to action_type='CUSTOM_ACTION'. 31 + export const BUILT_IN_ACTIONS = [ 32 + { 33 + actionType: 'ENQUEUE_TO_MRT', 34 + name: 'Enqueue Item to Manual Review', 35 + description: 36 + 'Sends the matched item directly to a manual review queue, routed by the org\u2019s MRT routing rules.', 37 + appliesToAllItemsOfKind: ['CONTENT', 'USER', 'THREAD'] as const, 38 + }, 39 + { 40 + actionType: 'ENQUEUE_AUTHOR_TO_MRT', 41 + name: 'Enqueue Author for Manual Review', 42 + description: 43 + 'Sends the author of the matched content to a manual review queue, with the matched item attached as context.', 44 + appliesToAllItemsOfKind: ['CONTENT'] as const, 45 + }, 46 + { 47 + actionType: 'ENQUEUE_TO_NCMEC', 48 + name: 'Enqueue for NCMEC Review', 49 + description: 50 + 'Sends the user associated with the matched item to the NCMEC review flow, gathering their media for reporting.', 51 + appliesToAllItemsOfKind: ['CONTENT', 'USER'] as const, 52 + }, 53 + ] as const satisfies readonly { 54 + actionType: Exclude<Action['actionType'], 'CUSTOM_ACTION'>; 55 + name: string; 56 + description: string; 57 + appliesToAllItemsOfKind: readonly ItemTypeKind[]; 58 + }[]; 59 + 29 60 const actionDbSelection = [ 30 61 'id', 31 62 'name', ··· 132 163 } 133 164 throw e; 134 165 } 166 + }); 167 + } 168 + 169 + // Idempotent: existing built-ins are detected by (org_id, action_type). 170 + async upsertBuiltInActions(orgId: string): Promise<readonly Action[]> { 171 + return this.transactionWithRetry(async (trx) => { 172 + const existingByType = new Set( 173 + ( 174 + (await trx 175 + .selectFrom('public.actions') 176 + .select('action_type as actionType') 177 + .where('org_id', '=', orgId) 178 + .where('action_type', '!=', 'CUSTOM_ACTION') 179 + .execute()) as { actionType: Action['actionType'] }[] 180 + ).map((row) => row.actionType), 181 + ); 182 + 183 + const toInsert = BUILT_IN_ACTIONS.filter( 184 + (b) => !existingByType.has(b.actionType), 185 + ).map((b) => ({ 186 + id: uid(), 187 + name: b.name, 188 + description: b.description, 189 + org_id: orgId, 190 + action_type: b.actionType, 191 + callback_url: null, 192 + callback_url_headers: null, 193 + callback_url_body: null, 194 + penalty: 'NONE' as const, 195 + apply_user_strikes: false, 196 + applies_to_all_items_of_kind: [...b.appliesToAllItemsOfKind], 197 + updated_at: new Date(), 198 + })); 199 + 200 + if (toInsert.length > 0) { 201 + await trx 202 + .insertInto('public.actions') 203 + .values(toInsert) 204 + .onConflict((oc) => oc.doNothing()) 205 + .execute(); 206 + } 207 + 208 + const refreshed = (await trx 209 + .selectFrom('public.actions') 210 + .select(actionDbSelection) 211 + .where('org_id', '=', orgId) 212 + .where('action_type', '!=', 'CUSTOM_ACTION') 213 + .execute()) as ActionDbResult[]; 214 + 215 + return refreshed.map((row) => this.#dbResultToAction(row)); 135 216 }); 136 217 } 137 218
+4
server/test/fixtureHelpers/createOrg.ts
··· 40 40 orgId, 41 41 ).catch(logErrorAndThrow); 42 42 43 + await deps.ModerationConfigService.upsertBuiltInActions(orgId).catch( 44 + logErrorAndThrow, 45 + ); 46 + 43 47 return { 44 48 org, 45 49 apiKey,