From 396a170df550b5b9811fe81e663b274e16ebe685 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 12 Jun 2026 23:11:40 +0200 Subject: [PATCH 01/28] fix(backend): purge user profile cache when updating settings i/update wrote to the user_profile table but only emitted 'userUpdated', which purges userByIdCache alone. The userProfileCache entry (populated at the start of the same request) was never invalidated - not even in the local process - so the endpoint returned stale profile data and all workers kept serving old settings until the 30-minute cache lifetime expired. Every other profile-writing call site already emits 'updateUserProfile'; this was the one exception. This also un-breaks federation of profile-only changes: the stale re-fetch made profileNeedsPublishing() compare the cached object against itself, so bio/location/birthday/fields edits were never delivered to followers unless a user-table field changed too. Also: - compare 'fields' structurally in profileNeedsPublishing; Array.join rendered every element as "[object Object]" so changes were invisible - emit 'updateUserProfile' before packing the response in i/update-email (and include the email keys), so the response reflects the new data - correct a backwards comment in CacheService.onProfileEvent https://claude.ai/code/session_01AB5H3dSqWnemYD2EjYRZkp --- packages/backend/src/core/CacheService.ts | 7 +++++-- .../server/api/endpoints/i/update-email.ts | 20 ++++++++++--------- .../src/server/api/endpoints/i/update.ts | 6 ++++-- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 7126f9d6f5..950ba431ee 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -789,8 +789,11 @@ export class CacheService implements OnApplicationShutdown { this.userRelationsCache.dropMany(relationKeysToClear); } - // Update the profile cache for local events only - // (isLocal may be undefined for local events, but will *always* be true for remote ones) + // Drop the cached profile. This runs for both local and remote events: + // isLocal is undefined for local events and false for remote ones, so the condition only skips + // callers that explicitly mark the event as local-only. + // For local events the quantum delete also syncs the purge to other processes; for remote events + // it is a redundant (but harmless) second purge. if (ctx.isLocal !== true) { await this.userProfileCache.delete(body.userId); } diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 77e2dd2c36..81c2f3e0fe 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -116,14 +116,6 @@ export default class extends Endpoint { // eslint- emailVerifyCode: null, }); - const iObj = await this.userEntityService.pack(me, me, { - schema: 'MeDetailed', - includeSecrets: true, - }); - - // Publish meUpdated event - await this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); - if (ps.email != null) { const code = secureRndstr(16, { chars: L_CHARS }); @@ -138,7 +130,17 @@ export default class extends Endpoint { // eslint- `To verify email, please click this link: ${link}`)); } - await this.internalEventService.emit('updateUserProfile', { userId: me.id, keys: ['emailVerifyCode'] }); + // Purge the profile cache (in all processes) *before* packing, so the response reflects the new data. + await this.internalEventService.emit('updateUserProfile', { userId: me.id, keys: ['email', 'emailVerified', 'emailVerifyCode'] }); + + const iObj = await this.userEntityService.pack(me, me, { + schema: 'MeDetailed', + includeSecrets: true, + }); + + // Publish meUpdated event + await this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + return iObj; }); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 8714768ec6..ea2081d4b0 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -623,8 +623,9 @@ export default class extends Endpoint { // eslint- profileUpdates.verifiedLinks = await verifyFieldLinks(newFields, profileUrls, this.httpRequestService); await this.userProfilesRepository.update(user.id, profileUpdates); - // Internal event purges the cache, which we immediately refill + // Internal events purge the caches (in all processes), which we immediately refill await this.internalEventService.emit('userUpdated', { id: user.id }); + await this.internalEventService.emit('updateUserProfile', { userId: user.id, keys: Object.keys(profileUpdates) as (keyof MiUserProfile)[] }); const updatedUser = await this.cacheService.findLocalUserById(user.id); const updatedProfile = await this.cacheService.userProfileCache.fetch(user.id); @@ -735,7 +736,8 @@ export default class extends Endpoint { // eslint- if (!Array.isArray(oldArray) || !Array.isArray(newArray)) { return true; } - if (oldArray.join('\0') !== newArray.join('\0')) { + // Elements are objects, so compare structurally - Array.join would render them all as "[object Object]". + if (JSON.stringify(oldArray) !== JSON.stringify(newArray)) { return true; } } From df5b09ca059ec17b4eb1af0934631219ebed114f Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 12 Jun 2026 23:11:40 +0200 Subject: [PATCH 02/28] fix(frontend): refresh stale instance meta within 10s instead of 101s The randomized refresh delay was written as 10_1000 (101,000 ms), an apparent typo for 10_000. Stale cached instance meta could persist for over a minute and a half after page load before the async refresh ran. https://claude.ai/code/session_01AB5H3dSqWnemYD2EjYRZkp --- packages/frontend/src/instance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 4ef8654794..08ccadbaf3 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -73,7 +73,7 @@ async function createInstance(): Promise { fetchInstance(true).catch(err => { console.warn('Failed to refresh instance meta, using stale cache', err); }); - }, 10_1000 * Math.abs(Math.random())); + }, 10_000 * Math.abs(Math.random())); } return cached.meta; From e582a9cdee007e121457a3bffcc62cde680857c4 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 12 Jun 2026 23:11:40 +0200 Subject: [PATCH 03/28] fix(backend): restore RSS feeds for legacy unapproved local users The "active user" refactor (126d7e261) made isActiveLocalUser() require user.approved unconditionally, while its sibling assertActiveLocalUser() and the signin check only enforce approval when meta.approvalRequiredForSignup is enabled. Local accounts created before the approval-signup migration (1697580470000) default to approved=false and were never backfilled, so on instances that don't require approval, every such account started returning 404 from /@user.atom, /@user.rss, and /@user.json (and other isActiveUser-gated routes like WebFinger and user SSR). Align isActiveLocalUser() with assertActiveLocalUser(): only treat an unapproved user as inactive when the instance actually requires approval for signup. https://claude.ai/code/session_01ALjqseHZ7wvaepdXwBko4d --- packages/backend/src/core/UtilityService.ts | 2 +- packages/backend/test/unit/UtilityService.ts | 42 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index cb599b1501..635f2a0578 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -253,7 +253,7 @@ export class UtilityService { @bindThis public isActiveLocalUser(user: MiLocalUser): boolean { - return !user.isDeleted && !user.isSuspended && user.approved; + return !user.isDeleted && !user.isSuspended && (user.approved || !this.meta.approvalRequiredForSignup); } @bindThis diff --git a/packages/backend/test/unit/UtilityService.ts b/packages/backend/test/unit/UtilityService.ts index 2240f53cc9..9fe0097622 100644 --- a/packages/backend/test/unit/UtilityService.ts +++ b/packages/backend/test/unit/UtilityService.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import type { MiMeta } from '@/models/_.js'; +import type { MiLocalUser } from '@/models/User.js'; import type { Config } from '@/config.js'; import type { SoftwareSuspension } from '@/models/Meta.js'; import type { MiInstance } from '@/models/Instance.js'; @@ -259,4 +260,45 @@ describe('UtilityService', () => { }); }); }); + + describe('isActiveLocalUser', () => { + const localUser = (overrides: Partial = {}) => ({ + host: null, + uri: null, + isDeleted: false, + isSuspended: false, + approved: true, + ...overrides, + }) as MiLocalUser; + + test('should be active when approved', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser())).toBe(true); + }); + + test('should be active when unapproved but approval is not required', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ approved: false }))).toBe(true); + }); + + test('should not be active when unapproved and approval is required', () => { + meta.approvalRequiredForSignup = true; + expect(utilityService.isActiveLocalUser(localUser({ approved: false }))).toBe(false); + }); + + test('should be active when approved and approval is required', () => { + meta.approvalRequiredForSignup = true; + expect(utilityService.isActiveLocalUser(localUser())).toBe(true); + }); + + test('should not be active when deleted', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ isDeleted: true }))).toBe(false); + }); + + test('should not be active when suspended', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ isSuspended: true }))).toBe(false); + }); + }); }); From cd1bcd479d200e84f0a49fe6eccffa5e351b7d0d Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 12 Jun 2026 23:05:08 +0200 Subject: [PATCH 04/28] fix(backend): bust user cache after admin avatar/banner unset Both endpoints wrote to the users table but neither injected InternalEventService nor emitted an invalidation event, leaving userByIdCache stale for up to 5 minutes after a moderator action. Also fix a pre-existing missing await on moderationLogService.log() in unset-user-banner, which could silently swallow audit log write errors. --- .../src/server/api/endpoints/admin/unset-user-avatar.ts | 3 +++ .../src/server/api/endpoints/admin/unset-user-banner.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts index 3466a09895..815e9057e3 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts @@ -8,6 +8,7 @@ import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -33,6 +34,7 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private moderationLogService: ModerationLogService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -49,6 +51,7 @@ export default class extends Endpoint { avatarUrl: null, avatarBlurhash: null, }); + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id }); await this.moderationLogService.log(me, 'unsetUserAvatar', { userId: user.id, diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts index e16dad719c..d132aefd29 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts @@ -8,6 +8,7 @@ import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -33,6 +34,7 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private moderationLogService: ModerationLogService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -49,8 +51,9 @@ export default class extends Endpoint { bannerUrl: null, bannerBlurhash: null, }); + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id }); - this.moderationLogService.log(me, 'unsetUserBanner', { + await this.moderationLogService.log(me, 'unsetUserBanner', { userId: user.id, userUsername: user.username, userHost: user.host, From 3d813c3756d700c5720e47cfcb4a6135f0a386a4 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 17 Jun 2026 16:29:23 +0200 Subject: [PATCH 05/28] Add Lua MRF policy runtime --- packages/backend/package.json | 1 + .../activitypub/mrf/MrfLuaPolicyService.ts | 561 +++++++++++++ .../queue/processors/MrfLuaPolicyService.ts | 783 ++++++++++++++++++ pnpm-lock.yaml | 188 ++++- 4 files changed, 1528 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts create mode 100644 packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 28a81b2405..db75515770 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -175,6 +175,7 @@ "ulid": "3.0.1", "uuid": "13.0.0", "vary": "1.1.2", + "wasmoon": "1.16.0", "web-push": "3.6.7", "ws": "8.19.0" }, diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts new file mode 100644 index 0000000000..ddc1ed15ed --- /dev/null +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -0,0 +1,561 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { LuaFactory } from 'wasmoon'; +import type { IActivity } from '@/core/activitypub/type.js'; + +export type MrfLuaPolicy = { + id: string; + name: string; + source: string; + timeoutMs?: number; + failureMode?: 'reject' | 'accept'; + paramsSchema?: MrfLuaParamsSchema; + params?: MrfLuaParams; +}; + +export type MrfLuaPolicyContext = { + activity: IActivity; + params?: MrfLuaParams; + actor: { + uri: string; + host: string | null; + followersCount?: number; + followingCount?: number; + }; + localHost: string; + signerHost: string; + receivedAt: string; +}; + +export type MrfLuaDecision = + | { action: 'accept'; reason?: string } + | { action: 'reject'; reason: string } + | { action: 'rewrite'; activity: IActivity; reason?: string }; + +export type MrfLuaRunResult = { + policy: Pick; + decision: MrfLuaDecision; + durationMs: number; +}; + +export type MrfLuaLookupApi = { + userByUri?: (uri: string) => Promise | null>; + userByMention?: (mention: Record | string) => Promise | null>; + instanceByHost?: (host: string) => Promise | null>; + noteByUri?: (uri: string) => Promise | null>; +}; + +export type MrfLuaParamType = 'string' | 'boolean' | 'number' | 'integer' | 'string_array'; + +export type MrfLuaParamSchemaEntry = { + type: MrfLuaParamType; + default?: string | boolean | number | string[]; + label?: string; + description?: string; +}; + +export type MrfLuaParamsSchema = Record; +export type MrfLuaParams = Record; + +export type MrfLuaRunOptions = { + lookup?: MrfLuaLookupApi; +}; + +export type MrfLuaPolicyServiceOptions = { + defaultTimeoutMs: number; + defaultMemoryLimitBytes: number; +}; + +const DEFAULT_OPTIONS = { + defaultTimeoutMs: 50, + defaultMemoryLimitBytes: 1024 * 1024 * 8, +} satisfies MrfLuaPolicyServiceOptions; + +const SANDBOX_PRELUDE = ` + os = nil + io = nil + package = nil + require = nil + debug = nil + dofile = nil + loadfile = nil + load = nil + collectgarbage = nil + Promise = nil + Error = nil + + local __mrf_null = rawget(_G, "null") + local __mrf_accept_fn = __mrf_accept + local __mrf_reject_fn = __mrf_reject + local __mrf_rewrite_fn = __mrf_rewrite + local __mrf_lookup_user_by_uri_fn = __mrf_lookup_user_by_uri + local __mrf_lookup_user_by_mention_fn = __mrf_lookup_user_by_mention + local __mrf_lookup_instance_by_host_fn = __mrf_lookup_instance_by_host + local __mrf_lookup_note_by_uri_fn = __mrf_lookup_note_by_uri + + null = nil + __mrf_accept = nil + __mrf_reject = nil + __mrf_rewrite = nil + __mrf_lookup_user_by_uri = nil + __mrf_lookup_user_by_mention = nil + __mrf_lookup_instance_by_host = nil + __mrf_lookup_note_by_uri = nil + + mrf = {} + + function mrf.is_nil(value) + return value == nil or value == __mrf_null + end + + function mrf.accept(reason) + return __mrf_accept_fn(reason) + end + + function mrf.reject(reason) + return __mrf_reject_fn(reason) + end + + function mrf.rewrite(activity, reason) + return __mrf_rewrite_fn(activity, reason) + end + + mrf.activity = {} + + function mrf.activity.object(activity) + if type(activity) ~= "table" then + return nil + end + return activity.object + end + + function mrf.activity.type(activity) + if type(activity) ~= "table" then + return nil + end + return activity.type + end + + function mrf.activity.actor_uri(activity) + if type(activity) ~= "table" then + return nil + end + if type(activity.actor) == "string" then + return activity.actor + end + if type(activity.actor) == "table" then + return activity.actor.id + end + return nil + end + + function mrf.activity.note(activity) + if type(activity) ~= "table" or activity.type ~= "Create" then + return nil + end + local object = activity.object + if type(object) ~= "table" or object.type ~= "Note" then + return nil + end + return object + end + + mrf.note = {} + + function mrf.note.content(note) + if type(note) ~= "table" then + return nil + end + return note.content + end + + function mrf.note.mentions(note) + local mentions = {} + if type(note) ~= "table" or type(note.tag) ~= "table" then + return mentions + end + for _, tag in ipairs(note.tag) do + if type(tag) == "table" and tag.type == "Mention" then + mentions[#mentions + 1] = tag + end + end + return mentions + end + + function mrf.note.mention_count(note) + return #mrf.note.mentions(note) + end + + function mrf.note.remove_mentions(note) + if type(note) ~= "table" or type(note.tag) ~= "table" then + return note + end + local tags = {} + for _, tag in ipairs(note.tag) do + if type(tag) ~= "table" or tag.type ~= "Mention" then + tags[#tags + 1] = tag + end + end + note.tag = tags + return note + end + + function mrf.note.mark_sensitive(note, reason) + if type(note) ~= "table" then + return note + end + note.sensitive = true + if reason ~= nil and note.summary == nil then + note.summary = reason + end + return note + end + + function mrf.note.unlist(note) + if type(note) ~= "table" then + return note + end + local public = "https://www.w3.org/ns/activitystreams#Public" + local to = note.to + local cc = note.cc + if type(to) == "string" and to == public then + note.to = cc + note.cc = public + end + return note + end + + function mrf.note.has_media(note) + return type(note) == "table" and type(note.attachment) == "table" and #note.attachment > 0 + end + + mrf.lookup = {} + + local function __mrf_nil_if_null(value) + if mrf.is_nil(value) then + return nil + end + return value + end + + function mrf.lookup.user_by_uri(uri) + if type(uri) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_user_by_uri_fn(uri):await()) + end + + function mrf.lookup.user_by_mention(mention) + if type(mention) ~= "table" and type(mention) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_user_by_mention_fn(mention):await()) + end + + function mrf.lookup.instance_by_host(host) + if type(host) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_instance_by_host_fn(host):await()) + end + + function mrf.lookup.note_by_uri(uri) + if type(uri) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_note_by_uri_fn(uri):await()) + end +`; + +export class MrfLuaPolicyError extends Error { + constructor(policy: Pick, cause: unknown) { + super(`policy ${policy.id} failed: ${renderLuaError(cause)}`); + this.name = 'MrfLuaPolicyError'; + } +} + +export class MrfLuaPolicyService { + private readonly luaFactory = new LuaFactory(); + private readonly options: MrfLuaPolicyServiceOptions; + + constructor(options?: Partial) { + this.options = { + ...DEFAULT_OPTIONS, + ...options, + }; + } + + public async run(policy: MrfLuaPolicy, context: MrfLuaPolicyContext, options: MrfLuaRunOptions = {}): Promise { + const start = performance.now(); + const timeoutMs = policy.timeoutMs ?? this.options.defaultTimeoutMs; + const paramsSchema = policy.paramsSchema ?? await this.extractParamsSchema(policy); + const params = this.resolveParams(paramsSchema, { + ...policy.params, + ...context.params, + }); + const clonedContext = structuredClone({ + ...context, + params, + }); + + const lua = await this.luaFactory.createEngine({ + openStandardLibs: true, + injectObjects: true, + enableProxy: false, + traceAllocations: true, + functionTimeout: timeoutMs, + }); + + try { + const mrfApi = this.createMrfApi(); + lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); + lua.global.set('__mrf_accept', mrfApi.accept); + lua.global.set('__mrf_reject', mrfApi.reject); + lua.global.set('__mrf_rewrite', mrfApi.rewrite); + lua.global.set('__mrf_lookup_user_by_uri', options.lookup?.userByUri ?? (async () => null)); + lua.global.set('__mrf_lookup_user_by_mention', options.lookup?.userByMention ?? (async () => null)); + lua.global.set('__mrf_lookup_instance_by_host', options.lookup?.instanceByHost ?? (async () => null)); + lua.global.set('__mrf_lookup_note_by_uri', options.lookup?.noteByUri ?? (async () => null)); + lua.global.set('ctx', clonedContext); + await lua.doString(SANDBOX_PRELUDE); + await this.runString(lua, policy.source, policy.name, timeoutMs); + + const thread = lua.global.newThread(); + thread.loadString('return filter(ctx)', policy.name); + const returns = await thread.run(0, { timeout: timeoutMs }); + const decision = this.parseDecision(returns[0]); + + return { + policy: { + id: policy.id, + name: policy.name, + }, + decision, + durationMs: performance.now() - start, + }; + } catch (error) { + throw new MrfLuaPolicyError(policy, error); + } finally { + lua.global.close(); + } + } + + public async extractParamsSchema(policy: Pick): Promise { + const timeoutMs = policy.timeoutMs ?? this.options.defaultTimeoutMs; + const lua = await this.luaFactory.createEngine({ + openStandardLibs: true, + injectObjects: true, + enableProxy: false, + traceAllocations: true, + functionTimeout: timeoutMs, + }); + + try { + lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); + lua.global.set('__mrf_accept', this.createMrfApi().accept); + lua.global.set('__mrf_reject', this.createMrfApi().reject); + lua.global.set('__mrf_rewrite', this.createMrfApi().rewrite); + lua.global.set('__mrf_lookup_user_by_uri', async () => null); + lua.global.set('__mrf_lookup_user_by_mention', async () => null); + lua.global.set('__mrf_lookup_instance_by_host', async () => null); + lua.global.set('__mrf_lookup_note_by_uri', async () => null); + await lua.doString(SANDBOX_PRELUDE); + await this.runString(lua, policy.source, policy.name, timeoutMs); + const rawPolicy = lua.global.get('policy'); + + if (!isRecord(rawPolicy) || rawPolicy.params == null) { + return {}; + } + + return this.parseParamsSchema(rawPolicy.params); + } catch (error) { + throw new MrfLuaPolicyError(policy, error); + } finally { + lua.global.close(); + } + } + + public resolveParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const parsedParams = this.validateParams(schema, params); + const resolved: MrfLuaParams = {}; + for (const [key, entry] of Object.entries(schema)) { + if (Object.hasOwn(parsedParams, key)) { + resolved[key] = parsedParams[key]; + } else if (Object.hasOwn(entry, 'default')) { + resolved[key] = structuredClone(entry.default); + } + } + + return resolved; + } + + public validateParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const parsedParams: MrfLuaParams = {}; + for (const key of Object.keys(params)) { + if (!Object.hasOwn(schema, key)) { + throw new Error(`unknown policy parameter: ${key}`); + } + parsedParams[key] = this.parseParamValue(key, schema[key], params[key]); + } + return parsedParams; + } + + public filterCompatibleParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const filtered: MrfLuaParams = {}; + for (const [key, value] of Object.entries(params)) { + const entry = schema[key]; + if (entry == null) continue; + try { + filtered[key] = this.parseParamValue(key, entry, value); + } catch { + // Drop stale values when a custom policy source changes its schema. + } + } + return filtered; + } + + private createMrfApi() { + return { + accept: (reason?: string) => ({ + action: 'accept', + ...(typeof reason === 'string' ? { reason } : {}), + }), + reject: (reason: string) => ({ + action: 'reject', + reason, + }), + rewrite: (activity: IActivity, reason?: string) => ({ + action: 'rewrite', + activity, + ...(typeof reason === 'string' ? { reason } : {}), + }), + }; + } + + private async runString(lua: Awaited>, source: string, name: string, timeoutMs: number): Promise { + const thread = lua.global.newThread(); + thread.loadString(source, name); + await thread.run(0, { timeout: timeoutMs }); + } + + private parseParamsSchema(value: unknown): MrfLuaParamsSchema { + if (!isRecord(value)) { + throw new Error('policy.params must be an object'); + } + + const schema: MrfLuaParamsSchema = {}; + for (const [key, entry] of Object.entries(value)) { + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + throw new Error(`invalid policy parameter name: ${key}`); + } + if (!isRecord(entry)) { + throw new Error(`policy parameter ${key} must be an object`); + } + if (!isParamType(entry.type)) { + throw new Error(`policy parameter ${key} has unsupported type: ${String(entry.type)}`); + } + + const parsed: MrfLuaParamSchemaEntry = { + type: entry.type, + }; + if (Object.hasOwn(entry, 'default')) { + parsed.default = this.parseParamValue(key, parsed, entry.default) as MrfLuaParamSchemaEntry['default']; + } + if (entry.label !== undefined) { + if (typeof entry.label !== 'string' || entry.label.length > 128) { + throw new Error(`policy parameter ${key} label must be a string`); + } + parsed.label = entry.label; + } + if (entry.description !== undefined) { + if (typeof entry.description !== 'string' || entry.description.length > 512) { + throw new Error(`policy parameter ${key} description must be a string`); + } + parsed.description = entry.description; + } + + schema[key] = parsed; + } + + return schema; + } + + private parseParamValue(key: string, entry: MrfLuaParamSchemaEntry, value: unknown): unknown { + switch (entry.type) { + case 'string': + if (typeof value !== 'string') throw new Error(`policy parameter ${key} must be a string`); + return value; + case 'boolean': + if (typeof value !== 'boolean') throw new Error(`policy parameter ${key} must be a boolean`); + return value; + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`policy parameter ${key} must be a finite number`); + return value; + case 'integer': + if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`policy parameter ${key} must be an integer`); + return value; + case 'string_array': + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) { + throw new Error(`policy parameter ${key} must be an array of strings`); + } + return [...value]; + } + } + + private parseDecision(value: unknown): MrfLuaDecision { + if (!isRecord(value)) { + throw new Error('policy returned a non-object decision'); + } + + if (value.action === 'accept') { + return { + action: 'accept', + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + }; + } + + if (value.action === 'reject') { + if (typeof value.reason !== 'string' || value.reason.length === 0) { + throw new Error('reject decision must include a reason'); + } + + return { + action: 'reject', + reason: value.reason, + }; + } + + if (value.action === 'rewrite') { + if (!isRecord(value.activity)) { + throw new Error('rewrite decision must include an activity'); + } + + return { + action: 'rewrite', + activity: value.activity as unknown as IActivity, + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + }; + } + + throw new Error(`unknown policy action: ${String(value.action)}`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isParamType(value: unknown): value is MrfLuaParamType { + return value === 'string' || value === 'boolean' || value === 'number' || value === 'integer' || value === 'string_array'; +} + +function renderLuaError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts new file mode 100644 index 0000000000..80603d2a49 --- /dev/null +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -0,0 +1,783 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'assert'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; + +const baseActivity = { + id: 'https://remote.example/activities/1', + type: 'Create', + actor: 'https://remote.example/users/alice', + object: { + id: 'https://remote.example/notes/1', + type: 'Note', + content: 'hello', + tag: [], + }, +} satisfies IActivity; + +const builtinPolicyFixtures = [ + { + id: 'keyword-filter', + name: 'Keyword filter', + source: ` + policy = { + params = { + keywords = { + type = "string_array", + default = { + "https://discord.gg/ctkpaarr", + "@ap12@mastodon-japan.net", + "ctkpaarr", + }, + label = "Blocked keywords", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local content = mrf.note.content(note) + if type(content) ~= "string" then + return mrf.accept() + end + + for _, keyword in ipairs(ctx.params.keywords) do + if string.find(content, keyword, 1, true) ~= nil then + return mrf.reject("keyword filter matched: " .. keyword) + end + end + + return mrf.accept() + end + `, + }, + { + id: 'new-user-spam', + name: 'New user spam mention filter', + source: ` + policy = { + params = { + maxFollowers = { + type = "integer", + default = 0, + label = "Maximum followers", + }, + maxFollowing = { + type = "integer", + default = 0, + label = "Maximum following", + }, + onlyTopLevelPosts = { + type = "boolean", + default = true, + label = "Only top-level posts", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local mentions = mrf.note.mentions(note) + if #mentions == 0 then + return mrf.accept() + end + + local local_prefix = "https://" .. ctx.localHost + local has_local_mention = false + for _, mention in ipairs(mentions) do + if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then + has_local_mention = true + break + end + end + + if not has_local_mention then + return mrf.accept() + end + + if (ctx.actor.followersCount or 0) <= ctx.params.maxFollowers and (ctx.actor.followingCount or 0) <= ctx.params.maxFollowing and (not ctx.params.onlyTopLevelPosts or mrf.is_nil(note.inReplyTo)) then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped unsolicited local mentions from new remote actor") + end + + return mrf.accept() + end + `, + }, + { + id: 'hellthread', + name: 'Hellthread mention filter', + source: ` + policy = { + params = { + mentionThreshold = { + type = "integer", + default = 15, + label = "Mention threshold", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + if mrf.note.mention_count(note) >= ctx.params.mentionThreshold then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped hellthread mentions") + end + + return mrf.accept() + end + `, + }, +] satisfies MrfLuaPolicy[]; + +function createService() { + return new MrfLuaPolicyService({ + defaultTimeoutMs: 100, + defaultMemoryLimitBytes: 1024 * 1024 * 4, + }); +} + +async function runBundledPolicies(activity: IActivity, actor: { uri: string; host: string; followersCount: number; followingCount: number }) { + const service = createService(); + let currentActivity = structuredClone(activity); + const decisions: Array<{ policyId: string; action: string; activity: IActivity }> = []; + + for (const policy of builtinPolicyFixtures) { + const result = await service.run(policy, { + activity: currentActivity, + actor, + localHost: 'local.example', + signerHost: actor.host, + receivedAt: '2026-06-14T00:00:00.000Z', + }); + decisions.push({ + policyId: policy.id, + action: result.decision.action, + activity: currentActivity, + }); + + if (result.decision.action === 'rewrite') { + currentActivity = result.decision.activity; + } + + if (result.decision.action === 'reject') { + break; + } + } + + return { + activity: currentActivity, + decisions, + }; +} + +function createRealisticReplyActivity() { + return { + id: 'https://remote.example/users/alice/statuses/1/activity', + type: 'Create', + actor: 'https://remote.example/users/alice', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://remote.example/users/alice/followers', 'https://local.example/users/bob'], + object: { + id: 'https://remote.example/users/alice/statuses/1', + type: 'Note', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://remote.example/users/alice/followers', 'https://local.example/users/bob'], + tag: [ + { type: 'Mention', href: 'https://local.example/users/bob', name: '@bob@local.example' }, + { type: 'Hashtag', href: 'https://remote.example/tags/test', name: '#test' }, + ], + content: '

@bob hello

', + inReplyTo: 'https://local.example/users/bob/statuses/1', + published: '2026-06-14T00:00:00Z', + sensitive: false, + attachment: [], + attributedTo: 'https://remote.example/users/alice', + }, + } satisfies IActivity; +} + +function createHellthreadReplayActivity() { + const activity = createRealisticReplyActivity(); + const note = activity.object as typeof activity.object & { tag: Array> }; + note.tag = [ + ...Array.from({ length: 15 }, (_, i) => ({ + type: 'Mention', + href: `https://remote${i}.example/users/person${i}`, + name: `@person${i}@remote${i}.example`, + })), + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]; + return activity; +} + +function createNewUserSpamReplayActivity() { + const activity = createRealisticReplyActivity(); + const note = activity.object as { inReplyTo: string | null; tag: Array> }; + note.inReplyTo = null; + note.tag = [ + { type: 'Mention', href: 'https://local.example/users/bob', name: '@bob@local.example' }, + { type: 'Mention', href: 'https://remote.example/users/carol', name: '@carol@remote.example' }, + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]; + return activity; +} + +describe('MrfLuaPolicyService', () => { + test('returns an accept decision from a Lua policy', async () => { + const service = createService(); + + const result = await service.run({ + id: 'accept', + name: 'Accept Policy', + source: ` + function filter(ctx) + return mrf.accept("no changes") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'no changes', + }); + assert.equal(result.policy.id, 'accept'); + }); + + test('returns a rewritten cloned activity without mutating the caller activity', async () => { + const service = createService(); + + const result = await service.run({ + id: 'rewrite', + name: 'Rewrite Policy', + source: ` + function filter(ctx) + ctx.activity.object.content = "rewritten" + return mrf.rewrite(ctx.activity, "changed content") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + assert.equal(result.decision.reason, 'changed content'); + if (result.decision.action === 'rewrite') { + assert.equal((result.decision.activity.object as { content: string }).content, 'rewritten'); + } + assert.equal(baseActivity.object.content, 'hello'); + }); + + test('does not expose unsafe Lua standard library globals', async () => { + const service = createService(); + + const result = await service.run({ + id: 'sandbox', + name: 'Sandbox Policy', + source: ` + function filter(ctx) + if os ~= nil or io ~= nil or package ~= nil or debug ~= nil or require ~= nil or Promise ~= nil or Error ~= nil then + return mrf.reject("unsafe global exposed") + end + if __mrf_accept ~= nil or __mrf_lookup_user_by_uri ~= nil then + return mrf.reject("bridge global exposed") + end + return mrf.accept("sandboxed") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'sandboxed', + }); + }); + + test('times out policies that do not yield', async () => { + const service = createService(); + + await assert.rejects( + () => service.run({ + id: 'loop', + name: 'Loop Policy', + timeoutMs: 20, + source: ` + function filter(ctx) + while true do + end + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }), + /policy loop failed/i, + ); + }); + + test('times out policy source that does not yield while loading', async () => { + const service = createService(); + + await assert.rejects( + () => service.extractParamsSchema({ + id: 'load-loop', + name: 'Load Loop Policy', + timeoutMs: 20, + source: ` + while true do + end + `, + }), + /policy load-loop failed/i, + ); + }); + + test('exposes note helpers for mention counting and removal', async () => { + const service = createService(); + const activity = { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + { type: 'Mention', href: 'https://local.example/@alice' }, + { type: 'Hashtag', name: '#test' }, + { type: 'Mention', href: 'https://local.example/@bob' }, + ], + }, + } satisfies IActivity; + + const result = await service.run({ + id: 'helpers', + name: 'Helper Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.reject("note missing") + end + if mrf.note.mention_count(note) ~= 2 then + return mrf.reject("wrong mention count") + end + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "removed mentions") + end + `, + }, { + activity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#test' }, + ]); + } + }); + + test('exposes read-only lookup helpers to Lua policies', async () => { + const service = createService(); + + const result = await service.run({ + id: 'lookup', + name: 'Lookup Policy', + source: ` + function filter(ctx) + local user = mrf.lookup.user_by_uri("https://remote.example/users/alice") + if user == nil or user.followersCount ~= 0 then + return mrf.reject("lookup failed") + end + return mrf.accept("lookup worked") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }, { + lookup: { + userByUri: async () => ({ + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }), + }, + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'lookup worked', + }); + }); + + test('normalizes lookup null results to Lua nil', async () => { + const service = createService(); + + const result = await service.run({ + id: 'lookup-null', + name: 'Lookup Null Policy', + source: ` + function filter(ctx) + local user = mrf.lookup.user_by_uri("https://remote.example/users/missing") + if user ~= nil then + return mrf.reject("lookup miss was not nil") + end + return mrf.accept("lookup miss") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }, { + lookup: { + userByUri: async () => null, + }, + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'lookup miss', + }); + }); + + test('exposes a helper for JavaScript null activity fields', async () => { + const service = createService(); + + const result = await service.run({ + id: 'null-helper', + name: 'Null Helper Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.reject("note missing") + end + if not mrf.is_nil(note.inReplyTo) then + return mrf.reject("null field was not considered nil") + end + return mrf.accept("null helper") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'null helper', + }); + }); + + test('extracts policy parameter schema from Lua metadata', async () => { + const service = createService(); + + const schema = await service.extractParamsSchema({ + id: 'params-schema', + name: 'Params Schema Policy', + source: ` + policy = { + params = { + keywords = { + type = "string_array", + default = { "spam", "scam" }, + label = "Blocked keywords", + description = "Plain substrings to reject", + }, + caseSensitive = { + type = "boolean", + default = true, + }, + }, + } + + function filter(ctx) + return mrf.accept() + end + `, + }); + + assert.deepStrictEqual(schema, { + keywords: { + type: 'string_array', + default: ['spam', 'scam'], + label: 'Blocked keywords', + description: 'Plain substrings to reject', + }, + caseSensitive: { + type: 'boolean', + default: true, + }, + }); + }); + + test('merges parameter defaults and overrides into ctx.params', async () => { + const service = createService(); + + const result = await service.run({ + id: 'params', + name: 'Params Policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 15, + }, + keywords = { + type = "string_array", + default = { "default" }, + }, + }, + } + + function filter(ctx) + if ctx.params.threshold ~= 3 then + return mrf.reject("threshold override missing") + end + if ctx.params.keywords[1] ~= "default" then + return mrf.reject("keyword default missing") + end + return mrf.accept("params ok") + end + `, + params: { + threshold: 3, + }, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'params ok', + }); + }); + + describe('bundled policies', () => { + test('keyword policy rejects matching note content', async () => { + const service = createService(); + const keywordPolicy = builtinPolicyFixtures.find(policy => policy.id === 'keyword-filter'); + assert.ok(keywordPolicy); + + const result = await service.run(keywordPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + content: 'join https://discord.gg/ctkpaarr now', + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'reject'); + if (result.decision.action === 'reject') { + assert.match(result.decision.reason, /keyword/i); + } + }); + + test('hellthread policy strips mentions at the current threshold', async () => { + const service = createService(); + const hellthreadPolicy = builtinPolicyFixtures.find(policy => policy.id === 'hellthread'); + assert.ok(hellthreadPolicy); + const mentions = Array.from({ length: 15 }, (_, i) => ({ + type: 'Mention', + href: `https://local.example/users/${i}`, + })); + + const result = await service.run(hellthreadPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + ...mentions, + { type: 'Hashtag', name: '#kept' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#kept' }, + ]); + } + }); + + test('new-user spam policy strips unsolicited local mentions', async () => { + const service = createService(); + const newUserPolicy = builtinPolicyFixtures.find(policy => policy.id === 'new-user-spam'); + assert.ok(newUserPolicy); + + const result = await service.run(newUserPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + tag: [ + { type: 'Mention', href: 'https://local.example/users/alice' }, + { type: 'Hashtag', name: '#kept' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/spam', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#kept' }, + ]); + } + }); + + test('realistic replay activities exercise bundled rewrite policies', async () => { + const replayActivities = [ + { + activity: createHellthreadReplayActivity(), + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 12, + followingCount: 8, + }, + policyId: 'hellthread', + }, + { + activity: createNewUserSpamReplayActivity(), + actor: { + uri: 'https://remote.example/users/new', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + policyId: 'new-user-spam', + }, + ]; + + const results = await Promise.all(replayActivities.map(async replay => ({ + ...replay, + result: await runBundledPolicies(replay.activity, replay.actor), + }))); + + for (const replay of results) { + const rewriteDecision = replay.result.decisions.find(decision => decision.action === 'rewrite'); + assert.equal(rewriteDecision?.policyId, replay.policyId); + assert.deepStrictEqual((replay.result.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]); + } + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad93defa4e..d354f2d389 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: vary: specifier: 1.1.2 version: 1.1.2 + wasmoon: + specifier: 1.16.0 + version: 1.16.0 web-push: specifier: 3.6.7 version: 3.6.7 @@ -570,7 +573,7 @@ importers: version: 7.1.4 ts-jest: specifier: 29.4.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.1)(esbuild-register@3.5.0(esbuild@0.27.4))(ts-node@10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.24.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.24.7))(esbuild@0.27.4)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.1)(esbuild-register@3.5.0(esbuild@0.27.4))(ts-node@10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3) @@ -2479,6 +2482,7 @@ packages: '@github/webauthn-json@2.1.1': resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==} + deprecated: 'Deprecated: Modern browsers support built-in WebAuthn JSON methods. Please use native browser methods instead. For more information, visit https://github.com/github/webauthn-json' hasBin: true '@hapi/address@5.1.1': @@ -3783,6 +3787,9 @@ packages: '@sinonjs/text-encoding@0.7.3': resolution: {integrity: sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==} + deprecated: |- + Deprecated: no longer maintained and no longer used by Sinon packages. See + https://github.com/sinonjs/nise/issues/243 for replacement details. '@smithy/abort-controller@4.2.15': resolution: {integrity: sha512-98zuTrdp06d5jhoIA7gBzEAh+4zZMywxdS1qiiUg0BOTq/EE4zc0bqgbSBKMcRKSNFZrpVFjspExiP7ScO2l5A==} @@ -4309,6 +4316,9 @@ packages: '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/emscripten@1.39.10': + resolution: {integrity: sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==} + '@types/eslint@7.29.0': resolution: {integrity: sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==} @@ -4620,6 +4630,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -7116,6 +7127,7 @@ packages: intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. ioredis@5.8.0: resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} @@ -8826,6 +8838,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -10379,10 +10392,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v-code-diff@1.13.1: @@ -10549,8 +10564,8 @@ packages: vue-component-type-helpers@3.0.8: resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==} - vue-component-type-helpers@3.3.1: - resolution: {integrity: sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==} + vue-component-type-helpers@3.3.5: + resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} vue-demi@0.14.7: resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} @@ -10617,6 +10632,10 @@ packages: resolution: {integrity: sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==} engines: {node: '>=12'} + wasmoon@1.16.0: + resolution: {integrity: sha512-FlRLb15WwAOz1A9OQDbf6oOKKSiefi5VK0ZRF2wgH9xk3o5SnU11tNPaOnQuAh1Ucr66cwwvVXaeVRaFdRBt5g==} + hasBin: true + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} @@ -10643,6 +10662,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -11411,36 +11431,78 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -11451,41 +11513,89 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.22.5 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -13887,7 +13997,7 @@ snapshots: storybook: 10.3.1(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.6.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(utf-8-validate@6.0.5) type-fest: 2.19.0 vue: 3.5.21(typescript@5.9.3) - vue-component-type-helpers: 3.3.1 + vue-component-type-helpers: 3.3.5 '@stylistic/eslint-plugin@5.6.1(eslint@9.39.4)': dependencies: @@ -14163,6 +14273,8 @@ snapshots: '@types/doctrine@0.0.9': {} + '@types/emscripten@1.39.10': {} + '@types/eslint@7.29.0': dependencies: '@types/estree': 1.0.8 @@ -15209,6 +15321,20 @@ snapshots: b4a@1.6.4: {} + babel-jest@30.2.0(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.24.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -15236,6 +15362,26 @@ snapshots: dependencies: '@types/babel__core': 7.20.5 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.7) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.24.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7) + optional: true + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -15255,6 +15401,13 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + babel-preset-jest@30.2.0(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.24.7) + optional: true + babel-preset-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -20898,6 +21051,27 @@ snapshots: ts-dedent@2.2.0: {} + ts-jest@29.4.5(@babel/core@7.24.7)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.24.7))(esbuild@0.27.4)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.1)(esbuild-register@3.5.0(esbuild@0.27.4))(ts-node@10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@22.18.1)(esbuild-register@3.5.0(esbuild@0.27.4))(ts-node@10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.24.7 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.24.7) + esbuild: 0.27.4 + jest-util: 30.2.0 + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.4)(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.1)(esbuild-register@3.5.0(esbuild@0.27.4))(ts-node@10.9.2(@swc/core@1.15.18)(@swc/wasm@1.2.130)(@types/node@22.18.1)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -21443,7 +21617,7 @@ snapshots: vue-component-type-helpers@3.0.8: {} - vue-component-type-helpers@3.3.1: {} + vue-component-type-helpers@3.3.5: {} vue-demi@0.14.7(vue@3.5.21(typescript@5.9.3)): dependencies: @@ -21527,6 +21701,10 @@ snapshots: wanakana@5.3.1: {} + wasmoon@1.16.0: + dependencies: + '@types/emscripten': 1.39.10 + web-push@3.6.7: dependencies: asn1.js: 5.4.1 From a1e9799a5c9ca7f601d3c84366435dda35a57802 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 17 Jun 2026 16:29:57 +0200 Subject: [PATCH 06/28] Add DB-backed MRF policy management --- .../migration/1792000000000-MrfPolicy.js | 134 +++++++++++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/MrfPolicy.ts | 79 ++++++++ .../backend/src/models/RepositoryModule.ts | 8 + packages/backend/src/models/_.ts | 3 + packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/endpoint-list.ts | 5 + .../endpoints/admin/mrf-policies/create.ts | 100 ++++++++++ .../endpoints/admin/mrf-policies/delete.ts | 55 ++++++ .../api/endpoints/admin/mrf-policies/list.ts | 55 ++++++ .../api/endpoints/admin/mrf-policies/test.ts | 86 ++++++++ .../endpoints/admin/mrf-policies/update.ts | 126 ++++++++++++ .../unit/server/api/MrfPolicyEndpoints.ts | 183 ++++++++++++++++++ 13 files changed, 837 insertions(+) create mode 100644 packages/backend/migration/1792000000000-MrfPolicy.js create mode 100644 packages/backend/src/models/MrfPolicy.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts create mode 100644 packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts diff --git a/packages/backend/migration/1792000000000-MrfPolicy.js b/packages/backend/migration/1792000000000-MrfPolicy.js new file mode 100644 index 0000000000..704b360859 --- /dev/null +++ b/packages/backend/migration/1792000000000-MrfPolicy.js @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MrfPolicy1792000000000 { + name = 'MrfPolicy1792000000000' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "failureMode" character varying(32) NOT NULL DEFAULT 'reject', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_mrf_policy_enabled_priority" ON "mrf_policy" ("enabled", "priority")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_mrf_policy_builtinPolicyId" ON "mrf_policy" ("builtinPolicyId")`); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 10, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + 'mrfbuiltinnewuserspam001', + 'New user spam mention filter', + ` + policy = { + params = { + maxFollowers = { + type = "integer", + default = 0, + label = "Maximum followers", + }, + maxFollowing = { + type = "integer", + default = 0, + label = "Maximum following", + }, + onlyTopLevelPosts = { + type = "boolean", + default = true, + label = "Only top-level posts", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local mentions = mrf.note.mentions(note) + if #mentions == 0 then + return mrf.accept() + end + + local local_prefix = "https://" .. ctx.localHost + local has_local_mention = false + for _, mention in ipairs(mentions) do + if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then + has_local_mention = true + break + end + end + + if not has_local_mention then + return mrf.accept() + end + + if (ctx.actor.followersCount or 0) <= ctx.params.maxFollowers and (ctx.actor.followingCount or 0) <= ctx.params.maxFollowing and (not ctx.params.onlyTopLevelPosts or mrf.is_nil(note.inReplyTo)) then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped unsolicited local mentions from new remote actor") + end + + return mrf.accept() + end + `, + 'new-user-spam', + JSON.stringify({ + maxFollowers: { + type: 'integer', + default: 0, + label: 'Maximum followers', + }, + maxFollowing: { + type: 'integer', + default: 0, + label: 'Maximum following', + }, + onlyTopLevelPosts: { + type: 'boolean', + default: true, + label: 'Only top-level posts', + }, + }), + '{}', + ]); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 20, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + 'mrfbuiltinhellthread001', + 'Hellthread mention filter', + ` + policy = { + params = { + mentionThreshold = { + type = "integer", + default = 15, + label = "Mention threshold", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + if mrf.note.mention_count(note) >= ctx.params.mentionThreshold then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped hellthread mentions") + end + + return mrf.accept() + end + `, + 'hellthread', + JSON.stringify({ + mentionThreshold: { + type: 'integer', + default: 15, + label: 'Mention threshold', + }, + }), + '{}', + ]); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_mrf_policy_builtinPolicyId"`); + await queryRunner.query(`DROP INDEX "IDX_mrf_policy_enabled_priority"`); + await queryRunner.query(`DROP TABLE "mrf_policy"`); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index dcabf48ee9..11f9d8d1d1 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -34,6 +34,7 @@ export const DI = { apContextsRepository: Symbol('apContextsRepository'), apFetchLogsRepository: Symbol('apFetchLogsRepository'), apInboxLogsRepository: Symbol('apInboxLogsRepository'), + mrfPoliciesRepository: Symbol('mrfPoliciesRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/models/MrfPolicy.ts b/packages/backend/src/models/MrfPolicy.ts new file mode 100644 index 0000000000..bc9b731459 --- /dev/null +++ b/packages/backend/src/models/MrfPolicy.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import type { MrfLuaParams, MrfLuaParamsSchema } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { id } from './util/id.js'; + +export const mrfPolicyFailureModes = ['reject', 'accept'] as const; +export type MrfPolicyFailureMode = typeof mrfPolicyFailureModes[number]; + +@Index('IDX_mrf_policy_enabled_priority', ['enabled', 'priority']) +@Index('IDX_mrf_policy_builtinPolicyId', ['builtinPolicyId'], { unique: true }) +@Entity('mrf_policy') +export class MiMrfPolicy { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('boolean', { + default: true, + }) + public enabled: boolean; + + @Column('integer', { + default: 1000, + }) + public priority: number; + + @Column('text') + public source: string; + + @Column('integer', { + default: 50, + }) + public timeoutMs: number; + + @Column('varchar', { + length: 32, + default: 'reject', + }) + public failureMode: MrfPolicyFailureMode; + + @Column('boolean', { + default: false, + }) + public isBuiltin: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public builtinPolicyId: string | null; + + @Column('jsonb', { + default: {}, + }) + public paramsSchema: MrfLuaParamsSchema; + + @Column('jsonb', { + default: {}, + }) + public params: MrfLuaParams; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e114654c77..44a70c58c5 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -37,6 +37,7 @@ import { MiInstance, MiMeta, MiModerationLog, + MiMrfPolicy, MiMuting, MiNote, MiNoteFavorite, @@ -391,6 +392,12 @@ const $moderationLogsRepository: Provider = { inject: [DI.db], }; +const $mrfPoliciesRepository: Provider = { + provide: DI.mrfPoliciesRepository, + useFactory: (db: DataSource) => db.getRepository(MiMrfPolicy).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $clipsRepository: Provider = { provide: DI.clipsRepository, useFactory: (db: DataSource) => db.getRepository(MiClip).extend(miRepository as MiRepository), @@ -623,6 +630,7 @@ export const repositoryProviders: Provider[] = [ $galleryPostsRepository, $galleryLikesRepository, $moderationLogsRepository, + $mrfPoliciesRepository, $clipsRepository, $clipNotesRepository, $clipFavoritesRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 7fc5878672..61984b1cc5 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -39,6 +39,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMrfPolicy } from '@/models/MrfPolicy.js'; import { MiMuting } from '@/models/Muting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; @@ -178,6 +179,7 @@ export { MiInstance, MiMeta, MiModerationLog, + MiMrfPolicy, MiMuting, MiRenoteMuting, MiNote, @@ -257,6 +259,7 @@ export type GalleryPostsRepository = Repository & MiRepository & MiRepository; export type InstancesRepository = Repository & MiRepository; export type MetasRepository = Repository & MiRepository; +export type MrfPoliciesRepository = Repository & MiRepository; export type LatestNotesRepository = Repository & MiRepository; export type ApContextsRepository = Repository & MiRepository; export type ApFetchLogsRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1997182624..84834eba72 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -41,6 +41,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMrfPolicy } from '@/models/MrfPolicy.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -269,6 +270,7 @@ export const entities = [ MiRegistrationTicket, MiSignin, MiModerationLog, + MiMrfPolicy, MiClip, MiClipNote, MiClipFavorite, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 11bc981984..10b5648523 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -69,6 +69,11 @@ export * as 'admin/get-user-ips' from './endpoints/admin/get-user-ips.js'; export * as 'admin/invite/create' from './endpoints/admin/invite/create.js'; export * as 'admin/invite/list' from './endpoints/admin/invite/list.js'; export * as 'admin/meta' from './endpoints/admin/meta.js'; +export * as 'admin/mrf-policies/create' from './endpoints/admin/mrf-policies/create.js'; +export * as 'admin/mrf-policies/delete' from './endpoints/admin/mrf-policies/delete.js'; +export * as 'admin/mrf-policies/list' from './endpoints/admin/mrf-policies/list.js'; +export * as 'admin/mrf-policies/test' from './endpoints/admin/mrf-policies/test.js'; +export * as 'admin/mrf-policies/update' from './endpoints/admin/mrf-policies/update.js'; export * as 'admin/nsfw-user' from './endpoints/admin/nsfw-user.js'; export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts new file mode 100644 index 0000000000..dca9a577e9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import { mrfPolicyFailureModes } from '@/models/MrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: '33231d77-34ed-4454-83a4-1f0d456acdf8', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 256 }, + enabled: { type: 'boolean', default: true }, + priority: { type: 'integer', default: 1000 }, + source: { type: 'string', minLength: 1 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, + failureMode: { type: 'string', enum: mrfPolicyFailureModes, default: 'reject' }, + params: { type: 'object', additionalProperties: true, default: {} }, + }, + required: ['name', 'source'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); + + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly idService: IdService, + ) { + super(meta, paramDef, async (ps) => { + const paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema({ + id: 'new-policy', + name: ps.name, + source: ps.source, + timeoutMs: ps.timeoutMs, + }); + let params; + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + + const policy = await this.mrfPoliciesRepository.insertOne({ + id: this.idService.gen(), + name: ps.name, + enabled: ps.enabled ?? true, + priority: ps.priority ?? 1000, + source: ps.source, + timeoutMs: ps.timeoutMs ?? 50, + failureMode: ps.failureMode ?? 'reject', + isBuiltin: false, + builtinPolicyId: null, + paramsSchema, + params, + }); + + return { + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + failureMode: policy.failureMode, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts new file mode 100644 index 0000000000..6c9aac2f24 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + noSuchPolicy: { + message: 'No such MRF policy.', + code: 'NO_SUCH_MRF_POLICY', + id: '67e53d9b-c972-49a4-86bd-15a448c4cbf0', + }, + cannotDeleteBuiltinPolicy: { + message: 'Built-in MRF policies cannot be deleted. Disable them instead.', + code: 'CANNOT_DELETE_BUILTIN_MRF_POLICY', + id: '9850e3d2-3846-471d-a3db-662650228516', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + ) { + super(meta, paramDef, async (ps) => { + const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); + if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); + if (existing.isBuiltin) throw new ApiError(meta.errors.cannotDeleteBuiltinPolicy); + + const result = await this.mrfPoliciesRepository.delete(ps.id); + if (result.affected === 0) throw new ApiError(meta.errors.noSuchPolicy); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts new file mode 100644 index 0000000000..1a27dae0c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:federation', +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + ) { + super(meta, paramDef, async () => { + const policies = await this.mrfPoliciesRepository.find({ + order: { + priority: 'ASC', + id: 'ASC', + }, + }); + + return policies.map(policy => ({ + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + failureMode: policy.failureMode, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts new file mode 100644 index 0000000000..673cb7c761 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: 'e0a7e055-f639-4024-ab41-293c7fa43d0a', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + source: { type: 'string', minLength: 1 }, + activity: { type: 'object', additionalProperties: true }, + actor: { type: 'object', additionalProperties: true }, + localHost: { type: 'string', default: 'example.com' }, + signerHost: { type: 'string', default: 'remote.example' }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, + params: { type: 'object', additionalProperties: true, default: {} }, + }, + required: ['source', 'activity', 'actor'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); + + constructor() { + super(meta, paramDef, async (ps) => { + const policy = { + id: 'dry-run', + name: 'Dry run', + source: ps.source, + timeoutMs: ps.timeoutMs, + }; + const paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema(policy); + let params; + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + const result = await this.mrfLuaPolicyService.run({ + ...policy, + paramsSchema, + params, + }, { + activity: ps.activity as IActivity, + actor: { + uri: typeof ps.actor.uri === 'string' ? ps.actor.uri : '', + host: typeof ps.actor.host === 'string' ? ps.actor.host : null, + followersCount: typeof ps.actor.followersCount === 'number' ? ps.actor.followersCount : undefined, + followingCount: typeof ps.actor.followingCount === 'number' ? ps.actor.followingCount : undefined, + }, + localHost: ps.localHost, + signerHost: ps.signerHost, + receivedAt: new Date().toISOString(), + }); + + return { + ...result, + paramsSchema, + params, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts new file mode 100644 index 0000000000..9cad8cdb79 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import { mrfPolicyFailureModes } from '@/models/MrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + noSuchPolicy: { + message: 'No such MRF policy.', + code: 'NO_SUCH_MRF_POLICY', + id: '7f0b839e-527f-49d0-b56a-c5db4f7596c9', + }, + cannotModifyBuiltinPolicy: { + message: 'Built-in MRF policies cannot be modified. Create a custom copy and disable the built-in policy instead.', + code: 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + id: 'ab931093-a145-47d2-aa4f-863b62ad0625', + }, + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: '5e19454b-3100-4354-9dfd-f2187ec1fe14', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1, maxLength: 256 }, + enabled: { type: 'boolean' }, + priority: { type: 'integer' }, + source: { type: 'string', minLength: 1 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000 }, + failureMode: { type: 'string', enum: mrfPolicyFailureModes }, + params: { type: 'object', additionalProperties: true }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); + + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + ) { + super(meta, paramDef, async (ps) => { + const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); + if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); + if (existing.isBuiltin && ( + ps.name !== undefined || + ps.source !== undefined || + ps.timeoutMs !== undefined || + ps.failureMode !== undefined + )) { + throw new ApiError(meta.errors.cannotModifyBuiltinPolicy); + } + + let paramsSchema = existing.paramsSchema; + let params = existing.params; + if (ps.source !== undefined) { + paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema({ + id: existing.id, + name: ps.name ?? existing.name, + source: ps.source, + timeoutMs: ps.timeoutMs ?? existing.timeoutMs, + }); + params = this.mrfLuaPolicyService.filterCompatibleParams(paramsSchema, params); + } + if (ps.params !== undefined) { + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + const updates = { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.enabled !== undefined ? { enabled: ps.enabled } : {}), + ...(ps.priority !== undefined ? { priority: ps.priority } : {}), + ...(ps.source !== undefined ? { source: ps.source } : {}), + ...(ps.timeoutMs !== undefined ? { timeoutMs: ps.timeoutMs } : {}), + ...(ps.failureMode !== undefined ? { failureMode: ps.failureMode } : {}), + ...(ps.source !== undefined ? { paramsSchema } : {}), + ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), + updatedAt: new Date(), + }; + await this.mrfPoliciesRepository.update(ps.id, updates as any); + + const policy = await this.mrfPoliciesRepository.findOneByOrFail({ id: ps.id }); + return { + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + failureMode: policy.failureMode, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + }; + }); + } +} diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts new file mode 100644 index 0000000000..3d982b7422 --- /dev/null +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import UpdateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/update.js'; +import DeleteMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/delete.js'; +import CreateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/create.js'; +import { ApiError } from '@/server/api/error.js'; + +function createPolicy(overrides: Record = {}) { + return { + id: 'mrftestbuiltin000000000000001', + createdAt: new Date('2026-06-14T00:00:00.000Z'), + updatedAt: new Date('2026-06-14T00:00:00.000Z'), + name: 'Built-in policy', + enabled: true, + priority: 10, + source: 'function filter(ctx) return mrf.accept() end', + timeoutMs: 50, + failureMode: 'reject', + isBuiltin: true, + builtinPolicyId: 'keyword-filter', + paramsSchema: { + keywords: { + type: 'string_array', + default: ['spam'], + }, + }, + params: {}, + ...overrides, + }; +} + +function createRepository(policy = createPolicy()) { + let current = policy; + return { + findOneBy: async ({ id }: { id: string }) => id === current.id ? current : null, + findOneByOrFail: async ({ id }: { id: string }) => { + if (id !== current.id) throw new Error('not found'); + return current; + }, + update: async (id: string, updates: Record) => { + if (id === current.id) { + current = { + ...current, + ...updates, + }; + } + return { affected: id === current.id ? 1 : 0 }; + }, + delete: async (id: string) => ({ affected: id === current.id ? 1 : 0 }), + insertOne: async (policy: Record) => { + current = { + ...createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + }), + ...policy, + createdAt: new Date('2026-06-14T00:00:00.000Z'), + updatedAt: new Date('2026-06-14T00:00:00.000Z'), + }; + return current; + }, + get current() { + return current; + }, + }; +} + +describe('MRF policy admin endpoints', () => { + test('allows disabling built-in policies without modifying their source', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + const result = await endpoint.exec({ + id: repository.current.id, + enabled: false, + }, {} as any, null); + + assert.equal(result.enabled, false); + assert.equal(result.isBuiltin, true); + assert.equal(result.builtinPolicyId, 'keyword-filter'); + assert.equal(repository.current.source, 'function filter(ctx) return mrf.accept() end'); + }); + + test('allows updating built-in policy params', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + const result = await endpoint.exec({ + id: repository.current.id, + params: { + keywords: ['custom'], + }, + }, {} as any, null); + + assert.deepStrictEqual(result.params, { + keywords: ['custom'], + }); + assert.equal(repository.current.source, 'function filter(ctx) return mrf.accept() end'); + }); + + test('rejects unknown built-in policy params', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + params: { + unknown: true, + }, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_PARAMS', + ); + }); + + test('extracts params schema when creating custom policies', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000001', + } as any); + + const result = await endpoint.exec({ + name: 'Custom policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 4, + }, + }, + } + + function filter(ctx) + return mrf.accept() + end + `, + params: { + threshold: 9, + }, + }, {} as any, null); + + assert.equal(result.isBuiltin, false); + assert.deepStrictEqual(result.paramsSchema, { + threshold: { + type: 'integer', + default: 4, + }, + }); + assert.deepStrictEqual(result.params, { + threshold: 9, + }); + }); + + test('rejects source edits for built-in policies', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + source: 'function filter(ctx) return mrf.reject("changed") end', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + ); + }); + + test('rejects deleting built-in policies', async () => { + const repository = createRepository(); + const endpoint = new DeleteMrfPolicyEndpoint(repository as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_DELETE_BUILTIN_MRF_POLICY', + ); + }); +}); From caa14a87bae02d0a182302109d934182993c6bd0 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 17 Jun 2026 16:30:22 +0200 Subject: [PATCH 07/28] Run inbox MRF policies from Lua database rules --- .../backend/src/queue/QueueProcessorModule.ts | 2 + .../queue/processors/InboxProcessorService.ts | 20 +- .../MMrfPolicies/HellthreadPolicy.ts | 39 ---- .../MMrfPolicies/KeywordFilterPolicy.ts | 37 ---- .../MMrfPolicies/NewUserSpamPolicy.ts | 67 ------ .../src/queue/processors/MMrfPolicy.ts | 206 +++++++++++++++--- .../queue/processors/MMrfPolicyService.ts | 63 ++++++ 7 files changed, 251 insertions(+), 183 deletions(-) delete mode 100644 packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts delete mode 100644 packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts delete mode 100644 packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts create mode 100644 packages/backend/test/unit/queue/processors/MMrfPolicyService.ts diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 76daeaae39..7e8a6ee809 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -46,6 +46,7 @@ import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostP import { CleanupApLogsProcessorService } from './processors/CleanupApLogsProcessorService.js'; import { HibernateUsersProcessorService } from './processors/HibernateUsersProcessorService.js'; import { BackgroundTaskProcessorService } from './processors/BackgroundTaskProcessorService.js'; +import { MMrfPolicyService } from './processors/MMrfPolicy.js'; /** External module dependencies */ const $Imports = [ @@ -89,6 +90,7 @@ const $Imports = [ SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, + MMrfPolicyService, InboxProcessorService, AggregateRetentionProcessorService, CheckExpiredMutingsProcessorService, diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 8fbe221360..778af2a07d 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -23,10 +23,9 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { JsonLdError, JsonLdService, Signed } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; -import { MMrfAction, runMMrf } from '@/queue/processors/MMrfPolicy.js'; +import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; @@ -64,7 +63,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, private readonly apLogService: ApLogService, private readonly queueService: QueueService, - private idService: IdService, + private readonly mrfPolicyService: MMrfPolicyService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); } @@ -277,10 +276,21 @@ export class InboxProcessorService { } const mmrfLogger = this.queueLoggerService.logger.createSubLogger('mmrf'); - const mMrfResponse = await runMMrf(activity, mmrfLogger, this.idService, this.apDbResolverService); + const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); + const mMrfResponse = await this.mrfPolicyService.run(activity, mmrfLogger, { + actor: { + uri: authUser.user.uri!, + host: authUser.user.host, + followersCount: authUser.user.followersCount, + followingCount: authUser.user.followingCount, + }, + localHost: this.config.host, + signerHost, + receivedAt: new Date().toISOString(), + }); const rewrittenActivity = mMrfResponse.data; if (mMrfResponse.action === MMrfAction.RejectNote) { - throw new Bull.UnrecoverableError('skip: rejected by MMrf'); + throw new Bull.UnrecoverableError(`skip: rejected by MMrf${mMrfResponse.reason ? `: ${mMrfResponse.reason}` : ''}`); } // Update instance stats diff --git a/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts deleted file mode 100644 index 5fa854aec7..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import Logger from '@/logger.js'; - -export class HellthreadPolicy implements MMrfPolicy { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - if (object.tag === undefined || !(object.tag instanceof Array)) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - const mentions = object.tag.filter(tag => tag.type === 'Mention'); - - if (mentions.length >= 15) { - this.logger.warn('Rewriting note due to hellthread, triggered by: ' + object.content); - object.tag = object.tag.filter(tag => tag.type !== 'Mention'); - activity.object = object; - - return { - action: MMrfAction.RewriteNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts deleted file mode 100644 index ae0f93dd86..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Logger from '@/logger.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; - -export class KeywordFilterPolicy implements MMrfPolicy { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - if (object.content === undefined) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - const keywords = ['https://discord.gg/ctkpaarr', '@ap12@mastodon-japan.net', 'ctkpaarr']; - - if (keywords.some(keyword => object.content?.includes(keyword))) { - this.logger.warn('Rejected note due to keyword filter, triggered by: ' + object.content); - - return { - action: MMrfAction.RejectNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts deleted file mode 100644 index 781aa2f21c..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { IdService } from '@/core/IdService.js'; -import Logger from '@/logger.js'; - -export class NewUserSpamPolicy implements MMrfPolicy { - private apDbResolverService: ApDbResolverService; - private idService: IdService; - private logger: Logger; - - constructor(apDbResolverService: ApDbResolverService, idService: IdService, logger: Logger) { - this.apDbResolverService = apDbResolverService; - this.idService = idService; - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - - if (object.tag === undefined || !(object.tag instanceof Array)) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - const objectMentions = object.tag.filter(tag => tag.type === 'Mention'); - const mentionCount = objectMentions.length; - - // Verify that the mention contains at least one local user mention - const localMention = objectMentions.some(tag => tag.href?.startsWith('https://plasmatrap.com')); - if (!localMention) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - // Get user reference from AP Actor - const actor = activity.actor as IObject; - const user = await this.apDbResolverService.getUserFromApId(actor); - if (user === null) { - this.logger.warn('User not found for remote actor ' + actor.url); - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - // Disallow mentions out of the blue by accounts followed by no one and with no avatar - if (user.followersCount === 0 && user.followingCount === 0 && object.inReplyTo === null) { - this.logger.warn('Rewriting note mentions, triggered by remote actor ' + user.uri + ' and note: ' + object.id); - object.tag = object.tag.filter(tag => tag.type !== 'Mention'); - activity.object = object; - - return { - action: MMrfAction.RewriteNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index 98670888bc..43ce7d63b3 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -1,10 +1,11 @@ -import { IActivity, IObject } from '@/core/activitypub/type.js'; +import { IActivity } from '@/core/activitypub/type.js'; import Logger from '@/logger.js'; -import { IdService } from '@/core/IdService.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { InstancesRepository, MrfPoliciesRepository, NotesRepository } from '@/models/_.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { KeywordFilterPolicy } from '@/queue/processors/MMrfPolicies/KeywordFilterPolicy.js'; -import { NewUserSpamPolicy } from '@/queue/processors/MMrfPolicies/NewUserSpamPolicy.js'; -import { HellthreadPolicy } from '@/queue/processors/MMrfPolicies/HellthreadPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; export enum MMrfAction { Neutral, @@ -15,46 +16,181 @@ export enum MMrfAction { export type MMrfResponse = { action: MMrfAction; data: IActivity; + reason?: string; }; -export interface MMrfPolicy { - runPolicy(activity: IActivity): Promise; -} +export type MMrfRuntimeContext = { + actor: { + uri: string; + host: string | null; + followersCount: number; + followingCount: number; + }; + localHost: string; + signerHost: string; + receivedAt: string; +}; -async function applyPolicy(policy: any, activity: IActivity): Promise { - let response = await policy.runPolicy(activity); - while (response.action === MMrfAction.RewriteNote) { - activity = response.data; - response = await policy.runPolicy(activity); - } - return response; -} +@Injectable() +export class MMrfPolicyService { + private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); -export async function runMMrf(activity: IActivity, logger: Logger, idService: IdService, apDbResolverService: ApDbResolverService): Promise { - if (activity.type !== 'Create') { - return { action: MMrfAction.Neutral, data: activity }; - } + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, - const object: IObject = activity.object as IObject; - if (object.type !== 'Note') { - return { action: MMrfAction.Neutral, data: activity }; + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + private readonly apDbResolverService: ApDbResolverService, + ) { } - const policies = [ - new KeywordFilterPolicy(logger), - new NewUserSpamPolicy(apDbResolverService, idService, logger), - new HellthreadPolicy(logger), - ]; + public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { + let mmrfActivity = structuredClone(activity); + const policies = await this.getEnabledPolicies(); + const lookup = this.createLookupApi(); + + for (const policy of policies) { + try { + const result = await this.mrfLuaPolicyService.run(policy, { + ...context, + activity: mmrfActivity, + }, { + lookup, + }); - let mmrfActivity = activity; + if (result.decision.action === 'reject') { + logger.warn(`policy ${policy.id} rejected activity: ${result.decision.reason}`); + return { + action: MMrfAction.RejectNote, + data: mmrfActivity, + reason: `${policy.id}: ${result.decision.reason}`, + }; + } - for (const policy of policies) { - const response = await applyPolicy(policy, mmrfActivity); - if (response.action === MMrfAction.RejectNote) { - return response; + if (result.decision.action === 'rewrite') { + logger.info(`policy ${policy.id} rewrote activity: ${result.decision.reason ?? 'no reason provided'}`); + mmrfActivity = result.decision.activity; + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`policy ${policy.id} failed: ${reason}`); + if (policy.failureMode === 'accept') { + continue; + } + + return { + action: MMrfAction.RejectNote, + data: mmrfActivity, + reason: `${policy.id}: ${reason}`, + }; + } } - mmrfActivity = response.data; + + return { action: MMrfAction.Neutral, data: mmrfActivity }; } - return { action: MMrfAction.Neutral, data: mmrfActivity }; + private async getEnabledPolicies(): Promise { + const policies = await this.mrfPoliciesRepository.find({ + where: { + enabled: true, + }, + order: { + priority: 'ASC', + id: 'ASC', + }, + }); + + return policies.map(policy => ({ + id: policy.id, + name: policy.name, + source: policy.source, + timeoutMs: policy.timeoutMs, + failureMode: policy.failureMode, + paramsSchema: policy.paramsSchema, + params: policy.params, + })); + } + + private createLookupApi() { + const userCache = new Map | null>>(); + const instanceCache = new Map | null>>(); + const noteCache = new Map | null>>(); + + const userByUri = async (uri: string): Promise | null> => { + if (!userCache.has(uri)) { + userCache.set(uri, this.apDbResolverService.getUserFromApId(uri) + .then(user => user == null ? null : ({ + id: user.id, + uri: user.uri, + username: user.username, + host: user.host, + hasAvatar: user.avatarId != null || user.avatarUrl != null, + hasBanner: user.bannerId != null || user.bannerUrl != null, + followersCount: user.followersCount, + followingCount: user.followingCount, + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + isSuspended: user.isSuspended, + isSilenced: user.isSilenced, + isLocal: user.host == null, + }))); + } + + return await userCache.get(uri)!; + }; + + return { + userByUri, + userByMention: async (mention: Record | string): Promise | null> => { + const uri = typeof mention === 'string' ? mention : mention.href; + if (typeof uri !== 'string') return null; + return await userByUri(uri); + }, + instanceByHost: async (host: string): Promise | null> => { + if (!instanceCache.has(host)) { + instanceCache.set(host, this.instancesRepository.findOneBy({ host }) + .then(instance => instance == null ? null : ({ + host: instance.host, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + isBlocked: instance.isBlocked, + isSilenced: instance.isSilenced, + isMediaSilenced: instance.isMediaSilenced, + isAllowListed: instance.isAllowListed, + moderationNote: instance.moderationNote, + }))); + } + + return await instanceCache.get(host)!; + }, + noteByUri: async (uri: string): Promise | null> => { + if (!noteCache.has(uri)) { + noteCache.set(uri, this.notesRepository.findOne({ + where: [ + { uri }, + { url: uri }, + ], + }).then(note => note == null ? null : ({ + id: note.id, + uri: note.uri, + url: note.url, + visibility: note.visibility, + localOnly: note.localOnly, + userId: note.userId, + userHost: note.userHost, + updatedAt: note.updatedAt?.toISOString() ?? null, + isReply: note.replyId != null, + isRenote: note.renoteId != null, + }))); + } + + return await noteCache.get(uri)!; + }, + }; + } } diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts new file mode 100644 index 0000000000..2c7876817b --- /dev/null +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; +import type { IActivity } from '@/core/activitypub/type.js'; + +const logger = { + warn: () => undefined, + info: () => undefined, + error: () => undefined, +}; + +const keywordActivity = { + id: 'https://remote.example/activities/1', + type: 'Create', + actor: 'https://remote.example/users/alice', + object: { + id: 'https://remote.example/notes/1', + type: 'Note', + content: 'join https://discord.gg/ctkpaarr now', + tag: [], + }, +} satisfies IActivity; + +function createService(policyRows: unknown[]) { + return new MMrfPolicyService( + { + find: async () => policyRows, + } as any, + { + findOneBy: async () => null, + } as any, + { + findOne: async () => null, + } as any, + { + getUserFromApId: async () => null, + } as any, + ); +} + +describe('MMrfPolicyService', () => { + test('uses DB configured policies instead of hardcoded bundled fallbacks', async () => { + const service = createService([]); + + const result = await service.run(keywordActivity, logger as any, { + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 1, + followingCount: 1, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.action, MMrfAction.Neutral); + }); +}); From e92ca3f7248fe5112ea22bcd14b386c358fdf9c2 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 17 Jun 2026 17:44:43 +0200 Subject: [PATCH 08/28] fix(backend): harden Lua MRF policy basics --- ...rfPolicy.js => 1781706597000-MrfPolicy.js} | 58 +++++++++++++++++- .../activitypub/mrf/MrfLuaPolicyService.ts | 27 ++++++++- .../src/queue/processors/MMrfPolicy.ts | 9 ++- .../api/endpoints/admin/mrf-policies/list.ts | 2 +- .../test/unit/migration/MrfPolicyMigration.ts | 26 ++++++++ .../queue/processors/MrfLuaPolicyService.ts | 60 +++++++++++++++---- .../unit/server/api/MrfPolicyEndpoints.ts | 6 ++ 7 files changed, 169 insertions(+), 19 deletions(-) rename packages/backend/migration/{1792000000000-MrfPolicy.js => 1781706597000-MrfPolicy.js} (75%) create mode 100644 packages/backend/test/unit/migration/MrfPolicyMigration.ts diff --git a/packages/backend/migration/1792000000000-MrfPolicy.js b/packages/backend/migration/1781706597000-MrfPolicy.js similarity index 75% rename from packages/backend/migration/1792000000000-MrfPolicy.js rename to packages/backend/migration/1781706597000-MrfPolicy.js index 704b360859..4ca2fbf3c8 100644 --- a/packages/backend/migration/1792000000000-MrfPolicy.js +++ b/packages/backend/migration/1781706597000-MrfPolicy.js @@ -3,14 +3,66 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export class MrfPolicy1792000000000 { - name = 'MrfPolicy1792000000000' +export class MrfPolicy1781706597000 { + name = 'MrfPolicy1781706597000' async up(queryRunner) { await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "failureMode" character varying(32) NOT NULL DEFAULT 'reject', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE INDEX "IDX_mrf_policy_enabled_priority" ON "mrf_policy" ("enabled", "priority")`); await queryRunner.query(`CREATE UNIQUE INDEX "IDX_mrf_policy_builtinPolicyId" ON "mrf_policy" ("builtinPolicyId")`); await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 10, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + 'mrfbuiltinkeyword001', + 'Keyword filter', + ` + policy = { + params = { + keywords = { + type = "string_array", + default = { + "https://discord.gg/ctkpaarr", + "@ap12@mastodon-japan.net", + "ctkpaarr", + }, + label = "Blocked keywords", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local content = mrf.note.content(note) + if type(content) ~= "string" then + return mrf.accept() + end + + for _, keyword in ipairs(ctx.params.keywords) do + if string.find(content, keyword, 1, true) ~= nil then + return mrf.reject("keyword filter matched: " .. keyword) + end + end + + return mrf.accept() + end + `, + 'keyword-filter', + JSON.stringify({ + keywords: { + type: 'string_array', + default: [ + 'https://discord.gg/ctkpaarr', + '@ap12@mastodon-japan.net', + 'ctkpaarr', + ], + label: 'Blocked keywords', + }, + }), + '{}', + ]); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 20, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ 'mrfbuiltinnewuserspam001', 'New user spam mention filter', ` @@ -86,7 +138,7 @@ export class MrfPolicy1792000000000 { }), '{}', ]); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 20, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 30, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ 'mrfbuiltinhellthread001', 'Hellthread mention filter', ` diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index ddc1ed15ed..fe1bb4f43c 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -326,7 +326,7 @@ export class MrfLuaPolicyService { const thread = lua.global.newThread(); thread.loadString('return filter(ctx)', policy.name); const returns = await thread.run(0, { timeout: timeoutMs }); - const decision = this.parseDecision(returns[0]); + const decision = this.parseDecision(returns[0], clonedContext.activity); return { policy: { @@ -505,7 +505,7 @@ export class MrfLuaPolicyService { } } - private parseDecision(value: unknown): MrfLuaDecision { + private parseDecision(value: unknown, originalActivity: IActivity): MrfLuaDecision { if (!isRecord(value)) { throw new Error('policy returned a non-object decision'); } @@ -535,13 +535,34 @@ export class MrfLuaPolicyService { return { action: 'rewrite', - activity: value.activity as unknown as IActivity, + activity: this.normalizeLuaArrayShapes(value.activity, originalActivity) as unknown as IActivity, ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), }; } throw new Error(`unknown policy action: ${String(value.action)}`); } + + private normalizeLuaArrayShapes(value: unknown, template: unknown): unknown { + if (Array.isArray(template) && isRecord(value) && Object.keys(value).length === 0) { + return []; + } + + if (Array.isArray(value)) { + const templateArray = Array.isArray(template) ? template : []; + return value.map((item, index) => this.normalizeLuaArrayShapes(item, templateArray[index])); + } + + if (!isRecord(value) || !isRecord(template)) { + return value; + } + + for (const [key, item] of Object.entries(value)) { + value[key] = this.normalizeLuaArrayShapes(item, template[key]); + } + + return value; + } } function isRecord(value: unknown): value is Record { diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index 43ce7d63b3..5308a84268 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -33,7 +33,7 @@ export type MMrfRuntimeContext = { @Injectable() export class MMrfPolicyService { - private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); + private mrfLuaPolicyService: MrfLuaPolicyService | null = null; constructor( @Inject(DI.mrfPoliciesRepository) @@ -56,7 +56,7 @@ export class MMrfPolicyService { for (const policy of policies) { try { - const result = await this.mrfLuaPolicyService.run(policy, { + const result = await this.getMrfLuaPolicyService().run(policy, { ...context, activity: mmrfActivity, }, { @@ -94,6 +94,11 @@ export class MMrfPolicyService { return { action: MMrfAction.Neutral, data: mmrfActivity }; } + private getMrfLuaPolicyService(): MrfLuaPolicyService { + this.mrfLuaPolicyService ??= new MrfLuaPolicyService(); + return this.mrfLuaPolicyService; + } + private async getEnabledPolicies(): Promise { const policies = await this.mrfPoliciesRepository.find({ where: { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts index 1a27dae0c0..38850b938d 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -11,7 +11,7 @@ import type { MrfPoliciesRepository } from '@/models/_.js'; export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, kind: 'write:admin:federation', } as const; diff --git a/packages/backend/test/unit/migration/MrfPolicyMigration.ts b/packages/backend/test/unit/migration/MrfPolicyMigration.ts new file mode 100644 index 0000000000..3247451dc6 --- /dev/null +++ b/packages/backend/test/unit/migration/MrfPolicyMigration.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { MrfPolicy1781706597000 } from '../../../migration/1781706597000-MrfPolicy.js'; + +describe('MRF policy migration', () => { + test('seeds all built-in policies from the old hardcoded MRF set', async () => { + const queries: Array<{ sql: string; params?: unknown[] }> = []; + const migration = new MrfPolicy1781706597000(); + + await migration.up({ + query: async (sql: string, params?: unknown[]) => { + queries.push({ sql, params }); + }, + }); + + const builtinPolicyIds = queries + .flatMap(query => query.params ?? []) + .filter(param => param === 'keyword-filter' || param === 'new-user-spam' || param === 'hellthread'); + + assert.deepStrictEqual(builtinPolicyIds, ['keyword-filter', 'new-user-spam', 'hellthread']); + }); +}); diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 80603d2a49..42710d011b 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -381,11 +381,11 @@ describe('MrfLuaPolicyService', () => { ); }); - test('exposes note helpers for mention counting and removal', async () => { - const service = createService(); - const activity = { - ...baseActivity, - object: { + test('exposes note helpers for mention counting and removal', async () => { + const service = createService(); + const activity = { + ...baseActivity, + object: { ...baseActivity.object, tag: [ { type: 'Mention', href: 'https://local.example/@alice' }, @@ -427,13 +427,53 @@ describe('MrfLuaPolicyService', () => { assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ { type: 'Hashtag', name: '#test' }, ]); - } - }); + } + }); - test('exposes read-only lookup helpers to Lua policies', async () => { - const service = createService(); + test('remove_mentions keeps an empty tag collection as a JavaScript array', async () => { + const service = createService(); + const activity = { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + { type: 'Mention', href: 'https://local.example/@alice' }, + { type: 'Mention', href: 'https://local.example/@bob' }, + ], + }, + } satisfies IActivity; - const result = await service.run({ + const result = await service.run({ + id: 'remove-all-mentions', + name: 'Remove All Mentions Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "removed all mentions") + end + `, + }, { + activity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, []); + } + }); + + test('exposes read-only lookup helpers to Lua policies', async () => { + const service = createService(); + + const result = await service.run({ id: 'lookup', name: 'Lookup Policy', source: ` diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index 3d982b7422..ee35581ca8 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -7,6 +7,7 @@ import * as assert from 'node:assert'; import UpdateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/update.js'; import DeleteMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/delete.js'; import CreateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/create.js'; +import { meta as listMeta } from '@/server/api/endpoints/admin/mrf-policies/list.js'; import { ApiError } from '@/server/api/error.js'; function createPolicy(overrides: Record = {}) { @@ -70,6 +71,11 @@ function createRepository(policy = createPolicy()) { } describe('MRF policy admin endpoints', () => { + test('requires administrator privileges to list policy source', () => { + assert.equal(listMeta.requireAdmin, true); + assert.equal('requireModerator' in listMeta, false); + }); + test('allows disabling built-in policies without modifying their source', async () => { const repository = createRepository(); const endpoint = new UpdateMrfPolicyEndpoint(repository as any); From 7fde99ac450bd496778160053c95aa6ed580b14e Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 18 Jun 2026 12:05:48 +0200 Subject: [PATCH 09/28] fix(backend): scope Lua MRF policy execution --- .../migration/1781706597000-MrfPolicy.js | 20 +++- packages/backend/src/models/MrfPolicy.ts | 50 +++++++- .../src/queue/processors/MMrfPolicy.ts | 45 ++++++- .../endpoints/admin/mrf-policies/create.ts | 17 ++- .../api/endpoints/admin/mrf-policies/list.ts | 1 + .../endpoints/admin/mrf-policies/update.ts | 15 ++- .../test/unit/migration/MrfPolicyMigration.ts | 11 ++ .../queue/processors/MMrfPolicyService.ts | 110 ++++++++++++++++-- .../unit/server/api/MrfPolicyEndpoints.ts | 52 ++++++++- 9 files changed, 294 insertions(+), 27 deletions(-) diff --git a/packages/backend/migration/1781706597000-MrfPolicy.js b/packages/backend/migration/1781706597000-MrfPolicy.js index 4ca2fbf3c8..10054ec9c9 100644 --- a/packages/backend/migration/1781706597000-MrfPolicy.js +++ b/packages/backend/migration/1781706597000-MrfPolicy.js @@ -7,10 +7,10 @@ export class MrfPolicy1781706597000 { name = 'MrfPolicy1781706597000' async up(queryRunner) { - await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "failureMode" character varying(32) NOT NULL DEFAULT 'reject', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "failureMode" character varying(32) NOT NULL DEFAULT 'accept', "scope" jsonb NOT NULL DEFAULT '{"activityTypes":["Create"],"objectTypes":["Note"]}', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE INDEX "IDX_mrf_policy_enabled_priority" ON "mrf_policy" ("enabled", "priority")`); await queryRunner.query(`CREATE UNIQUE INDEX "IDX_mrf_policy_builtinPolicyId" ON "mrf_policy" ("builtinPolicyId")`); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 10, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 10, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinkeyword001', 'Keyword filter', ` @@ -61,8 +61,12 @@ export class MrfPolicy1781706597000 { }, }), '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), ]); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 20, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 20, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinnewuserspam001', 'New user spam mention filter', ` @@ -137,8 +141,12 @@ export class MrfPolicy1781706597000 { }, }), '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), ]); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params") VALUES ($1, $2, true, 30, $3, 50, 'reject', true, $4, $5::jsonb, $6::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 30, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinhellthread001', 'Hellthread mention filter', ` @@ -175,6 +183,10 @@ export class MrfPolicy1781706597000 { }, }), '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), ]); } diff --git a/packages/backend/src/models/MrfPolicy.ts b/packages/backend/src/models/MrfPolicy.ts index bc9b731459..f5549642e6 100644 --- a/packages/backend/src/models/MrfPolicy.ts +++ b/packages/backend/src/models/MrfPolicy.ts @@ -9,6 +9,49 @@ import { id } from './util/id.js'; export const mrfPolicyFailureModes = ['reject', 'accept'] as const; export type MrfPolicyFailureMode = typeof mrfPolicyFailureModes[number]; +export const DEFAULT_MRF_POLICY_FAILURE_MODE = 'accept' satisfies MrfPolicyFailureMode; + +export type MrfPolicyScope = { + activityTypes: string[] | null; + objectTypes: string[] | null; +}; + +export const DEFAULT_MRF_POLICY_SCOPE = { + activityTypes: ['Create'], + objectTypes: ['Note'], +} satisfies MrfPolicyScope; + +export function normalizeMrfPolicyScope(scope: unknown): MrfPolicyScope { + if (!isRecord(scope)) { + return structuredClone(DEFAULT_MRF_POLICY_SCOPE); + } + + return { + activityTypes: normalizeScopeTypes(scope.activityTypes, DEFAULT_MRF_POLICY_SCOPE.activityTypes), + objectTypes: normalizeScopeTypes(scope.objectTypes, DEFAULT_MRF_POLICY_SCOPE.objectTypes), + }; +} + +function normalizeScopeTypes(value: unknown, fallback: string[] | null): string[] | null { + if (value === null) return null; + if (!Array.isArray(value)) return fallback == null ? null : [...fallback]; + + const seen = new Set(); + const types: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.length === 0) { + return fallback == null ? null : [...fallback]; + } + if (seen.has(item)) continue; + seen.add(item); + types.push(item); + } + return types; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} @Index('IDX_mrf_policy_enabled_priority', ['enabled', 'priority']) @Index('IDX_mrf_policy_builtinPolicyId', ['builtinPolicyId'], { unique: true }) @@ -52,10 +95,15 @@ export class MiMrfPolicy { @Column('varchar', { length: 32, - default: 'reject', + default: DEFAULT_MRF_POLICY_FAILURE_MODE, }) public failureMode: MrfPolicyFailureMode; + @Column('jsonb', { + default: DEFAULT_MRF_POLICY_SCOPE, + }) + public scope: MrfPolicyScope; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index 5308a84268..b373c274b0 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -1,4 +1,4 @@ -import { IActivity } from '@/core/activitypub/type.js'; +import { IActivity, IObject } from '@/core/activitypub/type.js'; import Logger from '@/logger.js'; import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; @@ -6,6 +6,8 @@ import type { InstancesRepository, MrfPoliciesRepository, NotesRepository } from import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { DEFAULT_MRF_POLICY_FAILURE_MODE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import type { MrfPolicyFailureMode, MrfPolicyScope } from '@/models/MrfPolicy.js'; export enum MMrfAction { Neutral, @@ -31,6 +33,11 @@ export type MMrfRuntimeContext = { receivedAt: string; }; +type ScopedMrfLuaPolicy = MrfLuaPolicy & { + failureMode: MrfPolicyFailureMode; + scope: MrfPolicyScope; +}; + @Injectable() export class MMrfPolicyService { private mrfLuaPolicyService: MrfLuaPolicyService | null = null; @@ -50,12 +57,17 @@ export class MMrfPolicyService { } public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { - let mmrfActivity = structuredClone(activity); + let mmrfActivity = activity; const policies = await this.getEnabledPolicies(); - const lookup = this.createLookupApi(); + let lookup: ReturnType | undefined; for (const policy of policies) { + if (!this.matchesPolicyScope(policy.scope, mmrfActivity)) { + continue; + } + try { + lookup ??= this.createLookupApi(); const result = await this.getMrfLuaPolicyService().run(policy, { ...context, activity: mmrfActivity, @@ -94,12 +106,34 @@ export class MMrfPolicyService { return { action: MMrfAction.Neutral, data: mmrfActivity }; } + private matchesPolicyScope(scope: MrfPolicyScope, activity: IActivity): boolean { + return this.matchesTypeSet(scope.activityTypes, this.getTypeSet(activity)) && + this.matchesTypeSet(scope.objectTypes, this.getObjectTypeSet(activity)); + } + + private matchesTypeSet(allowedTypes: string[] | null, actualTypes: string[]): boolean { + if (allowedTypes === null) return true; + return actualTypes.some(type => allowedTypes.includes(type)); + } + + private getTypeSet(object: IObject): string[] { + if (typeof object.type === 'string') return [object.type]; + if (Array.isArray(object.type)) return object.type.filter(type => typeof type === 'string'); + return []; + } + + private getObjectTypeSet(activity: IActivity): string[] { + const object = Array.isArray(activity.object) ? activity.object[0] : activity.object; + if (typeof object !== 'object' || object === null || Array.isArray(object)) return []; + return this.getTypeSet(object); + } + private getMrfLuaPolicyService(): MrfLuaPolicyService { this.mrfLuaPolicyService ??= new MrfLuaPolicyService(); return this.mrfLuaPolicyService; } - private async getEnabledPolicies(): Promise { + private async getEnabledPolicies(): Promise { const policies = await this.mrfPoliciesRepository.find({ where: { enabled: true, @@ -115,7 +149,8 @@ export class MMrfPolicyService { name: policy.name, source: policy.source, timeoutMs: policy.timeoutMs, - failureMode: policy.failureMode, + failureMode: policy.failureMode ?? DEFAULT_MRF_POLICY_FAILURE_MODE, + scope: normalizeMrfPolicyScope(policy.scope), paramsSchema: policy.paramsSchema, params: policy.params, })); diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index dca9a577e9..67a267ec62 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; -import { mrfPolicyFailureModes } from '@/models/MrfPolicy.js'; +import { DEFAULT_MRF_POLICY_FAILURE_MODE, DEFAULT_MRF_POLICY_SCOPE, mrfPolicyFailureModes, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApiError } from '../../../error.js'; @@ -35,7 +35,16 @@ export const paramDef = { priority: { type: 'integer', default: 1000 }, source: { type: 'string', minLength: 1 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, - failureMode: { type: 'string', enum: mrfPolicyFailureModes, default: 'reject' }, + failureMode: { type: 'string', enum: mrfPolicyFailureModes, default: DEFAULT_MRF_POLICY_FAILURE_MODE }, + scope: { + type: 'object', + properties: { + activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + }, + additionalProperties: false, + default: DEFAULT_MRF_POLICY_SCOPE, + }, params: { type: 'object', additionalProperties: true, default: {} }, }, required: ['name', 'source'], @@ -73,7 +82,8 @@ export default class extends Endpoint { // eslint- priority: ps.priority ?? 1000, source: ps.source, timeoutMs: ps.timeoutMs ?? 50, - failureMode: ps.failureMode ?? 'reject', + failureMode: ps.failureMode ?? DEFAULT_MRF_POLICY_FAILURE_MODE, + scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE), isBuiltin: false, builtinPolicyId: null, paramsSchema, @@ -90,6 +100,7 @@ export default class extends Endpoint { // eslint- source: policy.source, timeoutMs: policy.timeoutMs, failureMode: policy.failureMode, + scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, paramsSchema: policy.paramsSchema, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts index 38850b938d..1dc708aded 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -45,6 +45,7 @@ export default class extends Endpoint { // eslint- source: policy.source, timeoutMs: policy.timeoutMs, failureMode: policy.failureMode, + scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, paramsSchema: policy.paramsSchema, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 9cad8cdb79..55c848d332 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; -import { mrfPolicyFailureModes } from '@/models/MrfPolicy.js'; +import { DEFAULT_MRF_POLICY_SCOPE, mrfPolicyFailureModes, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApiError } from '../../../error.js'; @@ -46,6 +46,14 @@ export const paramDef = { source: { type: 'string', minLength: 1 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 5000 }, failureMode: { type: 'string', enum: mrfPolicyFailureModes }, + scope: { + type: 'object', + properties: { + activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + }, + additionalProperties: false, + }, params: { type: 'object', additionalProperties: true }, }, required: ['id'], @@ -66,7 +74,8 @@ export default class extends Endpoint { // eslint- ps.name !== undefined || ps.source !== undefined || ps.timeoutMs !== undefined || - ps.failureMode !== undefined + ps.failureMode !== undefined || + ps.scope !== undefined )) { throw new ApiError(meta.errors.cannotModifyBuiltinPolicy); } @@ -99,6 +108,7 @@ export default class extends Endpoint { // eslint- ...(ps.source !== undefined ? { source: ps.source } : {}), ...(ps.timeoutMs !== undefined ? { timeoutMs: ps.timeoutMs } : {}), ...(ps.failureMode !== undefined ? { failureMode: ps.failureMode } : {}), + ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE) } : {}), ...(ps.source !== undefined ? { paramsSchema } : {}), ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), updatedAt: new Date(), @@ -116,6 +126,7 @@ export default class extends Endpoint { // eslint- source: policy.source, timeoutMs: policy.timeoutMs, failureMode: policy.failureMode, + scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, paramsSchema: policy.paramsSchema, diff --git a/packages/backend/test/unit/migration/MrfPolicyMigration.ts b/packages/backend/test/unit/migration/MrfPolicyMigration.ts index 3247451dc6..7553746029 100644 --- a/packages/backend/test/unit/migration/MrfPolicyMigration.ts +++ b/packages/backend/test/unit/migration/MrfPolicyMigration.ts @@ -20,7 +20,18 @@ describe('MRF policy migration', () => { const builtinPolicyIds = queries .flatMap(query => query.params ?? []) .filter(param => param === 'keyword-filter' || param === 'new-user-spam' || param === 'hellthread'); + const createTable = queries.find(query => query.sql.includes('CREATE TABLE "mrf_policy"')); + const builtinInserts = queries.filter(query => query.sql.includes('INSERT INTO "mrf_policy"')); assert.deepStrictEqual(builtinPolicyIds, ['keyword-filter', 'new-user-spam', 'hellthread']); + assert.match(createTable?.sql ?? '', /"scope" jsonb NOT NULL DEFAULT/); + for (const query of builtinInserts) { + assert.match(query.sql, /"scope"/); + assert.match(query.sql, /'accept'/); + assert.deepStrictEqual(JSON.parse(query.params?.[6] as string), { + activityTypes: ['Create'], + objectTypes: ['Note'], + }); + } }); }); diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts index 2c7876817b..be61226ae0 100644 --- a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -13,6 +13,18 @@ const logger = { error: () => undefined, }; +const runtimeContext = { + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 1, + followingCount: 1, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', +}; + const keywordActivity = { id: 'https://remote.example/activities/1', type: 'Create', @@ -25,6 +37,36 @@ const keywordActivity = { }, } satisfies IActivity; +const followActivity = { + id: 'https://remote.example/activities/2', + type: 'Follow', + actor: 'https://remote.example/users/alice', + object: 'https://local.example/users/bob', +} satisfies IActivity; + +const announceActivity = { + id: 'https://remote.example/activities/3', + type: 'Announce', + actor: 'https://remote.example/users/alice', + object: 'https://remote.example/notes/1', +} satisfies IActivity; + +function createPolicyRow(overrides: Record = {}) { + return { + id: 'policy1', + name: 'Policy 1', + source: 'function filter(ctx) return mrf.accept() end', + timeoutMs: 100, + paramsSchema: {}, + params: {}, + scope: { + activityTypes: ['Create'], + objectTypes: ['Note'], + }, + ...overrides, + }; +} + function createService(policyRows: unknown[]) { return new MMrfPolicyService( { @@ -46,18 +88,64 @@ describe('MMrfPolicyService', () => { test('uses DB configured policies instead of hardcoded bundled fallbacks', async () => { const service = createService([]); - const result = await service.run(keywordActivity, logger as any, { - actor: { - uri: 'https://remote.example/users/alice', - host: 'remote.example', - followersCount: 1, - followingCount: 1, - }, - localHost: 'local.example', - signerHost: 'remote.example', - receivedAt: '2026-06-14T00:00:00.000Z', - }); + const result = await service.run(keywordActivity, logger as any, runtimeContext); assert.equal(result.action, MMrfAction.Neutral); }); + + test('skips Lua execution when a policy scope does not match the inbox activity', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.reject("should not run") end', + }), + ]); + + const result = await service.run(followActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, followActivity); + }); + + test('runs policies scoped to non-note activity types', async () => { + const service = createService([ + createPolicyRow({ + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + source: 'function filter(ctx) return mrf.reject("blocked announce") end', + }), + ]); + + const result = await service.run(announceActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /blocked announce/); + }); + + test('fails open for runtime errors by default', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) error("broken policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, keywordActivity); + }); + + test('keeps explicit policy rejections as enforcement decisions', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.reject("explicit rejection") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /explicit rejection/); + }); }); diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index ee35581ca8..1bdc5cc08a 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -20,7 +20,11 @@ function createPolicy(overrides: Record = {}) { priority: 10, source: 'function filter(ctx) return mrf.accept() end', timeoutMs: 50, - failureMode: 'reject', + failureMode: 'accept', + scope: { + activityTypes: ['Create'], + objectTypes: ['Note'], + }, isBuiltin: true, builtinPolicyId: 'keyword-filter', paramsSchema: { @@ -160,6 +164,36 @@ describe('MRF policy admin endpoints', () => { assert.deepStrictEqual(result.params, { threshold: 9, }); + assert.equal(result.failureMode, 'accept'); + assert.deepStrictEqual(result.scope, { + activityTypes: ['Create'], + objectTypes: ['Note'], + }); + }); + + test('allows custom policies to target non-note activity types', async () => { + const repository = createRepository(createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + })); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + const result = await endpoint.exec({ + id: repository.current.id, + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + }, {} as any, null); + + assert.deepStrictEqual(result.scope, { + activityTypes: ['Announce'], + objectTypes: null, + }); + assert.deepStrictEqual(repository.current.scope, { + activityTypes: ['Announce'], + objectTypes: null, + }); }); test('rejects source edits for built-in policies', async () => { @@ -175,6 +209,22 @@ describe('MRF policy admin endpoints', () => { ); }); + test('rejects scope edits for built-in policies', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + ); + }); + test('rejects deleting built-in policies', async () => { const repository = createRepository(); const endpoint = new DeleteMrfPolicyEndpoint(repository as any); From d0a886e48146eb5513a2e08549096f7024b6ce67 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 18 Jun 2026 12:07:38 +0200 Subject: [PATCH 10/28] fix(backend): tolerate stale Lua MRF params --- .../activitypub/mrf/MrfLuaPolicyService.ts | 2 +- .../queue/processors/MrfLuaPolicyService.ts | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index fe1bb4f43c..18ae2059ef 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -379,7 +379,7 @@ export class MrfLuaPolicyService { } public resolveParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { - const parsedParams = this.validateParams(schema, params); + const parsedParams = this.filterCompatibleParams(schema, params); const resolved: MrfLuaParams = {}; for (const [key, entry] of Object.entries(schema)) { if (Object.hasOwn(parsedParams, key)) { diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 42710d011b..7daf04bf06 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -680,6 +680,60 @@ describe('MrfLuaPolicyService', () => { }); }); + test('filters stale stored parameters at runtime and falls back to defaults', async () => { + const service = createService(); + + const result = await service.run({ + id: 'stale-params', + name: 'Stale Params Policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 15, + }, + enabled = { + type = "boolean", + default = true, + }, + }, + } + + function filter(ctx) + if ctx.params.threshold ~= 15 then + return mrf.reject("threshold default missing") + end + if ctx.params.enabled ~= true then + return mrf.reject("enabled default missing") + end + if ctx.params.removed ~= nil then + return mrf.reject("unknown parameter leaked") + end + return mrf.accept("stale params ignored") + end + `, + params: { + threshold: 'wrong type', + removed: 'old schema key', + }, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'stale params ignored', + }); + }); + describe('bundled policies', () => { test('keyword policy rejects matching note content', async () => { const service = createService(); From 7d28d561fc1cdb45873349e7efbf693e2d79502f Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 18 Jun 2026 13:45:01 +0200 Subject: [PATCH 11/28] fix(backend): make Lua MRF failures fail open --- .../backend/migration/1781706597000-MrfPolicy.js | 8 ++++---- .../core/activitypub/mrf/MrfLuaPolicyService.ts | 1 - packages/backend/src/models/MrfPolicy.ts | 10 ---------- .../backend/src/queue/processors/MMrfPolicy.ts | 16 +++------------- .../api/endpoints/admin/mrf-policies/create.ts | 5 +---- .../api/endpoints/admin/mrf-policies/list.ts | 1 - .../api/endpoints/admin/mrf-policies/update.ts | 6 +----- .../test/unit/migration/MrfPolicyMigration.ts | 3 ++- .../unit/queue/processors/MMrfPolicyService.ts | 14 ++++++++++++++ .../test/unit/server/api/MrfPolicyEndpoints.ts | 2 -- 10 files changed, 25 insertions(+), 41 deletions(-) diff --git a/packages/backend/migration/1781706597000-MrfPolicy.js b/packages/backend/migration/1781706597000-MrfPolicy.js index 10054ec9c9..eff49d2f7d 100644 --- a/packages/backend/migration/1781706597000-MrfPolicy.js +++ b/packages/backend/migration/1781706597000-MrfPolicy.js @@ -7,10 +7,10 @@ export class MrfPolicy1781706597000 { name = 'MrfPolicy1781706597000' async up(queryRunner) { - await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "failureMode" character varying(32) NOT NULL DEFAULT 'accept', "scope" jsonb NOT NULL DEFAULT '{"activityTypes":["Create"],"objectTypes":["Note"]}', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "scope" jsonb NOT NULL DEFAULT '{"activityTypes":["Create"],"objectTypes":["Note"]}', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE INDEX "IDX_mrf_policy_enabled_priority" ON "mrf_policy" ("enabled", "priority")`); await queryRunner.query(`CREATE UNIQUE INDEX "IDX_mrf_policy_builtinPolicyId" ON "mrf_policy" ("builtinPolicyId")`); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 10, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 10, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinkeyword001', 'Keyword filter', ` @@ -66,7 +66,7 @@ export class MrfPolicy1781706597000 { objectTypes: ['Note'], }), ]); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 20, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 20, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinnewuserspam001', 'New user spam mention filter', ` @@ -146,7 +146,7 @@ export class MrfPolicy1781706597000 { objectTypes: ['Note'], }), ]); - await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "failureMode", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 30, $3, 50, 'accept', true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 30, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ 'mrfbuiltinhellthread001', 'Hellthread mention filter', ` diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index 18ae2059ef..f7d355064b 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -11,7 +11,6 @@ export type MrfLuaPolicy = { name: string; source: string; timeoutMs?: number; - failureMode?: 'reject' | 'accept'; paramsSchema?: MrfLuaParamsSchema; params?: MrfLuaParams; }; diff --git a/packages/backend/src/models/MrfPolicy.ts b/packages/backend/src/models/MrfPolicy.ts index f5549642e6..43ab8098da 100644 --- a/packages/backend/src/models/MrfPolicy.ts +++ b/packages/backend/src/models/MrfPolicy.ts @@ -7,10 +7,6 @@ import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; import type { MrfLuaParams, MrfLuaParamsSchema } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { id } from './util/id.js'; -export const mrfPolicyFailureModes = ['reject', 'accept'] as const; -export type MrfPolicyFailureMode = typeof mrfPolicyFailureModes[number]; -export const DEFAULT_MRF_POLICY_FAILURE_MODE = 'accept' satisfies MrfPolicyFailureMode; - export type MrfPolicyScope = { activityTypes: string[] | null; objectTypes: string[] | null; @@ -93,12 +89,6 @@ export class MiMrfPolicy { }) public timeoutMs: number; - @Column('varchar', { - length: 32, - default: DEFAULT_MRF_POLICY_FAILURE_MODE, - }) - public failureMode: MrfPolicyFailureMode; - @Column('jsonb', { default: DEFAULT_MRF_POLICY_SCOPE, }) diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index b373c274b0..c6d94649e2 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -6,8 +6,8 @@ import type { InstancesRepository, MrfPoliciesRepository, NotesRepository } from import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; -import { DEFAULT_MRF_POLICY_FAILURE_MODE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; -import type { MrfPolicyFailureMode, MrfPolicyScope } from '@/models/MrfPolicy.js'; +import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import type { MrfPolicyScope } from '@/models/MrfPolicy.js'; export enum MMrfAction { Neutral, @@ -34,7 +34,6 @@ export type MMrfRuntimeContext = { }; type ScopedMrfLuaPolicy = MrfLuaPolicy & { - failureMode: MrfPolicyFailureMode; scope: MrfPolicyScope; }; @@ -91,15 +90,7 @@ export class MMrfPolicyService { } catch (error) { const reason = error instanceof Error ? error.message : String(error); logger.error(`policy ${policy.id} failed: ${reason}`); - if (policy.failureMode === 'accept') { - continue; - } - - return { - action: MMrfAction.RejectNote, - data: mmrfActivity, - reason: `${policy.id}: ${reason}`, - }; + continue; } } @@ -149,7 +140,6 @@ export class MMrfPolicyService { name: policy.name, source: policy.source, timeoutMs: policy.timeoutMs, - failureMode: policy.failureMode ?? DEFAULT_MRF_POLICY_FAILURE_MODE, scope: normalizeMrfPolicyScope(policy.scope), paramsSchema: policy.paramsSchema, params: policy.params, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index 67a267ec62..f35d3b715e 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; -import { DEFAULT_MRF_POLICY_FAILURE_MODE, DEFAULT_MRF_POLICY_SCOPE, mrfPolicyFailureModes, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApiError } from '../../../error.js'; @@ -35,7 +35,6 @@ export const paramDef = { priority: { type: 'integer', default: 1000 }, source: { type: 'string', minLength: 1 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, - failureMode: { type: 'string', enum: mrfPolicyFailureModes, default: DEFAULT_MRF_POLICY_FAILURE_MODE }, scope: { type: 'object', properties: { @@ -82,7 +81,6 @@ export default class extends Endpoint { // eslint- priority: ps.priority ?? 1000, source: ps.source, timeoutMs: ps.timeoutMs ?? 50, - failureMode: ps.failureMode ?? DEFAULT_MRF_POLICY_FAILURE_MODE, scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE), isBuiltin: false, builtinPolicyId: null, @@ -99,7 +97,6 @@ export default class extends Endpoint { // eslint- priority: policy.priority, source: policy.source, timeoutMs: policy.timeoutMs, - failureMode: policy.failureMode, scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts index 1dc708aded..c9b2c04ade 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -44,7 +44,6 @@ export default class extends Endpoint { // eslint- priority: policy.priority, source: policy.source, timeoutMs: policy.timeoutMs, - failureMode: policy.failureMode, scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 55c848d332..f63cfa8979 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; -import { DEFAULT_MRF_POLICY_SCOPE, mrfPolicyFailureModes, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApiError } from '../../../error.js'; @@ -45,7 +45,6 @@ export const paramDef = { priority: { type: 'integer' }, source: { type: 'string', minLength: 1 }, timeoutMs: { type: 'integer', minimum: 1, maximum: 5000 }, - failureMode: { type: 'string', enum: mrfPolicyFailureModes }, scope: { type: 'object', properties: { @@ -74,7 +73,6 @@ export default class extends Endpoint { // eslint- ps.name !== undefined || ps.source !== undefined || ps.timeoutMs !== undefined || - ps.failureMode !== undefined || ps.scope !== undefined )) { throw new ApiError(meta.errors.cannotModifyBuiltinPolicy); @@ -107,7 +105,6 @@ export default class extends Endpoint { // eslint- ...(ps.priority !== undefined ? { priority: ps.priority } : {}), ...(ps.source !== undefined ? { source: ps.source } : {}), ...(ps.timeoutMs !== undefined ? { timeoutMs: ps.timeoutMs } : {}), - ...(ps.failureMode !== undefined ? { failureMode: ps.failureMode } : {}), ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE) } : {}), ...(ps.source !== undefined ? { paramsSchema } : {}), ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), @@ -125,7 +122,6 @@ export default class extends Endpoint { // eslint- priority: policy.priority, source: policy.source, timeoutMs: policy.timeoutMs, - failureMode: policy.failureMode, scope: policy.scope, isBuiltin: policy.isBuiltin, builtinPolicyId: policy.builtinPolicyId, diff --git a/packages/backend/test/unit/migration/MrfPolicyMigration.ts b/packages/backend/test/unit/migration/MrfPolicyMigration.ts index 7553746029..df7fca409a 100644 --- a/packages/backend/test/unit/migration/MrfPolicyMigration.ts +++ b/packages/backend/test/unit/migration/MrfPolicyMigration.ts @@ -25,9 +25,10 @@ describe('MRF policy migration', () => { assert.deepStrictEqual(builtinPolicyIds, ['keyword-filter', 'new-user-spam', 'hellthread']); assert.match(createTable?.sql ?? '', /"scope" jsonb NOT NULL DEFAULT/); + assert.doesNotMatch(createTable?.sql ?? '', /"failureMode"/); for (const query of builtinInserts) { assert.match(query.sql, /"scope"/); - assert.match(query.sql, /'accept'/); + assert.doesNotMatch(query.sql, /"failureMode"/); assert.deepStrictEqual(JSON.parse(query.params?.[6] as string), { activityTypes: ['Create'], objectTypes: ['Note'], diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts index be61226ae0..d2cec73762 100644 --- a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -136,6 +136,20 @@ describe('MMrfPolicyService', () => { assert.deepStrictEqual(result.data, keywordActivity); }); + test('fails open for runtime errors even when a policy was configured fail-closed', async () => { + const service = createService([ + createPolicyRow({ + failureMode: 'reject', + source: 'function filter(ctx) error("broken policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, keywordActivity); + }); + test('keeps explicit policy rejections as enforcement decisions', async () => { const service = createService([ createPolicyRow({ diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index 1bdc5cc08a..57d7c82bdd 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -20,7 +20,6 @@ function createPolicy(overrides: Record = {}) { priority: 10, source: 'function filter(ctx) return mrf.accept() end', timeoutMs: 50, - failureMode: 'accept', scope: { activityTypes: ['Create'], objectTypes: ['Note'], @@ -164,7 +163,6 @@ describe('MRF policy admin endpoints', () => { assert.deepStrictEqual(result.params, { threshold: 9, }); - assert.equal(result.failureMode, 'accept'); assert.deepStrictEqual(result.scope, { activityTypes: ['Create'], objectTypes: ['Note'], From ddcff0910aec7d424381a75df5181b0532feb81a Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 18 Jun 2026 15:41:12 +0200 Subject: [PATCH 12/28] perf(backend): pool Lua MRF policy engines --- .../activitypub/mrf/MrfLuaPolicyService.ts | 262 ++++++++++++++++-- .../src/queue/processors/MMrfPolicy.ts | 1 + .../queue/processors/MrfLuaPolicyService.ts | 167 ++++++++++- 3 files changed, 406 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index f7d355064b..ee99ec04f9 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { createHash } from 'node:crypto'; import { LuaFactory } from 'wasmoon'; import type { IActivity } from '@/core/activitypub/type.js'; @@ -66,11 +67,15 @@ export type MrfLuaRunOptions = { export type MrfLuaPolicyServiceOptions = { defaultTimeoutMs: number; defaultMemoryLimitBytes: number; + maxPreparedEnginesPerPolicy: number; + maxPreparedEngineUses: number; }; const DEFAULT_OPTIONS = { defaultTimeoutMs: 50, defaultMemoryLimitBytes: 1024 * 1024 * 8, + maxPreparedEnginesPerPolicy: 2, + maxPreparedEngineUses: 1000, } satisfies MrfLuaPolicyServiceOptions; const SANDBOX_PRELUDE = ` @@ -276,15 +281,46 @@ export class MrfLuaPolicyError extends Error { } } +type LuaEngine = Awaited>; + +type PreparedPolicyEngine = { + key: string; + lua: LuaEngine; + lookup?: MrfLuaLookupApi; + uses: number; +}; + +type PreparedPolicyEngineWaiter = { + resolve: (engine: PreparedPolicyEngine) => void; + reject: (error: unknown) => void; +}; + +type PreparedPolicyEnginePool = { + key: string; + policyId: string; + policy: Pick; + timeoutMs: number; + total: number; + available: PreparedPolicyEngine[]; + waiters: PreparedPolicyEngineWaiter[]; + retired: boolean; +}; + export class MrfLuaPolicyService { private readonly luaFactory = new LuaFactory(); private readonly options: MrfLuaPolicyServiceOptions; + private readonly preparedEnginePools = new Map(); constructor(options?: Partial) { - this.options = { + const resolvedOptions = { ...DEFAULT_OPTIONS, ...options, }; + this.options = { + ...resolvedOptions, + maxPreparedEnginesPerPolicy: normalizePositiveInteger(resolvedOptions.maxPreparedEnginesPerPolicy, DEFAULT_OPTIONS.maxPreparedEnginesPerPolicy), + maxPreparedEngineUses: normalizePositiveInteger(resolvedOptions.maxPreparedEngineUses, DEFAULT_OPTIONS.maxPreparedEngineUses), + }; } public async run(policy: MrfLuaPolicy, context: MrfLuaPolicyContext, options: MrfLuaRunOptions = {}): Promise { @@ -300,32 +336,20 @@ export class MrfLuaPolicyService { params, }); - const lua = await this.luaFactory.createEngine({ - openStandardLibs: true, - injectObjects: true, - enableProxy: false, - traceAllocations: true, - functionTimeout: timeoutMs, - }); + let engine: PreparedPolicyEngine | undefined; + let reusable = false; try { - const mrfApi = this.createMrfApi(); - lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); - lua.global.set('__mrf_accept', mrfApi.accept); - lua.global.set('__mrf_reject', mrfApi.reject); - lua.global.set('__mrf_rewrite', mrfApi.rewrite); - lua.global.set('__mrf_lookup_user_by_uri', options.lookup?.userByUri ?? (async () => null)); - lua.global.set('__mrf_lookup_user_by_mention', options.lookup?.userByMention ?? (async () => null)); - lua.global.set('__mrf_lookup_instance_by_host', options.lookup?.instanceByHost ?? (async () => null)); - lua.global.set('__mrf_lookup_note_by_uri', options.lookup?.noteByUri ?? (async () => null)); - lua.global.set('ctx', clonedContext); - await lua.doString(SANDBOX_PRELUDE); - await this.runString(lua, policy.source, policy.name, timeoutMs); + engine = await this.borrowPreparedPolicyEngine(policy, timeoutMs); + engine.lookup = options.lookup; + engine.uses += 1; + engine.lua.global.set('ctx', clonedContext); - const thread = lua.global.newThread(); + const thread = engine.lua.global.newThread(); thread.loadString('return filter(ctx)', policy.name); const returns = await thread.run(0, { timeout: timeoutMs }); const decision = this.parseDecision(returns[0], clonedContext.activity); + reusable = true; return { policy: { @@ -338,7 +362,9 @@ export class MrfLuaPolicyService { } catch (error) { throw new MrfLuaPolicyError(policy, error); } finally { - lua.global.close(); + if (engine != null) { + this.releasePreparedPolicyEngine(engine, reusable); + } } } @@ -377,6 +403,14 @@ export class MrfLuaPolicyService { } } + public retainPreparedPolicyEngines(policyIds: Iterable): void { + const retainedPolicyIds = new Set(policyIds); + for (const pool of this.preparedEnginePools.values()) { + if (retainedPolicyIds.has(pool.policyId)) continue; + this.retirePreparedPolicyEnginePool(pool); + } + } + public resolveParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { const parsedParams = this.filterCompatibleParams(schema, params); const resolved: MrfLuaParams = {}; @@ -440,6 +474,185 @@ export class MrfLuaPolicyService { await thread.run(0, { timeout: timeoutMs }); } + private async borrowPreparedPolicyEngine(policy: MrfLuaPolicy, timeoutMs: number): Promise { + const key = this.getPreparedPolicyEngineKey(policy, timeoutMs); + this.retireStalePreparedPolicyEngines(policy.id, key); + + let pool = this.preparedEnginePools.get(key); + if (pool == null) { + pool = { + key, + policyId: policy.id, + policy: { + id: policy.id, + name: policy.name, + source: policy.source, + timeoutMs: policy.timeoutMs, + }, + timeoutMs, + total: 0, + available: [], + waiters: [], + retired: false, + }; + this.preparedEnginePools.set(key, pool); + } + + const available = pool.available.pop(); + if (available != null) { + return available; + } + + if (pool.total < this.options.maxPreparedEnginesPerPolicy) { + pool.total += 1; + try { + return await this.createPreparedPolicyEngine(pool); + } catch (error) { + pool.total = Math.max(0, pool.total - 1); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + throw error; + } + } + + return await new Promise((resolve, reject) => { + pool.waiters.push({ resolve, reject }); + }); + } + + private async createPreparedPolicyEngine(pool: PreparedPolicyEnginePool): Promise { + const lua = await this.luaFactory.createEngine({ + openStandardLibs: true, + injectObjects: true, + enableProxy: false, + traceAllocations: true, + functionTimeout: pool.timeoutMs, + }); + const engine: PreparedPolicyEngine = { + key: pool.key, + lua, + uses: 0, + }; + + try { + const mrfApi = this.createMrfApi(); + lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); + lua.global.set('__mrf_accept', mrfApi.accept); + lua.global.set('__mrf_reject', mrfApi.reject); + lua.global.set('__mrf_rewrite', mrfApi.rewrite); + lua.global.set('__mrf_lookup_user_by_uri', async (uri: string) => await (engine.lookup?.userByUri?.(uri) ?? null)); + lua.global.set('__mrf_lookup_user_by_mention', async (mention: Record | string) => await (engine.lookup?.userByMention?.(mention) ?? null)); + lua.global.set('__mrf_lookup_instance_by_host', async (host: string) => await (engine.lookup?.instanceByHost?.(host) ?? null)); + lua.global.set('__mrf_lookup_note_by_uri', async (uri: string) => await (engine.lookup?.noteByUri?.(uri) ?? null)); + lua.global.set('ctx', null); + await lua.doString(SANDBOX_PRELUDE); + await this.runString(lua, pool.policy.source, pool.policy.name, pool.timeoutMs); + return engine; + } catch (error) { + lua.global.close(); + throw error; + } + } + + private releasePreparedPolicyEngine(engine: PreparedPolicyEngine, reusable: boolean): void { + const pool = this.preparedEnginePools.get(engine.key); + engine.lookup = undefined; + if (reusable) { + try { + engine.lua.global.set('ctx', null); + } catch { + reusable = false; + } + } + + if (pool == null || pool.retired || !reusable || engine.uses >= this.options.maxPreparedEngineUses) { + this.closePreparedPolicyEngine(engine); + if (pool != null) { + pool.total = Math.max(0, pool.total - 1); + this.startWaitingPreparedPolicyEngine(pool); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + } + return; + } + + const waiter = pool.waiters.shift(); + if (waiter != null) { + waiter.resolve(engine); + return; + } + + pool.available.push(engine); + } + + private startWaitingPreparedPolicyEngine(pool: PreparedPolicyEnginePool): void { + const waiter = pool.waiters.shift(); + if (waiter == null) return; + if (pool.retired) { + waiter.reject(new Error('policy engine pool was retired')); + this.startWaitingPreparedPolicyEngine(pool); + return; + } + + pool.total += 1; + void this.createPreparedPolicyEngine(pool) + .then(waiter.resolve) + .catch(error => { + pool.total = Math.max(0, pool.total - 1); + waiter.reject(error); + this.startWaitingPreparedPolicyEngine(pool); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + }); + } + + private retireStalePreparedPolicyEngines(policyId: string, currentKey: string): void { + for (const pool of this.preparedEnginePools.values()) { + if (pool.policyId !== policyId || pool.key === currentKey) continue; + this.retirePreparedPolicyEnginePool(pool); + } + } + + private retirePreparedPolicyEnginePool(pool: PreparedPolicyEnginePool): void { + pool.retired = true; + this.preparedEnginePools.delete(pool.key); + for (const engine of pool.available) { + this.closePreparedPolicyEngine(engine); + pool.total = Math.max(0, pool.total - 1); + } + pool.available = []; + for (const waiter of pool.waiters) { + waiter.reject(new Error('policy engine pool was retired')); + } + pool.waiters = []; + } + + private deletePreparedPolicyEnginePoolIfIdle(pool: PreparedPolicyEnginePool): void { + if (pool.total === 0 && pool.available.length === 0 && pool.waiters.length === 0 && this.preparedEnginePools.get(pool.key) === pool) { + this.preparedEnginePools.delete(pool.key); + } + } + + private closePreparedPolicyEngine(engine: PreparedPolicyEngine): void { + try { + engine.lua.global.close(); + } catch { + // Nothing useful can be done if an already-failed VM also fails to close. + } + } + + private getPreparedPolicyEngineKey(policy: MrfLuaPolicy, timeoutMs: number): string { + const hash = createHash('sha256') + .update(policy.id) + .update('\0') + .update(policy.name) + .update('\0') + .update(policy.source) + .update('\0') + .update(String(timeoutMs)) + .update('\0') + .update(String(this.options.defaultMemoryLimitBytes)) + .digest('hex'); + return `${policy.id}:${hash}`; + } + private parseParamsSchema(value: unknown): MrfLuaParamsSchema { if (!isRecord(value)) { throw new Error('policy.params must be an object'); @@ -572,6 +785,11 @@ function isParamType(value: unknown): value is MrfLuaParamType { return value === 'string' || value === 'boolean' || value === 'number' || value === 'integer' || value === 'string_array'; } +function normalizePositiveInteger(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(1, Math.trunc(value)); +} + function renderLuaError(error: unknown): string { if (error instanceof Error) { return error.message; diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index c6d94649e2..bdecab84f3 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -58,6 +58,7 @@ export class MMrfPolicyService { public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { let mmrfActivity = activity; const policies = await this.getEnabledPolicies(); + this.mrfLuaPolicyService?.retainPreparedPolicyEngines(policies.map(policy => policy.id)); let lookup: ReturnType | undefined; for (const policy of policies) { diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 7daf04bf06..d00cc9a457 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import type { IActivity } from '@/core/activitypub/type.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; -import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicy, MrfLuaPolicyServiceOptions } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; const baseActivity = { id: 'https://remote.example/activities/1', @@ -148,10 +148,11 @@ const builtinPolicyFixtures = [ }, ] satisfies MrfLuaPolicy[]; -function createService() { +function createService(options: Partial = {}) { return new MrfLuaPolicyService({ defaultTimeoutMs: 100, defaultMemoryLimitBytes: 1024 * 1024 * 4, + ...options, }); } @@ -271,6 +272,168 @@ describe('MrfLuaPolicyService', () => { assert.equal(result.policy.id, 'accept'); }); + test('reuses a prepared policy engine across successful runs', async () => { + const service = createService(); + const policy = { + id: 'pooled', + name: 'Pooled Policy', + source: ` + load_count = (load_count or 0) + 1 + + function filter(ctx) + run_count = (run_count or 0) + 1 + return mrf.accept("load:" .. load_count .. ",run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'load:1,run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'load:1,run:2', + }); + }); + + test('discards a prepared policy engine after a runtime error', async () => { + const service = createService(); + const policy = { + id: 'pooled-error', + name: 'Pooled Error Policy', + source: ` + function filter(ctx) + run_count = (run_count or 0) + 1 + if ctx.params.fail then + error("boom") + end + return mrf.accept("run:" .. run_count) + end + `, + paramsSchema: { + fail: { + type: 'boolean', + default: false, + }, + }, + } satisfies MrfLuaPolicy; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + await assert.rejects( + () => service.run(policy, { + ...context, + params: { + fail: true, + }, + }), + /policy pooled-error failed/i, + ); + const result = await service.run(policy, context); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + + test('recycles prepared policy engines after the configured use count', async () => { + const service = createService({ + maxPreparedEngineUses: 1, + }); + const policy = { + id: 'pooled-recycle', + name: 'Pooled Recycle Policy', + source: ` + function filter(ctx) + run_count = (run_count or 0) + 1 + return mrf.accept("run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + + test('prunes prepared policy engines for policies that are no longer retained', async () => { + const service = createService(); + const policy = { + id: 'pooled-pruned', + name: 'Pooled Pruned Policy', + source: ` + function filter(ctx) + run_count = (run_count or 0) + 1 + return mrf.accept("run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + service.retainPreparedPolicyEngines([]); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + test('returns a rewritten cloned activity without mutating the caller activity', async () => { const service = createService(); From 1a771460fc700ff3ab3bc1a5234281a1133a24c4 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Thu, 18 Jun 2026 15:56:50 +0200 Subject: [PATCH 13/28] feat(backend): warn on Lua MRF persistent globals --- .../activitypub/mrf/MrfLuaPolicyService.ts | 213 +++++++++++++++++- .../endpoints/admin/mrf-policies/create.ts | 4 +- .../api/endpoints/admin/mrf-policies/test.ts | 15 +- .../endpoints/admin/mrf-policies/update.ts | 7 +- .../queue/processors/MrfLuaPolicyService.ts | 113 +++++++++- .../unit/server/api/MrfPolicyEndpoints.ts | 25 ++ 6 files changed, 360 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index ee99ec04f9..30531fbc48 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -35,10 +35,26 @@ export type MrfLuaDecision = | { action: 'reject'; reason: string } | { action: 'rewrite'; activity: IActivity; reason?: string }; +export type MrfLuaPolicyWarningCode = + | 'persistent_global_defined' + | 'persistent_global_modified'; + +export type MrfLuaPolicyWarning = { + code: MrfLuaPolicyWarningCode; + key: string; + message: string; +}; + export type MrfLuaRunResult = { policy: Pick; decision: MrfLuaDecision; durationMs: number; + warnings: MrfLuaPolicyWarning[]; +}; + +export type MrfLuaPolicyMetadata = { + paramsSchema: MrfLuaParamsSchema; + warnings: MrfLuaPolicyWarning[]; }; export type MrfLuaLookupApi = { @@ -78,6 +94,8 @@ const DEFAULT_OPTIONS = { maxPreparedEngineUses: 1000, } satisfies MrfLuaPolicyServiceOptions; +const EXPECTED_POLICY_GLOBALS = new Set(['filter', 'mrf', 'policy']); + const SANDBOX_PRELUDE = ` os = nil io = nil @@ -274,6 +292,15 @@ const SANDBOX_PRELUDE = ` end `; +const POLICY_ENVIRONMENT_SOURCE = ` + local __mrf_policy_env = { + mrf = mrf, + } + __mrf_policy_env._G = __mrf_policy_env + setmetatable(__mrf_policy_env, { __index = _G }) + rawset(_G, "__mrf_policy_env", __mrf_policy_env) +`; + export class MrfLuaPolicyError extends Error { constructor(policy: Pick, cause: unknown) { super(`policy ${policy.id} failed: ${renderLuaError(cause)}`); @@ -286,6 +313,8 @@ type LuaEngine = Awaited>; type PreparedPolicyEngine = { key: string; lua: LuaEngine; + loadSnapshot: Map; + loadWarnings: MrfLuaPolicyWarning[]; lookup?: MrfLuaLookupApi; uses: number; }; @@ -346,10 +375,19 @@ export class MrfLuaPolicyService { engine.lua.global.set('ctx', clonedContext); const thread = engine.lua.global.newThread(); - thread.loadString('return filter(ctx)', policy.name); + thread.loadString('return __mrf_policy_env.filter(ctx)', policy.name); const returns = await thread.run(0, { timeout: timeoutMs }); const decision = this.parseDecision(returns[0], clonedContext.activity); + const runtimeSnapshot = await this.capturePolicyGlobalSnapshot(engine.lua, timeoutMs); + const runtimeWarnings = this.createRuntimeWarnings(engine.loadSnapshot, runtimeSnapshot); + const warnings = this.mergeWarnings([ + ...engine.loadWarnings, + ...runtimeWarnings, + ]); reusable = true; + if (runtimeWarnings.length > 0) { + reusable = false; + } return { policy: { @@ -358,6 +396,7 @@ export class MrfLuaPolicyService { }, decision, durationMs: performance.now() - start, + warnings, }; } catch (error) { throw new MrfLuaPolicyError(policy, error); @@ -369,6 +408,10 @@ export class MrfLuaPolicyService { } public async extractParamsSchema(policy: Pick): Promise { + return (await this.extractPolicyMetadata(policy)).paramsSchema; + } + + public async extractPolicyMetadata(policy: Pick): Promise { const timeoutMs = policy.timeoutMs ?? this.options.defaultTimeoutMs; const lua = await this.luaFactory.createEngine({ openStandardLibs: true, @@ -388,14 +431,20 @@ export class MrfLuaPolicyService { lua.global.set('__mrf_lookup_instance_by_host', async () => null); lua.global.set('__mrf_lookup_note_by_uri', async () => null); await lua.doString(SANDBOX_PRELUDE); - await this.runString(lua, policy.source, policy.name, timeoutMs); - const rawPolicy = lua.global.get('policy'); + const loadResult = await this.loadPolicySource(lua, policy.source, policy.name, timeoutMs); + const rawPolicy = await this.getPolicyEnvValue(lua, 'policy', timeoutMs); if (!isRecord(rawPolicy) || rawPolicy.params == null) { - return {}; + return { + paramsSchema: {}, + warnings: loadResult.warnings, + }; } - return this.parseParamsSchema(rawPolicy.params); + return { + paramsSchema: this.parseParamsSchema(rawPolicy.params), + warnings: loadResult.warnings, + }; } catch (error) { throw new MrfLuaPolicyError(policy, error); } finally { @@ -530,6 +579,8 @@ export class MrfLuaPolicyService { const engine: PreparedPolicyEngine = { key: pool.key, lua, + loadSnapshot: new Map(), + loadWarnings: [], uses: 0, }; @@ -545,7 +596,9 @@ export class MrfLuaPolicyService { lua.global.set('__mrf_lookup_note_by_uri', async (uri: string) => await (engine.lookup?.noteByUri?.(uri) ?? null)); lua.global.set('ctx', null); await lua.doString(SANDBOX_PRELUDE); - await this.runString(lua, pool.policy.source, pool.policy.name, pool.timeoutMs); + const loadResult = await this.loadPolicySource(lua, pool.policy.source, pool.policy.name, pool.timeoutMs); + engine.loadSnapshot = loadResult.snapshot; + engine.loadWarnings = loadResult.warnings; return engine; } catch (error) { lua.global.close(); @@ -653,6 +706,154 @@ export class MrfLuaPolicyService { return `${policy.id}:${hash}`; } + private async loadPolicySource(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise<{ snapshot: Map; warnings: MrfLuaPolicyWarning[] }> { + await this.runString(lua, POLICY_ENVIRONMENT_SOURCE, 'mrf policy environment', timeoutMs); + const baseline = await this.capturePolicyGlobalSnapshot(lua, timeoutMs); + await this.runString(lua, this.wrapPolicySource(source), name, timeoutMs); + const snapshot = await this.capturePolicyGlobalSnapshot(lua, timeoutMs); + return { + snapshot, + warnings: this.createLoadWarnings(baseline, snapshot), + }; + } + + private wrapPolicySource(source: string): string { + return ` + local __mrf_policy_env = rawget(_G, "__mrf_policy_env") + do + local _ENV = __mrf_policy_env +${source} + end + `; + } + + private async getPolicyEnvValue(lua: LuaEngine, key: string, timeoutMs: number): Promise { + const thread = lua.global.newThread(); + thread.loadString(`return rawget(rawget(_G, "__mrf_policy_env"), ${JSON.stringify(key)})`, 'mrf policy metadata'); + const returns = await thread.run(0, { timeout: timeoutMs }); + return returns[0]; + } + + private async capturePolicyGlobalSnapshot(lua: LuaEngine, timeoutMs: number): Promise> { + const thread = lua.global.newThread(); + thread.loadString(` + local env = rawget(_G, "__mrf_policy_env") + local result = {} + if type(env) ~= "table" then + return result + end + + local seen = {} + local function fingerprint(value, depth) + local value_type = type(value) + if value == nil then + return "nil" + end + if value_type == "string" or value_type == "number" or value_type == "boolean" then + return value_type .. ":" .. tostring(value) + end + if value_type ~= "table" then + return value_type .. ":" .. tostring(value) + end + if seen[value] then + return "table:" + end + if depth >= 3 then + return "table::" .. tostring(value) + end + + seen[value] = true + local entries = {} + local count = 0 + for item_key, item_value in pairs(value) do + count = count + 1 + if count > 64 then + entries[#entries + 1] = "..." + break + end + entries[#entries + 1] = fingerprint(item_key, depth + 1) .. "=" .. fingerprint(item_value, depth + 1) + end + table.sort(entries) + seen[value] = nil + return "table:{" .. table.concat(entries, ",") .. "}" + end + + for key, value in pairs(env) do + if type(key) == "string" and key ~= "_G" then + result[#result + 1] = { + key = key, + fingerprint = fingerprint(value, 0), + } + end + end + + return result + `, 'mrf policy globals snapshot'); + const returns = await thread.run(0, { timeout: timeoutMs }); + const entries = returns[0]; + const snapshot = new Map(); + if (!Array.isArray(entries)) { + return snapshot; + } + + for (const entry of entries) { + if (!isRecord(entry) || typeof entry.key !== 'string' || typeof entry.fingerprint !== 'string') continue; + snapshot.set(entry.key, entry.fingerprint); + } + + return new Map([...snapshot.entries()].sort(([left], [right]) => left.localeCompare(right))); + } + + private createLoadWarnings(before: Map, after: Map): MrfLuaPolicyWarning[] { + const keys = new Set([ + ...before.keys(), + ...after.keys(), + ]); + const warnings: MrfLuaPolicyWarning[] = []; + for (const key of [...keys].sort((left, right) => left.localeCompare(right))) { + if (!before.has(key)) { + if (EXPECTED_POLICY_GLOBALS.has(key)) continue; + warnings.push({ + code: 'persistent_global_defined', + key, + message: `Policy defines persistent global: ${key}`, + }); + continue; + } + if (before.get(key) !== after.get(key)) { + warnings.push({ + code: 'persistent_global_modified', + key, + message: `Policy modified persistent global while loading: ${key}`, + }); + } + } + return warnings; + } + + private createRuntimeWarnings(before: Map, after: Map): MrfLuaPolicyWarning[] { + const keys = new Set([ + ...before.keys(), + ...after.keys(), + ]); + return [...keys] + .sort((left, right) => left.localeCompare(right)) + .filter(key => before.get(key) !== after.get(key)) + .map(key => ({ + code: 'persistent_global_modified' as const, + key, + message: `Policy modified persistent global during execution: ${key}`, + })); + } + + private mergeWarnings(warnings: MrfLuaPolicyWarning[]): MrfLuaPolicyWarning[] { + const merged = new Map(); + for (const warning of warnings) { + merged.set(`${warning.code}:${warning.key}:${warning.message}`, warning); + } + return [...merged.values()]; + } + private parseParamsSchema(value: unknown): MrfLuaParamsSchema { if (!isRecord(value)) { throw new Error('policy.params must be an object'); diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index f35d3b715e..e9e5a1fc8a 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -59,12 +59,13 @@ export default class extends Endpoint { // eslint- private readonly idService: IdService, ) { super(meta, paramDef, async (ps) => { - const paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema({ + const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ id: 'new-policy', name: ps.name, source: ps.source, timeoutMs: ps.timeoutMs, }); + const paramsSchema = metadata.paramsSchema; let params; try { params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); @@ -102,6 +103,7 @@ export default class extends Endpoint { // eslint- builtinPolicyId: policy.builtinPolicyId, paramsSchema: policy.paramsSchema, params: policy.params, + warnings: metadata.warnings, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts index 673cb7c761..83be111f20 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -50,7 +50,8 @@ export default class extends Endpoint { // eslint- source: ps.source, timeoutMs: ps.timeoutMs, }; - const paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema(policy); + const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata(policy); + const paramsSchema = metadata.paramsSchema; let params; try { params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); @@ -80,7 +81,19 @@ export default class extends Endpoint { // eslint- ...result, paramsSchema, params, + warnings: dedupeWarnings([ + ...metadata.warnings, + ...result.warnings, + ]), }; }); } } + +function dedupeWarnings(warnings: T[]): T[] { + const deduped = new Map(); + for (const warning of warnings) { + deduped.set(`${warning.code}:${warning.key}:${warning.message}`, warning); + } + return [...deduped.values()]; +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index f63cfa8979..277e2e643a 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicyWarning } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -80,13 +81,16 @@ export default class extends Endpoint { // eslint- let paramsSchema = existing.paramsSchema; let params = existing.params; + let warnings: MrfLuaPolicyWarning[] = []; if (ps.source !== undefined) { - paramsSchema = await this.mrfLuaPolicyService.extractParamsSchema({ + const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ id: existing.id, name: ps.name ?? existing.name, source: ps.source, timeoutMs: ps.timeoutMs ?? existing.timeoutMs, }); + paramsSchema = metadata.paramsSchema; + warnings = metadata.warnings; params = this.mrfLuaPolicyService.filterCompatibleParams(paramsSchema, params); } if (ps.params !== undefined) { @@ -127,6 +131,7 @@ export default class extends Endpoint { // eslint- builtinPolicyId: policy.builtinPolicyId, paramsSchema: policy.paramsSchema, params: policy.params, + warnings, }; }); } diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index d00cc9a457..60e4043195 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -278,11 +278,11 @@ describe('MrfLuaPolicyService', () => { id: 'pooled', name: 'Pooled Policy', source: ` - load_count = (load_count or 0) + 1 + local run_count = 0 function filter(ctx) - run_count = (run_count or 0) + 1 - return mrf.accept("load:" .. load_count .. ",run:" .. run_count) + run_count = run_count + 1 + return mrf.accept("run:" .. run_count) end `, }; @@ -302,12 +302,103 @@ describe('MrfLuaPolicyService', () => { assert.deepStrictEqual(first.decision, { action: 'accept', - reason: 'load:1,run:1', + reason: 'run:1', }); assert.deepStrictEqual(second.decision, { action: 'accept', - reason: 'load:1,run:2', + reason: 'run:2', + }); + }); + + test('warns when policy source defines persistent globals', async () => { + const service = createService(); + + const metadata = await service.extractPolicyMetadata({ + id: 'global-source', + name: 'Global Source Policy', + source: ` + counter = 0 + + function helper() + return true + end + + function filter(ctx) + return mrf.accept() + end + `, + }); + + assert.deepStrictEqual(metadata.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + { code: 'persistent_global_defined', key: 'helper' }, + ]); + }); + + test('warns when policy source modifies the MRF global API table', async () => { + const service = createService(); + + const metadata = await service.extractPolicyMetadata({ + id: 'global-mrf-source', + name: 'Global MRF Source Policy', + source: ` + mrf.custom_helper = function() + return true + end + + function filter(ctx) + return mrf.accept() + end + `, }); + + assert.deepStrictEqual(metadata.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_modified', key: 'mrf' }, + ]); + }); + + test('warns when filter mutates persistent globals at runtime', async () => { + const service = createService(); + + const result = await service.run({ + id: 'global-runtime', + name: 'Global Runtime Policy', + source: ` + counter = 0 + + function filter(ctx) + counter = counter + 1 + mrf.custom_runtime = true + new_global = true + return mrf.accept() + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + { code: 'persistent_global_modified', key: 'counter' }, + { code: 'persistent_global_modified', key: 'mrf' }, + { code: 'persistent_global_modified', key: 'new_global' }, + ]); }); test('discards a prepared policy engine after a runtime error', async () => { @@ -316,8 +407,10 @@ describe('MrfLuaPolicyService', () => { id: 'pooled-error', name: 'Pooled Error Policy', source: ` + local run_count = 0 + function filter(ctx) - run_count = (run_count or 0) + 1 + run_count = run_count + 1 if ctx.params.fail then error("boom") end @@ -367,8 +460,10 @@ describe('MrfLuaPolicyService', () => { id: 'pooled-recycle', name: 'Pooled Recycle Policy', source: ` + local run_count = 0 + function filter(ctx) - run_count = (run_count or 0) + 1 + run_count = run_count + 1 return mrf.accept("run:" .. run_count) end `, @@ -403,8 +498,10 @@ describe('MrfLuaPolicyService', () => { id: 'pooled-pruned', name: 'Pooled Pruned Policy', source: ` + local run_count = 0 + function filter(ctx) - run_count = (run_count or 0) + 1 + run_count = run_count + 1 return mrf.accept("run:" .. run_count) end `, diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index 57d7c82bdd..9fb5aff498 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -169,6 +169,31 @@ describe('MRF policy admin endpoints', () => { }); }); + test('returns warnings for custom policy source globals when creating policies', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000002', + } as any); + + const result = await endpoint.exec({ + name: 'Custom policy with globals', + source: ` + counter = 0 + + function filter(ctx) + return mrf.accept() + end + `, + }, {} as any, null); + + assert.deepStrictEqual(result.warnings.map((warning: { code: string; key: string }) => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + ]); + }); + test('allows custom policies to target non-note activity types', async () => { const repository = createRepository(createPolicy({ isBuiltin: false, From d53fb4654037c94aeee2702e4ccfe75043015054 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 00:01:43 +0000 Subject: [PATCH 14/28] fix(activitypub): correctly access JsonLd instance when signing relay activities attachLdSignature() called this.jsonLdService.signRsaSignature2017(...) directly, but JsonLdService only exposes use(), which returns the JsonLd instance that actually implements signRsaSignature2017. This caused "this.jsonLdService.signRsaSignature2017 is not a function" when signing activities delivered to relays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JzqoS7cjvodFiWW6qZxyXL --- packages/backend/src/core/activitypub/ApRendererService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0d28cba47f..8a07f2327b 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -819,7 +819,7 @@ export class ApRendererService { const keypair = await this.userKeypairService.getUserKeypair(user.id); - activity = await this.jsonLdService.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + activity = await this.jsonLdService.use().signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } From 694909251ca76a008250571393e22c770fab266b Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:08:48 +0200 Subject: [PATCH 15/28] fix(backend): remove Lua MRF threads from the engine stack after use Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../activitypub/mrf/MrfLuaPolicyService.ts | 32 ++++++++++------- .../queue/processors/MrfLuaPolicyService.ts | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index 30531fbc48..0c46bd6d95 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -374,9 +374,7 @@ export class MrfLuaPolicyService { engine.uses += 1; engine.lua.global.set('ctx', clonedContext); - const thread = engine.lua.global.newThread(); - thread.loadString('return __mrf_policy_env.filter(ctx)', policy.name); - const returns = await thread.run(0, { timeout: timeoutMs }); + const returns = await this.runThread(engine.lua, 'return __mrf_policy_env.filter(ctx)', policy.name, timeoutMs); const decision = this.parseDecision(returns[0], clonedContext.activity); const runtimeSnapshot = await this.capturePolicyGlobalSnapshot(engine.lua, timeoutMs); const runtimeWarnings = this.createRuntimeWarnings(engine.loadSnapshot, runtimeSnapshot); @@ -517,10 +515,22 @@ export class MrfLuaPolicyService { }; } - private async runString(lua: Awaited>, source: string, name: string, timeoutMs: number): Promise { + private async runThread(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise { + // lua_newthread pushes the thread onto the parent stack; it MUST be removed + // after use or the main state's stack overflows its allocation after ~50 runs + // and corrupts the wasm heap. Mirrors wasmoon's own callByteCode() guard. const thread = lua.global.newThread(); - thread.loadString(source, name); - await thread.run(0, { timeout: timeoutMs }); + const threadIndex = lua.global.getTop(); + try { + thread.loadString(source, name); + return await thread.run(0, { timeout: timeoutMs }); + } finally { + lua.global.remove(threadIndex); + } + } + + private async runString(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise { + await this.runThread(lua, source, name, timeoutMs); } private async borrowPreparedPolicyEngine(policy: MrfLuaPolicy, timeoutMs: number): Promise { @@ -728,15 +738,12 @@ ${source} } private async getPolicyEnvValue(lua: LuaEngine, key: string, timeoutMs: number): Promise { - const thread = lua.global.newThread(); - thread.loadString(`return rawget(rawget(_G, "__mrf_policy_env"), ${JSON.stringify(key)})`, 'mrf policy metadata'); - const returns = await thread.run(0, { timeout: timeoutMs }); + const returns = await this.runThread(lua, `return rawget(rawget(_G, "__mrf_policy_env"), ${JSON.stringify(key)})`, 'mrf policy metadata', timeoutMs); return returns[0]; } private async capturePolicyGlobalSnapshot(lua: LuaEngine, timeoutMs: number): Promise> { - const thread = lua.global.newThread(); - thread.loadString(` + const returns = await this.runThread(lua, ` local env = rawget(_G, "__mrf_policy_env") local result = {} if type(env) ~= "table" then @@ -788,8 +795,7 @@ ${source} end return result - `, 'mrf policy globals snapshot'); - const returns = await thread.run(0, { timeout: timeoutMs }); + `, 'mrf policy globals snapshot', timeoutMs); const entries = returns[0]; const snapshot = new Map(); if (!Array.isArray(entries)) { diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 60e4043195..560e19c163 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -994,6 +994,40 @@ describe('MrfLuaPolicyService', () => { }); }); + test('survives hundreds of pooled runs on a single engine', async () => { + const service = createService({ + maxPreparedEnginesPerPolicy: 1, + maxPreparedEngineUses: 500, + }); + const policy = { + id: 'longevity', + name: 'Longevity Policy', + source: ` + function filter(ctx) + return mrf.accept("ok") + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + for (let i = 0; i < 200; i++) { + const result = await service.run(policy, context); + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'ok', + }); + } + }); + describe('bundled policies', () => { test('keyword policy rejects matching note content', async () => { const service = createService(); From fd443e92e4ad54d35c7887b80e917a0d4eae267d Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:16:50 +0200 Subject: [PATCH 16/28] fix(backend): do not discard Lua MRF decisions on snapshot failure Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../activitypub/mrf/MrfLuaPolicyService.ts | 20 +++++---- .../queue/processors/MrfLuaPolicyService.ts | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index 0c46bd6d95..4d983e9a83 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -376,14 +376,18 @@ export class MrfLuaPolicyService { const returns = await this.runThread(engine.lua, 'return __mrf_policy_env.filter(ctx)', policy.name, timeoutMs); const decision = this.parseDecision(returns[0], clonedContext.activity); - const runtimeSnapshot = await this.capturePolicyGlobalSnapshot(engine.lua, timeoutMs); - const runtimeWarnings = this.createRuntimeWarnings(engine.loadSnapshot, runtimeSnapshot); - const warnings = this.mergeWarnings([ - ...engine.loadWarnings, - ...runtimeWarnings, - ]); - reusable = true; - if (runtimeWarnings.length > 0) { + let warnings = this.mergeWarnings([...engine.loadWarnings]); + try { + const runtimeSnapshot = await this.capturePolicyGlobalSnapshot(engine.lua, timeoutMs); + const runtimeWarnings = this.createRuntimeWarnings(engine.loadSnapshot, runtimeSnapshot); + warnings = this.mergeWarnings([ + ...engine.loadWarnings, + ...runtimeWarnings, + ]); + reusable = runtimeWarnings.length === 0; + } catch { + // The decision is already computed; a snapshot failure must not discard it. + // Discard the engine instead, since we can no longer prove it is clean. reusable = false; } diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 560e19c163..fc8964de01 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -4,6 +4,7 @@ */ import * as assert from 'assert'; +import { jest } from '@jest/globals'; import type { IActivity } from '@/core/activitypub/type.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicy, MrfLuaPolicyServiceOptions } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; @@ -1028,6 +1029,50 @@ describe('MrfLuaPolicyService', () => { } }); + test('keeps the decision when the post-run snapshot fails', async () => { + const service = createService(); + const policy = { + id: 'snapshot-victim', + name: 'Snapshot Victim Policy', + source: ` + function filter(ctx) + return mrf.reject("spam detected") + end + `, + // Pre-set the params schema so run() skips extractParamsSchema (which itself + // captures globals snapshots). Without this, the one-time snapshot rejection + // below would be consumed by extractParamsSchema instead of the post-run + // snapshot this test is exercising. + paramsSchema: {}, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + // First run creates and pools the engine (load-time snapshots happen here). + await service.run(policy, context); + + // Second run reuses the pooled engine, so the next snapshot call is the post-run one. + const spy = jest.spyOn(service as any, 'capturePolicyGlobalSnapshot') + .mockRejectedValueOnce(new Error('snapshot boom')); + try { + const result = await service.run(policy, context); + assert.deepStrictEqual(result.decision, { + action: 'reject', + reason: 'spam detected', + }); + } finally { + spy.mockRestore(); + } + }); + describe('bundled policies', () => { test('keyword policy rejects matching note content', async () => { const service = createService(); From c00bcc1b21503e3df3238afdf1b382bfc476228e Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:20:13 +0200 Subject: [PATCH 17/28] fix(backend): reject non-JSON Lua MRF rewrite payloads Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../activitypub/mrf/MrfLuaPolicyService.ts | 43 ++++++++++++++++++- .../queue/processors/MMrfPolicyService.ts | 23 ++++++++++ .../queue/processors/MrfLuaPolicyService.ts | 27 ++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index 4d983e9a83..f2da407ab3 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -956,9 +956,11 @@ ${source} throw new Error('rewrite decision must include an activity'); } + const activity = this.normalizeLuaArrayShapes(value.activity, originalActivity) as unknown as IActivity; + this.assertJsonSafe(activity, 'activity'); return { action: 'rewrite', - activity: this.normalizeLuaArrayShapes(value.activity, originalActivity) as unknown as IActivity, + activity, ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), }; } @@ -966,6 +968,45 @@ ${source} throw new Error(`unknown policy action: ${String(value.action)}`); } + private assertJsonSafe(value: unknown, path: string, seen = new Set()): void { + if (value === null) return; + switch (typeof value) { + case 'string': + case 'boolean': + return; + case 'number': + if (!Number.isFinite(value)) { + throw new Error(`rewrite activity contains a non-finite number at ${path}`); + } + return; + case 'object': + break; + default: + throw new Error(`rewrite activity contains a non-JSON value (${typeof value}) at ${path}`); + } + + const obj = value as object; + if (seen.has(obj)) { + throw new Error(`rewrite activity contains a cyclic reference at ${path}`); + } + seen.add(obj); + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + this.assertJsonSafe(obj[i], `${path}[${i}]`, seen); + } + } else { + const proto = Object.getPrototypeOf(obj); + if (proto !== Object.prototype && proto !== null) { + throw new Error(`rewrite activity contains a non-plain object at ${path}`); + } + for (const [key, item] of Object.entries(obj as Record)) { + if (item === undefined) continue; + this.assertJsonSafe(item, `${path}.${key}`, seen); + } + } + seen.delete(obj); + } + private normalizeLuaArrayShapes(value: unknown, template: unknown): unknown { if (Array.isArray(template) && isRecord(value) && Object.keys(value).length === 0) { return []; diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts index d2cec73762..ed833fe60d 100644 --- a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -162,4 +162,27 @@ describe('MMrfPolicyService', () => { assert.equal(result.action, MMrfAction.RejectNote); assert.match(result.reason ?? '', /explicit rejection/); }); + + test('a poisoned rewrite fails only its own policy, not the chain', async () => { + const service = createService([ + createPolicyRow({ + id: 'poisoner', + source: ` + function filter(ctx) + ctx.activity.evil = function() return 1 end + return mrf.rewrite(ctx.activity, "poisoned") + end + `, + }), + createPolicyRow({ + id: 'rejector', + source: 'function filter(ctx) return mrf.reject("caught by second policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /caught by second policy/); + }); }); diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index fc8964de01..7abfbd42b8 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -1213,4 +1213,31 @@ describe('MrfLuaPolicyService', () => { } }); }); + + test('rejects rewrite decisions that contain non-JSON values', async () => { + const service = createService(); + + await assert.rejects( + () => service.run({ + id: 'poison-rewrite', + name: 'Poison Rewrite Policy', + source: ` + function filter(ctx) + ctx.activity.evil = function() return 1 end + return mrf.rewrite(ctx.activity, "poisoned") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }), + /non-JSON value/, + ); + }); }); From 280da98219e9d6ffa8543e5b75d4779000161c8c Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:23:20 +0200 Subject: [PATCH 18/28] fix(backend): require a filter function in Lua MRF policy sources Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../activitypub/mrf/MrfLuaPolicyService.ts | 4 +++ .../queue/processors/MrfLuaPolicyService.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index f2da407ab3..b559e58cc0 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -434,6 +434,10 @@ export class MrfLuaPolicyService { lua.global.set('__mrf_lookup_note_by_uri', async () => null); await lua.doString(SANDBOX_PRELUDE); const loadResult = await this.loadPolicySource(lua, policy.source, policy.name, timeoutMs); + const filterType = (await this.runThread(lua, 'return type(rawget(rawget(_G, "__mrf_policy_env"), "filter"))', 'mrf policy filter check', timeoutMs))[0]; + if (filterType !== 'function') { + throw new Error('policy source must define a global filter(ctx) function'); + } const rawPolicy = await this.getPolicyEnvValue(lua, 'policy', timeoutMs); if (!isRecord(rawPolicy) || rawPolicy.params == null) { diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 7abfbd42b8..9dc77c86a2 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -1240,4 +1240,29 @@ describe('MrfLuaPolicyService', () => { /non-JSON value/, ); }); + + test('rejects policy sources that do not define a filter function', async () => { + const service = createService(); + + await assert.rejects( + () => service.extractPolicyMetadata({ + id: 'no-filter', + name: 'No Filter Policy', + source: ` + policy = {} + filter = "not a function" + `, + }), + /must define a global filter/, + ); + + await assert.rejects( + () => service.extractPolicyMetadata({ + id: 'empty', + name: 'Empty Policy', + source: 'local x = 1', + }), + /must define a global filter/, + ); + }); }); From 826e3d018b78bf2f5fe4c96f31d887a8d0f114b7 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:29:25 +0200 Subject: [PATCH 19/28] refactor(backend): provide MrfLuaPolicyService as a DI singleton Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- packages/backend/src/core/CoreModule.ts | 7 +++++++ .../core/activitypub/mrf/MrfLuaPolicyService.ts | 9 ++++++++- .../backend/src/queue/processors/MMrfPolicy.ts | 13 ++++--------- .../api/endpoints/admin/mrf-policies/create.ts | 3 +-- .../api/endpoints/admin/mrf-policies/test.ts | 6 +++--- .../api/endpoints/admin/mrf-policies/update.ts | 3 +-- .../unit/queue/processors/MMrfPolicyService.ts | 2 ++ .../test/unit/server/api/MrfPolicyEndpoints.ts | 17 +++++++++-------- 8 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 80d4b32432..519b1a13c8 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -142,6 +142,7 @@ import { MetaEntityService } from './entities/MetaEntityService.js'; import { ApAudienceService } from './activitypub/ApAudienceService.js'; import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; import { ApInboxService } from './activitypub/ApInboxService.js'; import { ApLoggerService } from './activitypub/ApLoggerService.js'; @@ -297,6 +298,10 @@ const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntitySer const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; +const $MrfLuaPolicyService: Provider = { + provide: MrfLuaPolicyService, + useFactory: () => new MrfLuaPolicyService(), +}; const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useExisting: ApDeliverManagerService }; const $ApInboxService: Provider = { provide: 'ApInboxService', useExisting: ApInboxService }; const $ApLoggerService: Provider = { provide: 'ApLoggerService', useExisting: ApLoggerService }; @@ -616,6 +621,7 @@ const $Imports = [ $ApAudienceService, $ApDbResolverService, + $MrfLuaPolicyService, $ApDeliverManagerService, $ApInboxService, $ApLoggerService, @@ -928,6 +934,7 @@ const $Imports = [ $ApAudienceService, $ApDbResolverService, + $MrfLuaPolicyService, $ApDeliverManagerService, $ApInboxService, $ApLoggerService, diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index b559e58cc0..f2e68ffb12 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'; import { LuaFactory } from 'wasmoon'; +import type { OnApplicationShutdown } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; export type MrfLuaPolicy = { @@ -335,7 +336,7 @@ type PreparedPolicyEnginePool = { retired: boolean; }; -export class MrfLuaPolicyService { +export class MrfLuaPolicyService implements OnApplicationShutdown { private readonly luaFactory = new LuaFactory(); private readonly options: MrfLuaPolicyServiceOptions; private readonly preparedEnginePools = new Map(); @@ -458,6 +459,12 @@ export class MrfLuaPolicyService { } } + public onApplicationShutdown(): void { + // Retaining an empty set retires every pool and closes idle engines; + // borrowed engines are closed on release because their pool is retired. + this.retainPreparedPolicyEngines([]); + } + public retainPreparedPolicyEngines(policyIds: Iterable): void { const retainedPolicyIds = new Set(policyIds); for (const pool of this.preparedEnginePools.values()) { diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index bdecab84f3..fac7f6e32e 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -39,8 +39,6 @@ type ScopedMrfLuaPolicy = MrfLuaPolicy & { @Injectable() export class MMrfPolicyService { - private mrfLuaPolicyService: MrfLuaPolicyService | null = null; - constructor( @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, @@ -52,13 +50,15 @@ export class MMrfPolicyService { private readonly notesRepository: NotesRepository, private readonly apDbResolverService: ApDbResolverService, + + private readonly mrfLuaPolicyService: MrfLuaPolicyService, ) { } public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { let mmrfActivity = activity; const policies = await this.getEnabledPolicies(); - this.mrfLuaPolicyService?.retainPreparedPolicyEngines(policies.map(policy => policy.id)); + this.mrfLuaPolicyService.retainPreparedPolicyEngines(policies.map(policy => policy.id)); let lookup: ReturnType | undefined; for (const policy of policies) { @@ -68,7 +68,7 @@ export class MMrfPolicyService { try { lookup ??= this.createLookupApi(); - const result = await this.getMrfLuaPolicyService().run(policy, { + const result = await this.mrfLuaPolicyService.run(policy, { ...context, activity: mmrfActivity, }, { @@ -120,11 +120,6 @@ export class MMrfPolicyService { return this.getTypeSet(object); } - private getMrfLuaPolicyService(): MrfLuaPolicyService { - this.mrfLuaPolicyService ??= new MrfLuaPolicyService(); - return this.mrfLuaPolicyService; - } - private async getEnabledPolicies(): Promise { const policies = await this.mrfPoliciesRepository.find({ where: { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index e9e5a1fc8a..2b90818635 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -51,12 +51,11 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export - private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); - constructor( @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, private readonly idService: IdService, + private readonly mrfLuaPolicyService: MrfLuaPolicyService, ) { super(meta, paramDef, async (ps) => { const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts index 83be111f20..7775827793 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -40,9 +40,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export - private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); - - constructor() { + constructor( + private readonly mrfLuaPolicyService: MrfLuaPolicyService, + ) { super(meta, paramDef, async (ps) => { const policy = { id: 'dry-run', diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 277e2e643a..63b6a9d287 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -61,11 +61,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export - private readonly mrfLuaPolicyService = new MrfLuaPolicyService(); - constructor( @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly mrfLuaPolicyService: MrfLuaPolicyService, ) { super(meta, paramDef, async (ps) => { const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts index ed833fe60d..98110857ec 100644 --- a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -5,6 +5,7 @@ import * as assert from 'node:assert'; import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { IActivity } from '@/core/activitypub/type.js'; const logger = { @@ -81,6 +82,7 @@ function createService(policyRows: unknown[]) { { getUserFromApId: async () => null, } as any, + new MrfLuaPolicyService(), ); } diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index 9fb5aff498..c56c5886b1 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -9,6 +9,7 @@ import DeleteMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/d import CreateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/create.js'; import { meta as listMeta } from '@/server/api/endpoints/admin/mrf-policies/list.js'; import { ApiError } from '@/server/api/error.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; function createPolicy(overrides: Record = {}) { return { @@ -81,7 +82,7 @@ describe('MRF policy admin endpoints', () => { test('allows disabling built-in policies without modifying their source', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); const result = await endpoint.exec({ id: repository.current.id, @@ -96,7 +97,7 @@ describe('MRF policy admin endpoints', () => { test('allows updating built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); const result = await endpoint.exec({ id: repository.current.id, @@ -113,7 +114,7 @@ describe('MRF policy admin endpoints', () => { test('rejects unknown built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); await assert.rejects( () => endpoint.exec({ @@ -130,7 +131,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000001', - } as any); + } as any, new MrfLuaPolicyService()); const result = await endpoint.exec({ name: 'Custom policy', @@ -173,7 +174,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000002', - } as any); + } as any, new MrfLuaPolicyService()); const result = await endpoint.exec({ name: 'Custom policy with globals', @@ -199,7 +200,7 @@ describe('MRF policy admin endpoints', () => { isBuiltin: false, builtinPolicyId: null, })); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); const result = await endpoint.exec({ id: repository.current.id, @@ -221,7 +222,7 @@ describe('MRF policy admin endpoints', () => { test('rejects source edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); await assert.rejects( () => endpoint.exec({ @@ -234,7 +235,7 @@ describe('MRF policy admin endpoints', () => { test('rejects scope edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); await assert.rejects( () => endpoint.exec({ From db786d981a5ca28b9a73456b355df92add78d4e1 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:33:07 +0200 Subject: [PATCH 20/28] fix(backend): return structured errors for invalid Lua MRF sources Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../endpoints/admin/mrf-policies/create.ts | 24 ++++++--- .../api/endpoints/admin/mrf-policies/test.ts | 53 +++++++++++++------ .../endpoints/admin/mrf-policies/update.ts | 24 ++++++--- .../unit/server/api/MrfPolicyEndpoints.ts | 30 +++++++++++ 4 files changed, 102 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index 2b90818635..34516091bc 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -24,6 +24,11 @@ export const meta = { code: 'INVALID_MRF_POLICY_PARAMS', id: '33231d77-34ed-4454-83a4-1f0d456acdf8', }, + invalidSource: { + message: 'Invalid MRF policy source.', + code: 'INVALID_MRF_POLICY_SOURCE', + id: 'c9727ba3-6c83-46ba-9d34-1cf5d3c0b4c8', + }, }, } as const; @@ -58,12 +63,19 @@ export default class extends Endpoint { // eslint- private readonly mrfLuaPolicyService: MrfLuaPolicyService, ) { super(meta, paramDef, async (ps) => { - const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ - id: 'new-policy', - name: ps.name, - source: ps.source, - timeoutMs: ps.timeoutMs, - }); + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ + id: 'new-policy', + name: ps.name, + source: ps.source, + timeoutMs: ps.timeoutMs, + }); + } catch (error) { + throw new ApiError(meta.errors.invalidSource, { + reason: error instanceof Error ? error.message : String(error), + }); + } const paramsSchema = metadata.paramsSchema; let params; try { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts index 7775827793..77fafcea4f 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -21,6 +21,11 @@ export const meta = { code: 'INVALID_MRF_POLICY_PARAMS', id: 'e0a7e055-f639-4024-ab41-293c7fa43d0a', }, + luaFailed: { + message: 'The MRF policy failed to load or run.', + code: 'MRF_POLICY_LUA_FAILED', + id: '8e2c9b0d-51f3-4c7a-9c25-6a3f6d0e4b91', + }, }, } as const; @@ -50,7 +55,14 @@ export default class extends Endpoint { // eslint- source: ps.source, timeoutMs: ps.timeoutMs, }; - const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata(policy); + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata(policy); + } catch (error) { + throw new ApiError(meta.errors.luaFailed, { + reason: error instanceof Error ? error.message : String(error), + }); + } const paramsSchema = metadata.paramsSchema; let params; try { @@ -60,22 +72,29 @@ export default class extends Endpoint { // eslint- reason: error instanceof Error ? error.message : String(error), }); } - const result = await this.mrfLuaPolicyService.run({ - ...policy, - paramsSchema, - params, - }, { - activity: ps.activity as IActivity, - actor: { - uri: typeof ps.actor.uri === 'string' ? ps.actor.uri : '', - host: typeof ps.actor.host === 'string' ? ps.actor.host : null, - followersCount: typeof ps.actor.followersCount === 'number' ? ps.actor.followersCount : undefined, - followingCount: typeof ps.actor.followingCount === 'number' ? ps.actor.followingCount : undefined, - }, - localHost: ps.localHost, - signerHost: ps.signerHost, - receivedAt: new Date().toISOString(), - }); + let result; + try { + result = await this.mrfLuaPolicyService.run({ + ...policy, + paramsSchema, + params, + }, { + activity: ps.activity as IActivity, + actor: { + uri: typeof ps.actor.uri === 'string' ? ps.actor.uri : '', + host: typeof ps.actor.host === 'string' ? ps.actor.host : null, + followersCount: typeof ps.actor.followersCount === 'number' ? ps.actor.followersCount : undefined, + followingCount: typeof ps.actor.followingCount === 'number' ? ps.actor.followingCount : undefined, + }, + localHost: ps.localHost, + signerHost: ps.signerHost, + receivedAt: new Date().toISOString(), + }); + } catch (error) { + throw new ApiError(meta.errors.luaFailed, { + reason: error instanceof Error ? error.message : String(error), + }); + } return { ...result, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 63b6a9d287..b3165b08c2 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -34,6 +34,11 @@ export const meta = { code: 'INVALID_MRF_POLICY_PARAMS', id: '5e19454b-3100-4354-9dfd-f2187ec1fe14', }, + invalidSource: { + message: 'Invalid MRF policy source.', + code: 'INVALID_MRF_POLICY_SOURCE', + id: '2f4bd3d7-2c86-4a0f-bd35-6d1f4f9b1b0a', + }, }, } as const; @@ -82,12 +87,19 @@ export default class extends Endpoint { // eslint- let params = existing.params; let warnings: MrfLuaPolicyWarning[] = []; if (ps.source !== undefined) { - const metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ - id: existing.id, - name: ps.name ?? existing.name, - source: ps.source, - timeoutMs: ps.timeoutMs ?? existing.timeoutMs, - }); + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ + id: existing.id, + name: ps.name ?? existing.name, + source: ps.source, + timeoutMs: ps.timeoutMs ?? existing.timeoutMs, + }); + } catch (error) { + throw new ApiError(meta.errors.invalidSource, { + reason: error instanceof Error ? error.message : String(error), + }); + } paramsSchema = metadata.paramsSchema; warnings = metadata.warnings; params = this.mrfLuaPolicyService.filterCompatibleParams(paramsSchema, params); diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index c56c5886b1..ae5c0a775e 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -260,4 +260,34 @@ describe('MRF policy admin endpoints', () => { (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_DELETE_BUILTIN_MRF_POLICY', ); }); + + test('rejects creating a policy whose source has a Lua syntax error', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000003', + } as any, new MrfLuaPolicyService()); + + await assert.rejects( + () => endpoint.exec({ + name: 'Broken policy', + source: 'function filter(', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_SOURCE', + ); + }); + + test('rejects creating a policy without a filter function', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000004', + } as any, new MrfLuaPolicyService()); + + await assert.rejects( + () => endpoint.exec({ + name: 'Filterless policy', + source: 'policy = {}', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_SOURCE', + ); + }); }); From 391f79e49e2c47789983b982164bc8c60ef2818e Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:39:03 +0200 Subject: [PATCH 21/28] feat(backend): log MRF policy mutations to the moderation log Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- locales/en-US.yml | 3 ++ .../endpoints/admin/mrf-policies/create.ts | 9 +++- .../endpoints/admin/mrf-policies/delete.ts | 9 +++- .../endpoints/admin/mrf-policies/update.ts | 11 +++- packages/backend/src/types.ts | 16 ++++++ .../unit/server/api/MrfPolicyEndpoints.ts | 53 +++++++++++++++---- 6 files changed, 87 insertions(+), 14 deletions(-) diff --git a/locales/en-US.yml b/locales/en-US.yml index 10207d3a5e..1867609272 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2734,6 +2734,9 @@ _moderationLogTypes: createInvitation: "Invite generated" createAd: "Ad created" deleteAd: "Ad deleted" + createMrfPolicy: "MRF policy created" + updateMrfPolicy: "MRF policy updated" + deleteMrfPolicy: "MRF policy deleted" updateAd: "Ad updated" createAvatarDecoration: "Avatar decoration created" updateAvatarDecoration: "Avatar decoration updated" diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index 34516091bc..bbd2c364a9 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -10,6 +10,7 @@ import { IdService } from '@/core/IdService.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -61,8 +62,9 @@ export default class extends Endpoint { // eslint- private readonly mrfPoliciesRepository: MrfPoliciesRepository, private readonly idService: IdService, private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { let metadata; try { metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ @@ -100,6 +102,11 @@ export default class extends Endpoint { // eslint- params, }); + await this.moderationLogService.log(me, 'createMrfPolicy', { + policyId: policy.id, + policy, + }); + return { id: policy.id, createdAt: policy.createdAt.toISOString(), diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts index 6c9aac2f24..7ecc53c9e5 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -42,14 +43,20 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); if (existing.isBuiltin) throw new ApiError(meta.errors.cannotDeleteBuiltinPolicy); const result = await this.mrfPoliciesRepository.delete(ps.id); if (result.affected === 0) throw new ApiError(meta.errors.noSuchPolicy); + + await this.moderationLogService.log(me, 'deleteMrfPolicy', { + policyId: existing.id, + policy: existing, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index b3165b08c2..744212681f 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -10,6 +10,7 @@ import type { MrfPoliciesRepository } from '@/models/_.js'; import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicyWarning } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -70,8 +71,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly moderationLogService: ModerationLogService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); if (existing.isBuiltin && ( @@ -128,6 +130,13 @@ export default class extends Endpoint { // eslint- await this.mrfPoliciesRepository.update(ps.id, updates as any); const policy = await this.mrfPoliciesRepository.findOneByOrFail({ id: ps.id }); + + await this.moderationLogService.log(me, 'updateMrfPolicy', { + policyId: policy.id, + before: existing, + after: policy, + }); + return { id: policy.id, createdAt: policy.createdAt.toISOString(), diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d62f997ee2..2c209c1de2 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -126,6 +126,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createMrfPolicy', + 'updateMrfPolicy', + 'deleteMrfPolicy', 'createAvatarDecoration', 'updateAvatarDecoration', 'deleteAvatarDecoration', @@ -383,6 +386,19 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createMrfPolicy: { + policyId: string; + policy: any; + }; + updateMrfPolicy: { + policyId: string; + before: any; + after: any; + }; + deleteMrfPolicy: { + policyId: string; + policy: any; + }; createAvatarDecoration: { avatarDecorationId: string; avatarDecoration: any; diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index ae5c0a775e..c0bb966b70 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -11,6 +11,16 @@ import { meta as listMeta } from '@/server/api/endpoints/admin/mrf-policies/list import { ApiError } from '@/server/api/error.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +function createModerationLogService() { + const entries: Array<{ type: string; info: Record }> = []; + return { + entries, + log: async (_moderator: unknown, type: string, info: Record) => { + entries.push({ type, info }); + }, + }; +} + function createPolicy(overrides: Record = {}) { return { id: 'mrftestbuiltin000000000000001', @@ -82,7 +92,7 @@ describe('MRF policy admin endpoints', () => { test('allows disabling built-in policies without modifying their source', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -97,7 +107,7 @@ describe('MRF policy admin endpoints', () => { test('allows updating built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -114,7 +124,7 @@ describe('MRF policy admin endpoints', () => { test('rejects unknown built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -131,7 +141,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000001', - } as any, new MrfLuaPolicyService()); + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); const result = await endpoint.exec({ name: 'Custom policy', @@ -174,7 +184,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000002', - } as any, new MrfLuaPolicyService()); + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); const result = await endpoint.exec({ name: 'Custom policy with globals', @@ -200,7 +210,7 @@ describe('MRF policy admin endpoints', () => { isBuiltin: false, builtinPolicyId: null, })); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -222,7 +232,7 @@ describe('MRF policy admin endpoints', () => { test('rejects source edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -235,7 +245,7 @@ describe('MRF policy admin endpoints', () => { test('rejects scope edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService()); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -251,7 +261,7 @@ describe('MRF policy admin endpoints', () => { test('rejects deleting built-in policies', async () => { const repository = createRepository(); - const endpoint = new DeleteMrfPolicyEndpoint(repository as any); + const endpoint = new DeleteMrfPolicyEndpoint(repository as any, createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -265,7 +275,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000003', - } as any, new MrfLuaPolicyService()); + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -280,7 +290,7 @@ describe('MRF policy admin endpoints', () => { const repository = createRepository(); const endpoint = new CreateMrfPolicyEndpoint(repository as any, { gen: () => 'mrfcustom00000000000000000004', - } as any, new MrfLuaPolicyService()); + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); await assert.rejects( () => endpoint.exec({ @@ -290,4 +300,25 @@ describe('MRF policy admin endpoints', () => { (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_SOURCE', ); }); + + test('writes moderation log entries for create, update, and delete', async () => { + const repository = createRepository(createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + })); + const modlog = createModerationLogService(); + const admin = { id: 'admin0000000000000000000001' } as any; + + const update = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), modlog as any); + await update.exec({ id: repository.current.id, enabled: false }, admin, null); + + const del = new DeleteMrfPolicyEndpoint(repository as any, modlog as any); + await del.exec({ id: repository.current.id }, admin, null); + + assert.deepStrictEqual(modlog.entries.map(entry => entry.type), [ + 'updateMrfPolicy', + 'deleteMrfPolicy', + ]); + assert.equal(modlog.entries[0].info.policyId, repository.current.id); + }); }); From bcd6764f2c9c4c3abb519beb57ae2c798f25c7ae Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:43:15 +0200 Subject: [PATCH 22/28] perf(backend): cache enabled MRF policies for 10s Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../src/queue/processors/MMrfPolicy.ts | 45 +++++++++++-------- .../queue/processors/MMrfPolicyService.ts | 32 ++++++++++++- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index fac7f6e32e..399d49b3c4 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -8,6 +8,7 @@ import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService. import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import type { MrfPolicyScope } from '@/models/MrfPolicy.js'; +import { CacheManagementService, ManagedMemorySingleCache } from '@/global/CacheManagementService.js'; export enum MMrfAction { Neutral, @@ -39,6 +40,8 @@ type ScopedMrfLuaPolicy = MrfLuaPolicy & { @Injectable() export class MMrfPolicyService { + private readonly policiesCache: ManagedMemorySingleCache; + constructor( @Inject(DI.mrfPoliciesRepository) private readonly mrfPoliciesRepository: MrfPoliciesRepository, @@ -52,7 +55,11 @@ export class MMrfPolicyService { private readonly apDbResolverService: ApDbResolverService, private readonly mrfLuaPolicyService: MrfLuaPolicyService, + + cacheManagementService: CacheManagementService, ) { + // 10s TTL: admin policy changes take effect within 10 seconds per worker. + this.policiesCache = cacheManagementService.createMemorySingleCache('mrfPolicies', 1000 * 10); } public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { @@ -121,25 +128,27 @@ export class MMrfPolicyService { } private async getEnabledPolicies(): Promise { - const policies = await this.mrfPoliciesRepository.find({ - where: { - enabled: true, - }, - order: { - priority: 'ASC', - id: 'ASC', - }, + return await this.policiesCache.fetch(async () => { + const policies = await this.mrfPoliciesRepository.find({ + where: { + enabled: true, + }, + order: { + priority: 'ASC', + id: 'ASC', + }, + }); + + return policies.map(policy => ({ + id: policy.id, + name: policy.name, + source: policy.source, + timeoutMs: policy.timeoutMs, + scope: normalizeMrfPolicyScope(policy.scope), + paramsSchema: policy.paramsSchema, + params: policy.params, + })); }); - - return policies.map(policy => ({ - id: policy.id, - name: policy.name, - source: policy.source, - timeoutMs: policy.timeoutMs, - scope: normalizeMrfPolicyScope(policy.scope), - paramsSchema: policy.paramsSchema, - params: policy.params, - })); } private createLookupApi() { diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts index 98110857ec..206cd24df5 100644 --- a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -6,6 +6,7 @@ import * as assert from 'node:assert'; import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { IActivity } from '@/core/activitypub/type.js'; const logger = { @@ -68,10 +69,22 @@ function createPolicyRow(overrides: Record = {}) { }; } -function createService(policyRows: unknown[]) { +function createCacheManagementService() { + return { + createMemorySingleCache: (name: string, lifetime: number) => + new MemorySingleCache(name, { + timeService: { get now() { return Date.now(); } }, + } as any, { lifetime }), + }; +} + +function createService(policyRows: unknown[], counters: { finds: number } = { finds: 0 }) { return new MMrfPolicyService( { - find: async () => policyRows, + find: async () => { + counters.finds++; + return policyRows; + }, } as any, { findOneBy: async () => null, @@ -83,6 +96,7 @@ function createService(policyRows: unknown[]) { getUserFromApId: async () => null, } as any, new MrfLuaPolicyService(), + createCacheManagementService() as any, ); } @@ -187,4 +201,18 @@ describe('MMrfPolicyService', () => { assert.equal(result.action, MMrfAction.RejectNote); assert.match(result.reason ?? '', /caught by second policy/); }); + + test('caches the enabled policy list between runs', async () => { + const counters = { finds: 0 }; + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.accept() end', + }), + ], counters); + + await service.run(keywordActivity, logger as any, runtimeContext); + await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(counters.finds, 1); + }); }); From e2e73a370dc01e29d54b98f27e75d9c9f176df94 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:47:38 +0200 Subject: [PATCH 23/28] fix(backend): correct Lua MRF unlist helper and local-host prefix check Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../migration/1781706597000-MrfPolicy.js | 2 +- .../activitypub/mrf/MrfLuaPolicyService.ts | 46 ++++++++- .../queue/processors/MrfLuaPolicyService.ts | 99 ++++++++++++++++++- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/packages/backend/migration/1781706597000-MrfPolicy.js b/packages/backend/migration/1781706597000-MrfPolicy.js index eff49d2f7d..0bcd251b7a 100644 --- a/packages/backend/migration/1781706597000-MrfPolicy.js +++ b/packages/backend/migration/1781706597000-MrfPolicy.js @@ -101,7 +101,7 @@ export class MrfPolicy1781706597000 { return mrf.accept() end - local local_prefix = "https://" .. ctx.localHost + local local_prefix = "https://" .. ctx.localHost .. "/" local has_local_mention = false for _, mention in ipairs(mentions) do if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index f2e68ffb12..679a455954 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -242,12 +242,48 @@ const SANDBOX_PRELUDE = ` return note end local public = "https://www.w3.org/ns/activitystreams#Public" - local to = note.to - local cc = note.cc - if type(to) == "string" and to == public then - note.to = cc - note.cc = public + local function to_list(value) + if type(value) == "table" then + return value + end + if mrf.is_nil(value) then + return {} + end + return { value } + end + + local found = false + local kept_to = {} + for _, target in ipairs(to_list(note.to)) do + if target == public then + found = true + else + kept_to[#kept_to + 1] = target + end + end + + if not found then + return note + end + + local new_cc = {} + local has_public = false + for _, target in ipairs(to_list(note.cc)) do + if target == public then + has_public = true + end + new_cc[#new_cc + 1] = target + end + if not has_public then + new_cc[#new_cc + 1] = public + end + + if #kept_to > 0 then + note.to = kept_to + else + note.to = nil end + note.cc = new_cc return note end diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index 9dc77c86a2..be30cc8c3c 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -96,7 +96,7 @@ const builtinPolicyFixtures = [ return mrf.accept() end - local local_prefix = "https://" .. ctx.localHost + local local_prefix = "https://" .. ctx.localHost .. "/" local has_local_mention = false for _, mention in ipairs(mentions) do if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then @@ -1265,4 +1265,101 @@ describe('MrfLuaPolicyService', () => { /must define a global filter/, ); }); + + test('unlist moves Public from an array to and into cc', async () => { + const service = createService(); + const result = await service.run({ + id: 'unlist-array', + name: 'Unlist Array Policy', + source: ` + function filter(ctx) + mrf.note.unlist(mrf.activity.note(ctx.activity)) + return mrf.rewrite(ctx.activity, "unlisted") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + to: ['https://www.w3.org/ns/activitystreams#Public', 'https://remote.example/users/alice/followers'], + cc: [], + }, + }, + actor: { uri: 'https://remote.example/users/alice', host: 'remote.example' }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + const note = result.decision.activity.object as { to: unknown; cc: unknown }; + assert.deepStrictEqual(note.to, ['https://remote.example/users/alice/followers']); + assert.deepStrictEqual(note.cc, ['https://www.w3.org/ns/activitystreams#Public']); + } + }); + + test('unlist handles a bare-string Public to field', async () => { + const service = createService(); + const result = await service.run({ + id: 'unlist-string', + name: 'Unlist String Policy', + source: ` + function filter(ctx) + mrf.note.unlist(mrf.activity.note(ctx.activity)) + return mrf.rewrite(ctx.activity, "unlisted") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + to: 'https://www.w3.org/ns/activitystreams#Public', + }, + }, + actor: { uri: 'https://remote.example/users/alice', host: 'remote.example' }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + const note = result.decision.activity.object as Record; + assert.equal('to' in note, false); + assert.deepStrictEqual(note.cc, ['https://www.w3.org/ns/activitystreams#Public']); + } + }); + + test('new-user spam policy ignores lookalike local hosts', async () => { + const service = createService(); + const newUserPolicy = builtinPolicyFixtures.find(policy => policy.id === 'new-user-spam'); + assert.ok(newUserPolicy); + + const result = await service.run(newUserPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + tag: [ + { type: 'Mention', href: 'https://local.example.evil.com/users/alice' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/spam', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'accept'); + }); }); From 81e8fafd943834c1a9037d712d8dce95c1ba8677 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 01:54:05 +0200 Subject: [PATCH 24/28] refactor(backend): tighten MRF endpoint typing and document Lua limits Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- .../src/core/activitypub/mrf/MrfLuaPolicyService.ts | 12 ++++++++++++ .../api/endpoints/admin/mrf-policies/create.ts | 3 ++- .../api/endpoints/admin/mrf-policies/update.ts | 12 +++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts index 679a455954..3e11baa195 100644 --- a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -12,6 +12,11 @@ export type MrfLuaPolicy = { id: string; name: string; source: string; + /** + * Wall-clock budget for each Lua execution. wasmoon checks this across coroutine + * resumes, so time spent awaiting JS promises (mrf.lookup.* database calls) + * counts against it. Policies using lookups need a generous budget. + */ timeoutMs?: number; paramsSchema?: MrfLuaParamsSchema; params?: MrfLuaParams; @@ -888,6 +893,13 @@ ${source} return warnings; } + /** + * NOTE: the persistent-global warnings fingerprint only the policy environment + * (depth <= 3, <= 64 entries per table). Chunk-level `local` upvalues captured by + * filter() persist across pooled runs WITHOUT triggering a warning, as do + * mutations deeper than the fingerprint depth. Treat warnings as a lint, + * not an isolation guarantee. + */ private createRuntimeWarnings(before: Map, after: Map): MrfLuaPolicyWarning[] { const keys = new Set([ ...before.keys(), diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index bbd2c364a9..66c92f39ef 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -40,9 +40,10 @@ export const paramDef = { enabled: { type: 'boolean', default: true }, priority: { type: 'integer', default: 1000 }, source: { type: 'string', minLength: 1 }, - timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50, description: 'Wall-clock budget per execution in milliseconds. Time spent awaiting mrf.lookup.* database calls counts against this budget.' }, scope: { type: 'object', + description: 'Activity/object type filter. objectTypes only matches inline objects; activities whose object is a bare URI string (e.g. Announce, Like, Delete) never match a non-null objectTypes — use objectTypes: null to receive those.', properties: { activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 744212681f..1ad54003b0 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -7,7 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; -import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import type { MiMrfPolicy } from '@/models/MrfPolicy.js'; +import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicyWarning } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -51,9 +52,10 @@ export const paramDef = { enabled: { type: 'boolean' }, priority: { type: 'integer' }, source: { type: 'string', minLength: 1 }, - timeoutMs: { type: 'integer', minimum: 1, maximum: 5000 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, description: 'Wall-clock budget per execution in milliseconds. Time spent awaiting mrf.lookup.* database calls counts against this budget.' }, scope: { type: 'object', + description: 'Activity/object type filter. objectTypes only matches inline objects; activities whose object is a bare URI string (e.g. Announce, Like, Delete) never match a non-null objectTypes — use objectTypes: null to receive those.', properties: { activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, @@ -116,18 +118,18 @@ export default class extends Endpoint { // eslint- } } - const updates = { + const updates: Partial = { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.enabled !== undefined ? { enabled: ps.enabled } : {}), ...(ps.priority !== undefined ? { priority: ps.priority } : {}), ...(ps.source !== undefined ? { source: ps.source } : {}), ...(ps.timeoutMs !== undefined ? { timeoutMs: ps.timeoutMs } : {}), - ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE) } : {}), + ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope) } : {}), ...(ps.source !== undefined ? { paramsSchema } : {}), ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), updatedAt: new Date(), }; - await this.mrfPoliciesRepository.update(ps.id, updates as any); + await this.mrfPoliciesRepository.update(ps.id, updates); const policy = await this.mrfPoliciesRepository.findOneByOrFail({ id: ps.id }); From 14bf0f9116ebd6201e4f62011e9a8d7f64b35cbb Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 02:00:10 +0200 Subject: [PATCH 25/28] feat(backend): add response schemas to MRF policy endpoints Claude-Session: https://claude.ai/code/session_013oBj7TTUguZUGUj9bZSjxD --- packages/backend/src/misc/json-schema.ts | 2 + .../src/models/json-schema/mrf-policy.ts | 47 +++++++++++++++++++ .../endpoints/admin/mrf-policies/create.ts | 5 ++ .../api/endpoints/admin/mrf-policies/list.ts | 8 ++++ .../api/endpoints/admin/mrf-policies/test.ts | 28 +++++++++++ .../endpoints/admin/mrf-policies/update.ts | 5 ++ 6 files changed, 95 insertions(+) create mode 100644 packages/backend/src/models/json-schema/mrf-policy.ts diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 8c9749ba2b..b869949ae4 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -62,6 +62,7 @@ import { packedRoleSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; +import { packedMrfPolicySchema } from '@/models/json-schema/mrf-policy.js'; import { packedReversiGameDetailedSchema, packedReversiGameLiteSchema } from '@/models/json-schema/reversi-game.js'; import { packedMetaDetailedOnlySchema, @@ -146,6 +147,7 @@ export const refs = { ChatRoom: packedChatRoomSchema, ChatRoomInvitation: packedChatRoomInvitationSchema, ChatRoomMembership: packedChatRoomMembershipSchema, + MrfPolicy: packedMrfPolicySchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/json-schema/mrf-policy.ts b/packages/backend/src/models/json-schema/mrf-policy.ts new file mode 100644 index 0000000000..6c6af39116 --- /dev/null +++ b/packages/backend/src/models/json-schema/mrf-policy.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedMrfPolicySchema = { + type: 'object', + properties: { + id: { type: 'string', optional: false, nullable: false, format: 'id' }, + createdAt: { type: 'string', optional: false, nullable: false, format: 'date-time' }, + updatedAt: { type: 'string', optional: false, nullable: false, format: 'date-time' }, + name: { type: 'string', optional: false, nullable: false }, + enabled: { type: 'boolean', optional: false, nullable: false }, + priority: { type: 'number', optional: false, nullable: false }, + source: { type: 'string', optional: false, nullable: false }, + timeoutMs: { type: 'number', optional: false, nullable: false }, + scope: { + type: 'object', + optional: false, nullable: false, + properties: { + activityTypes: { + type: 'array', optional: false, nullable: true, + items: { type: 'string', optional: false, nullable: false }, + }, + objectTypes: { + type: 'array', optional: false, nullable: true, + items: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + isBuiltin: { type: 'boolean', optional: false, nullable: false }, + builtinPolicyId: { type: 'string', optional: false, nullable: true }, + paramsSchema: { type: 'object', optional: false, nullable: false }, + params: { type: 'object', optional: false, nullable: false }, + warnings: { + type: 'array', optional: true, nullable: false, + items: { + type: 'object', optional: false, nullable: false, + properties: { + code: { type: 'string', optional: false, nullable: false }, + key: { type: 'string', optional: false, nullable: false }, + message: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts index 66c92f39ef..e79f30058f 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -31,6 +31,11 @@ export const meta = { id: 'c9727ba3-6c83-46ba-9d34-1cf5d3c0b4c8', }, }, + + res: { + type: 'object', + ref: 'MrfPolicy', + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts index c9b2c04ade..a47caac6bb 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -13,6 +13,14 @@ export const meta = { requireCredential: true, requireAdmin: true, kind: 'write:admin:federation', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'MrfPolicy', + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts index 77fafcea4f..5176b6e35f 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -27,6 +27,34 @@ export const meta = { id: '8e2c9b0d-51f3-4c7a-9c25-6a3f6d0e4b91', }, }, + + res: { + type: 'object', + properties: { + policy: { + type: 'object', optional: false, nullable: false, + properties: { + id: { type: 'string', optional: false, nullable: false }, + name: { type: 'string', optional: false, nullable: false }, + }, + }, + decision: { type: 'object', optional: false, nullable: false }, + durationMs: { type: 'number', optional: false, nullable: false }, + paramsSchema: { type: 'object', optional: false, nullable: false }, + params: { type: 'object', optional: false, nullable: false }, + warnings: { + type: 'array', optional: false, nullable: false, + items: { + type: 'object', optional: false, nullable: false, + properties: { + code: { type: 'string', optional: false, nullable: false }, + key: { type: 'string', optional: false, nullable: false }, + message: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 1ad54003b0..2fbc34c058 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -42,6 +42,11 @@ export const meta = { id: '2f4bd3d7-2c86-4a0f-bd35-6d1f4f9b1b0a', }, }, + + res: { + type: 'object', + ref: 'MrfPolicy', + }, } as const; export const paramDef = { From d7bde53aca5c3fc72c1a3d7d4968b57536b66d88 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Fri, 3 Jul 2026 02:19:41 +0200 Subject: [PATCH 26/28] fix(backend): satisfy typecheck and lint in MRF policy endpoints --- .../api/endpoints/admin/mrf-policies/test.ts | 4 +++- .../api/endpoints/admin/mrf-policies/update.ts | 13 +++++++++---- .../queue/processors/MrfLuaPolicyService.ts | 2 +- .../test/unit/server/api/MrfPolicyEndpoints.ts | 18 +++++++++++------- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts index 5176b6e35f..cc643df682 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { IActivity } from '@/core/activitypub/type.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { TimeService } from '@/global/TimeService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -75,6 +76,7 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly timeService: TimeService, ) { super(meta, paramDef, async (ps) => { const policy = { @@ -116,7 +118,7 @@ export default class extends Endpoint { // eslint- }, localHost: ps.localHost, signerHost: ps.signerHost, - receivedAt: new Date().toISOString(), + receivedAt: this.timeService.date.toISOString(), }); } catch (error) { throw new ApiError(meta.errors.luaFailed, { diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts index 2fbc34c058..da88278080 100644 --- a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -7,11 +7,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import type { MrfPoliciesRepository } from '@/models/_.js'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import type { MiMrfPolicy } from '@/models/MrfPolicy.js'; import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import type { MrfLuaPolicyWarning } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { TimeService } from '@/global/TimeService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -79,6 +81,7 @@ export default class extends Endpoint { // eslint- private readonly mrfPoliciesRepository: MrfPoliciesRepository, private readonly mrfLuaPolicyService: MrfLuaPolicyService, private readonly moderationLogService: ModerationLogService, + private readonly timeService: TimeService, ) { super(meta, paramDef, async (ps, me) => { const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); @@ -123,7 +126,7 @@ export default class extends Endpoint { // eslint- } } - const updates: Partial = { + const updates = { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.enabled !== undefined ? { enabled: ps.enabled } : {}), ...(ps.priority !== undefined ? { priority: ps.priority } : {}), @@ -132,9 +135,11 @@ export default class extends Endpoint { // eslint- ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope) } : {}), ...(ps.source !== undefined ? { paramsSchema } : {}), ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), - updatedAt: new Date(), - }; - await this.mrfPoliciesRepository.update(ps.id, updates); + updatedAt: this.timeService.date, + } satisfies Partial; + // Cast at the TypeORM boundary only: QueryDeepPartialEntity's mapped type + // cannot absorb Record jsonb columns (params/paramsSchema). + await this.mrfPoliciesRepository.update(ps.id, updates as QueryDeepPartialEntity); const policy = await this.mrfPoliciesRepository.findOneByOrFail({ id: ps.id }); diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts index be30cc8c3c..23ee686326 100644 --- a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -1327,7 +1327,7 @@ describe('MrfLuaPolicyService', () => { assert.equal(result.decision.action, 'rewrite'); if (result.decision.action === 'rewrite') { - const note = result.decision.activity.object as Record; + const note = result.decision.activity.object as unknown as Record; assert.equal('to' in note, false); assert.deepStrictEqual(note.cc, ['https://www.w3.org/ns/activitystreams#Public']); } diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts index c0bb966b70..b899cd0f1e 100644 --- a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -21,6 +21,10 @@ function createModerationLogService() { }; } +function createTimeService() { + return { get date() { return new Date(); } }; +} + function createPolicy(overrides: Record = {}) { return { id: 'mrftestbuiltin000000000000001', @@ -92,7 +96,7 @@ describe('MRF policy admin endpoints', () => { test('allows disabling built-in policies without modifying their source', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -107,7 +111,7 @@ describe('MRF policy admin endpoints', () => { test('allows updating built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -124,7 +128,7 @@ describe('MRF policy admin endpoints', () => { test('rejects unknown built-in policy params', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); await assert.rejects( () => endpoint.exec({ @@ -210,7 +214,7 @@ describe('MRF policy admin endpoints', () => { isBuiltin: false, builtinPolicyId: null, })); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); const result = await endpoint.exec({ id: repository.current.id, @@ -232,7 +236,7 @@ describe('MRF policy admin endpoints', () => { test('rejects source edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); await assert.rejects( () => endpoint.exec({ @@ -245,7 +249,7 @@ describe('MRF policy admin endpoints', () => { test('rejects scope edits for built-in policies', async () => { const repository = createRepository(); - const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); await assert.rejects( () => endpoint.exec({ @@ -309,7 +313,7 @@ describe('MRF policy admin endpoints', () => { const modlog = createModerationLogService(); const admin = { id: 'admin0000000000000000000001' } as any; - const update = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), modlog as any); + const update = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), modlog as any, createTimeService() as any); await update.exec({ id: repository.current.id, enabled: false }, admin, null); const del = new DeleteMrfPolicyEndpoint(repository as any, modlog as any); From d2f511aee746fd45247e387b58e65d29b43342a7 Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 12 Aug 2026 17:30:26 +0200 Subject: [PATCH 27/28] Add emoji suggestion backend workflow --- .../1786507200000-EmojiSuggestion.js | 34 +++ packages/backend/src/core/CoreModule.ts | 12 + .../src/core/EmojiSuggestionService.ts | 231 +++++++++++++++++ .../entities/EmojiSuggestionEntityService.ts | 55 ++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/misc/json-schema.ts | 2 + .../backend/src/models/EmojiSuggestion.ts | 70 +++++ .../backend/src/models/RepositoryModule.ts | 8 + packages/backend/src/models/_.ts | 3 + .../models/json-schema/emoji-suggestion.ts | 68 +++++ packages/backend/src/postgres.ts | 2 + .../src/server/api/emoji-suggestion.ts | 83 ++++++ .../backend/src/server/api/endpoint-list.ts | 6 + .../admin/emoji-suggestions/accept.ts | 46 ++++ .../endpoints/admin/emoji-suggestions/list.ts | 45 ++++ .../admin/emoji-suggestions/reject.ts | 37 +++ .../api/endpoints/emoji-suggestions/cancel.ts | 36 +++ .../api/endpoints/emoji-suggestions/create.ts | 51 ++++ .../api/endpoints/emoji-suggestions/list.ts | 45 ++++ .../test/unit/EmojiSuggestionService.ts | 239 ++++++++++++++++++ 20 files changed, 1074 insertions(+) create mode 100644 packages/backend/migration/1786507200000-EmojiSuggestion.js create mode 100644 packages/backend/src/core/EmojiSuggestionService.ts create mode 100644 packages/backend/src/core/entities/EmojiSuggestionEntityService.ts create mode 100644 packages/backend/src/models/EmojiSuggestion.ts create mode 100644 packages/backend/src/models/json-schema/emoji-suggestion.ts create mode 100644 packages/backend/src/server/api/emoji-suggestion.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts create mode 100644 packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts create mode 100644 packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts create mode 100644 packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts create mode 100644 packages/backend/test/unit/EmojiSuggestionService.ts diff --git a/packages/backend/migration/1786507200000-EmojiSuggestion.js b/packages/backend/migration/1786507200000-EmojiSuggestion.js new file mode 100644 index 0000000000..0e6dd4fa98 --- /dev/null +++ b/packages/backend/migration/1786507200000-EmojiSuggestion.js @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class EmojiSuggestion1786507200000 { + name = 'EmojiSuggestion1786507200000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "emoji_suggestion" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "fileId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "category" character varying(128), "aliases" character varying(128) array NOT NULL DEFAULT '{}', "license" character varying(1024), "localOnly" boolean NOT NULL DEFAULT false, "isSensitive" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_emoji_suggestion" PRIMARY KEY ("id"), CONSTRAINT "FK_emoji_suggestion_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_emoji_suggestion_file" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`CREATE INDEX "IDX_emoji_suggestion_user" ON "emoji_suggestion" ("userId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_file" ON "emoji_suggestion" ("fileId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_name" ON "emoji_suggestion" ("name")`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "emoji_suggestion"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 519b1a13c8..92276d822b 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -33,6 +33,7 @@ import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; +import { EmojiSuggestionService } from './EmojiSuggestionService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; import { DownloadService } from './DownloadService.js'; import { DriveService } from './DriveService.js'; @@ -115,6 +116,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { EmojiSuggestionEntityService } from './entities/EmojiSuggestionEntityService.js'; import { FollowingEntityService } from './entities/FollowingEntityService.js'; import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; @@ -176,6 +178,7 @@ const $AchievementService: Provider = { provide: 'AchievementService', useExisti const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; +const $EmojiSuggestionService: Provider = { provide: 'EmojiSuggestionService', useExisting: EmojiSuggestionService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; @@ -270,6 +273,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $EmojiSuggestionEntityService: Provider = { provide: 'EmojiSuggestionEntityService', useExisting: EmojiSuggestionEntityService }; const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; @@ -344,6 +348,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -435,6 +440,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -499,6 +505,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -593,6 +600,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -660,6 +668,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -750,6 +759,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -814,6 +824,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -906,6 +917,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, diff --git a/packages/backend/src/core/EmojiSuggestionService.ts b/packages/backend/src/core/EmojiSuggestionService.ts new file mode 100644 index 0000000000..dd9d566090 --- /dev/null +++ b/packages/backend/src/core/EmojiSuggestionService.ts @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + DriveFilesRepository, + EmojisRepository, + EmojiSuggestionsRepository, + MiDriveFile, + MiEmoji, + MiEmojiSuggestion, + MiUser, +} from '@/models/_.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; + +export const MAX_PENDING_EMOJI_SUGGESTIONS = 20; + +export type EmojiSuggestionError = + | 'duplicateName' + | 'duplicateSuggestion' + | 'noSuchFile' + | 'noSuchSuggestion' + | 'tooManyPendingSuggestions' + | 'unsupportedFileType'; + +export type EmojiSuggestionResult = + | { ok: true, value: T } + | { ok: false, reason: EmojiSuggestionError }; + +export type CreateEmojiSuggestionOptions = { + name: string; + fileId: string; + category: string | null; + aliases: string[]; + license: string | null; + localOnly: boolean; + isSensitive: boolean; +}; + +@Injectable() +export class EmojiSuggestionService { + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private readonly emojisRepository: EmojisRepository, + + private readonly customEmojiService: CustomEmojiService, + private readonly driveService: DriveService, + private readonly idService: IdService, + ) { + } + + @bindThis + public async create( + options: CreateEmojiSuggestionOptions, + user: MiUser, + ): Promise> { + const name = options.name.normalize('NFC'); + const file = await this.driveFilesRepository.findOneBy({ + id: options.fileId, + userId: user.id, + }); + if (file == null) return { ok: false, reason: 'noSuchFile' }; + if (!FILE_TYPE_IMAGE.includes(file.type)) return { ok: false, reason: 'unsupportedFileType' }; + if (await this.customEmojiService.checkDuplicate(name)) return { ok: false, reason: 'duplicateName' }; + + const [pendingCount, duplicateSuggestion] = await Promise.all([ + this.emojiSuggestionsRepository.countBy({ userId: user.id }), + this.emojiSuggestionsRepository.exists({ + where: [ + { userId: user.id, name }, + { fileId: file.id }, + ], + }), + ]); + if (pendingCount >= MAX_PENDING_EMOJI_SUGGESTIONS) return { ok: false, reason: 'tooManyPendingSuggestions' }; + if (duplicateSuggestion) return { ok: false, reason: 'duplicateSuggestion' }; + + let suggestion: MiEmojiSuggestion; + try { + suggestion = await this.emojiSuggestionsRepository.insertOne({ + id: this.idService.gen(), + userId: user.id, + fileId: file.id, + name, + category: options.category?.normalize('NFC') ?? null, + aliases: options.aliases.map(alias => alias.normalize('NFC')), + license: options.license, + localOnly: options.localOnly, + isSensitive: options.isSensitive, + }, { + relations: { + file: true, + user: true, + }, + }); + } catch (error) { + // The preflight check gives a useful early response, while the unique + // constraints close the race between simultaneous submissions. + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateSuggestion' }; + throw error; + } + + return { ok: true, value: suggestion }; + } + + @bindThis + public async accept( + suggestionId: string, + moderator: MiUser, + ): Promise> { + const suggestion = await this.emojiSuggestionsRepository.findOne({ + where: { id: suggestionId }, + relations: { file: true }, + }); + if (suggestion == null) return { ok: false, reason: 'noSuchSuggestion' }; + + // Consume the suggestion before doing any work. This makes acceptance, + // cancellation, rejection, and another acceptance mutually exclusive. + const claimed = await this.emojiSuggestionsRepository.delete({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + }); + if (claimed.affected !== 1) return { ok: false, reason: 'noSuchSuggestion' }; + + const restoreSuggestion = async () => { + await this.emojiSuggestionsRepository.insert({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }; + + let isDuplicate: boolean; + try { + isDuplicate = await this.customEmojiService.checkDuplicate(suggestion.name); + } catch (error) { + await restoreSuggestion(); + throw error; + } + if (isDuplicate) { + await restoreSuggestion(); + return { ok: false, reason: 'duplicateName' }; + } + + let emojiFile: MiDriveFile | undefined; + try { + // A suggestion may reference an avatar, banner, page image, or other + // shared Drive row. Give the emoji its own system-owned copy so its + // lifecycle cannot mutate or delete the proposer's original file. + emojiFile = await this.driveService.uploadFromUrl({ + url: suggestion.file.url, + user: null, + force: true, + }); + + const emoji = await this.customEmojiService.createEmoji({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl ?? emojiFile.url, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + + return { ok: true, value: emoji }; + } catch (error) { + if (emojiFile != null) { + // createEmoji inserts before publishing and moderation logging. If a + // post-insert hook failed, the durable result is still an acceptance. + const insertedEmoji = await this.emojisRepository.findOneBy({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + if (insertedEmoji != null) return { ok: true, value: insertedEmoji }; + + try { + await this.driveService.deleteFile(emojiFile, false, moderator); + } finally { + await restoreSuggestion(); + } + } else { + await restoreSuggestion(); + } + + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateName' }; + throw error; + } + } + + @bindThis + public async cancel(suggestionId: string, user: MiUser): Promise { + const result = await this.emojiSuggestionsRepository.delete({ + id: suggestionId, + userId: user.id, + }); + return result.affected === 1; + } + + @bindThis + public async reject(suggestionId: string): Promise { + const result = await this.emojiSuggestionsRepository.delete(suggestionId); + return result.affected === 1; + } +} diff --git a/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts new file mode 100644 index 0000000000..cec74c766c --- /dev/null +++ b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import type { MiEmojiSuggestion, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class EmojiSuggestionEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + + constructor( + private readonly moduleRef: ModuleRef, + private readonly idService: IdService, + ) { + } + + @bindThis + public onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + } + + @bindThis + public async pack( + suggestion: MiEmojiSuggestion, + me: MiUser, + ): Promise> { + return { + id: suggestion.id, + createdAt: this.idService.parse(suggestion.id).date.toISOString(), + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + url: suggestion.file.webpublicUrl ?? suggestion.file.url, + user: await this.userEntityService.pack(suggestion.user, me, { schema: 'UserLite' }), + }; + } + + @bindThis + public async packMany( + suggestions: MiEmojiSuggestion[], + me: MiUser, + ): Promise[]> { + return await Promise.all(suggestions.map(suggestion => this.pack(suggestion, me))); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 11f9d8d1d1..d474830796 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -55,6 +55,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + emojiSuggestionsRepository: Symbol('emojiSuggestionsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index b869949ae4..92ede82004 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -45,6 +45,7 @@ import { packedEmojiDetailedSchema, packedEmojiSimpleSchema, } from '@/models/json-schema/emoji.js'; +import { packedEmojiSuggestionSchema } from '@/models/json-schema/emoji-suggestion.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -120,6 +121,7 @@ export const refs = { EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, + EmojiSuggestion: packedEmojiSuggestionSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, diff --git a/packages/backend/src/models/EmojiSuggestion.ts b/packages/backend/src/models/EmojiSuggestion.ts new file mode 100644 index 0000000000..ab67a4cbae --- /dev/null +++ b/packages/backend/src/models/EmojiSuggestion.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { MiUser } from '@/models/User.js'; +import { id } from './util/id.js'; + +@Entity('emoji_suggestion') +export class MiEmojiSuggestion { + @PrimaryColumn(id()) + public id: string; + + @Index('IDX_emoji_suggestion_user') + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser; + + @Index('IDX_emoji_suggestion_file', { unique: true }) + @Column(id()) + public fileId: MiDriveFile['id']; + + @ManyToOne(() => MiDriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: MiDriveFile; + + @Index('IDX_emoji_suggestion_name', { unique: true }) + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public category: string | null; + + @Column('varchar', { + array: true, + length: 128, + default: '{}', + }) + public aliases: string[]; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public license: string | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 44a70c58c5..5eae64b09d 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -27,6 +27,7 @@ import { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFlash, MiFlashLike, MiFollowing, @@ -272,6 +273,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $emojiSuggestionsRepository: Provider = { + provide: DI.emojiSuggestionsRepository, + useFactory: (db: DataSource) => db.getRepository(MiEmojiSuggestion).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(MiDriveFile).extend(miRepository as MiRepository), @@ -610,6 +617,7 @@ export const repositoryProviders: Provider[] = [ $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiSuggestionsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 61984b1cc5..ce75ad1f8e 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -29,6 +29,7 @@ import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; @@ -171,6 +172,7 @@ export { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -252,6 +254,7 @@ export type ClipFavoritesRepository = Repository & MiRepository< export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; +export type EmojiSuggestionsRepository = Repository & MiRepository; export type FollowingsRepository = Repository & MiRepository; export type FollowRequestsRepository = Repository & MiRepository; export type GalleryLikesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/emoji-suggestion.ts b/packages/backend/src/models/json-schema/emoji-suggestion.ts new file mode 100644 index 0000000000..e7368f0fbd --- /dev/null +++ b/packages/backend/src/models/json-schema/emoji-suggestion.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedEmojiSuggestionSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, + nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, + nullable: false, + }, + name: { + type: 'string', + optional: false, + nullable: false, + }, + category: { + type: 'string', + optional: false, + nullable: true, + }, + aliases: { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'string', + optional: false, + nullable: false, + }, + }, + license: { + type: 'string', + optional: false, + nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, + nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, + nullable: false, + }, + url: { + type: 'string', + optional: false, + nullable: false, + }, + user: { + type: 'object', + optional: false, + nullable: false, + ref: 'UserLite', + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 84834eba72..037e5ca908 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -33,6 +33,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -262,6 +263,7 @@ export const entities = [ MiPoll, MiPollVote, MiEmoji, + MiEmojiSuggestion, MiHashtag, MiSwSubscription, MiSystemAccount, diff --git a/packages/backend/src/server/api/emoji-suggestion.ts b/packages/backend/src/server/api/emoji-suggestion.ts new file mode 100644 index 0000000000..6c0a4cb4eb --- /dev/null +++ b/packages/backend/src/server/api/emoji-suggestion.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { EmojiSuggestionError } from '@/core/EmojiSuggestionService.js'; + +export const emojiSuggestionErrors = { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'd7c980e6-221b-4b60-a50e-a6f77ac8f3f9', + }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: '63a9ff92-f992-4bc9-9d43-9fe1ea0ea3ec', + }, + duplicateName: { + message: 'An emoji with this name already exists.', + code: 'DUPLICATE_NAME', + id: '90a1f02e-45f9-4d19-a43e-a0e634b094ea', + }, + duplicateSuggestion: { + message: 'A matching emoji suggestion is already pending.', + code: 'DUPLICATE_SUGGESTION', + id: '7bdf044b-3753-484b-8d51-acd0ea2df264', + }, + tooManyPendingSuggestions: { + message: 'You have too many pending emoji suggestions.', + code: 'TOO_MANY_PENDING_SUGGESTIONS', + id: '16049091-cb93-45e6-a843-b9d9171bc6e5', + }, + noSuchSuggestion: { + message: 'No such emoji suggestion.', + code: 'NO_SUCH_EMOJI_SUGGESTION', + id: 'd023907b-288c-4d29-9e63-743b6ccc8320', + }, +} as const satisfies Record; + +export const emojiSuggestionParamDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 128, pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, + category: { type: 'string', nullable: true, maxLength: 128 }, + aliases: { + type: 'array', + maxItems: 100, + items: { type: 'string', maxLength: 128 }, + }, + license: { type: 'string', nullable: true, maxLength: 1024 }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + }, + required: ['name', 'fileId'], +} as const; + +export const emojiSuggestionListParamDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +export const emojiSuggestionListResponse = { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'object', + optional: false, + nullable: false, + ref: 'EmojiSuggestion', + }, +} as const; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 10b5648523..f9150b9c68 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -57,6 +57,9 @@ export * as 'admin/emoji/set-aliases-bulk' from './endpoints/admin/emoji/set-ali export * as 'admin/emoji/set-category-bulk' from './endpoints/admin/emoji/set-category-bulk.js'; export * as 'admin/emoji/set-license-bulk' from './endpoints/admin/emoji/set-license-bulk.js'; export * as 'admin/emoji/update' from './endpoints/admin/emoji/update.js'; +export * as 'admin/emoji-suggestions/accept' from './endpoints/admin/emoji-suggestions/accept.js'; +export * as 'admin/emoji-suggestions/list' from './endpoints/admin/emoji-suggestions/list.js'; +export * as 'admin/emoji-suggestions/reject' from './endpoints/admin/emoji-suggestions/reject.js'; export * as 'admin/federation/delete-all-files' from './endpoints/admin/federation/delete-all-files.js'; export * as 'admin/federation/refresh-remote-instance-metadata' from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; export * as 'admin/federation/remove-all-following' from './endpoints/admin/federation/remove-all-following.js'; @@ -465,4 +468,7 @@ export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; export * as 'admin/antennas/global' from './endpoints/admin/antennas/global.js'; export * as 'drive/files/generate-alt-text' from './endpoints/drive/files/generate-alt.js'; +export * as 'emoji-suggestions/cancel' from './endpoints/emoji-suggestions/cancel.js'; +export * as 'emoji-suggestions/create' from './endpoints/emoji-suggestions/create.js'; +export * as 'emoji-suggestions/list' from './endpoints/emoji-suggestions/list.js'; export * as 'top-posts' from './endpoints/top-posts.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts new file mode 100644 index 0000000000..e7a6da6e84 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.accept(ps.suggestionId, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiEntityService.packDetailed(result.value); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts new file mode 100644 index 0000000000..bd7011dc41 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'read:admin:emoji', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user'); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts new file mode 100644 index 0000000000..895d487c9f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps) => { + if (!await this.emojiSuggestionService.reject(ps.suggestionId)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts new file mode 100644 index 0000000000..2ab87b2e18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps, me) => { + if (!await this.emojiSuggestionService.cancel(ps.suggestionId, me)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts new file mode 100644 index 0000000000..02b69ee9d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors, emojiSuggestionParamDef } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + limit: { + duration: 1000 * 60, + max: 10, + }, + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiSuggestion', + }, +} as const; + +export const paramDef = emojiSuggestionParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.create({ + name: ps.name, + fileId: ps.fileId, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiSuggestionEntityService.pack(result.value, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts new file mode 100644 index 0000000000..4e43f955a8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'read:drive', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user') + .andWhere('suggestion.userId = :userId', { userId: me.id }); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/test/unit/EmojiSuggestionService.ts b/packages/backend/test/unit/EmojiSuggestionService.ts new file mode 100644 index 0000000000..cff311be70 --- /dev/null +++ b/packages/backend/test/unit/EmojiSuggestionService.ts @@ -0,0 +1,239 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { IsNull, QueryFailedError } from 'typeorm'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; + +describe('EmojiSuggestionService', () => { + const user = { id: '9abc000001', username: 'proposer', host: null }; + const moderator = { id: '9abc000002', username: 'moderator', host: null }; + const file = { + id: '9abc000003', + userId: user.id, + type: 'image/png', + url: 'https://example.test/original.png', + webpublicUrl: 'https://example.test/public.webp', + }; + const emojiFile = { + ...file, + id: '9abc000006', + userId: null, + url: 'https://example.test/emoji-original.png', + webpublicUrl: 'https://example.test/emoji-public.webp', + }; + const suggestion = { + id: '9abc000004', + userId: user.id, + fileId: file.id, + file, + name: 'party_blob', + category: 'Blobs', + aliases: ['party'], + license: 'CC0', + localOnly: false, + isSensitive: false, + }; + + function createService(overrides?: { + suggestions?: Record; + drive?: Record; + emojis?: Record; + customEmoji?: Record; + driveService?: Record; + }) { + const suggestions = { + countBy: jest.fn(async () => 0), + exists: jest.fn(async () => false), + insertOne: jest.fn(async () => ({ ...suggestion, user })), + insert: jest.fn(async () => ({ identifiers: [{ id: suggestion.id }] })), + findOne: jest.fn(async () => suggestion), + delete: jest.fn(async () => ({ affected: 1 })), + ...overrides?.suggestions, + }; + const drive = { + findOneBy: jest.fn(async () => file), + ...overrides?.drive, + }; + const emoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const emojis = { + findOneBy: jest.fn(async () => null), + ...overrides?.emojis, + }; + const customEmoji = { + checkDuplicate: jest.fn(async () => false), + createEmoji: jest.fn(async () => emoji), + ...overrides?.customEmoji, + }; + const driveService = { + uploadFromUrl: jest.fn(async () => emojiFile), + deleteFile: jest.fn(async () => undefined), + ...overrides?.driveService, + }; + const service = new EmojiSuggestionService( + suggestions as any, + drive as any, + emojis as any, + customEmoji as any, + driveService as any, + { gen: jest.fn(() => suggestion.id) } as any, + ); + + return { service, suggestions, drive, emojis, customEmoji, driveService, emoji }; + } + + test('submission only accepts an image owned by the proposer', async () => { + const { service, suggestions, drive } = createService({ + drive: { findOneBy: jest.fn(async () => null) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'noSuchFile' }); + + expect(drive.findOneBy).toHaveBeenCalledWith({ id: file.id, userId: user.id }); + expect(suggestions.insertOne).not.toHaveBeenCalled(); + }); + + test('a simultaneous duplicate submission returns a domain error', async () => { + const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); + const duplicateError = new QueryFailedError('', [], driverError); + const { service } = createService({ + suggestions: { insertOne: jest.fn(async () => { throw duplicateError; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); + }); + + test('acceptance consumes the suggestion and creates the emoji from a dedicated file', async () => { + const { service, suggestions, drive, customEmoji, driveService, emoji } = createService(); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: emoji }); + + expect(suggestions.delete).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + }); + expect(driveService.uploadFromUrl).toHaveBeenCalledWith({ + url: file.url, + user: null, + force: true, + }); + expect(customEmoji.createEmoji).toHaveBeenCalledWith({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + expect(drive.findOneBy).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('a lost suggestion claim cannot create a second emoji', async () => { + const { service, customEmoji, suggestions, driveService } = createService({ + suggestions: { delete: jest.fn(async () => ({ affected: 0 })) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'noSuchSuggestion' }); + expect(customEmoji.createEmoji).not.toHaveBeenCalled(); + expect(driveService.uploadFromUrl).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('cancellation cannot succeed after acceptance has consumed the suggestion', async () => { + let pending = true; + let releaseCopy!: () => void; + let markCopyStarted!: () => void; + const copyStarted = new Promise(resolve => { + markCopyStarted = resolve; + }); + const copying = new Promise(resolve => { + releaseCopy = () => resolve(emojiFile); + }); + const { service } = createService({ + suggestions: { + delete: jest.fn(async () => { + if (!pending) return { affected: 0 }; + pending = false; + return { affected: 1 }; + }), + }, + driveService: { + uploadFromUrl: jest.fn(async () => { + markCopyStarted(); + return await copying; + }), + }, + }); + + const accepting = service.accept(suggestion.id, moderator as any); + await copyStarted; + await expect(service.cancel(suggestion.id, user as any)).resolves.toBe(false); + releaseCopy(); + await expect(accepting).resolves.toEqual({ ok: true, value: expect.objectContaining({ id: '9abc000005' }) }); + }); + + test('a failed emoji creation deletes its copy and restores the suggestion', async () => { + const failure = new Error('creation failed'); + const { service, suggestions, driveService } = createService({ + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).rejects.toBe(failure); + expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); + expect(suggestions.insert).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }); + + test('a post-insert hook failure is treated as an accepted suggestion', async () => { + const failure = new Error('broadcast failed'); + const insertedEmoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const { service, suggestions, emojis, driveService } = createService({ + emojis: { findOneBy: jest.fn(async () => insertedEmoji) }, + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: insertedEmoji }); + expect(emojis.findOneBy).toHaveBeenCalledWith({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + expect(driveService.deleteFile).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); +}); From d514a6d52bf9c89a71eb87d1cd0ffa8acf06e87e Mon Sep 17 00:00:00 2001 From: PrivateGER Date: Wed, 12 Aug 2026 17:30:58 +0200 Subject: [PATCH 28/28] Add emoji suggestion review interface --- locales/index.d.ts | 32 +++ packages/frontend/src/pages/about.emojis.vue | 5 +- .../frontend/src/pages/emoji-edit-dialog.vue | 34 ++- .../frontend/src/pages/emoji-suggestions.vue | 207 ++++++++++++++++++ packages/frontend/src/router.definition.ts | 4 + packages/frontend/src/ui/_common_/common.ts | 5 + packages/misskey-js/src/api.types.ts | 50 ++++- packages/misskey-js/src/entities.ts | 14 ++ sharkey-locales/en-US.yml | 8 + 9 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 packages/frontend/src/pages/emoji-suggestions.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index 0a9d09cfe3..fba8794289 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12072,6 +12072,38 @@ export interface Locale extends ILocale { * Approvals */ "approvals": string; + /** + * Emoji suggestions + */ + "emojiSuggestions": string; + /** + * Suggest an emoji + */ + "suggestEmoji": string; + /** + * Propose a custom emoji for this server. Your image remains in your Drive while moderators review it. + */ + "emojiSuggestionDescription": string; + /** + * There are no pending emoji suggestions. + */ + "emojiSuggestionNoPending": string; + /** + * Proposed by + */ + "emojiSuggestionProposedBy": string; + /** + * Accept :{name}: and add it to this server? + */ + "emojiSuggestionConfirmAccept": ParameterizedString<"name">; + /** + * Reject the suggestion for :{name}:? The image will remain in the proposer's Drive. + */ + "emojiSuggestionConfirmReject": ParameterizedString<"name">; + /** + * Cancel your suggestion for :{name}:? The image will remain in your Drive. + */ + "emojiSuggestionConfirmCancel": ParameterizedString<"name">; /** * Open remote profile */ diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index b166dfd940..5693fd6c17 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only