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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.

## [Unreleased]

### Security: App Review mod-permission gate (2026-06-07)

Reddit App Review rejected cm-devvit 0.3.0 on mod-permission handling. This wave closes the gap: every mod-only surface is verified server-side, and the permission model is documented for installing subreddits.

- **Gate all mod-menu handlers with `requireModerator()`** (`src/routes/menu.ts`): the six `/internal/menu/*` handlers (reload-config, recent-actions, set-openai-key, explain-rule, simulate-rule, test-rules) previously relied only on the menu item's `forUserType: "moderator"`. But the handler endpoints are HTTP-reachable by any viewer of the Observatory custom post (the same reachability the `/api/*` and form-submit handlers already defend against), so a non-mod could POST them directly. They now verify moderator status server-side before reading the wiki, publishing config, or creating the dashboard post, and return a "Mod-only action" toast otherwise. The two action handlers resolve the sub via `requireModerator()` (dropping a redundant `getCurrentSubreddit` call). Pinned by `tests/routes/menu-auth.test.ts`.
- **Extract shared `authFailToast` helper** (`src/lib/authFailToast.ts`): the toast wording (including the Polish #10 transient-vs-terminal split) was a local function in `forms.ts`. Extracted so `menu.ts` reuses the exact same copy. One source of truth, same anti-drift rationale as `openaiErrors.ts`. Pinned by `tests/lib/authFailToast.test.ts`.
- **Client "Moderators only" gate** (`src/client/{App.tsx,lib/api.ts,lib/types.ts}`): when a dashboard data endpoint replies `403`, the webview shows a dedicated "Moderators only" notice instead of the misleading "Telemetry API unreachable" retry banner. The gate triggers strictly on `403` (a `forbidden` flag on the API result), so a transient `503`/`500` for a real mod still shows the retry banner. Pinned by `tests/client/app.test.tsx` + `tests/client/api.test.ts`.
- **Document the permission model** (`README.md`): new "Moderator permissions and data access" section enumerates every gated surface, states the gate is "is a moderator of this subreddit" (binary, not granular per Reddit mod-permission), and documents the intentional exception (any moderator can edit config via the app, analogous to AutoModerator) plus the safe unauthenticated exceptions (`/api/health` liveness, `?demo=1` synthetic fixtures, platform-only trigger/cron routes). The FAQ "Can other mods edit the config?" answer is corrected to describe both the in-app editor path and the direct-wiki path.

Verified: `npm run type-check` and `npm run lint` clean, 923/923 tests pass. A second-model adversarial audit (gemini-agent, since the Codex CLI was unavailable) confirmed every mod-data and mod-action endpoint is gated and fails closed.

Forward-looking (post-v0.6.7): see [`ROADMAP.md`](./ROADMAP.md).

## [0.6.7]: 2026-05-19
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,37 @@ The Devvit port preserves the rule/check/action concept model that 15+ existing

> **No hosting. No tokens. No central bottleneck.** Everything lives inside your subreddit's Devvit installation.

## Moderator permissions and data access

ContextMod is a moderator-only tool. Every surface that shows moderation data or performs a moderation action is gated on the caller being a moderator of the subreddit, verified server-side against Reddit's live moderator list (`reddit.getModerators()`) on every request, not just by the menu's `forUserType`. This section is the "who can see and do what" contract for any subreddit installing the app.

**Menu items.** All six mod-menu entries (Reload config, View recent actions, Test rules, Simulate rule, Explain rule with AI, Set OpenAI API key) declare `"forUserType": "moderator"` in `devvit.json`, so non-moderators never see them in the menu.

**Server-side verification (defense in depth).** The Observatory custom post is a webview, and its server endpoints are HTTP-reachable by anyone who can open the post, not only by the mod who clicked a menu item. So `forUserType` alone is not a security boundary. Every endpoint that returns mod data or performs an action calls `requireModerator()` before doing any work. The dashboard data and action API replies `403`; the menu and form handlers reply with a "Mod-only action" toast (the Devvit response shape they require). In both cases the work never runs for a non-moderator.

| Surface | Endpoint(s) | Gate |
|---|---|---|
| Recent actions feed | `GET /api/recent` | `requireModerator` |
| Stats counters | `GET /api/stats` | `requireModerator` |
| Mod-activity feed | `GET /api/mod-activity` | `requireModerator` |
| Config revision history | `GET /api/config-history` | `requireModerator` |
| Muted rules (read + mutate) | `GET /api/muted-rules`, `POST /api/mute-rule`, `POST /api/unmute-rule` | `requireModerator` |
| AI event explainer | `POST /api/explain-event` | `requireModerator` |
| Config editor | `GET /api/config/raw`, `POST /api/config/{validate,simulate-live,explain,save}` | `requireModerator` |
| Mod menu actions + forms | `POST /internal/menu/*`, `POST /internal/form/*` | `requireModerator` |

A non-moderator who opens the Observatory post sees a "Moderators only" notice, never moderation data.

**Permission granularity (documented exception).** The gate is "is a moderator of this subreddit." ContextMod does not distinguish granular Reddit moderator permissions (`wiki`, `config`, `posts`, `flair`, and so on): any moderator can view the telemetry and edit the rule config through the app. This is intentional. ContextMod's wiki config is the bot's control surface, managed by the moderator team as a whole, the same way AutoModerator's config is. The in-app editor writes the config wiki page on behalf of any moderator using the app's moderator scope. If your team needs to restrict who changes the bot's behavior, use Reddit's native moderator permissions plus your team's own process.

**Exceptions to the mod gate (and why they are safe).**

- `GET /api/health` is an unauthenticated liveness probe. It returns only `{ok, app name, version, timestamp}`: no moderation data, no Redis access. The dashboard's reload button and external uptime checks use it. `GET /api/health/deep` (which does touch Redis and Reddit context) IS mod-gated.
- `?demo=1` on the read endpoints returns clearly-synthetic fixtures (`demo_mod_alice`, and so on) so the dashboard can be demoed without a live install. Real subreddit data is never served on the demo path.
- `/internal/triggers/*` and `/internal/cron/*` are invoked by the Devvit platform (event triggers and schedulers), not by users, so they are not part of the moderator-facing surface.

**Data isolation.** Each install runs in its own Devvit Redis namespace, so one subreddit's moderation data is never visible to another.

## Try locally in 3 commands (for judges + devs)

See the Observatory dashboard render without installing the app, no Devvit credentials needed:
Expand Down Expand Up @@ -408,7 +439,7 @@ The concept model, schema validation, config publish pipeline, idempotency primi
No. Devvit runs the server. You install via the Reddit App Directory, write your rules in your sub's wiki, and that's it.

**Can other mods edit the config?**
Yes, anyone with `wiki` permissions in your sub can edit `/wiki/botconfig/contextmod`. Standard Reddit wiki access control applies.
Two paths. (1) Through the app: any moderator can edit and save the config via the in-app editor. The app writes the wiki page using its own moderator scope, so a granular `wiki` permission is not required in-app (see [Moderator permissions and data access](#moderator-permissions-and-data-access) for why this is intentional). (2) Directly: editing `/wiki/botconfig/contextmod` outside the app follows standard Reddit wiki access control, which needs the `wiki` moderator permission.

**What happens if I edit the wiki and break the config?**
The 5-minute refresh cron validates new config against an AJV JSON Schema. If it fails to parse or validate, the previous `cfg:current_rev` stays active and the error is logged. Your sub stays moderated by the last good config until you fix the wiki page.
Expand Down
63 changes: 63 additions & 0 deletions docs/submission/app-review-feedback-2026-06-07.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Reddit App Review feedback response: mod permissions (2026-06-07)

Structured response to the Reddit App Review rejection of cm-devvit 0.3.0, received 2026-06-07 in the r/Devvit App Review chat thread. 0.3.0 was the first version the reviewer could actually test: 0.2.7 auto-rejected on subreddit privacy before any functional review (the sub has been public since 2026-05-24). This wave closes the mod-permission gap, then re-submits via `npm run launch`.

## Rejection (verbatim)

From r/Devvit (App Review), 2026-06-07 1:00 PM, to u/CowSufficient3840:

> Thank you for submitting cm-devvit (cm-devvit). Please ensure the following is resolved before an approval can be made.
>
> 1: Privacy and Data Rules: Be transparent – be up front about your data practices, ensure all consents and permissions are complete, accurate, and clearly labeled, and notify Reddit and your users if your app is compromised (e.g., data breach, unauthorized access).
>
> Your mod tool doesn't handle mod permissions correctly. Remember:
>
> - Any mod-only menu items should use "forUserType": "moderator"
> - Any mod-only forms or webview posts should only display mod data to mods with proper permissions to see that data
> - Any mod-only actions should check for proper mod perms before allowing the action to be submitted.
>
> Any exceptions must have a valid use case for your tool and be properly documented in your README so subreddits using the app know what to expect.

## Audit findings (tiered)

### TIER 1 (BLOCKER, fixed this wave)

**Mod menu action handlers did not verify mod status server-side.** All six `/internal/menu/*` handlers relied only on the menu item's `forUserType: "moderator"`. Those handler endpoints are HTTP-reachable by any authenticated viewer of the Observatory custom post (the same class of reachability the `/api/*` and `/internal/form/*` handlers already defend against, per Polish #135 and Wave W). `reload-config` (publishes config to internal state) and `recent-actions` (creates a custom post) were the live exposures; the four form-display handlers were lower risk but are gated too for a uniform posture. This is the literal subject of the reviewer's bullet 3.

Fix: `requireModerator()` on all six handlers (`src/routes/menu.ts`), shared `authFailToast` (`src/lib/authFailToast.ts`), regression test `tests/routes/menu-auth.test.ts`.

### TIER 2 (HIGH, fixed this wave)

**The non-mod webview state was a misleading error banner, not an explicit gate.** The `/api/*` data endpoints were already mod-gated (Polish #135 and Wave W), so a non-mod never received mod data. But the dashboard rendered an empty shell plus a red "Telemetry API unreachable" banner, which reads as a bug rather than an intentional permission boundary. Reviewer bullet 2 is about webview posts displaying mod data only to mods; an explicit gate removes the ambiguity.

Fix: client "Moderators only" notice on `403` (`src/client/App.tsx`, `lib/api.ts`, `lib/types.ts`), tests in `tests/client/`.

### TIER 3 (DOC, fixed this wave)

**The permission model was not documented for installing subreddits.** Reviewer bullet 4 requires that any exceptions have a valid use case documented in the README. The gate is binary "is a moderator" (not granular per Reddit mod-permission), and the in-app editor lets any moderator edit config via the app's moderator scope. Both are intentional and now documented.

Fix: README "Moderator permissions and data access" section + corrected FAQ.

## Bullet-by-bullet status

1. Mod-only menu items use `forUserType: "moderator"`: already true in `devvit.json` for all six items (no code change; now documented).
2. Forms/webview display mod data only to mods: server-gated already; the client gate now makes it explicit.
3. Mod-only actions check perms before submit: now gated server-side on all menu, form, and api handlers.
4. Exceptions documented in README: new permission-model section.

## On granular permissions

The reviewer wrote "proper mod perms." ContextMod gates on subreddit moderator membership (binary), not on granular Reddit moderator permissions (`wiki` / `config` / `flair` / etc). This is intentional and documented as the "exception" the reviewer's bullet 4 anticipates: ContextMod's wiki config is the bot's control surface, managed by the moderator team as a whole, the same way AutoModerator's config is. Restricting config editing to specific mods is left to Reddit's native moderator-permission system plus the team's own process. The binary model also matches the prior production smoke test (Polish #135) that proved `requireModerator` returns 403 to non-mods and 200 to mods in the live webview.

## Verification

- `npm run type-check`, `npm run lint`: clean.
- `npm run test`: 923/923 pass (adds menu-auth, authFailToast, and client 403-gate coverage).
- Adversarial review: the Codex CLI was unavailable (account model restriction), so a second-model audit was run via gemini-agent. It enumerated every route and confirmed every mod-data or mod-action endpoint calls `requireModerator()` and fails closed; the only ungated endpoints are the unauthenticated liveness probe (`/api/health`, no data), platform-fired triggers, and platform-fired crons.

## Remaining (Stephen-side)

- Confirm r/cm_devvit_test is still public and the Observatory example post is visible, so the reviewer can test (the sub-privacy gotcha that auto-rejected 0.2.7).
- After CI is green on main, re-submit via `npm run launch` (ships the next patch, 0.3.1, for review).
- Optional reply in the App Review chat once re-submitted (Stephen sends; the CLI cannot drive Reddit chat).
- Live smoke (nice-to-have): hit a `/api/*` route and a `/internal/menu/*` route from a non-mod test account to empirically confirm the 403 / mod-only toast in the menu execution context (the source is correct by inspection and the form-submit context was already live-verified).
35 changes: 35 additions & 0 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export default function App() {
const [tourOpen, setTourOpen] = useState<boolean>(() => !hasSeenTour());
const [historyOpen, setHistoryOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
// App Review (2026-06-07): when the API 403s (viewer is not a mod), show a
// dedicated "moderators only" notice instead of the retry banner.
const [forbidden, setForbidden] = useState(false);
// Y2-X56: track initial load so first paint shows shimmer skeletons
// instead of the empty-state CTA (which would mislead the mod into
// thinking the bot is idle when actually we just haven't fetched yet).
Expand All @@ -55,12 +58,24 @@ export default function App() {
// If EITHER call errored, surface the error and keep the last-good state
// so the dashboard doesn't lose its display while the API recovers.
if (!recent.ok || !statsData.ok) {
// A 403 (not a moderator) is not an outage. The server already gates
// every data endpoint; render the dedicated "moderators only" notice
// rather than the "Telemetry API unreachable" retry banner so a non-mod
// viewing the Observatory post sees an accurate, intentional state.
if ((!recent.ok && recent.forbidden) || (!statsData.ok && statsData.forbidden)) {
setForbidden(true);
setInitialLoad(false);
setRefreshedAt(Date.now());
return;
}
const errMsg = !recent.ok ? recent.error : !statsData.ok ? statsData.error : 'unknown';
setApiError(errMsg);
setRefreshedAt(Date.now());
return;
}

// Recovered (or a mod was just added): clear any prior forbidden gate.
setForbidden(false);
setApiError(null);

// Both calls succeeded. Three branches: real data, empty + demo, empty + zero-state.
Expand Down Expand Up @@ -138,6 +153,26 @@ export default function App() {
);
useKeyboardShortcuts(shortcuts);

// Moderators-only gate (App Review 2026-06-07). All hooks above run
// unconditionally; this early return is safe below them.
if (forbidden) {
return (
<div className="relative w-full h-full overflow-hidden flex flex-col items-center justify-center bg-ink-950 grain px-8 text-center">
<div className="relative z-10 max-w-sm">
<h1 className="text-bone-100 text-sm tracking-[0.18em] uppercase font-medium mb-2">
Moderators only
</h1>
<p className="telemetry text-[12px] leading-relaxed text-bone-300/80">
The ContextMod Observatory dashboard is visible only to moderators of
r/{subreddit}. Mod-action telemetry, rule config, and AI tools are
gated server-side. If you moderate this community, sign in with the
account that holds your mod permissions.
</p>
</div>
</div>
);
}

return (
<div className="relative w-full h-full overflow-hidden flex flex-col bg-ink-950 grain">
<div
Expand Down
8 changes: 6 additions & 2 deletions src/client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ export async function extractServerError(res: Response): Promise<string> {
export async function fetchRecentSafe(): Promise<ApiResult<EventRecord[]>> {
try {
const res = await fetch(`/api/recent${demoSuffix()}`);
if (!res.ok) return { ok: false, error: await extractServerError(res) };
// forbidden: a 403 means "not a moderator", not an outage — the dashboard
// renders a "moderators only" notice instead of the retry banner.
if (!res.ok)
return { ok: false, error: await extractServerError(res), forbidden: res.status === 403 };
const data = await res.json();
const events = Array.isArray(data?.events) ? (data.events as EventRecord[]) : [];
if (events.length === 0) return { ok: true, empty: true };
Expand All @@ -57,7 +60,8 @@ export async function fetchRecentSafe(): Promise<ApiResult<EventRecord[]>> {
export async function fetchStatsSafe(): Promise<ApiResult<StatsRollup>> {
try {
const res = await fetch(`/api/stats${demoSuffix()}`);
if (!res.ok) return { ok: false, error: await extractServerError(res) };
if (!res.ok)
return { ok: false, error: await extractServerError(res), forbidden: res.status === 403 };
const data = await res.json();
const c = data?.counters;
if (!c || typeof c !== 'object' || !Array.isArray(c.hourlyActions24h)) {
Expand Down
7 changes: 6 additions & 1 deletion src/client/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,18 @@ export type StatsRollup = {
* - { ok: true, empty: true }: server returned 200 + empty payload → show zero-state (fresh install, no rules firing yet)
* - { ok: false, error }: network failure, 5xx, parse error → show error banner
*
* `forbidden` is set when the server replied 403 (caller is not a moderator).
* The dashboard renders a dedicated "moderators only" notice for this case
* instead of the "Telemetry API unreachable" retry banner — a non-mod viewing
* the Observatory custom post is not an outage (App Review fix 2026-06-07).
*
* Replaces the prior "return [] on any failure" pattern (Codex review HIGH F5)
* where a backend outage was indistinguishable from "no events yet."
*/
export type ApiResult<T> =
| { ok: true; empty: false; data: T }
| { ok: true; empty: true }
| { ok: false; error: string };
| { ok: false; error: string; forbidden?: boolean };

export type ConfigRaw = { content: string; revisionId: string | null; isDefaultTemplate: boolean };
export type SaveResult = { rev: number; ruleCount: number };
Expand Down
Loading
Loading