diff --git a/src/core/config.ts b/src/core/config.ts index 4722a9d..5f21083 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -65,6 +65,67 @@ function tryYaml(text: string): { ok: true; value: unknown } | { ok: false; erro } } +/** + * Normalize an upstream-ContextMod-shaped config toward our internal shape + * BEFORE AJV validation. ContextMod's Schema/App.json names a few check fields + * differently or nests them where ours does not: + * - `condition` (AND/OR) is our `combinator`. + * - check-level `itemIs` / `authorIs` are our nested `filters.{itemIs,authorIs}`. + * A missing combinator defaults to AND (upstream marks it required, but a check + * is unambiguous without it). `enable`, `description`, and `kind` are accepted + * natively by the schema and honored in runCheck, so they pass through. Mutates + * `raw` in place. Genuinely unsupported fields are LEFT for AJV to reject with a + * clear "additional property" error rather than silently dropped — a moderator + * must know when a field is being ignored. 2026-06-09, SampleOfNone feedback. + */ +function normalizeUpstreamShape(raw: Record): void { + const runs = raw.runs; + if (!Array.isArray(runs)) return; + for (const run of runs) { + if (!run || typeof run !== 'object') continue; + const checks = (run as { checks?: unknown }).checks; + if (!Array.isArray(checks)) continue; + for (const check of checks) { + if (!check || typeof check !== 'object') continue; + const c = check as Record; + + // upstream `condition` (AND/OR) -> our `combinator`. A recognized value is + // consumed (mapped + alias removed). An UNRECOGNIZED `condition` is LEFT in + // place so AJV rejects it with a clear "additional property" error rather + // than silently coercing the check to AND — a silent semantic flip (OR->AND) + // would change moderation behavior without telling the operator. + if (c.combinator != null) { + // native shape (or both supplied) — combinator wins; drop any alias + delete c.condition; + } else if (typeof c.condition === 'string') { + const cond = c.condition.toUpperCase(); + if (cond === 'AND' || cond === 'OR' || cond === 'NOT') { + c.combinator = cond; + delete c.condition; // consumed + } + // unrecognized string: leave c.condition -> AJV surfaces the error + } else if (c.condition === undefined) { + // neither combinator nor condition supplied -> unambiguous default + c.combinator = 'AND'; + } + // (condition present but non-string -> left in place -> AJV rejects) + + // upstream check-level itemIs/authorIs -> our nested filters.{itemIs,authorIs} + if (c.itemIs != null || c.authorIs != null) { + const filters = + c.filters != null && typeof c.filters === 'object' + ? (c.filters as Record) + : {}; + if (c.itemIs != null && filters.itemIs == null) filters.itemIs = c.itemIs; + if (c.authorIs != null && filters.authorIs == null) filters.authorIs = c.authorIs; + c.filters = filters; + delete c.itemIs; + delete c.authorIs; + } + } + } +} + export function parseConfig(text: string): ParseResult { const detected = sniffFormat(text); // Try detected format first. On failure, try the other so leading-comment @@ -116,6 +177,10 @@ export function parseConfig(text: string): ParseResult { }; } + // Map upstream ContextMod check shape onto ours before validating, so real + // ContextMod configs validate instead of tripping additionalProperties. + normalizeUpstreamShape(raw as Record); + if (!validate(raw)) { return { ok: false, errors: validate.errors ?? [] }; } diff --git a/src/core/runCheck.ts b/src/core/runCheck.ts index 058ecdd..be50484 100644 --- a/src/core/runCheck.ts +++ b/src/core/runCheck.ts @@ -15,6 +15,7 @@ */ import type { Check, CheckResult, Item, Author } from '../shared/types'; +import { isPostId, isCommentId } from '../shared/types'; import { passesFilters } from './filters'; import { runRule } from './runRule'; import { isRuleMuted } from '../state/muteSet'; @@ -26,6 +27,18 @@ export async function runCheck( sub?: string, runName?: string ): Promise { + // Upstream ContextMod compat (2026-06-09): a disabled check never runs. + if (check.enable === false) { + return { triggered: false, checkName: check.name, actions: [] }; + } + // Upstream ContextMod compat: `kind` scopes a check to one item type. A + // submission-only check skips comments and vice versa; absent = both. + if ( + (check.kind === 'submission' && !isPostId(item.id)) || + (check.kind === 'comment' && !isCommentId(item.id)) + ) { + return { triggered: false, checkName: check.name, actions: [] }; + } // AE CRITICAL #4: hard-mute short-circuit. Skip if (sub, runName) absent // (e.g. dry-run sibling path may not thread them) so we don't break the // existing call site contract. diff --git a/src/routes/menu.ts b/src/routes/menu.ts index fd5026d..4ea3765 100644 --- a/src/routes/menu.ts +++ b/src/routes/menu.ts @@ -81,6 +81,24 @@ menu.post('/reload-config', async (c) => { } }); +// Splash cover shown on the Observatory post before the webview launches. +// NB: SubmitCustomPostSplashOptions is @deprecated in @devvit 0.12.24 +// ("implement splash as an HTML inline entrypoint; support removed soon"), +// but it is the only splash-customization API in this version and is still +// functional. Migrate to an inline splash entrypoint with the 0.13.x bump. +const OBSERVATORY_SPLASH = { + appDisplayName: 'ContextMod Observatory', + heading: 'ContextMod Observatory', + description: 'Moderator-only moderation telemetry and config editor.', + buttonLabel: 'Open dashboard', +} as const; + +const OBSERVATORY_TEXT_FALLBACK = { + text: + 'ContextMod Observatory — recent rule firings and mod-action telemetry. ' + + 'Open this post in a Devvit-compatible Reddit client to view the dashboard.', +} as const; + menu.post('/recent-actions', async (c) => { try { await c.req.json(); @@ -88,22 +106,65 @@ menu.post('/recent-actions', async (c) => { // check (defense-in-depth beyond the menu's forUserType). const auth = await requireModerator(); if (!auth.ok) return c.json({ showToast: authFailToast(auth.status, 'open the Observatory dashboard') }); + + // Reuse this sub's existing Observatory post instead of spawning a new one + // on every click (SampleOfNone 2026-06-09). If the stored post is gone + // (mod-deleted, etc.), fall through and recreate. + const existingId = await redis.get(K.dashboardPostId(auth.sub)); + if (existingId) { + try { + const existing = await reddit.getPostById(existingId as `t3_${string}`); + if (existing?.permalink) { + log.info('cm/menu/recent-actions', 'reusing Observatory post', { postId: existing.id }); + await logMenuAction('recent-actions'); + return c.json({ + navigateTo: `https://reddit.com${existing.permalink}`, + showToast: 'Opening the Observatory dashboard', + }); + } + } catch (lookupErr) { + log.warn('cm/menu/recent-actions', 'stored dashboard post gone — recreating', { + existingId, + lookupErr, + }); + } + } + log.info('cm/menu/recent-actions', 'creating Observatory post'); const post = await reddit.submitCustomPost({ subredditName: auth.sub, title: 'ContextMod Observatory', entry: 'default', - textFallback: { - text: - 'ContextMod Observatory — recent rule firings and mod-action telemetry. ' + - 'Open this post in a Devvit-compatible Reddit client to view the dashboard.', - }, + splash: OBSERVATORY_SPLASH, + textFallback: OBSERVATORY_TEXT_FALLBACK, }); + + // Immediately remove the post so the dashboard never sits in the public + // feed (SampleOfNone 2026-06-09). Mods reach it via the stored permalink; + // non-mods that open it hit the server-gated "Moderators only" screen. + // Best-effort: a remove failure must not strand the mod without a link, so + // we log and still return the dashboard. The post id is reused next time. + let removeFailed = false; + try { + await reddit.remove(post.id, false); + } catch (removeErr) { + removeFailed = true; + log.warn('cm/menu/recent-actions', 'post-create remove failed — post left visible', { + postId: post.id, + removeErr, + }); + } + + await redis.set(K.dashboardPostId(auth.sub), post.id); log.info('cm/menu/recent-actions', 'post created', { postId: post.id }); await logMenuAction('recent-actions'); return c.json({ navigateTo: `https://reddit.com${post.permalink}`, - showToast: 'Observatory dashboard pinned', + // Surface the remove failure: a mod can't read server logs, and a + // dashboard post left in the public feed needs manual removal. + showToast: removeFailed + ? 'Dashboard ready, but it could not be hidden from the feed — remove the post manually.' + : 'Observatory dashboard ready', }); } catch (err) { // Surface real error class to the mod so they have something actionable. diff --git a/src/schema/app.schema.json b/src/schema/app.schema.json index 7db94b8..8a61ee0 100644 --- a/src/schema/app.schema.json +++ b/src/schema/app.schema.json @@ -45,7 +45,7 @@ "Check": { "type": "object", "additionalProperties": false, - "required": ["name", "combinator", "rules"], + "required": ["name", "rules"], "properties": { "name": { "type": "string", @@ -53,7 +53,19 @@ }, "combinator": { "enum": ["AND", "OR", "NOT"], - "description": "How rules are combined: AND requires all rules to trigger, OR requires any, NOT requires none." + "description": "How rules are combined: AND requires all rules to trigger, OR requires any, NOT requires none. Optional; defaults to AND. Upstream ContextMod's `condition` is normalized to this at parse time." + }, + "enable": { + "type": "boolean", + "description": "Upstream ContextMod compat: when false the check is skipped entirely. Defaults to true." + }, + "description": { + "type": "string", + "description": "Upstream ContextMod compat: human-readable note. Accepted and ignored by the engine." + }, + "kind": { + "enum": ["submission", "comment"], + "description": "Upstream ContextMod compat: scope this check to submissions or comments only. Omit to apply to both." }, "rules": { "type": "array", diff --git a/src/shared/types.ts b/src/shared/types.ts index b83b036..ad36eec 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -358,6 +358,13 @@ export interface Check { actions?: Action[]; // fired when the check triggers; the run // collects these in order on a triggered run. postBehavior?: PostBehavior; // default 'next' + // Upstream ContextMod compatibility (2026-06-09, SampleOfNone feedback). + // `condition` (AND/OR) and check-level `itemIs`/`authorIs` are normalized + // into `combinator`/`filters` at parse time (see config.ts); these three + // pass through and are honored by the engine (runCheck.ts). + enable?: boolean; // when false the check is skipped entirely (default true) + description?: string; // cosmetic; accepted and ignored by the engine + kind?: 'submission' | 'comment'; // scope to one item type (default: both) } export interface Run { diff --git a/src/state/keys.ts b/src/state/keys.ts index c526ed1..7e68935 100644 --- a/src/state/keys.ts +++ b/src/state/keys.ts @@ -70,6 +70,12 @@ export const K = { // Stable pointer to the current install's installId so cron handlers (no // inbound request context) can resolve the install-scope state. currentInstallId: () => `cm:current-install-id`, + + // Observatory dashboard custom-post id, per sub. The "View recent actions" + // menu reuses this post instead of spawning a new one on every click, and + // the post is removed from the public feed right after creation (it is only + // reachable by mods via its permalink). 2026-06-09, SampleOfNone feedback. + dashboardPostId: (sub: string = SUB_DEFAULT) => `cm:${sub}:dashboard:post-id`, }; export const SUB_SENTINEL = SUB_DEFAULT; diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index 30bf7b0..bc74a0b 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -62,6 +62,96 @@ describe('parseConfig — valid configs', () => { }); }); +describe('parseConfig — upstream ContextMod shape (SampleOfNone 2026-06-09)', () => { + it('accepts a check using upstream condition, enable, description, kind', () => { + const json5 = `{ + runs: [{ + name: 'main', + checks: [{ + name: 'block-scam', + condition: 'OR', + enable: true, + description: 'blocks scam titles', + kind: 'submission', + rules: [{ kind: 'regex', pattern: 'scam', target: 'title' }], + actions: [{ kind: 'remove' }], + }], + }], + }`; + const r = parseConfig(json5); + expect(r.ok).toBe(true); + if (r.ok) { + const check = r.config.runs[0]!.checks[0]!; + expect(check.combinator).toBe('OR'); // condition normalized -> combinator + expect(check.enable).toBe(true); + expect(check.kind).toBe('submission'); + expect((check as { condition?: unknown }).condition).toBeUndefined(); + } + }); + + it('defaults a missing combinator to AND', () => { + const json5 = `{ + runs: [{ name: 'main', checks: [{ + name: 'c', rules: [{ kind: 'regex', pattern: 'x' }], + }] }], + }`; + const r = parseConfig(json5); + expect(r.ok).toBe(true); + if (r.ok) expect(r.config.runs[0]!.checks[0]!.combinator).toBe('AND'); + }); + + it('lifts check-level itemIs/authorIs into filters', () => { + const json5 = `{ + runs: [{ name: 'main', checks: [{ + name: 'c', combinator: 'AND', + itemIs: { over18: true }, + authorIs: { isMod: false }, + rules: [{ kind: 'regex', pattern: 'x' }], + }] }], + }`; + const r = parseConfig(json5); + expect(r.ok).toBe(true); + if (r.ok) { + const check = r.config.runs[0]!.checks[0]!; + expect(check.filters?.itemIs).toEqual({ over18: true }); + expect(check.filters?.authorIs).toEqual({ isMod: false }); + expect((check as { itemIs?: unknown }).itemIs).toBeUndefined(); + } + }); + + it('still rejects a genuinely unsupported check field (clear error, not silent)', () => { + const json5 = `{ + runs: [{ name: 'main', checks: [{ + name: 'c', combinator: 'AND', rules: [{ kind: 'regex', pattern: 'x' }], + notARealField: true, + }] }], + }`; + const r = parseConfig(json5); + expect(r.ok).toBe(false); + }); + + it('rejects an unrecognized condition value instead of silently defaulting to AND', () => { + const json5 = `{ + runs: [{ name: 'main', checks: [{ + name: 'c', condition: 'XOR', rules: [{ kind: 'regex', pattern: 'x' }], + }] }], + }`; + const r = parseConfig(json5); + // left in place -> AJV rejects it as an additional property (no silent AND coercion) + expect(r.ok).toBe(false); + }); + + it('rejects a non-string condition', () => { + const json5 = `{ + runs: [{ name: 'main', checks: [{ + name: 'c', condition: 5, rules: [{ kind: 'regex', pattern: 'x' }], + }] }], + }`; + const r = parseConfig(json5); + expect(r.ok).toBe(false); + }); +}); + describe('parseConfig — malformed configs', () => { it('returns ok=false on JSON5 parse error', () => { const r = parseConfig('this is not json'); @@ -89,9 +179,9 @@ describe('parseConfig — malformed configs', () => { runs: [{ name: 'r', checks: [{ - // missing combinator + // missing required \`rules\` (combinator is optional since 2026-06-09) name: 'c', - rules: [{ kind: 'regex', pattern: 'x' }], + combinator: 'AND', }], }], }`; diff --git a/tests/core/runCheck.test.ts b/tests/core/runCheck.test.ts index a51ab16..9805a23 100644 --- a/tests/core/runCheck.test.ts +++ b/tests/core/runCheck.test.ts @@ -65,6 +65,43 @@ describe('runCheck', () => { expect(r.triggered).toBe(true); }); + it('enable:false → skipped, never triggers even with a matching rule', async () => { + const r = await runCheck( + { name: 'c', combinator: 'OR', enable: false, rules: [ruleHit], actions: [removeAction] }, + baseItem, + baseAuthor + ); + expect(r.triggered).toBe(false); + expect(r.actions).toEqual([]); + }); + + it("kind:'comment' on a post item (t3_) → skipped", async () => { + const r = await runCheck( + { name: 'c', combinator: 'OR', kind: 'comment', rules: [ruleHit], actions: [removeAction] }, + baseItem, // id 't3_a' → a post + baseAuthor + ); + expect(r.triggered).toBe(false); + }); + + it("kind:'submission' on a post item (t3_) → runs normally", async () => { + const r = await runCheck( + { name: 'c', combinator: 'OR', kind: 'submission', rules: [ruleHit], actions: [removeAction] }, + baseItem, + baseAuthor + ); + expect(r.triggered).toBe(true); + }); + + it("kind:'comment' on a comment item (t1_) → runs normally", async () => { + const r = await runCheck( + { name: 'c', combinator: 'OR', kind: 'comment', rules: [ruleHit], actions: [removeAction] }, + { ...baseItem, id: 't1_b' }, + baseAuthor + ); + expect(r.triggered).toBe(true); + }); + it('filter mismatch → not triggered, no rules run', async () => { const r = await runCheck( { diff --git a/tests/routes/menu-auth.test.ts b/tests/routes/menu-auth.test.ts index 6df5c3f..3ad14be 100644 --- a/tests/routes/menu-auth.test.ts +++ b/tests/routes/menu-auth.test.ts @@ -17,18 +17,26 @@ const requireModeratorMock = vi.fn(); const loadFromWiki = vi.fn(); const publish = vi.fn(); const submitCustomPost = vi.fn(); +const getPostById = vi.fn(); +const removePost = vi.fn(); const getCurrentSubreddit = vi.fn(async () => ({ name: 'r_test' })); const getCurrentUser = vi.fn(async () => ({ username: 'mod_alice' })); const logModActivity = vi.fn(); const redisSet = vi.fn(); +const redisGet = vi.fn(); vi.mock('@devvit/web/server', () => ({ reddit: { getCurrentSubreddit: () => getCurrentSubreddit(), getCurrentUser: () => getCurrentUser(), submitCustomPost: (...a: unknown[]) => submitCustomPost(...a), + getPostById: (...a: unknown[]) => getPostById(...a), + remove: (...a: unknown[]) => removePost(...a), + }, + redis: { + set: (...a: unknown[]) => redisSet(...a), + get: (...a: unknown[]) => redisGet(...a), }, - redis: { set: (...a: unknown[]) => redisSet(...a) }, })); vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock(), @@ -44,7 +52,10 @@ vi.mock('../../src/state/modActivity', () => ({ logModActivity: (...a: unknown[]) => logModActivity(...a), })); vi.mock('../../src/state/keys', () => ({ - K: { cfgLastWikiRev: (sub: string) => `cm:cfg:lastwiki:${sub}` }, + K: { + cfgLastWikiRev: (sub: string) => `cm:cfg:lastwiki:${sub}`, + dashboardPostId: (sub: string) => `cm:dash:${sub}`, + }, })); import { menu } from '../../src/routes/menu'; @@ -80,6 +91,8 @@ beforeEach(() => { vi.clearAllMocks(); getCurrentSubreddit.mockResolvedValue({ name: 'r_test' }); getCurrentUser.mockResolvedValue({ username: 'mod_alice' }); + // Default: no stored dashboard post, so recent-actions takes the create path. + redisGet.mockResolvedValue(null); }); describe('mod-menu handlers reject non-mods (App Review fix 2026-06-07)', () => { @@ -153,6 +166,64 @@ describe('mod-menu handlers allow mods (happy path stays intact)', () => { expect(json.navigateTo).toContain('/r/r_test/comments/x/'); }); + it('recent-actions: a new post is created with a splash, removed from the feed, and its id stored', async () => { + requireModeratorMock.mockResolvedValue(AS_MOD); + redisGet.mockResolvedValue(null); + submitCustomPost.mockResolvedValue({ id: 't3_new', permalink: '/r/r_test/comments/new/' }); + await postMenu('/recent-actions'); + // created with a splash cover (SampleOfNone 2026-06-09) + expect(submitCustomPost).toHaveBeenCalledWith( + expect.objectContaining({ + entry: 'default', + splash: expect.objectContaining({ appDisplayName: 'ContextMod Observatory' }), + }) + ); + // removed from the public feed immediately + expect(removePost).toHaveBeenCalledWith('t3_new', false); + // id persisted for reuse + expect(redisSet).toHaveBeenCalledWith('cm:dash:r_test', 't3_new'); + }); + + it('recent-actions: reuses the existing dashboard post instead of creating a new one', async () => { + requireModeratorMock.mockResolvedValue(AS_MOD); + redisGet.mockResolvedValue('t3_existing'); + getPostById.mockResolvedValue({ id: 't3_existing', permalink: '/r/r_test/comments/old/' }); + const res = await postMenu('/recent-actions'); + const json = (await res.json()) as { navigateTo?: string }; + expect(getPostById).toHaveBeenCalledWith('t3_existing'); + expect(submitCustomPost).not.toHaveBeenCalled(); + expect(removePost).not.toHaveBeenCalled(); + // reuse must not rewrite the stored id (no double-write invariant) + expect(redisSet).not.toHaveBeenCalled(); + expect(json.navigateTo).toContain('/r/r_test/comments/old/'); + }); + + it('recent-actions: recreates the post when the stored id is gone', async () => { + requireModeratorMock.mockResolvedValue(AS_MOD); + redisGet.mockResolvedValue('t3_gone'); + getPostById.mockRejectedValue(new Error('404 not found')); + submitCustomPost.mockResolvedValue({ id: 't3_fresh', permalink: '/r/r_test/comments/fresh/' }); + const res = await postMenu('/recent-actions'); + const json = (await res.json()) as { navigateTo?: string }; + expect(submitCustomPost).toHaveBeenCalledTimes(1); + expect(removePost).toHaveBeenCalledWith('t3_fresh', false); + expect(redisSet).toHaveBeenCalledWith('cm:dash:r_test', 't3_fresh'); + expect(json.navigateTo).toContain('/r/r_test/comments/fresh/'); + }); + + it('recent-actions: a remove failure still returns the dashboard link (best-effort)', async () => { + requireModeratorMock.mockResolvedValue(AS_MOD); + redisGet.mockResolvedValue(null); + submitCustomPost.mockResolvedValue({ id: 't3_x', permalink: '/r/r_test/comments/x/' }); + removePost.mockRejectedValue(new Error('remove blew up')); + const res = await postMenu('/recent-actions'); + const json = (await res.json()) as { navigateTo?: string; showToast?: string }; + expect(redisSet).toHaveBeenCalledWith('cm:dash:r_test', 't3_x'); + expect(json.navigateTo).toContain('/r/r_test/comments/x/'); + // the mod is told the post could not be hidden (can't read server logs) + expect(json.showToast).toMatch(/could not be hidden|remove the post manually/i); + }); + it('set-openai-key: a mod sees the form', async () => { requireModeratorMock.mockResolvedValue(AS_MOD); const res = await postMenu('/set-openai-key');