Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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<string, unknown>;

// 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<string, unknown>)
: {};
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
Expand Down Expand Up @@ -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<string, unknown>);

if (!validate(raw)) {
return { ok: false, errors: validate.errors ?? [] };
}
Expand Down
13 changes: 13 additions & 0 deletions src/core/runCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,18 @@ export async function runCheck(
sub?: string,
runName?: string
): Promise<CheckResult> {
// 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.
Expand Down
73 changes: 67 additions & 6 deletions src/routes/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,29 +81,90 @@ 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<MenuItemRequest>();
// App Review (2026-06-07): gate post-creation behind a server-side mod
// 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.
Expand Down
16 changes: 14 additions & 2 deletions src/schema/app.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,27 @@
"Check": {
"type": "object",
"additionalProperties": false,
"required": ["name", "combinator", "rules"],
"required": ["name", "rules"],
"properties": {
"name": {
"type": "string",
"description": "Unique label for this check, used in logs and postBehavior goto references."
},
"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",
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/state/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
94 changes: 92 additions & 2 deletions tests/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
}],
}],
}`;
Expand Down
Loading
Loading