From bf84224417c27b907efd9ecb4afe1bd12f4aa056 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Tue, 9 Jun 2026 15:29:19 -0400 Subject: [PATCH 1/3] fix(menu): reuse + remove Observatory dashboard post, add splash cover Addresses SampleOfNone feedback (2026-06-09) on the dashboard custom-post lifecycle. The "View recent actions" menu previously created a brand-new Observatory post on every click, left it sitting in the public feed, and set no splash cover. - Reuse: store the post id per sub (K.dashboardPostId) and reopen the existing post via getPostById; only create a new one when none exists or the stored post is gone. - Remove: immediately reddit.remove() the post after creation so the dashboard never sits in the public feed. Mods reach it via the stored permalink; non-mods hit the server-gated "Moderators only" screen. Best-effort (a remove failure still returns the mod a working link). - Splash: set a splash cover on the post. NB SubmitCustomPostSplashOptions is deprecated in @devvit 0.12.24 (migrate to an inline HTML splash entrypoint with the 0.13.x bump) but is the only splash API in-version. Tests: +4 cases (create-removes-and-stores, reuse-existing, recreate-when- gone, remove-failure-still-returns-link). Full suite 927 green; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/menu.ts | 67 +++++++++++++++++++++++++++++--- src/state/keys.ts | 6 +++ tests/routes/menu-auth.test.ts | 71 +++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/routes/menu.ts b/src/routes/menu.ts index fd5026d..2392759 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,59 @@ 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. + try { + await reddit.remove(post.id, false); + } catch (removeErr) { + 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', + showToast: 'Observatory dashboard ready', }); } catch (err) { // Surface real error class to the mod so they have something actionable. 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/routes/menu-auth.test.ts b/tests/routes/menu-auth.test.ts index 6df5c3f..d3a716a 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,60 @@ 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(); + 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 }; + expect(redisSet).toHaveBeenCalledWith('cm:dash:r_test', 't3_x'); + expect(json.navigateTo).toContain('/r/r_test/comments/x/'); + }); + it('set-openai-key: a mod sees the form', async () => { requireModeratorMock.mockResolvedValue(AS_MOD); const res = await postMenu('/set-openai-key'); From 41c457b7d9da6276cfcef3d82a309acad17cd81b Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Tue, 9 Jun 2026 15:40:41 -0400 Subject: [PATCH 2/3] feat(config): accept upstream ContextMod check shape (enable/kind/condition/itemIs) SampleOfNone (2026-06-09) hit validation errors pasting a real ContextMod config: our MVP-trim Check schema rejected upstream-only fields (enable, description, kind, itemIs) and required `combinator`, which upstream calls `condition`. - Normalize upstream shape pre-validate (config.ts): map `condition` (AND/OR) to `combinator`, default a missing combinator to AND, and lift check-level `itemIs`/`authorIs` into our nested `filters`. Genuinely unsupported fields are still rejected with a clear error rather than silently dropped (a mod must know when a field is ignored). - Schema: `combinator` is now optional; `enable`, `description`, `kind` are accepted natively. - Honor the semantics, not just accept (runCheck.ts): a check with enable:false is skipped entirely; `kind` scopes a check to submissions or comments only. - Types: Check gains enable?/description?/kind?. Tests: +8 (upstream-shape parse + normalization, enable-skips, kind-targeting); re-pointed the "missing required field" test at `rules` since combinator is now optional. Full suite 935 green; tsc + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/config.ts | 65 +++++++++++++++++++++++++ src/core/runCheck.ts | 13 +++++ src/schema/app.schema.json | 16 ++++++- src/shared/types.ts | 7 +++ tests/core/config.test.ts | 94 ++++++++++++++++++++++++++++++++++++- tests/core/runCheck.test.ts | 37 +++++++++++++++ 6 files changed, 228 insertions(+), 4 deletions(-) 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/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/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( { From fc08d83393146e2642468db9643283e0c15e8f37 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Tue, 9 Jun 2026 15:48:59 -0400 Subject: [PATCH 3/3] fix(menu): tell the mod when the dashboard post could not be hidden Adversarial-review follow-up. The post-create reddit.remove() is best-effort; on failure the post stays in the public feed but the mod (who cannot read server logs) was told 'Observatory dashboard ready' with no caveat. Now the toast says the post could not be hidden and to remove it manually. Also pins the reuse-path no-rewrite invariant in tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/menu.ts | 8 +++++++- tests/routes/menu-auth.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/routes/menu.ts b/src/routes/menu.ts index 2392759..4ea3765 100644 --- a/src/routes/menu.ts +++ b/src/routes/menu.ts @@ -144,9 +144,11 @@ menu.post('/recent-actions', async (c) => { // 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, @@ -158,7 +160,11 @@ menu.post('/recent-actions', async (c) => { await logMenuAction('recent-actions'); return c.json({ navigateTo: `https://reddit.com${post.permalink}`, - showToast: 'Observatory dashboard ready', + // 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/tests/routes/menu-auth.test.ts b/tests/routes/menu-auth.test.ts index d3a716a..3ad14be 100644 --- a/tests/routes/menu-auth.test.ts +++ b/tests/routes/menu-auth.test.ts @@ -193,6 +193,8 @@ describe('mod-menu handlers allow mods (happy path stays intact)', () => { 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/'); }); @@ -215,9 +217,11 @@ describe('mod-menu handlers allow mods (happy path stays intact)', () => { 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 }; + 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 () => {