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
9 changes: 5 additions & 4 deletions devvit/DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ row. Reddit hosts and authenticates it: no bot password, no daemon, no SQLite.

| Path | Purpose |
| --- | --- |
| `devvit.json` | App manifest: server bundle, Redis + Reddit permissions, cron task, menu items, settings |
| `src/server/index.ts` | Hono server: `/internal/cron/*`, `/internal/menu/*`, `/internal/settings/*` |
| `devvit.json` | App manifest: server bundle, Redis + Reddit permissions, cron task, delete triggers, menu items, settings |
| `src/server/index.ts` | Hono server: `/internal/cron/*`, `/internal/triggers/*`, `/internal/menu/*`, `/internal/settings/*` |
| `src/server/reddit.ts` | Mod log and wiki calls through `reddit` from `@devvit/web/server` |
| `src/core/record.ts` | Mod-log entry to stored row (Python `store_processed_action`) |
| `src/core/render.ts` | Page renderer (Python `build_wiki_content`), FR-7 merging, FR-8 approval gating |
| `src/core/store.ts` | Redis layout (hashes and sorted sets only) |
| `src/core/deletion.ts` | Scrub rules for the `onPostDelete` and `onCommentDelete` triggers |
| `src/core/publish.ts` | One cycle: ingest, render, hash-skip, size guard, write, prune |
| `src/core/settings.ts` | Setting validators and config loader |
| `test/` | Vitest + `@devvit/test`; `test/parity/` holds the Python golden comparison |
Expand All @@ -34,8 +35,8 @@ row. Reddit hosts and authenticates it: no bot password, no daemon, no SQLite.
| Setting | Default | Notes |
| --- | --- | --- |
| Wiki page name | `modlog-devvit` | `config/*`, `automoderator`, `usernotes`, `toolbox` are refused |
| Days shown on the wiki page | 90 | 1-365, capped at days kept |
| Days of history kept | 90 | 1-365 |
| Days shown on the wiki page | 30 | 1-365, capped at days kept |
| Days of history kept | 30 | 1-365 |
| Actions shown on the wiki page | removals, spam, `addremovalreason`, approvals | Python `DEFAULT_WIKI_ACTIONS` |
| Anonymize moderators | on | Cannot be turned off, as in the Python app |
| Ignored moderators | empty | Comma-separated; their actions are never stored |
Expand Down
12 changes: 7 additions & 5 deletions devvit/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ autowikimod is not an official Reddit product. It runs on Reddit's Developer Pla
## Summary

- The app copies mod-log entries to a wiki page in the subreddit that installed it.
- It keeps those entries in Reddit-hosted storage for that subreddit only, for 90 days by default.
- It keeps those entries in Reddit-hosted storage for that subreddit only, for 30 days by default.
- It sends nothing to any outside server. It has no analytics, ads, payments, or AI use.
- Known gap: it does not yet remove an entry when the post, comment, or account is deleted. See "Deleted content".
- When a user or an admin deletes a post or comment, it blanks the stored author, link, and reason and rewrites the wiki page. Account deletion sends it no signal, so the retention period applies. See "Deleted content".

## What the app handles

Expand Down Expand Up @@ -40,14 +40,16 @@ The app calls no service other than Reddit's.

## Retention

- Stored entries are deleted when they are older than "Days of history kept". Default 90 days, allowed 1 to 365. The check runs after every publish cycle, which is every 20 minutes.
- Rows leave the wiki page when they pass "Days shown" (default 90, never more than the retention days) or when the page is trimmed for size.
- Stored entries are deleted when they are older than "Days of history kept". Default 30 days, allowed 1 to 365. The check runs after every publish cycle, which is every 20 minutes.
- Rows leave the wiki page when they pass "Days shown" (default 30, never more than the retention days) or when the page is trimmed for size.
- Run status values are overwritten on each run and are not deleted on a schedule.
- The wiki page itself is not deleted by the app.

## Deleted content

The app does not yet delete a stored entry when its post, comment, or author account is deleted on Reddit. The author's username, the content link, and the reason text stay in app storage. They also stay on the wiki page. This lasts until the retention period ends or the page is rewritten without them. Reddit's Devvit Rules require removal on deletion. This is a known gap. Use a short retention period until it is fixed.
- Post or comment deleted by its author or an admin: the app receives Reddit's delete event and blanks the author's username, the content link, and the reason text on every stored entry for that item. It then rewrites the wiki page. The action type, moderator label, and time remain. An entry for a removal by a moderator is kept, because that is a mod action and not a deletion.
- Author account deleted: Reddit sends the app no event for this. Entries are not scrubbed early. They are deleted when the retention period ends (default 30 days).
- Wiki revision history: the app cannot edit or delete revisions of the wiki page. Text it wrote before a scrub, including a deleted author's username, may remain in the page's revision history. Reddit's policies apply to that history, and moderators can manage it in Reddit's wiki tools.

## Uninstall and removal

Expand Down
14 changes: 10 additions & 4 deletions devvit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Before installing, note the following.

- The wiki page shows the author's username for each action ("Post by u/name") and the moderator's reason text. Set the wiki page's permission to what you want visible, in the subreddit's wiki settings, before the first run.
- Human moderators are always shown as `HumanModerator`. This cannot be turned off. `AutoModerator` and `Reddit` are shown by name.
- The app stores rows for up to the retention period you set (default 90 days). A deleted post, comment, or account is not yet scrubbed from the app's storage or the wiki page. See [Deleted content](#deleted-content). Until that is fixed, use the shortest retention that works for you.
- The app stores rows for up to the retention period you set (default 30 days). When a user or an admin deletes a post or comment, the app blanks that row's author, link, and reason and rewrites the wiki page. Account deletions are not signaled to the app, so retention is the limit there. See [Deleted content](#deleted-content).
- The app writes to one wiki page that you name. It refuses `config/*`, `automoderator`, `usernotes`, and `toolbox`. Point it at a page that holds nothing else, because each run overwrites the page.

## What it does
Expand All @@ -29,8 +29,8 @@ Before installing, note the following.
| Setting | Default | What it does |
| --- | --- | --- |
| Wiki page name | `modlog-devvit` | Page the log is written to. Lowercase letters, digits, `_`, `-`, `/`. Up to 128 characters. |
| Days shown on the wiki page | 90 | 1 to 365. Never more than the retention days. |
| Days of history kept | 90 | 1 to 365. Older entries are deleted from app storage each run. |
| Days shown on the wiki page | 30 | 1 to 365. Never more than the retention days. |
| Days of history kept | 30 | 1 to 365. Older entries are deleted from app storage each run. |
| Actions shown on the wiki page | removals, spam removals, removal reasons, approvals | Pick from 43 mod-log action types, such as `banuser`, `lock`, or `addnote`. At least one is required. |
| Anonymize moderators | on | Human moderators show as `HumanModerator`. Cannot be turned off. |
| Ignored moderators | empty | Comma-separated usernames. Their actions are never stored. |
Expand Down Expand Up @@ -58,7 +58,12 @@ The app does not use HTTP fetch, payments, or any LLM. It sends no data off Redd

## Deleted content

The app does not yet remove a stored row when its post, comment, or account is deleted. The author's username, the post or comment link, and the reason text stay in app storage and on the wiki page until the retention period ends. Reddit's Devvit Rules require removal on deletion. This is a known gap. If you need an entry removed sooner, lower "Days of history kept" and run **Modlog: Force rebuild**, or edit the wiki page, or contact us.
Reddit's Devvit Rules do not allow keeping deleted content, even anonymized. The app handles this in two ways.

- Post or comment deleted by its author or an admin: the app receives Reddit's delete event, blanks the author, link, and reason on every stored row for that item, and rewrites the wiki page. A removal by a moderator is a mod action, not a deletion, and its row stays.
- Account deleted: Reddit sends the app no event for this, so rows are not scrubbed early. The retention period (default 30 days) is the limit. Lower "Days of history kept" for a shorter limit.

Reddit keeps earlier versions of a wiki page in its revision history. The app cannot edit or delete revisions, so text it wrote before a scrub can remain there. Moderators can manage revisions in Reddit's wiki tools.

## Privacy and terms

Expand All @@ -73,6 +78,7 @@ To report an app that breaks Reddit's rules, use Reddit's own report flow.

## Changelog

- Unreleased: scrub author, link, and reason from stored rows and the wiki page when a user or admin deletes a post or comment. Default days kept and days shown are now 30. The package license is now GPL-3.0-only, matching the repository.
- 0.0.0: first Devvit Web version. Cron publish, three menu items, six settings.

## License
Expand Down
2 changes: 1 addition & 1 deletion devvit/TERMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The app's source code is licensed under the GNU General Public License v3.0 (see

## 5. Privacy

See the [Privacy Policy](PRIVACY.md). It includes a known gap in how deleted content is handled.
See the [Privacy Policy](PRIVACY.md). It covers how deleted content is handled.

## 6. No warranty

Expand Down
8 changes: 6 additions & 2 deletions devvit/devvit.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"reddit": true,
"redis": true
},
"triggers": {
"onPostDelete": "/internal/triggers/on-post-delete",
"onCommentDelete": "/internal/triggers/on-comment-delete"
},
"scheduler": {
"tasks": {
"publish-modlog": {
Expand Down Expand Up @@ -55,14 +59,14 @@
"type": "number",
"label": "Days shown on the wiki page",
"helpText": "1-365. Capped at the retention days.",
"defaultValue": 90,
"defaultValue": 30,
"validationEndpoint": "/internal/settings/validate-days"
},
"retentionDays": {
"type": "number",
"label": "Days of history kept",
"helpText": "1-365. Older entries are deleted from app storage.",
"defaultValue": 90,
"defaultValue": 30,
"validationEndpoint": "/internal/settings/validate-days"
},
"wikiActions": {
Expand Down
2 changes: 1 addition & 1 deletion devvit/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion devvit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"private": true,
"name": "autowikimod",
"version": "0.0.0",
"license": "BSD-3-Clause",
"license": "GPL-3.0-only",
"type": "module",
"description": "Publishes a subreddit's moderation log to a wiki page (Reddit Devvit Web app).",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions devvit/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export const ANON_LABEL = 'HumanModerator';

export const DAYS_MIN = 1;
export const DAYS_MAX = 365;
export const DEFAULT_RETENTION_DAYS = 90;
export const DEFAULT_DISPLAY_DAYS = 90;
export const DEFAULT_RETENTION_DAYS = 30;
export const DEFAULT_DISPLAY_DAYS = 30;
export const DEFAULT_WIKI_PAGE = 'modlog-devvit';

/** Python max_wiki_entries_per_page default. */
Expand Down
26 changes: 26 additions & 0 deletions devvit/src/core/deletion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Devvit Rules: deleted content must not be retained, even anonymized. A post or
* comment deleted by its author or an admin has its author, link and reason
* blanked in storage. A moderator removal is a mod action, not a deletion.
*/
import type { Store } from './store.js';

export type DeletionSource = number | string | undefined;

/** EventSource arrives as an enum number or name. MODERATOR is the only source that keeps rows. */
export function shouldScrub(source: DeletionSource): boolean {
return source !== 3 && source !== 'MODERATOR';
}

/** "t3_abc", "t1_abc" or "abc" to "abc"; null for an empty id. */
export function bareId(id: string | undefined): string | null {
const bare = (id ?? '').replace(/^t[13]_/, '');
return bare === '' ? null : bare;
}

/** Scrub stored rows for a deleted target. Returns rows changed (0 when ignored). */
export async function scrubDeleted(store: Store, rawId: string | undefined, source: DeletionSource): Promise<number> {
const id = bareId(rawId);
if (!id || !shouldScrub(source) || !/^[a-z0-9]+$/i.test(id)) return 0;
return store.scrubTarget(id);
}
23 changes: 23 additions & 0 deletions devvit/src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ export class Store {
return removed;
}

/**
* Blank targetAuthor, permalink and reason on every stored row for one post or
* comment, and the reason on its prior-removal record. Returns rows changed.
*/
async scrubTarget(contentId: string): Promise<number> {
const all = await this.redis.hGetAll(K.act);
const updates: Record<string, string> = {};
for (const [id, json] of Object.entries(all)) {
const row = JSON.parse(json) as StoredAction;
if (bareContentId(row.permalink) !== contentId) continue;
updates[id] = JSON.stringify({ ...row, targetAuthor: null, permalink: null, reason: '' } satisfies StoredAction);
}
for (const batch of chunks(Object.keys(updates), CHUNK)) {
await this.redis.hSet(K.act, Object.fromEntries(batch.map((id) => [id, updates[id] as string])));
}
const prior = await this.redis.hGet(K.prior, contentId);
if (prior) {
const prev = JSON.parse(prior) as PriorRemoval;
await this.redis.hSet(K.prior, { [contentId]: JSON.stringify({ ...prev, reason: '' } satisfies PriorRemoval) });
}
return Object.keys(updates).length;
}

async storedCount(): Promise<number> {
return this.redis.hLen(K.act);
}
Expand Down
28 changes: 27 additions & 1 deletion devvit/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import { serve } from '@hono/node-server';
import { context, createServer, getServerPort, redis, settings } from '@devvit/web/server';
import type { TaskResponse } from '@devvit/web/server';
import type { MenuItemRequest, SettingsValidationRequest, SettingsValidationResponse, UiResponse } from '@devvit/web/shared';
import type { MenuItemRequest, OnCommentDeleteRequest, OnPostDeleteRequest, SettingsValidationRequest, SettingsValidationResponse, UiResponse } from '@devvit/web/shared';
import { Hono } from 'hono';

import { scrubDeleted } from '../core/deletion.js';
import { describeResult, runCycle, type CycleMode } from '../core/publish.js';
import {
loadConfig,
Expand Down Expand Up @@ -85,6 +86,31 @@ app.post('/internal/menu/test-connection', async (c) => {
}
});

async function handleDeletion(kind: string, id: string | undefined, source: number | string | undefined): Promise<void> {
try {
const scrubbed = await scrubDeleted(store, id, source);
console.log(`${kind} deleted: scrubbed ${scrubbed} stored rows`);
if (scrubbed === 0) return;
const cfg = await currentConfig();
// A busy run is fine: the scrub changed the page content, so the next cycle rewrites it.
await runCycle(devvitReddit, store, cfg, 'force-rebuild');
} catch (err) {
console.error(`${kind} delete handler failed: ${errorText(err)}`);
}
}

app.post('/internal/triggers/on-post-delete', async (c) => {
const body = await c.req.json<OnPostDeleteRequest>();
await handleDeletion('post', body.postId, body.source);
return c.json({}, 200);
});

app.post('/internal/triggers/on-comment-delete', async (c) => {
const body = await c.req.json<OnCommentDeleteRequest>();
await handleDeletion('comment', body.commentId, body.source);
return c.json({}, 200);
});

function validationRoute<T>(path: string, validate: Validator<T>): void {
app.post(path, async (c) => {
const { value } = await c.req.json<SettingsValidationRequest<T>>();
Expand Down
66 changes: 66 additions & 0 deletions devvit/test/deletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { redis } from '@devvit/web/server';
import { createDevvitTest } from '@devvit/test/server/vitest';
import { expect } from 'vitest';

import { bareId, scrubDeleted, shouldScrub } from '../src/core/deletion.js';
import { runCycle } from '../src/core/publish.js';
import { Store } from '../src/core/store.js';
import type { StoredAction } from '../src/core/types.js';
import { FakeReddit, config } from './helpers.js';

const test = createDevvitTest();
const P = (id: string) => `https://reddit.com/r/Sub/comments/${id}/t/`;
const NOW = new Date('2026-11-03T12:00:00Z');
const nowSec = Math.floor(NOW.getTime() / 1000);

function row(over: Partial<StoredAction>): StoredAction {
return { id: 'a1', action: 'removelink', moderator: 'HumanModerator', permalink: P('p1'), reason: 'secret reason', targetAuthor: 'victim', createdAt: nowSec - 60, ...over };
}

test('shouldScrub keeps rows only for MODERATOR deletions', () => {
for (const s of [1, 2, 'USER', 'ADMIN', 0, undefined]) expect(shouldScrub(s)).toBe(true);
for (const s of [3, 'MODERATOR']) expect(shouldScrub(s)).toBe(false);
});

test('bareId strips prefixes and rejects empty ids', () => {
expect(bareId('t3_abc')).toBe('abc');
expect(bareId('t1_xyz')).toBe('xyz');
expect(bareId('')).toBeNull();
expect(bareId(undefined)).toBeNull();
});

test('a USER deletion scrubs rows and a forced rebuild drops the identifying text from the wiki', async () => {
const store = new Store(redis);
await store.saveAction(row({}));
const reddit = new FakeReddit([]);
const cfg = config();
await runCycle(reddit, store, cfg, 'force-rebuild', NOW);
expect(reddit.pages.get(cfg.wikiPage)).toContain('victim');

expect(await scrubDeleted(store, 't3_p1', 1)).toBe(1);
await runCycle(reddit, store, cfg, 'force-rebuild', NOW);
const page = reddit.pages.get(cfg.wikiPage) as string;
expect(page).not.toContain('victim');
expect(page).not.toContain('secret reason');
expect(page).not.toContain('/comments/p1/');
});

test('an ADMIN deletion scrubs too', async () => {
const store = new Store(redis);
await store.saveAction(row({}));
expect(await scrubDeleted(store, 't3_p1', 'ADMIN')).toBe(1);
});

test('a MODERATOR deletion leaves rows untouched', async () => {
const store = new Store(redis);
await store.saveAction(row({}));
expect(await scrubDeleted(store, 't3_p1', 'MODERATOR')).toBe(0);
expect((await store.loadDisplayRows(['removelink'], 0))[0]).toMatchObject({ targetAuthor: 'victim', reason: 'secret reason' });
});

test('an unknown target scrubs nothing', async () => {
const store = new Store(redis);
await store.saveAction(row({}));
expect(await scrubDeleted(store, 't1_other', 1)).toBe(0);
expect(await scrubDeleted(store, undefined, 1)).toBe(0);
});
4 changes: 2 additions & 2 deletions devvit/test/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ describe('loadConfig', () => {
expect(cfg).toEqual({
subredditName: 'OpenSignups',
wikiPage: 'modlog-devvit',
retentionDays: 90,
displayDays: 90,
retentionDays: 30,
displayDays: 30,
wikiActions: [...DEFAULT_WIKI_ACTIONS],
anonymizeModerators: true,
ignoredModerators: [],
Expand Down
Loading
Loading