diff --git a/docs/superpowers/plans/2026-05-27-config-editor.md b/docs/superpowers/plans/2026-05-27-config-editor.md new file mode 100644 index 0000000..dd74e7b --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-config-editor.md @@ -0,0 +1,1355 @@ +# Config Editor (Observatory Workbench) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-app config editor to the ContextMod Observatory dashboard so mods load, edit (with YAML/JSON syntax + schema hints + inline validation), preview live impact, and save back to the wiki, with no copy/paste. + +**Architecture:** A new server surface adds read/validate/simulate/explain/save endpoints to the existing Hono `api` app, reusing `parseConfig` + AJV, `simulateRule`, `explainRule`, and `configStore.publish`. The client adds a lazy-loaded `ConfigWorkbench` opened via Devvit `requestExpandedMode`, built on CodeMirror 6 + `codemirror-json-schema` (CSP-safe, monaco is not viable on Devvit). Save-back uses `reddit.updateWikiPage`, gated by server-side re-validation + an optimistic lock. + +**Tech Stack:** TypeScript, Hono, React 18, Vite, CodeMirror 6, `codemirror-json-schema`, AJV, json5, js-yaml, Devvit Web, Vitest, Playwright. + +**Spec:** `docs/superpowers/specs/2026-05-27-config-editor-design.md` + +--- + +## Conventions (apply to every task) + +- Branch: `feat/config-editor` (already checked out). +- Test: `npm test` (alias for `vitest run --config vitest.config.ts`). Single file: `npm test -- `. +- Type-check: `npm run type-check`. Lint: `npm run lint`. Build: `npm run build`. +- Commit messages: Conventional Commits, subject <= 100 chars, no em-dash, end body with: + `Co-Authored-By: Claude Opus 4.7 (1M context) ` +- Test mocking pattern (mirror `tests/routes/forms-simulate-rule.test.ts`): `vi.mock('@devvit/web/server', ...)` and `vi.mock('../../src/lib/requireModerator', ...)` BEFORE importing the Hono app, then `await api.request(new Request('http://x/', {...}))`. +- Existing reused symbols: `WIKI_PAGE` + `loadFromWiki` (`src/core/configSource.ts`), `parseConfig` (`src/core/config.ts`), `publish` + `getCurrentRev` + `getRecentRevs` (`src/state/configStore.ts`), `requireModerator` (`src/lib/requireModerator.ts`, returns `{ok:true,sub,username} | {ok:false,status,error}`), `simulateRule` + `SimulationSample` (`src/core/simulateRule.ts`), `explainRule` (`src/core/explainRule.ts`), `checkRateLimit` (`src/lib/ratelimit`), `logModActivity` (`src/state/modActivity`), `K` (`src/state/keys.ts`). + +--- + +## File Structure + +New: +- `src/lib/resolveOpenaiKey.ts` — shared OpenAI key resolver (extracted from `forms.ts`). +- `src/core/recentSample.ts` — fetch + normalize + cache the sub's recent items as `SimulationSample[]`. +- `src/routes/configEditor.ts` — the new `/api/config/*` route group (raw, validate, simulate-live, explain, save). +- `src/client/components/ConfigEditor.tsx` — CodeMirror 6 wrapper. +- `src/client/components/ConfigWorkbench.tsx` — orchestrator (load, edit, preview, save), lazy-loaded. +- `src/client/components/PreviewPane.tsx` — Impact / Explain / Diff tabs. +- `scripts/gen-schema-descriptions.mjs` — inject `description` fields into `app.schema.json` from `src/shared/types.ts` JSDoc. + +Modified: +- `src/routes/api.ts` — mount the config-editor routes. +- `src/routes/forms.ts` — use the extracted `resolveOpenaiKey` + `getRecentSample`. +- `src/client/lib/api.ts` — add `fetchConfigRawSafe`, `validateConfigSafe`, `saveConfigSafe`, `simulateLiveSafe`, `explainConfigSafe`. +- `src/client/lib/types.ts` — add wire types. +- `src/client/components/ActionBar.tsx` — add "Edit config" button + `onEditConfig` prop. +- `src/client/App.tsx` — `editorOpen` state + lazy `` + Escape close. +- `src/schema/app.schema.json` — add `description` fields. +- `package.json` — add CodeMirror deps. + +--- + +## PHASE 1 — Server (read, validate, simulate, explain, save) + +### Task 1: Extract `resolveOpenaiKey` (DRY prep) + +**Files:** +- Create: `src/lib/resolveOpenaiKey.ts` +- Modify: `src/routes/forms.ts` (replace the local `resolveOpenaiKey`) +- Test: `tests/lib/resolveOpenaiKey.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/lib/resolveOpenaiKey.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getOpenaiKey = vi.fn(); +const settingsGet = vi.fn(); +vi.mock('../../src/state/apiKeyStore', () => ({ getOpenaiKey: (s: string) => getOpenaiKey(s) })); +vi.mock('@devvit/web/server', () => ({ settings: { get: (k: string) => settingsGet(k) } })); + +import { resolveOpenaiKey } from '../../src/lib/resolveOpenaiKey'; + +describe('resolveOpenaiKey', () => { + beforeEach(() => { getOpenaiKey.mockReset(); settingsGet.mockReset(); }); + + it('prefers the Redis key', async () => { + getOpenaiKey.mockResolvedValue('sk-redis'); + expect(await resolveOpenaiKey('sub')).toBe('sk-redis'); + }); + + it('falls back to settings, trimmed', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(' sk-settings '); + expect(await resolveOpenaiKey('sub')).toBe('sk-settings'); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/lib/resolveOpenaiKey.test.ts` +Expected: FAIL ("Cannot find module '../../src/lib/resolveOpenaiKey'"). + +- [ ] **Step 3: Implement** + +```ts +// src/lib/resolveOpenaiKey.ts +import { settings } from '@devvit/web/server'; +import { getOpenaiKey } from '../state/apiKeyStore'; + +/** Resolve the OpenAI key for a sub: encrypted-Redis key first, then the + * plaintext Devvit subreddit-setting fallback. Returns '' when neither set. */ +export async function resolveOpenaiKey(sub: string): Promise { + const fromRedis = await getOpenaiKey(sub); + if (fromRedis) return fromRedis; + return ((await settings.get('openai_api_key')) ?? '').trim(); +} +``` + +- [ ] **Step 4: Refactor `forms.ts`** — delete its local `resolveOpenaiKey` (the agent located it near line 46) and add `import { resolveOpenaiKey } from '../lib/resolveOpenaiKey';`. Leave call sites unchanged. + +- [ ] **Step 5: Run tests + type-check** + +Run: `npm test -- tests/lib/resolveOpenaiKey.test.ts && npm run type-check` +Expected: PASS, tsc clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/resolveOpenaiKey.ts src/routes/forms.ts tests/lib/resolveOpenaiKey.test.ts +git commit -m "refactor: extract resolveOpenaiKey into a shared lib" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 2: Extract + cache the recent-items sample (`getRecentSample`) + +**Files:** +- Create: `src/core/recentSample.ts` +- Modify: `src/routes/forms.ts` (use `getRecentSample` in the simulate-rule handler) +- Test: `tests/core/recentSample.test.ts` + +The simulate-rule form currently builds samples inline (`fetchRecentPostsSafe` + a `normalizePost` loop). Extract that into a cached helper so the editor's live-impact endpoint reuses it without re-fetching Reddit on every keystroke. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/core/recentSample.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getNewPosts = vi.fn(); +const redisGet = vi.fn(); +const redisSet = vi.fn(); +vi.mock('@devvit/web/server', () => ({ + reddit: { getNewPosts: (o: unknown) => getNewPosts(o) }, + redis: { get: (k: string) => redisGet(k), set: (k: string, v: string, o?: unknown) => redisSet(k, v, o) }, +})); + +import { getRecentSample } from '../../src/core/recentSample'; + +describe('getRecentSample', () => { + beforeEach(() => { getNewPosts.mockReset(); redisGet.mockReset(); redisSet.mockReset(); }); + + it('returns the cached sample without hitting reddit', async () => { + redisGet.mockResolvedValue(JSON.stringify([{ item: { id: 't3_x' }, author: { name: 'a' } }])); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect(getNewPosts).not.toHaveBeenCalled(); + }); + + it('fetches + caches on a cache miss', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ all: async () => [] }); + await getRecentSample('sub'); + expect(getNewPosts).toHaveBeenCalledOnce(); + expect(redisSet).toHaveBeenCalledOnce(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/core/recentSample.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** (move the `fetchRecentPostsSafe` + `normalizePost` logic out of `forms.ts`; reuse the existing `normalizePost` import there) + +```ts +// src/core/recentSample.ts +import { reddit, redis } from '@devvit/web/server'; +import type { SimulationSample } from './simulateRule'; +import { normalizePost } from './normalize'; // same module forms.ts imports normalizePost from +import { getCurrentRev } from '../state/configStore'; +import type { AppConfig, PostSubmitPayload } from '../shared/types'; + +const SAMPLE_LIMIT = 25; +const CACHE_TTL_MS = 60_000; // 60s: fresh enough for live preview, no per-keystroke fetch + +function cacheKey(sub: string) { return `cm:${sub}:editor:sample`; } + +/** Recent posts of the sub, normalized to SimulationSample[], cached 60s. */ +export async function getRecentSample(sub: string): Promise { + try { + const cached = await redis.get(cacheKey(sub)); + if (cached) return JSON.parse(cached) as SimulationSample[]; + } catch { + /* cache read miss/fail -> fall through to fetch */ + } + + const snapshot = await getCurrentRev(sub); + const config: AppConfig = snapshot?.config ?? { runs: [], needsAuthorEnrichment: false }; + + const redditAny = reddit as unknown as { getNewPosts: (o: unknown) => { all: () => Promise } }; + const listing = redditAny.getNewPosts({ subredditName: sub, limit: SAMPLE_LIMIT, pageSize: SAMPLE_LIMIT }); + const posts = (await listing.all()).slice(0, SAMPLE_LIMIT) as Array>; + + const samples: SimulationSample[] = []; + for (const p of posts) { + const payload = { post: p, author: { name: (p as { authorName?: string }).authorName } } as unknown as PostSubmitPayload; + const normalized = await normalizePost(payload, config); + samples.push({ item: normalized.item, author: normalized.author }); + } + + try { + await redis.set(cacheKey(sub), JSON.stringify(samples), { expiration: new Date(Date.now() + CACHE_TTL_MS) }); + } catch { + /* cache write fail is non-fatal */ + } + return samples; +} +``` + +NOTE for the implementer: open `src/routes/forms.ts` and copy the exact `normalizePost` import path + the exact `getNewPosts` option object it uses; match them here so the normalization is identical. The payload shape above is the structural form the existing handler builds. + +- [ ] **Step 4: Refactor `forms.ts`** — replace its inline sample-building in `/simulate-rule-submit` with `const samples = await getRecentSample(sub.name);`. Keep the `simulateRule(ruleJson5, samples, sub.name)` call. + +- [ ] **Step 5: Run tests + type-check** + +Run: `npm test -- tests/core/recentSample.test.ts tests/routes/forms-simulate-rule.test.ts && npm run type-check` +Expected: PASS (the existing forms test still passes against the refactor). + +- [ ] **Step 6: Commit** + +```bash +git add src/core/recentSample.ts src/routes/forms.ts tests/core/recentSample.test.ts +git commit -m "refactor: extract cached getRecentSample for reuse by the editor" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 3: Config-editor route group + `GET /api/config/raw` + +**Files:** +- Create: `src/routes/configEditor.ts` +- Modify: `src/routes/api.ts` (mount the sub-app) +- Test: `tests/routes/config-editor.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/routes/config-editor.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getWikiPage = vi.fn(); +const requireModeratorMock = vi.fn(); +vi.mock('@devvit/web/server', () => ({ + reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, + redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK') }, + settings: { get: vi.fn() }, +})); +vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); + +import { configEditor } from '../../src/routes/configEditor'; + +const MOD = { ok: true, sub: 'testsub', username: 'mod1' }; +const NON_MOD = { ok: false, status: 403, error: 'not a moderator of this sub' }; + +async function get(path: string) { + const res = await configEditor.request(new Request(`http://x${path}`)); + return { status: res.status, body: await res.json() }; +} + +describe('GET /raw', () => { + beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); + + it('returns wiki content + revisionId for a mod', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-1' }); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ content: 'runs: []', revisionId: 'rev-1' }); + }); + + it('rejects non-mods', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await get('/raw'); + expect(r.status).toBe(403); + }); + + it('returns the default template on a missing page', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('404 not found')); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body.isDefaultTemplate).toBe(true); + expect(typeof r.body.content).toBe('string'); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/routes/config-editor.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement the route group + `/raw`** + +```ts +// src/routes/configEditor.ts +import { Hono } from 'hono'; +import { reddit } from '@devvit/web/server'; +import { WIKI_PAGE } from '../core/configSource'; +import { requireModerator } from '../lib/requireModerator'; +import { DEFAULT_CONFIG_YAML } from '../config/default-config'; // see NOTE below +import { log } from '../lib/log'; + +export const configEditor = new Hono(); + +function isNotFound(err: unknown): boolean { + const m = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return m.includes('not found') || m.includes('404') || m.includes('does not exist'); +} + +configEditor.get('/raw', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ error: auth.error }, auth.status); + try { + const page = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + return c.json({ content: page.content, revisionId: page.revisionId, isDefaultTemplate: false }); + } catch (err) { + if (isNotFound(err)) { + return c.json({ content: DEFAULT_CONFIG_YAML, revisionId: null, isDefaultTemplate: true }); + } + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/raw', 'wiki read failed', { err: msg, sub: auth.sub }); + return c.json({ error: `wiki unavailable: ${msg}` }, 503); + } +}); +``` + +NOTE for the implementer: `src/config/default-config.ts` currently exports a JSON5 default. Add a `DEFAULT_CONFIG_YAML` export (the same starter config rendered as YAML, since YAML is the default editor format). If a YAML constant is awkward to maintain by hand, import the existing default object and `import { dump } from 'js-yaml'` to render it: `export const DEFAULT_CONFIG_YAML = dump(DEFAULT_CONFIG_OBJECT);`. + +- [ ] **Step 4: Mount in `api.ts`** + +In `src/routes/api.ts`, add near the other imports: `import { configEditor } from './configEditor';` and after `export const api = new Hono();` add: `api.route('/config', configEditor);` + +- [ ] **Step 5: Run tests + type-check + lint** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check && npm run lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/routes/configEditor.ts src/routes/api.ts src/config/default-config.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): GET /api/config/raw returns wiki text + revisionId" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 4: `POST /api/config/validate` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +```ts +// add to tests/routes/config-editor.test.ts +async function post(path: string, body: unknown) { + const res = await configEditor.request(new Request(`http://x${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +describe('POST /validate', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); }); + it('accepts a valid config', async () => { + const r = await post('/validate', { text: 'runs: []' }); + expect(r.body.ok).toBe(true); + }); + it('reports errors for an invalid config', async () => { + const r = await post('/validate', { text: 'runs: "not an array"' }); + expect(r.body.ok).toBe(false); + expect(r.body.errors).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** — Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL ("/validate" 404). + +- [ ] **Step 3: Implement** + +```ts +// add to src/routes/configEditor.ts +import { parseConfig } from '../core/config'; + +configEditor.post('/validate', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string') return c.json({ ok: false, error: 'text required' }, 400); + const parsed = parseConfig(text); + if (parsed.ok) return c.json({ ok: true, format: parsed.format }); + return c.json({ ok: false, errors: parsed.errors }); +}); +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check` +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/validate runs parseConfig + AJV" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 5: `POST /api/config/simulate-live` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +- [ ] **Step 1: Write the failing test** (mock `getRecentSample` + `simulateRule`) + +```ts +// add near the other vi.mock calls in tests/routes/config-editor.test.ts +const getRecentSample = vi.fn(); +const simulateRule = vi.fn(); +vi.mock('../../src/core/recentSample', () => ({ getRecentSample: (s: string) => getRecentSample(s) })); +vi.mock('../../src/core/simulateRule', () => ({ simulateRule: (...a: unknown[]) => simulateRule(...a) })); + +describe('POST /simulate-live', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); getRecentSample.mockResolvedValue([]); }); + it('returns the simulation result', async () => { + simulateRule.mockResolvedValue({ ok: true, totalSamples: 25, firedCount: 7, erroredCount: 0, breakdown: [] }); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, firedCount: 7, totalSamples: 25 }); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** (rate-limited; dry-run via `simulateRule`, which never executes actions) + +```ts +// add to src/routes/configEditor.ts +import { getRecentSample } from '../core/recentSample'; +import { simulateRule } from '../core/simulateRule'; +import { checkRateLimit } from '../lib/ratelimit'; + +configEditor.post('/simulate-live', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + // Per-sub limit: live preview fires on debounced edits; cap to protect Reddit-API budget. + const rl = await checkRateLimit('simulate-live', auth.sub, 120, 60); + if (!rl.allowed) return c.json({ ok: false, error: 'Slow down a moment, then keep editing.' }, 429); + + const samples = await getRecentSample(auth.sub); + const result = await simulateRule(text, samples, auth.sub); + return c.json(result); +}); +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check` +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/simulate-live (dry-run impact on cached sample)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 6: `POST /api/config/explain` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +Mirror the cost controls already on `/api/explain-event` (breaker + per-sub + per-user rate limit). Reuse `resolveOpenaiKey` (Task 1) + `explainRule`. + +- [ ] **Step 1: Write the failing test** + +```ts +const explainRule = vi.fn(); +const resolveOpenaiKey = vi.fn(); +vi.mock('../../src/core/explainRule', () => ({ explainRule: (...a: unknown[]) => explainRule(...a) })); +vi.mock('../../src/lib/resolveOpenaiKey', () => ({ resolveOpenaiKey: (s: string) => resolveOpenaiKey(s) })); + +describe('POST /explain', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); resolveOpenaiKey.mockResolvedValue('sk-x'); }); + it('returns the explanation', async () => { + explainRule.mockResolvedValue({ ok: true, value: 'This rule removes crypto spam.' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, explanation: 'This rule removes crypto spam.' }); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** (rate-limit cost gate, fail-closed on degraded as the explain-event route does) + +```ts +// add to src/routes/configEditor.ts +import { explainRule } from '../core/explainRule'; +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; + +configEditor.post('/explain', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + const rl = await checkRateLimit('explain', `${auth.sub}:${auth.username}`, 10, 3600); + if (rl.degraded) return c.json({ ok: false, error: 'Rate-limit subsystem degraded. Retry in ~60s.' }, 503); + if (!rl.allowed) return c.json({ ok: false, error: `Your limit: ${rl.count}/${rl.max} this hour.` }, 429); + + const apiKey = await resolveOpenaiKey(auth.sub); + if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); + const result = await explainRule(text, apiKey); + if (!result.ok) return c.json({ ok: false, error: result.error }, 500); + return c.json({ ok: true, explanation: result.value }); +}); +``` + +- [ ] **Step 4: Run + commit** + +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/explain (AI explainer for the editor)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 7: `POST /api/config/save` (the privileged write) + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +Order of operations: mod-auth, re-validate (reject invalid), optimistic-lock check (re-read current revisionId, 409 if it moved since the editor loaded), `updateWikiPage`, then `publish` + stamp `cfgLastWikiRev`, then `logModActivity`. + +- [ ] **Step 1: Write the failing tests** (invalid -> 400, conflict -> 409, success -> updateWikiPage + publish called) + +```ts +const updateWikiPage = vi.fn(); +const publish = vi.fn(); +const logModActivity = vi.fn(); +// extend the @devvit/web/server mock's reddit with updateWikiPage already declared in Task 3 mock. +vi.mock('../../src/state/configStore', () => ({ + publish: (...a: unknown[]) => publish(...a), + getCurrentRev: vi.fn(), + getRecentRevs: vi.fn(), +})); +vi.mock('../../src/state/modActivity', () => ({ logModActivity: (...a: unknown[]) => logModActivity(...a) })); + +describe('POST /save', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + updateWikiPage.mockReset(); publish.mockReset(); getWikiPage.mockReset(); + }); + + it('rejects an invalid config without writing', async () => { + const r = await post('/save', { text: 'runs: "bad"', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(400); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('409s when the wiki moved since load', async () => { + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-2' }); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(409); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('saves a valid config and publishes', async () => { + getWikiPage.mockResolvedValue({ content: 'old', revisionId: 'rev-1' }); + publish.mockResolvedValue(0); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(200); + expect(updateWikiPage).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + }); +}); +``` + +NOTE: in the `@devvit/web/server` mock from Task 3, change `updateWikiPage: vi.fn()` to `updateWikiPage: (o: unknown) => updateWikiPage(o)` and declare `const updateWikiPage = vi.fn();` at the top so the assertion can see it. + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// add to src/routes/configEditor.ts +import { publish } from '../state/configStore'; +import { redis } from '@devvit/web/server'; +import { K } from '../state/keys'; +import { logModActivity } from '../state/modActivity'; + +configEditor.post('/save', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text, baseRevisionId } = await c.req.json<{ text?: string; baseRevisionId?: string | null }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + + // Gate 1: never write an invalid config to the live moderation wiki. + const parsed = parseConfig(text); + if (!parsed.ok) return c.json({ ok: false, error: 'config invalid', errors: parsed.errors }, 400); + + // Gate 2: optimistic lock. Re-read the current wiki rev; if it moved since the + // editor loaded, refuse so we never silently clobber a concurrent edit. + try { + const current = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + if (baseRevisionId && current.revisionId !== baseRevisionId) { + return c.json({ ok: false, error: 'The wiki changed since you opened the editor. Reload to merge.', conflict: true }, 409); + } + } catch (err) { + if (!isNotFound(err)) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Could not verify current wiki state: ${msg}` }, 503); + } + // not-found = first save on a fresh sub; allow the create. + } + + // Write, then publish the parsed snapshot + stamp the wiki rev so the 5-min cron + // does not re-publish an identical config. + try { + await reddit.updateWikiPage({ + subredditName: auth.sub, + page: WIKI_PAGE, + content: text, + reason: `Edited via ContextMod Observatory by u/${auth.username}`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/save', 'updateWikiPage failed', { err: msg, sub: auth.sub }); + return c.json({ ok: false, error: `Wiki write failed: ${msg}` }, 502); + } + + const rev = await publish(parsed.config, auth.sub); + try { + const after = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + await redis.set(K.cfgLastWikiRev(auth.sub), after.revisionId); + } catch { + /* stamping is best-effort; the cron self-heals next tick */ + } + const ruleCount = parsed.config.runs.flatMap((r) => r.checks).flatMap((ch) => ch.rules).length; + await logModActivity(auth.sub, { ts: Date.now(), actor: auth.username, kind: 'edit-config', detail: `${ruleCount} rules @ rev ${rev}` }); + return c.json({ ok: true, rev, ruleCount }); +}); +``` + +NOTE: confirm `publish`'s return value (the agent reported `publish(config, sub)` allocating a rev; if it returns void, drop `rev` from the response and read it via `getCurrentRev`). Confirm `K.cfgLastWikiRev` exists in `src/state/keys.ts` (the agent confirmed it does). + +- [ ] **Step 4: Run tests + type-check + lint** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check && npm run lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/save with validation gate + optimistic lock" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 2 — Client shell + editor + +### Task 8: Add CodeMirror dependencies + +**Files:** Modify `package.json` + `package-lock.json`. + +- [ ] **Step 1: Install** (pinned, CSP-safe set) + +```bash +npm install @codemirror/state@^6 @codemirror/view@^6 @codemirror/commands@^6 @codemirror/language@^6 @codemirror/lang-yaml@^6 @codemirror/lang-json@^6 @codemirror/lint@^6 @codemirror/autocomplete@^6 codemirror-json-schema@^0.8 +``` + +- [ ] **Step 2: Verify it builds + type-checks** + +Run: `npm run type-check && npm run build` +Expected: clean (deps resolve; no usage yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "build: add CodeMirror 6 + codemirror-json-schema deps" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 9: Client API helpers + wire types + +**Files:** +- Modify: `src/client/lib/api.ts`, `src/client/lib/types.ts` +- Test: `tests/client/config-api.test.ts` + +- [ ] **Step 1: Write the failing test** (mirror `tests/client/api.test.ts`; mock global `fetch`) + +```ts +// tests/client/config-api.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchConfigRawSafe, saveConfigSafe } from '../../src/client/lib/api'; + +beforeEach(() => { vi.restoreAllMocks(); }); + +describe('config api helpers', () => { + it('fetchConfigRawSafe returns content on 200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data.content).toBe('runs: []'); + }); + + it('saveConfigSafe surfaces a 409 conflict', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'wiki changed', conflict: true }), + { status: 409, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-api.test.ts` Expected: FAIL. + +- [ ] **Step 3: Add wire types** to `src/client/lib/types.ts` + +```ts +export type ConfigRaw = { content: string; revisionId: string | null; isDefaultTemplate: boolean }; +export type SaveResult = { rev: number; ruleCount: number }; +export type SimResult = { totalSamples: number; firedCount: number; erroredCount: number; firstError?: string }; +``` + +- [ ] **Step 4: Implement helpers** in `src/client/lib/api.ts` (follow the existing `ApiResult` + `extractServerError` + `demoSuffix` pattern already in the file) + +```ts +import type { ConfigRaw, SaveResult, SimResult } from './types'; + +export async function fetchConfigRawSafe(): Promise> { + try { + const res = await fetch(`/api/config/raw${demoSuffix()}`); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as ConfigRaw; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function validateConfigSafe(text: string): Promise<{ ok: boolean; errors?: unknown }> { + try { + const res = await fetch('/api/config/validate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + return (await res.json()) as { ok: boolean; errors?: unknown }; + } catch (err) { + return { ok: false, errors: err instanceof Error ? err.message : String(err) }; + } +} + +export async function simulateLiveSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/simulate-live', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as { ok: boolean; error?: string } & SimResult; + if (!data.ok) return { ok: false, error: data.error ?? 'simulation failed' }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function explainConfigSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/explain', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + const data = (await res.json()) as { ok: boolean; explanation?: string; error?: string }; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? await extractServerError(res) }; + return { ok: true, empty: false, data: data.explanation ?? '' }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function saveConfigSafe(text: string, baseRevisionId: string | null): Promise> { + try { + const res = await fetch('/api/config/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, baseRevisionId }), + }); + const data = (await res.json()) as { ok: boolean; error?: string } & SaveResult; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? await extractServerError(res) }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +``` + +- [ ] **Step 5: Run + commit** + +Run: `npm test -- tests/client/config-api.test.ts && npm run type-check` +```bash +git add src/client/lib/api.ts src/client/lib/types.ts tests/client/config-api.test.ts +git commit -m "feat(client): config editor API helpers (raw/validate/simulate/explain/save)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 10: `ConfigEditor` (CodeMirror 6 wrapper) + +**Files:** +- Create: `src/client/components/ConfigEditor.tsx` +- Test: `tests/client/config-editor.test.tsx` + +- [ ] **Step 1: Write the failing test** (jsdom render; assert the editor host mounts + shows the doc) + +```tsx +// tests/client/config-editor.test.tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { ConfigEditor } from '../../src/client/components/ConfigEditor'; + +describe('ConfigEditor', () => { + it('mounts and renders the initial document', () => { + const { container } = render( + {}} /> + ); + expect(container.querySelector('.cm-editor')).toBeTruthy(); + expect(container.textContent).toContain('runs'); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-editor.test.tsx` Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +```tsx +// src/client/components/ConfigEditor.tsx +import { useEffect, useRef } from 'react'; +import { EditorState } from '@codemirror/state'; +import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { yaml } from '@codemirror/lang-yaml'; +import { json } from '@codemirror/lang-json'; +import { lintGutter } from '@codemirror/lint'; +import { yamlSchema, jsonSchema } from 'codemirror-json-schema'; +import appSchema from '../../schema/app.schema.json'; + +// Devvit may enforce a strict style-src; pass the nonce if the platform injects one. +const nonce = (document.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null)?.content; + +export function ConfigEditor({ + value, format, onChange, +}: { value: string; format: 'yaml' | 'json'; onChange: (text: string) => void }) { + const host = useRef(null); + const view = useRef(null); + + useEffect(() => { + if (!host.current) return; + const schemaExt = format === 'json' + ? jsonSchema(appSchema as object) + : yamlSchema(appSchema as object); + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), highlightActiveLine(), history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + format === 'json' ? json() : yaml(), + schemaExt, + lintGutter(), + EditorView.updateListener.of((u) => { if (u.docChanged) onChange(u.state.doc.toString()); }), + ...(nonce ? [EditorView.cspNonce.of(nonce)] : []), + EditorView.theme({ '&': { height: '100%', fontSize: '13px' }, '.cm-scroller': { fontFamily: 'var(--cm-mono, monospace)' } }), + ], + }); + view.current = new EditorView({ state, parent: host.current }); + return () => { view.current?.destroy(); view.current = null; }; + // Re-init on format switch only; live value changes flow through onChange. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [format]); + + return
; +} +``` + +NOTE: confirm `codemirror-json-schema` exports `yamlSchema` + `jsonSchema` at the installed version (per its README); if the names differ, adjust the import. They each return a CM6 extension wiring schema completion + hover + lint. + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/client/config-editor.test.tsx && npm run type-check` +```bash +git add src/client/components/ConfigEditor.tsx tests/client/config-editor.test.tsx +git commit -m "feat(client): CodeMirror 6 ConfigEditor with schema hints + lint" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 11: `ConfigWorkbench` + ActionBar button + App wiring + +**Files:** +- Create: `src/client/components/ConfigWorkbench.tsx` +- Modify: `src/client/components/ActionBar.tsx`, `src/client/App.tsx` +- Test: `tests/client/config-workbench.test.tsx` + +- [ ] **Step 1: Write the failing test** (mock the api helpers; render workbench; assert load + Save call) + +```tsx +// tests/client/config-workbench.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; + +const fetchConfigRawSafe = vi.fn(); +const saveConfigSafe = vi.fn(); +const validateConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + fetchConfigRawSafe: () => fetchConfigRawSafe(), + saveConfigSafe: (...a: unknown[]) => saveConfigSafe(...a), + validateConfigSafe: (...a: unknown[]) => validateConfigSafe(...a), + simulateLiveSafe: vi.fn(async () => ({ ok: true, empty: false, data: { totalSamples: 0, firedCount: 0, erroredCount: 0 } })), + explainConfigSafe: vi.fn(async () => ({ ok: true, empty: false, data: '' })), +})); +vi.mock('@devvit/web/client', () => ({ requestExpandedMode: vi.fn() })); + +import { ConfigWorkbench } from '../../src/client/components/ConfigWorkbench'; + +beforeEach(() => { + fetchConfigRawSafe.mockResolvedValue({ ok: true, empty: false, data: { content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false } }); + validateConfigSafe.mockResolvedValue({ ok: true }); + saveConfigSafe.mockResolvedValue({ ok: true, empty: false, data: { rev: 1, ruleCount: 0 } }); +}); + +describe('ConfigWorkbench', () => { + it('loads config then saves', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(saveConfigSafe).toHaveBeenCalled()); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-workbench.test.tsx` Expected: FAIL. + +- [ ] **Step 3: Implement `ConfigWorkbench`** + +```tsx +// src/client/components/ConfigWorkbench.tsx +import { useEffect, useRef, useState } from 'react'; +import { ConfigEditor } from './ConfigEditor'; +import { PreviewPane } from './PreviewPane'; +import { fetchConfigRawSafe, validateConfigSafe, saveConfigSafe } from '../lib/api'; + +function detectFormat(text: string): 'yaml' | 'json' { + const t = text.trimStart(); + return t.startsWith('{') || t.startsWith('[') ? 'json' : 'yaml'; +} + +export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onClose: () => void }) { + const [text, setText] = useState(''); + const [format, setFormat] = useState<'yaml' | 'json'>('yaml'); + const [baseRev, setBaseRev] = useState(null); + const [valid, setValid] = useState(null); + const [status, setStatus] = useState('Loading...'); + const [saving, setSaving] = useState(false); + const debounce = useRef | null>(null); + + useEffect(() => { + (async () => { + const r = await fetchConfigRawSafe(); + if (r.ok && !r.empty) { + setText(r.data.content); setFormat(detectFormat(r.data.content)); + setBaseRev(r.data.revisionId); + setStatus(r.data.isDefaultTemplate ? 'New config (template)' : 'Loaded'); + } else { + setStatus(r.ok ? 'Empty' : `Load failed: ${r.error}`); + } + })(); + }, []); + + function onChange(next: string) { + setText(next); + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const v = await validateConfigSafe(next); + setValid(v.ok); + }, 600); + } + + async function onSave() { + if (saving || valid === false) return; + setSaving(true); setStatus('Saving...'); + const r = await saveConfigSafe(text, baseRev); + if (r.ok && !r.empty) { setStatus(`Saved, ${r.data.ruleCount} rules live (rev ${r.data.rev})`); } + else { setStatus(r.ok ? 'Saved' : `Save failed: ${r.error}`); } + setSaving(false); + } + + return ( +
+
+ Edit config: r/{subreddit} +
+ {valid === false ? 'invalid' : valid === true ? 'valid' : ''} + + +
+
+
+
+
+
+
{status}
+
+ ); +} +``` + +- [ ] **Step 4: ActionBar button.** In `src/client/components/ActionBar.tsx`, add `onEditConfig: () => void` to the props type and render a button in the left group (next to Reload), using the lucide `FileEdit` icon: + +```tsx + +``` + +Add `FileEdit` to the `lucide-react` import line. + +- [ ] **Step 5: App wiring.** In `src/client/App.tsx`: + - Add `const [editorOpen, setEditorOpen] = useState(false);` with the other state. + - Add a lazy import at top: `const ConfigWorkbench = lazy(() => import('./components/ConfigWorkbench').then(m => ({ default: m.ConfigWorkbench })));` and `import { lazy, Suspense } from 'react';` + - Pass `onEditConfig={() => setEditorOpen(true)}` to ``. + - In the Escape keyboard shortcut handler, add `setEditorOpen(false);`. + - Render near the other overlays: + ```tsx + {editorOpen && ( + + setEditorOpen(false)} /> + + )} + ``` + +- [ ] **Step 6: Run tests + type-check + lint + build** + +Run: `npm test -- tests/client/config-workbench.test.tsx && npm run type-check && npm run lint && npm run build` +Expected: PASS; build emits a separate lazy chunk for the workbench. + +- [ ] **Step 7: Commit** + +```bash +git add src/client/components/ConfigWorkbench.tsx src/client/components/ActionBar.tsx src/client/App.tsx tests/client/config-workbench.test.tsx +git commit -m "feat(client): ConfigWorkbench + Edit config entry (lazy-loaded)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +NOTE: `requestExpandedMode` from `@devvit/web/client` must be called from the click handler (a trusted event) to satisfy Devvit. Wire it inside `ActionBar`'s `onEditConfig` click (call `requestExpandedMode(e, 'default')` then `onEditConfig()`); pass the event through. Confirm the exact arg shape against the Devvit docs during Task 18 verification. + +--- + +## PHASE 3 — Schema hints + +### Task 12: Add `description` fields to the schema + +**Files:** +- Create: `scripts/gen-schema-descriptions.mjs` +- Modify: `src/schema/app.schema.json` +- Test: `tests/schema/descriptions.test.ts` + +`app.schema.json` has no `description` fields, so hover docs are empty. The JSDoc on the interfaces in `src/shared/types.ts` is the source of truth. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/schema/descriptions.test.ts +import { describe, it, expect } from 'vitest'; +import schema from '../../src/schema/app.schema.json'; + +describe('schema descriptions', () => { + it('documents the top-level runs property', () => { + const runs = (schema as any).properties?.runs; + expect(typeof runs?.description).toBe('string'); + expect(runs.description.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/schema/descriptions.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** — hand-add `description` to the common properties for v1 (root `runs`, and within each rule/action definition the high-traffic fields: `kind`, `name`, `checks`, `rules`, `actions`, `authorIs`, `itemIs`). Keep the text short and copied from the matching JSDoc in `src/shared/types.ts`. Example edit to `app.schema.json`: + +```json +"runs": { + "type": "array", + "description": "Ordered list of runs. Each run groups checks evaluated against new posts and comments.", + "items": { "$ref": "#/definitions/Run" } +} +``` + +Also create `scripts/gen-schema-descriptions.mjs` that reads `src/shared/types.ts`, extracts each interface property's leading JSDoc, and writes matching `description` fields, so future schema changes can regenerate rather than hand-edit. Add an npm script `"gen:schema-docs": "node scripts/gen-schema-descriptions.mjs"`. (For v1 the hand-added descriptions are the shipping artifact; the generator is the maintenance path.) + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/schema/descriptions.test.ts && npm run type-check` +```bash +git add src/schema/app.schema.json scripts/gen-schema-descriptions.mjs package.json tests/schema/descriptions.test.ts +git commit -m "feat(schema): add property descriptions for editor hover docs" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 4 — Preview pane + +### Task 13: `PreviewPane` shell + Explain tab + +**Files:** +- Create: `src/client/components/PreviewPane.tsx` +- Test: `tests/client/preview-pane.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// tests/client/preview-pane.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +vi.mock('../../src/client/lib/api', () => ({ + simulateLiveSafe: vi.fn(async () => ({ ok: true, empty: false, data: { totalSamples: 25, firedCount: 3, erroredCount: 0 } })), + explainConfigSafe: vi.fn(async () => ({ ok: true, empty: false, data: 'Explanation text' })), +})); +import { PreviewPane } from '../../src/client/components/PreviewPane'; + +describe('PreviewPane', () => { + it('shows the three tabs', () => { + render(); + expect(screen.getByRole('tab', { name: /impact/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /explain/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /diff/i })).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** (tabs; Impact debounced; Explain on demand; Diff placeholder wired in Task 14) + +```tsx +// src/client/components/PreviewPane.tsx +import { useEffect, useRef, useState } from 'react'; +import { simulateLiveSafe, explainConfigSafe } from '../lib/api'; + +type Tab = 'impact' | 'explain' | 'diff'; + +export function PreviewPane({ text }: { text: string }) { + const [tab, setTab] = useState('impact'); + const [impact, setImpact] = useState('edit to preview'); + const [explanation, setExplanation] = useState(''); + const debounce = useRef | null>(null); + + useEffect(() => { + if (tab !== 'impact') return; + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const r = await simulateLiveSafe(text); + setImpact(r.ok && !r.empty ? `Would fire on ${r.data.firedCount}/${r.data.totalSamples} recent items` : r.ok ? 'no result' : r.error); + }, 700); + return () => { if (debounce.current) clearTimeout(debounce.current); }; + }, [text, tab]); + + async function runExplain() { + setExplanation('...'); + const r = await explainConfigSafe(text); + setExplanation(r.ok && !r.empty ? r.data : `Explain failed: ${r.ok ? 'empty' : r.error}`); + } + + return ( +
+
+ + + +
+
+ {tab === 'impact' &&

{impact}

} + {tab === 'explain' && (
{explanation}
)} + {tab === 'diff' &&

Diff vs current rev (Task 14).

} +
+
+ ); +} +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/client/preview-pane.test.tsx && npm run type-check` +```bash +git add src/client/components/PreviewPane.tsx tests/client/preview-pane.test.tsx +git commit -m "feat(client): PreviewPane with live Impact + AI Explain tabs" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 14: Diff tab (edited text vs current rev) + +**Files:** +- Modify: `src/client/components/PreviewPane.tsx` +- Possibly modify: `src/client/components/ConfigDiffViewer.tsx` (export its LCS line-diff helper if not already exported) +- Test: `tests/client/preview-pane.test.tsx` (extend) + +- [ ] **Step 1: Write the failing test** — assert the Diff tab renders changed lines when `text` differs from a `currentText` prop. + +```tsx +it('diff tab shows changed lines', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + expect(screen.getByText(/runs: \[a\]/)).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** — add an optional `currentText?: string` prop to `PreviewPane`; in the diff tab, reuse the LCS line-diff helper from `ConfigDiffViewer.tsx` (export it from there if needed) to render added/removed lines between `currentText` and `text`. `ConfigWorkbench` passes `currentText` = the originally-loaded content. + +- [ ] **Step 4: Wire `currentText` from `ConfigWorkbench`** — store the loaded content in a ref (`loadedText`) and pass it: ``. + +- [ ] **Step 5: Run + commit** + +Run: `npm test -- tests/client/preview-pane.test.tsx && npm run type-check` +```bash +git add src/client/components/PreviewPane.tsx src/client/components/ConfigDiffViewer.tsx src/client/components/ConfigWorkbench.tsx tests/client/preview-pane.test.tsx +git commit -m "feat(client): Diff tab shows edited config vs current rev" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 5 — Polish + verification + +### Task 15: Conflict UX + format toggle + template seeding + +**Files:** Modify `src/client/components/ConfigWorkbench.tsx`. Test: extend `tests/client/config-workbench.test.tsx`. + +- [ ] **Step 1: Write failing tests** — (a) on a 409 save result, status shows a reload prompt and a "Reload" button appears; (b) a format toggle button switches `format` between yaml/json. + +```tsx +it('shows a reload prompt on 409 conflict', async () => { + saveConfigSafe.mockResolvedValue({ ok: false, error: 'The wiki changed since you opened the editor. Reload to merge.' }); + render( {}} />); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(screen.getByText(/wiki changed/i)).toBeTruthy()); + expect(screen.getByRole('button', { name: /reload/i })).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** — add a `conflict` state set when `saveConfigSafe` returns an error containing "wiki changed"; render a Reload button that re-runs the loader; add a format toggle button in the header that flips `format` (the editor re-inits on format change per Task 10). Seed the template note when `isDefaultTemplate` is true. + +- [ ] **Step 4: Run + commit** + +```bash +git add src/client/components/ConfigWorkbench.tsx tests/client/config-workbench.test.tsx +git commit -m "feat(client): conflict-reload UX + format toggle + template seeding" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 16: E2E (Playwright, demo mode) + +**Files:** Create `tests/e2e/config-editor.spec.ts` (mirror the existing E2E setup; serve via the dev mock server / built dashboard). + +- [ ] **Step 1: Write the E2E test** + +```ts +// tests/e2e/config-editor.spec.ts +import { test, expect } from '@playwright/test'; + +test('open editor, edit, see impact, save', async ({ page }) => { + await page.goto('http://127.0.0.1:5173/?demo=1'); + await page.getByRole('button', { name: /edit config/i }).click(); + await expect(page.locator('.cm-editor')).toBeVisible(); + await page.locator('.cm-content').click(); + await page.keyboard.type('\nruns: []'); + await page.getByRole('tab', { name: /impact/i }).click(); + await expect(page.getByText(/would fire on/i)).toBeVisible(); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.getByText(/saved|rules live/i)).toBeVisible(); +}); +``` + +NOTE: demo mode must back the new endpoints. Add `?demo=1` handling to `/api/config/raw` (return a sample YAML config), `/simulate-live` (return a canned `{ ok:true, totalSamples:25, firedCount:3 }`), and `/save` (return `{ ok:true, rev:1, ruleCount:1 }` without writing) so the dashboard runs end-to-end with no live install. Add these demo branches to `configEditor.ts` and a quick unit assertion for each. + +- [ ] **Step 2: Run** — Run: `npm run build && npx playwright test tests/e2e/config-editor.spec.ts` (start the mock server per the existing E2E harness). Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/config-editor.spec.ts src/routes/configEditor.ts +git commit -m "test(e2e): config editor open/edit/impact/save happy path (demo)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 17: Devvit build-phase verification (playtest) + +**Files:** none (operational). Record results in the PR description. + +- [ ] **Step 1:** `npx devvit playtest `; open the dashboard custom post; click Edit config. Confirm: (a) the CM6 editor renders (no blank pane), (b) the browser console shows no CSP violation; if a `style-src` violation appears, confirm the `meta[property="csp-nonce"]` exists and is read by `ConfigEditor` (if Devvit injects the nonce under a different selector, update the selector). +- [ ] **Step 2:** Type a known-bad config; confirm inline lint squiggles + Save disabled. Type a valid config; confirm Impact shows a fire-rate. +- [ ] **Step 3:** Click Save; confirm `reddit.updateWikiPage` succeeds (open the wiki page and verify the new content + the "Edited via ContextMod Observatory" revision reason). If it 403s, the app account lacks wiki-edit on the sub: confirm the app's moderator permissions include wiki, or document the required permission in the README. +- [ ] **Step 4:** Re-check the dashboard initial JS chunk size (`npm run build` output) is unchanged from before this feature; confirm the workbench is a separate lazy chunk. +- [ ] **Step 5: Commit** any selector/permission fixes found, then open the PR. + +```bash +git commit -am "fix(client): align CSP nonce selector with Devvit webview" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Self-review (run before handoff) + +- Spec coverage: load (T3), validate (T4), live impact (T2+T5+T13/14), AI explain (T1+T6+T13), diff (T14), save-back with validation gate + optimistic lock (T7), editor + schema hints (T8-T12), surface + lazy-load (T11), conflict/format/template polish (T15), tests + e2e (per task + T16), build-phase verification (T17). All spec sections map to a task. +- Placeholder scan: every code step has real code; the two `NOTE` items (confirm `publish` return; confirm `codemirror-json-schema` export names) are explicit verification steps with a fallback, not blanks. +- Type consistency: `ConfigRaw`/`SaveResult`/`SimResult` defined in T9 are used consistently in T9-T15; `ApiResult` matches the existing client convention; endpoint paths (`/api/config/raw|validate|simulate-live|explain|save`) are consistent across server (T3-T7) and client (T9). + +--- + +## Open verification items carried into build + +1. `publish(config, sub)` return value (rev number vs void) — T7 NOTE. +2. `codemirror-json-schema` export names at the installed version — T10 NOTE. +3. Devvit `requestExpandedMode` exact signature — T11 NOTE. +4. Devvit webview CSP `style-src` nonce selector — T17. +5. App account wiki-edit permission — T17. diff --git a/docs/superpowers/specs/2026-05-27-config-editor-design.md b/docs/superpowers/specs/2026-05-27-config-editor-design.md new file mode 100644 index 0000000..266d212 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-config-editor-design.md @@ -0,0 +1,141 @@ +# ContextMod Observatory: In-App Config Editor (Workbench) — Design Spec + +- Date: 2026-05-27 +- Status: Approved for planning +- Source feedback: FoxxMD (upstream ContextMod author), Discord 2026-05-27 + +## 1. Problem + +Today a mod edits the rule config only in Reddit's raw wiki page (`r//wiki/botconfig/contextmod`). The Observatory dashboard just links out to it (`src/client/components/ActionBar.tsx:122`). No syntax help, no validation, no save-back. FoxxMD flagged this as the top friction point: "in-place editing and syntax validation are definitely the most crucial components." Non-technical mods must copy/paste between windows and match whitespace exactly. + +## 2. Goal + +An in-app config editor inside the Observatory dashboard that loads the current wiki config, edits it with syntax and schema help, validates inline, previews real moderation impact, and saves back to the wiki. No copy/paste. No external hosting. CSP-safe. + +## 3. Scope (v1 = full feature set) + +In: +- Expanded-mode workbench (`requestExpandedMode`) with split layout. +- CodeMirror 6 editor: YAML default, JSON5 supported, auto-detected. +- Schema-driven autocomplete + hover docs + inline AJV validation (squiggles + gutter). +- Live Impact pane: rule simulation against a cached sample of the sub's recent items, updating on debounced edit. +- AI Explain pane: plain-English summary of the current config (reuse `explainRule`). +- Diff pane: edited config vs current published rev (reuse `ConfigDiffViewer`). +- Save-back to wiki via `updateWikiPage`, server-validated, with optimistic-lock conflict guard. + +Non-goals (deferred): +- Operator-level vs subreddit-level schema toggle (not needed for the per-sub Devvit model). +- Removal-reasons helper, multi-wiki-page editing, real-time multi-user collaboration. + +## 4. Key decisions + rationale + +- Editor is CodeMirror 6, not monaco. Monaco hard-requires `unsafe-eval`: its language workers bootstrap by evaluating code strings at runtime, and it loads those workers from external URLs. Devvit webviews block client-side external fetch and do not guarantee `unsafe-eval`. Monaco is also multi-MB. CM6 core is free of runtime code evaluation (verified against published bundles), worker-free, roughly 120 to 180 KB gzipped, and `codemirror-json-schema` provides schema autocomplete + hover + lint for YAML and JSON. Net: the monaco-style experience delivered CSP-safe. +- Surface is `requestExpandedMode` (Devvit-native: fullscreen on mobile, large modal on web), triggered on click. Replaces FoxxMD's new-tab pattern, which depends on his external host. Keeps the no-hosting model. +- Format is YAML default, JSON5 also supported, auto-detected via the existing `sniffFormat`. Rationale (FoxxMD): AutoMod uses YAML, so mods transfer the skill. The parser already accepts both. +- Reuse, do not rebuild: `simulateRule`, `explainRule`, `ConfigDiffViewer`, `parseConfig` + AJV, `app.schema.json`, `configStore.publish`, `loadFromWiki` all already exist. + +## 5. Architecture + +### 5.1 Surface + layout + +`ActionBar` gets an "Edit config" button. On click, the client calls `requestExpandedMode` (from `@devvit/web/client`) and mounts `ConfigWorkbench`. Layout: left is the editor, right is a tabbed preview (Impact / Explain / Diff). Top bar: format indicator, validity indicator (valid or N errors), Validate, and Save to r/. + +### 5.2 Client components (new + reused) + +- `ConfigWorkbench.tsx` (new): orchestrator; lazy-loaded via dynamic import so CM6 is not in the dashboard's initial bundle. +- `ConfigEditor.tsx` (new): CM6 wrapper. Extensions: lang-yaml + lang-json, `codemirror-json-schema` (schema is `app.schema.json`), lint (AJV-backed), `EditorView.cspNonce`. +- `PreviewPane.tsx` (new): tabs. + - ImpactTab: per-rule "would fire on N/M recent items" + example thingIds. + - ExplainTab: AI explanation (reuse `explainRule`). + - DiffTab: reuse `ConfigDiffViewer` (edited text vs current rev). +- api client (`src/client/lib`): add raw / save / simulate-live calls. + +### 5.3 Server endpoints (`src/routes/api.ts`, new) + +- `GET /api/config/raw`: returns `{ content, revisionId, format }` via the existing wiki read (`reddit.getWikiPage`, `src/core/configSource.ts:64`). +- `POST /api/config/validate`: body `{ text }`; runs `parseConfig` + AJV; returns `{ ok, errors }`. The server validate is the authority; the client may also run AJV for instant feedback. +- `POST /api/config/simulate-live`: body `{ text }`; parses, then runs the existing dry-run `simulateRule` against a cached sample of recent items; returns per-rule fire counts + examples. Dry-run only, zero side effects. +- `POST /api/config/save`: body `{ text, baseRevisionId }`; server re-parses + AJV-validates (reject if invalid); re-reads the current wiki `revisionId`, returns 409 if it differs from `baseRevisionId` (optimistic lock); else `reddit.updateWikiPage({ subredditName, page: WIKI_PAGE, content: text, reason })`; then publish + reload; returns `{ rev, ruleCount }`. + +### 5.4 Live Impact sampling (the looks-impossible part, made cheap) + +On workbench open, fetch ONE sample of recent items (for example the last 25 posts plus recent comments) via the existing read path, normalize, and cache (Redis, short TTL, keyed by sub + session). On each debounced valid edit (about 700 ms), `simulate-live` re-runs the proposed rules against the CACHED sample only. No repeated Reddit fetches, pure CPU, so live updates are fast and stay within API limits. It uses the dry-run simulation path, so no real moderation actions ever fire. + +### 5.5 Save safety (writes to the live moderation config) + +- Server-side re-validation is the gate: a config that fails AJV is never written to the wiki. +- Optimistic lock: save includes the `revisionId` the editor loaded; if the wiki changed since (concurrent edit), return 409 and prompt reload. Reuses the `cfgLastWikiRev` concept. +- Write as the app account (existing moderator scope). The `reason` string records the acting mod for the wiki audit log. +- Auth: save + simulate endpoints verify the caller is a moderator of the sub (reuse the app's existing mod-auth pattern, defense-in-depth per the prior review wave). +- The wiki retains revision history and `configStore` keeps `cfg:rev` snapshots, so revert is restoring a prior rev (the diff viewer shows what changed). + +### 5.6 Schema hover docs + +`app.schema.json` currently has no `description` fields. Add them so `codemirror-json-schema` shows hover documentation. Source the text from the JSDoc already on the interfaces in `src/shared/types.ts`. Prefer generating the schema (or its descriptions) from the types via `ts-json-schema-generator` to keep them in sync; if generation is heavy, hand-add descriptions for the common rule / action / filter properties in v1. + +## 6. Data flow (end to end) + +1. Open: click Edit config, call `requestExpandedMode`, lazy-load `ConfigWorkbench`, `GET /api/config/raw`, populate CM6, stamp `baseRevisionId`. Kick off one sample fetch for Impact. +2. Edit: keystrokes, CM6 + schema autocomplete/hover, debounced AJV lint (squiggles), validity indicator updates. +3. Impact: on debounced valid edit, `POST /api/config/simulate-live` (cached sample), ImpactTab renders fire-rate + examples. +4. Explain / Diff: on demand per tab. +5. Save: `POST /api/config/save`, server validate + lock check, `updateWikiPage`, publish + reload, toast "Saved, N rules live (rev R)". On 409, "Wiki changed, reload to merge". + +## 7. Error handling + edge cases + +- Invalid config: lint shows errors; Save disabled; server double-checks and rejects. +- Wiki read fails (404 fresh install, or breaker open): editor opens with the default-config template (`src/config/default-config.ts`) and a note. +- Save conflict (409): prompt reload; do not overwrite. +- Empty or first config: seed the editor with the default template; offer the YAML variant given AutoMod parity. +- Reddit API hiccup on save: surface the error, do not mark saved, config unchanged. +- CSP: pass `EditorView.cspNonce`; verify in playtest. +- Large config: CM6 handles large docs; lint debounced. + +## 8. Bundle + performance + +- Lazy-load the workbench (dynamic import) so the dashboard initial bundle stays near its current size (about 120 KB); CM6 (about 120 to 180 KB gzipped) loads only when Edit config is clicked. +- Tree-shake CM6 to the needed packages (state, view, language, lang-yaml, lang-json, lint, autocomplete, `codemirror-json-schema`, ajv). +- The sample is cached so live simulation is CPU-only after the first fetch. + +## 9. Security + +- Wiki-write is a new privileged surface. Gate every save behind a server-side mod-auth check plus server-side AJV validation. Never trust client-validated input. +- `updateWikiPage` runs as the app account (moderator scope already declared). Confirm the app's mod permissions include wiki edit at install (build-phase verification). +- Dry-run only for simulation; no real actions from the editor. +- The `reason` string on the wiki write records the acting mod for audit. + +## 10. Testing + +- Unit (vitest): `/api/config/raw` read; `/api/config/save` (rejects invalid, 409 on rev mismatch, success path calls `updateWikiPage` + publish); `simulate-live` reuses `simulateRule` + cached sample + dry-run; auth gate rejects non-mods. +- Client: `ConfigEditor` mounts CM6 and surfaces AJV lint; `PreviewPane` tab switching; Save disabled when invalid. +- E2E (Playwright, `?demo=1` with a mock wiki): open workbench, type invalid YAML (error + Save disabled), type valid (Impact shows fire-rate), Save (toast). axe-core scan in light + dark. +- Reuse the existing test harness and patterns. + +## 11. Build-phase verifications (must pass before ship) + +- CM6 renders inside a real Devvit playtest webview; determine whether `style-src` needs the nonce; confirm no runtime code-evaluation path is hit. +- App account has wiki-EDIT permission for `updateWikiPage` at install. +- Bundle budget: dashboard initial load unchanged; workbench chunk acceptable. + +## 12. Build order within v1 + +1. Server: `/api/config/raw` + `/api/config/save` (validate gate + `updateWikiPage` + publish + 409 lock + mod-auth). Wiki-edit perm. +2. Client shell: ActionBar button + `requestExpandedMode` + `ConfigWorkbench` + `ConfigEditor` (CM6 YAML/JSON5 syntax) + load/save + lazy-load. +3. Schema help: descriptions on `app.schema.json` (from types JSDoc) + `codemirror-json-schema` autocomplete/hover/lint. +4. Preview: Diff tab (reuse), Explain tab (reuse), Impact tab (cached sample + live dry-run simulate). +5. Polish: validity + conflict UX, default-config seeding, mobile, accessibility. + +## 13. Traceability to FoxxMD's asks + +| FoxxMD ask | Delivered by | +|---|---| +| in-place editing (crucial) | `ConfigWorkbench` + CM6 in expanded mode | +| syntax validation (crucial) | `codemirror-json-schema` lint + AJV, inline | +| load + save wiki, no copy/paste | `/api/config/raw` + `/api/config/save` (`updateWikiPage`) | +| YAML (AutoMod parity) | YAML default, JSON5 also | +| monaco bells-and-whistles | CM6 schema autocomplete + hover (CSP-safe) | +| exceeds his editor | live Impact simulation + AI explain + diff | + +## 14. Open questions + +None blocking. Decide at build time: generate schema descriptions from JSDoc versus hand-author (lean toward generate). diff --git a/package-lock.json b/package-lock.json index e63902c..b2b30d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,18 @@ "version": "0.6.7", "license": "MIT", "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", "@hono/node-server": "^2.0.3", "ajv": "^8.17.1", "blockhash-core": "^0.1.0", + "codemirror-json-schema": "^0.8.1", "hono": "^4.12.21", "jpeg-js": "^0.4.4", "js-yaml": "^4.1.1", @@ -527,6 +536,101 @@ "specificity": "bin/cli.js" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@commitlint/cli": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.1.tgz", @@ -3074,6 +3178,58 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -4066,6 +4222,22 @@ "win32" ] }, + "node_modules/@sagold/json-pointer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@sagold/json-pointer/-/json-pointer-5.1.2.tgz", + "integrity": "sha512-+wAhJZBXa6MNxRScg6tkqEbChEHMgVZAhTHVJ60Y7sbtXtu9XA49KfUkdWlS2x78D6H9nryiKePiYozumauPfA==", + "license": "MIT" + }, + "node_modules/@sagold/json-query": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@sagold/json-query/-/json-query-6.2.0.tgz", + "integrity": "sha512-7bOIdUE6eHeoWtFm8TvHQHfTVSZuCs+3RpOKmZCDBIOrxpvF/rNFTeuvIyjHva/RR0yVS3kQtr+9TW72LQEZjA==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.2", + "ebnf": "^1.9.1" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -4073,6 +4245,85 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", + "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", + "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "^2.2.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", + "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", + "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/markdown-it": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-1.29.2.tgz", + "integrity": "sha512-RPHqGU8RGQZ2TGMnEqLnSyM9CjPSjb0f8bwSLnJgBmWPWguoygoaFyYkXG0kwMtBtChNYsqQz1C0fLcbo6dY8g==", + "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.0", + "shiki": "1.29.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", + "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/types": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", + "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -4298,6 +4549,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -4312,6 +4572,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mustache": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz", @@ -4356,6 +4625,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -4651,6 +4926,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -5139,6 +5420,12 @@ "node": ">=6.0.0" } }, + "node_modules/best-effort-json-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/best-effort-json-parser/-/best-effort-json-parser-1.4.1.tgz", + "integrity": "sha512-1e2yvg4LieOlqDw7rAOvHetMJeJ2sGezLX/2Ma0oykFd8TMfMxdDBzNUXjISPeMZAHe5CCvPRJtHmISoTC6MsA==", + "license": "BSD-2-Clause" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5412,6 +5699,16 @@ "cdl": "bin/cdl.js" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5456,6 +5753,26 @@ "node": ">=12.20" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -5701,6 +6018,53 @@ "node": ">=6" } }, + "node_modules/codemirror-json-schema": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/codemirror-json-schema/-/codemirror-json-schema-0.8.1.tgz", + "integrity": "sha512-4lKPjW+nugNAmM5MsggJyn6TUxYdCCwAJIr9T4cZeTFPdkbBvPteCOGtDedrTOIeTC2ZFJtVg7VHIXnYU32t8w==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.1", + "@shikijs/markdown-it": "^1.22.2", + "best-effort-json-parser": "^1.1.2", + "json-schema": "^0.4.0", + "json-schema-library": "^9.3.5", + "loglevel": "^1.9.1", + "markdown-it": "^14.1.0", + "shiki": "^1.22.2", + "yaml": "^2.3.4" + }, + "optionalDependencies": { + "@codemirror/autocomplete": "^6.16.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-yaml": "^6.1.1", + "codemirror-json5": "^1.0.3", + "json5": "^2.2.3" + }, + "peerDependencies": { + "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.2.1" + } + }, + "node_modules/codemirror-json5": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/codemirror-json5/-/codemirror-json5-1.0.3.tgz", + "integrity": "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "json5": "^2.2.1", + "lezer-json5": "^2.0.2" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5734,6 +6098,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -5923,6 +6297,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6215,6 +6595,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -6377,9 +6766,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6394,6 +6781,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/devvit": { "version": "0.12.24", "resolved": "https://registry.npmjs.org/devvit/-/devvit-0.12.24.tgz", @@ -6444,6 +6844,12 @@ "node": ">=8" } }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -6521,6 +6927,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ebnf": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ebnf/-/ebnf-1.9.1.tgz", + "integrity": "sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==", + "license": "MIT", + "bin": { + "ebnf": "dist/bin.js" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6551,6 +6966,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -7032,6 +7453,12 @@ "node": ">=12.17.0" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7693,6 +8120,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.12.21", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", @@ -7736,6 +8199,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-call": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", @@ -8715,6 +9188,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-library": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/json-schema-library/-/json-schema-library-9.3.5.tgz", + "integrity": "sha512-5eBDx7cbfs+RjylsVO+N36b0GOPtv78rfqgf2uON+uaHUIC62h63Y8pkV2ovKbaL4ZpQcHp21968x5nx/dFwqQ==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.2", + "@sagold/json-query": "^6.1.3", + "deepmerge": "^4.3.1", + "fast-copy": "^3.0.2", + "fast-deep-equal": "^3.1.3", + "smtp-address-parser": "1.0.10", + "valid-url": "^1.0.9" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -8870,6 +9364,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-json5": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lezer-json5/-/lezer-json5-2.0.2.tgz", + "integrity": "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@lezer/lr": "^1.0.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -9161,6 +9665,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9234,6 +9757,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -9345,6 +9881,45 @@ "dev": true, "license": "ISC" }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9355,6 +9930,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -9362,6 +9958,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -9385,6 +9987,95 @@ "node": ">= 8" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -9520,6 +10211,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9591,6 +10288,34 @@ "node": "*" } }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -9712,6 +10437,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-to-es": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" + } + }, "node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -10436,6 +11172,16 @@ "node": ">= 6" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -10484,6 +11230,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", @@ -10529,6 +11284,25 @@ ], "license": "MIT" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10643,6 +11417,31 @@ "esprima": "~4.0.0" } }, + "node_modules/regex": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "license": "MIT", + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -10724,6 +11523,15 @@ "dev": true, "license": "ISC" }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11046,6 +11854,22 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", + "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "1.29.2", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/langs": "1.29.2", + "@shikijs/themes": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -11148,6 +11972,18 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smtp-address-parser": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", + "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", + "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11158,6 +11994,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -11282,6 +12128,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11362,6 +12222,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -11910,6 +12776,16 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -12178,6 +13054,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -12221,6 +13103,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -12320,6 +13270,39 @@ "dev": true, "license": "MIT" }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", @@ -13507,6 +14490,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -13790,6 +14779,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -13907,6 +14911,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 61e40d0..e29c4e1 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,18 @@ "node": "^20.19.0 || >=22.12.0" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", "@hono/node-server": "^2.0.3", "ajv": "^8.17.1", "blockhash-core": "^0.1.0", + "codemirror-json-schema": "^0.8.1", "hono": "^4.12.21", "jpeg-js": "^0.4.4", "js-yaml": "^4.1.1", diff --git a/scripts/dev/mock-server.cjs b/scripts/dev/mock-server.cjs index c11f2ab..a9c98d9 100644 --- a/scripts/dev/mock-server.cjs +++ b/scripts/dev/mock-server.cjs @@ -110,6 +110,36 @@ const server = http.createServer((req, res) => { 'Mock explanation — dev:web mock-server returns a stub here. In production this is OpenAI gpt-4o-mini via /api/explain-event.', }); + // Config editor endpoints — demo/dev stubs so the editor can be opened, + // screenshotted, and E2E-tested without a live Devvit install. + // GET /api/config/raw: client appends ?demo=1 via demoSuffix(); match on + // pathname prefix so the query string does not affect routing. + if (req.method === 'GET' && url.pathname.startsWith('/api/config/raw')) + return sendJson(res, 200, { + content: 'runs:\n - name: spam-filter\n checks: []', + revisionId: 'demo-rev-1', + isDefaultTemplate: false, + }); + // POST endpoints: client sends to /api/config/ without ?demo=1 + // (POSTs never append demoSuffix in api.ts). Match by pathname. + if (req.method === 'POST' && url.pathname === '/api/config/validate') + return sendJson(res, 200, { ok: true, format: 'yaml' }); + if (req.method === 'POST' && url.pathname === '/api/config/simulate-live') + return sendJson(res, 200, { + ok: true, + totalSamples: 25, + firedCount: 3, + erroredCount: 0, + breakdown: [], + }); + if (req.method === 'POST' && url.pathname === '/api/config/explain') + return sendJson(res, 200, { + ok: true, + explanation: 'This config removes spam posts that match the rule.', + }); + if (req.method === 'POST' && url.pathname === '/api/config/save') + return sendJson(res, 200, { ok: true, rev: 2, ruleCount: 1 }); + // Unknown /api/* paths return JSON 404 — not HTML — so the client // gets a parseable error instead of crashing on "Unexpected token <". if (url.pathname.startsWith('/api/')) { diff --git a/src/client/App.tsx b/src/client/App.tsx index 8b8bef0..96e3013 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'; import { Header } from './components/Header'; import { StatsRow } from './components/StatsRow'; import { Sparkline } from './components/Sparkline'; @@ -19,6 +19,12 @@ import { ModActivityFeed } from './components/ModActivityFeed'; import { fetchRecentSafe, fetchStatsSafe, DEMO_EVENTS, DEMO_STATS, ZERO_STATS } from './lib/api'; import type { EventRecord, StatsRollup } from './lib/types'; +// ConfigWorkbench contains CodeMirror 6 (~70KB). Lazy-load so CM6 stays in +// a separate chunk and does NOT inflate the main dashboard entry bundle. +const ConfigWorkbench = lazy(() => + import('./components/ConfigWorkbench').then((m) => ({ default: m.ConfigWorkbench })) +); + const POLL_MS = 10_000; // Demo data is OPT-IN only via ?demo=1 — production never shows fabricated mod @@ -37,6 +43,7 @@ export default function App() { const [overlayOpen, setOverlayOpen] = useState(false); const [tourOpen, setTourOpen] = useState(() => !hasSeenTour()); const [historyOpen, setHistoryOpen] = useState(false); + const [editorOpen, setEditorOpen] = 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). @@ -118,6 +125,7 @@ export default function App() { handler: () => { setOverlayOpen(false); setHistoryOpen(false); + setEditorOpen(false); }, }, { @@ -282,7 +290,7 @@ export default function App() { - + setEditorOpen(true)} />
setHistoryOpen(false)} /> + {editorOpen && ( + + setEditorOpen(false)} /> + + )} {/* AE Polish #3: gate tour on !initialLoad so the modal doesn't open on top of shimmer-skeleton dashboard. Judges who haven't seen the tour got the modal at t=0 + couldn't see what it was diff --git a/src/client/components/ActionBar.tsx b/src/client/components/ActionBar.tsx index 03fb2da..98d49ae 100644 --- a/src/client/components/ActionBar.tsx +++ b/src/client/components/ActionBar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { RefreshCw, FileText, ExternalLink, Download } from 'lucide-react'; +import { RefreshCw, FileText, ExternalLink, Download, FileEdit } from 'lucide-react'; import type { EventRecord } from '../lib/types'; import { eventsToCsv, csvFilename } from '../lib/csv-export'; @@ -22,10 +22,12 @@ export function ActionBar({ subreddit, onReload, events, + onEditConfig, }: { subreddit: string; onReload: () => Promise | void; events: EventRecord[]; + onEditConfig: () => void; }) { const [busy, setBusy] = useState(false); const [flash, setFlash] = useState(null); @@ -68,6 +70,22 @@ export function ActionBar({ scheduleFlashClear(); }; + // requestExpandedMode is in @devvit/client (browser condition only; the + // package exports map has no `types` field so tsc resolves to the panic + // shim in non-browser environments). We call it via a runtime-only path: + // dynamic import + unknown cast so the static type-checker is satisfied, + // and the whole block is best-effort (catch keeps the editor opening even + // when the Devvit runtime is absent or already expanded). + const handleEdit = async (e: React.MouseEvent) => { + try { + const devvitClient = await import('@devvit/client') as unknown as { + requestExpandedMode: (event: MouseEvent, entry: string) => Promise | void; + }; + await devvitClient.requestExpandedMode(e.nativeEvent, 'default'); + } catch { /* expand is best-effort; open the editor regardless */ } + onEditConfig(); + }; + const wikiUrl = `https://www.reddit.com/r/${subreddit}/wiki/botconfig/contextmod`; const exportDisabled = events.length === 0; @@ -115,6 +133,15 @@ export function ActionBar({ )} + +
diff --git a/src/client/components/ConfigEditor.tsx b/src/client/components/ConfigEditor.tsx new file mode 100644 index 0000000..254e91b --- /dev/null +++ b/src/client/components/ConfigEditor.tsx @@ -0,0 +1,112 @@ +/** + * ConfigEditor — CodeMirror 6 editor wired to the app JSON Schema. + * + * Provides syntax highlighting, inline lint markers, autocompletion, and hover + * documentation for both YAML and JSON formats, driven by the same + * app.schema.json that AJV uses server-side for validation. + * + * Format-change causes a full editor teardown+recreate (different language + + * schema extension). External value changes (load, reload, conflict-reload) are + * patched in via a single dispatch without recreating the editor, preserving + * undo history and cursor position when possible. + */ +import { useEffect, useRef } from 'react'; +import { EditorState } from '@codemirror/state'; +import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { yaml } from '@codemirror/lang-yaml'; +import { json } from '@codemirror/lang-json'; +import { lintGutter } from '@codemirror/lint'; +import { jsonSchema } from 'codemirror-json-schema'; +import { yamlSchema } from 'codemirror-json-schema/yaml'; +import type { JSONSchema7 } from 'json-schema'; +import appSchemaRaw from '../../schema/app.schema.json'; + +// Cast the bundled JSON to JSONSchema7 so the schema extension functions accept +// it without complaint. The schema IS draft-07 so the cast is semantically +// correct. Using `unknown` as the intermediate step is the type-safe way to +// cross runtime / declaration boundaries when the import type is `object`. +const appSchema = appSchemaRaw as unknown as JSONSchema7; + +// Devvit may enforce a strict style-src; pass the nonce if the platform +// injects one via a tag. Guard against jsdom / +// SSR where document may not have that element. +function readCspNonce(): string | undefined { + if (typeof document === 'undefined') return undefined; + return ( + (document.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null)?.content ?? + undefined + ); +} + +export interface ConfigEditorProps { + value: string; + format: 'yaml' | 'json'; + onChange: (text: string) => void; +} + +export function ConfigEditor({ value, format, onChange }: ConfigEditorProps) { + const host = useRef(null); + const view = useRef(null); + + // Create / recreate the editor when the format changes (the language extension + // and schema extension both differ). The onChange closure is captured via a + // stable ref so that switching format doesn't re-trigger this effect. + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }); + + useEffect(() => { + if (!host.current) return; + + const schemaExt = + format === 'json' ? jsonSchema(appSchema) : yamlSchema(appSchema); + + const nonce = readCspNonce(); + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + format === 'json' ? json() : yaml(), + ...schemaExt, + lintGutter(), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + }), + ...(nonce ? [EditorView.cspNonce.of(nonce)] : []), + EditorView.theme({ '&': { height: '100%', fontSize: '13px' } }), + ], + }); + + view.current = new EditorView({ state, parent: host.current }); + + return () => { + view.current?.destroy(); + view.current = null; + }; + // Recreate only on format change. External value edits handled by the sync + // effect below. `value` and `onChange` are intentionally omitted from the + // dep array: value changes are applied via dispatch (no recreate needed), + // and onChange is captured through onChangeRef so its identity is stable. + }, [format]); + + // Sync EXTERNAL value changes (async load, reload, conflict-reload) into the + // live editor without recreating it. The identity guard prevents a feedback + // loop: a user keystroke flows out via onChange -> parent state -> value prop, + // but value already equals the current doc, so no dispatch fires. + useEffect(() => { + const v = view.current; + if (v && value !== v.state.doc.toString()) { + v.dispatch({ + changes: { from: 0, to: v.state.doc.length, insert: value }, + }); + } + }, [value]); + + return
; +} diff --git a/src/client/components/ConfigWorkbench.tsx b/src/client/components/ConfigWorkbench.tsx new file mode 100644 index 0000000..70b9cb7 --- /dev/null +++ b/src/client/components/ConfigWorkbench.tsx @@ -0,0 +1,147 @@ +/** + * ConfigWorkbench — full-screen config editor overlay. + * + * Renders the CodeMirror config editor on the left and a PreviewPane + * (Impact / Explain / Diff) on the right. Handles load, debounced + * validation, save, and optimistic-lock conflict detection. + * + * baseRev-refresh invariant: after a successful save the wiki revisionId + * is re-fetched (via load()) so a second save in the same session does + * not false-conflict with a 409. The /save response returns internal + * rev + ruleCount, NOT the wiki revisionId, so we cannot derive it from + * the save response alone. + */ +import { useEffect, useRef, useState } from 'react'; +import { ConfigEditor } from './ConfigEditor'; +import { PreviewPane } from './PreviewPane'; +import { fetchConfigRawSafe, validateConfigSafe, saveConfigSafe } from '../lib/api'; + +function detectFormat(text: string): 'yaml' | 'json' { + const t = text.trimStart(); + return t.startsWith('{') || t.startsWith('[') ? 'json' : 'yaml'; +} + +export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onClose: () => void }) { + const [text, setText] = useState(''); + // Baseline for the Diff tab. Set by load() to the wiki content on + // initial load and after a save-triggered reload (so it converges with + // `text` and the Diff shows no changes post-save). Never updated on + // user edits. + const [loadedText, setLoadedText] = useState(''); + const [format, setFormat] = useState<'yaml' | 'json'>('yaml'); + const [baseRev, setBaseRev] = useState(null); + const [valid, setValid] = useState(null); + const [status, setStatus] = useState('Loading...'); + const [saving, setSaving] = useState(false); + const [conflict, setConflict] = useState(false); + const debounce = useRef | null>(null); + + /** + * Load (and re-load) the raw config from the wiki. + * statusOverride lets a post-save call keep the "Saved" message visible + * while still refreshing baseRev to the current wiki revisionId. + */ + async function load(statusOverride?: string) { + const r = await fetchConfigRawSafe(); + if (r.ok && !r.empty) { + setText(r.data.content); + setLoadedText(r.data.content); + setFormat(detectFormat(r.data.content)); + setBaseRev(r.data.revisionId); + setConflict(false); + setStatus(statusOverride ?? (r.data.isDefaultTemplate ? 'New config (starter template)' : 'Loaded')); + } else { + setStatus(r.ok ? 'Empty' : `Load failed: ${r.error}`); + } + } + + useEffect(() => { void load(); }, []); // load is stable: defined inside the component, only calls setters + + useEffect(() => { + return () => { if (debounce.current) clearTimeout(debounce.current); }; + }, []); + + function onChange(next: string) { + setText(next); + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const v = await validateConfigSafe(next); + setValid(v.ok); + }, 600); + } + + async function onSave() { + if (saving || valid === false) return; + setSaving(true); + setStatus('Saving...'); + try { + const r = await saveConfigSafe(text, baseRev); + if (r.ok && !r.empty) { + // Refresh baseRev from the wiki so a subsequent save does not + // false-conflict. Keep the Saved status visible during the re-fetch. + await load(`Saved. ${r.data.ruleCount} rules live (rev ${r.data.rev}).`); + } else { + const msg = r.ok ? 'Saved' : r.error; + setStatus(`Save failed: ${msg}`); + if (!r.ok && /wiki changed/i.test(r.error)) setConflict(true); + } + } finally { + setSaving(false); + } + } + + return ( +
+
+ Edit config: r/{subreddit} +
+ + + {valid === false ? 'invalid' : valid === true ? 'valid' : ''} + + {conflict && ( + + )} + + +
+
+ +
+
+ +
+
+ +
+
+ +
+ {status} +
+
+ ); +} diff --git a/src/client/components/PreviewPane.tsx b/src/client/components/PreviewPane.tsx new file mode 100644 index 0000000..b07e70b --- /dev/null +++ b/src/client/components/PreviewPane.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'react'; +import { simulateLiveSafe, explainConfigSafe } from '../lib/api'; +import { simpleDiff } from './ConfigDiffViewer'; + +type Tab = 'impact' | 'explain' | 'diff'; + +export function PreviewPane({ text, currentText }: { text: string; currentText: string }) { + const [tab, setTab] = useState('impact'); + const [impact, setImpact] = useState('Edit to preview impact.'); + const [explanation, setExplanation] = useState(''); + const debounce = useRef | null>(null); + + useEffect(() => { + if (tab !== 'impact') return; + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const r = await simulateLiveSafe(text); + setImpact( + r.ok && !r.empty + ? `Would fire on ${r.data.firedCount}/${r.data.totalSamples} recent items` + : r.ok + ? 'No result.' + : r.error + ); + }, 700); + return () => { + if (debounce.current) clearTimeout(debounce.current); + }; + }, [text, tab]); + + async function runExplain() { + setExplanation('...'); + const r = await explainConfigSafe(text); + setExplanation( + r.ok && !r.empty + ? r.data + : `Explain failed: ${r.ok ? 'empty' : r.error}` + ); + } + + const diffLines = simpleDiff(currentText, text); + + return ( +
+
+ + + +
+ +
+ {tab === 'impact' &&

{impact}

} + + {tab === 'explain' && ( +
+ +
{explanation}
+
+ )} + + {tab === 'diff' && ( +
+            {!diffLines.some((d) => d.tag === 'add' || d.tag === 'del') ? (
+              No changes.
+            ) : (
+              diffLines.map((d, i) => (
+                
+ {d.tag === 'add' + ? '+' + : d.tag === 'del' + ? '-' + : d.tag === 'too-large' + ? '!' + : ' '}{' '} + {d.line} +
+ )) + )} +
+ )} +
+
+ ); +} diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 55e1d9e..af80ad1 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,4 +1,4 @@ -import type { ApiResult, EventRecord, StatsRollup } from './types'; +import type { ApiResult, EventRecord, StatsRollup, ConfigRaw, SaveResult, SimResult } from './types'; /** * fetchRecentSafe / fetchStatsSafe — return a discriminated ApiResult so the @@ -91,3 +91,71 @@ export const ZERO_STATS: StatsRollup = { import { demoEvents, DEMO_STATS as SHARED_DEMO_STATS } from '../../lib/demo-fixtures'; export const DEMO_EVENTS: EventRecord[] = demoEvents() as EventRecord[]; export const DEMO_STATS: StatsRollup = SHARED_DEMO_STATS as StatsRollup; + +// --------------------------------------------------------------------------- +// Config editor API helpers (Task 9) +// Follow the same ApiResult + extractServerError + demoSuffix pattern. +// --------------------------------------------------------------------------- + +export async function fetchConfigRawSafe(): Promise> { + try { + const res = await fetch(`/api/config/raw${demoSuffix()}`); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as ConfigRaw; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function validateConfigSafe(text: string): Promise<{ ok: boolean; errors?: unknown }> { + try { + const res = await fetch('/api/config/validate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + return (await res.json()) as { ok: boolean; errors?: unknown }; + } catch (err) { + return { ok: false, errors: err instanceof Error ? err.message : String(err) }; + } +} + +export async function simulateLiveSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/simulate-live', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as { ok: boolean; error?: string } & SimResult; + if (!data.ok) return { ok: false, error: data.error ?? 'simulation failed' }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function explainConfigSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/explain', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + const data = (await res.json()) as { ok: boolean; explanation?: string; error?: string }; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? `HTTP ${res.status}` }; + return { ok: true, empty: false, data: data.explanation ?? '' }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function saveConfigSafe(text: string, baseRevisionId: string | null): Promise> { + try { + const res = await fetch('/api/config/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, baseRevisionId }), + }); + const data = (await res.json()) as { ok: boolean; error?: string } & SaveResult; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? `HTTP ${res.status}` }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/client/lib/types.ts b/src/client/lib/types.ts index a95b335..a6ce677 100644 --- a/src/client/lib/types.ts +++ b/src/client/lib/types.ts @@ -64,3 +64,7 @@ export type ApiResult = | { ok: true; empty: false; data: T } | { ok: true; empty: true } | { ok: false; error: string }; + +export type ConfigRaw = { content: string; revisionId: string | null; isDefaultTemplate: boolean }; +export type SaveResult = { rev: number; ruleCount: number }; +export type SimResult = { totalSamples: number; firedCount: number; erroredCount: number; firstError?: string }; diff --git a/src/config/default-config.ts b/src/config/default-config.ts index 96e1f3f..bae2ad7 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -1,17 +1,17 @@ /** * Default config seeded on fresh install (Step 3.1). * - * Minimal — one regex rule + one remove action — so a new install shows + * Minimal (one regex rule + one remove action) so a new install shows * something useful in the Observatory dashboard immediately. Mods replace * this by editing `r//wiki/botconfig/contextmod` (Step 3.2). * * Stored as a JSON5 string (not a JS literal) so the install path exercises - * the same `parseConfig` → AJV pipeline a wiki edit hits — if the schema + * the same `parseConfig` -> AJV pipeline a wiki edit hits. If the schema * tightens and this default drifts, tests catch it. * * AE Polish #28: seed with `dryRun: true` for safety. Previously dryRun * was false, meaning a fresh install would *immediately* auto-remove any - * post matching `scam|giveaway|free crypto` — including legit giveaway + * post matching `scam|giveaway|free crypto`, including legit giveaway * threads on subs like r/HailCorporate. That's a wildly bad first * impression on a sub the mod just installed the bot on. * @@ -22,12 +22,43 @@ * (12 in examples/ all behind dryRun OR an authorIs mod-bypass filter). */ +/** + * YAML rendering of the same default config for the config editor's seed + * template. Derived from DEFAULT_CONFIG_JSON5 by parsing with json5 then + * dumping with js-yaml so both exports stay in sync with the same logical + * content. YAML is the default editor format (FoxxMD Discord 2026-05-20: + * "most mods use [YAML] since it's the same syntax as automod"). + * + * The leading comment block tells a new mod what the file is + what to do. + */ +export const DEFAULT_CONFIG_YAML = `# ContextMod config. Edit this wiki page to customise your rules. +# Full reference: https://github.com/FoxxMD/context-mod +# +# dryRun: true means actions are SIMULATED, not executed. +# Watch the Observatory dashboard for a day, then set dryRun: false. + +dryRun: true +runs: + - name: starter + checks: + - name: crypto-giveaway-spam + combinator: OR + rules: + - kind: regex + name: scam-words + pattern: 'scam|giveaway|free crypto' + flags: i + actions: + - kind: remove + isSpam: true +`; + export const DEFAULT_CONFIG_JSON5 = `{ // Default config seeded on install. Edit r//wiki/botconfig/contextmod // to replace, then click "ContextMod: Reload config from wiki" in the mod // menu (or wait up to 5 min for the cron to pick it up). // - // IMPORTANT: dryRun:true means actions are SIMULATED, not executed — + // IMPORTANT: dryRun:true means actions are SIMULATED, not executed. // dashboard shows what would have happened. Flip to false in the wiki // when you trust the rule's judgment (recommend: watch for ~1 day first). dryRun: true, diff --git a/src/core/recentSample.ts b/src/core/recentSample.ts new file mode 100644 index 0000000..4a84753 --- /dev/null +++ b/src/core/recentSample.ts @@ -0,0 +1,138 @@ +/** + * Task 2: getRecentSample — cached recent-post helper. + * + * Fetches the sub's N most recent posts via reddit.getNewPosts, normalizes + * them to SimulationSample[], and caches the result in Redis for 60 s. + * + * Both the simulate-rule form handler (forms.ts) and the future live-impact + * editor endpoint share this helper so neither re-fetches Reddit on every + * keystroke; the 60 s TTL keeps the preview "fresh enough" while staying + * well within Reddit API rate limits. + * + * The implementation mirrors the inline sample-building loop that previously + * lived in forms.ts /simulate-rule-submit. Any change to the normalization + * path must be reflected here to keep simulation and live-moderation + * consistent. + */ + +import { reddit, redis } from '@devvit/web/server'; +import type { SimulationSample } from './simulateRule'; +import { normalizePost, asPayloadTimestamp, type PostSubmitPayload } from '../shared/normalize'; +import { getCurrentRev } from '../state/configStore'; +import type { AppConfig } from '../shared/types'; +import { log } from '../lib/log'; + +const SAMPLE_LIMIT = 25; +// 60 s: fresh enough for live preview, no per-keystroke Reddit fetch. +const CACHE_TTL_MS = 60_000; + +function cacheKey(sub: string): string { + return `cm:${sub}:recent-sample`; +} + +interface RedditPostLike { + id?: string; + title?: string; + body?: string; + url?: string; + authorId?: string; + authorName?: string; + score?: number; + nsfw?: boolean; + locked?: boolean; + stickied?: boolean; + createdAt?: number | Date | string; +} + + +/** + * Recent posts of the sub, normalized to SimulationSample[], cached 60 s. + * + * On cache hit: returns the cached slice immediately (no Reddit call). + * On cache miss: fetches via reddit.getNewPosts, normalizes, writes through to + * Redis, returns the slice. Cache write failures are non-fatal (logged + ignored). + * + * Per-post normalize errors are also non-fatal: the offending post is skipped + * and logged; the remaining samples are returned. This mirrors the same posture + * the forms.ts simulate-rule handler uses for its per-post try/catch. + */ +export async function getRecentSample(sub: string): Promise { + // Cache read. + try { + const cached = await redis.get(cacheKey(sub)); + if (cached != null) return JSON.parse(cached) as SimulationSample[]; + } catch { + /* cache read miss/fail — fall through to fetch */ + } + + // Fetch current config for author-enrichment decision — same path as + // the forms.ts simulate handler so normalization is consistent. + const snapshot = await getCurrentRev(sub); + const config: AppConfig = snapshot?.config ?? { + runs: [], + needsAuthorEnrichment: false, + }; + + // reddit.getNewPosts is not in the typed @devvit/web/server surface; cast + // to avoid `ts(2339)`. This matches the same cast in forms.ts. + const redditAny = reddit as unknown as { + getNewPosts: (opts: { + subredditName: string; + limit: number; + pageSize: number; + }) => Promise<{ all: () => Promise }> | { all: () => Promise }; + }; + + const listing = await redditAny.getNewPosts({ + subredditName: sub, + limit: SAMPLE_LIMIT, + pageSize: SAMPLE_LIMIT, + }); + // I1: guard against a null/undefined listing before accessing .all. + if (!listing || typeof listing.all !== 'function') return []; + const allPosts = await listing.all(); + const recent = allPosts.slice(0, SAMPLE_LIMIT); + + const samples: SimulationSample[] = []; + for (const post of recent) { + try { + const payload: PostSubmitPayload = { + post: { + id: post.id, + title: post.title, + selftext: post.body ?? '', + url: post.url ?? '', + authorId: post.authorId ?? '', + score: post.score ?? 0, + // I2: prefer the real isSelf field from the Reddit API; fall back to + // URL heuristic only when the field is absent. The URL heuristic + // diverges from the dry-run/test-rules path which reads isSelf directly. + isSelf: typeof (post as { isSelf?: boolean }).isSelf === 'boolean' + ? !!(post as { isSelf?: boolean }).isSelf + : !!(post as { url?: string }).url?.includes(sub), + nsfw: !!post.nsfw, + locked: !!post.locked, + stickied: !!post.stickied, + createdAt: asPayloadTimestamp(post.createdAt), + }, + author: { name: post.authorName ?? '', id: post.authorId ?? '' }, + } as PostSubmitPayload; + const normalized = await normalizePost(payload, config); + samples.push({ item: normalized.item, author: normalized.author }); + } catch (err) { + log.warn('cm/core/getRecentSample', 'skipped sample', { err }); + } + } + + // Cache write-through. Non-fatal: a Redis blip must not kill the sample + // fetch path, only the caching benefit. + try { + await redis.set(cacheKey(sub), JSON.stringify(samples), { + expiration: new Date(Date.now() + CACHE_TTL_MS), + }); + } catch { + /* cache write fail is non-fatal */ + } + + return samples; +} diff --git a/src/core/simulateRule.ts b/src/core/simulateRule.ts index 86096a3..1fe974d 100644 --- a/src/core/simulateRule.ts +++ b/src/core/simulateRule.ts @@ -16,11 +16,19 @@ * Non-contract: this does NOT call runRun / runCheck / runAction — it bypasses * combinators + filters + actions for a focused "would THIS RULE fire on these * items" answer. That's the question mods actually ask when prototyping. + * + * simulateFullConfig (added for /simulate-live fix): evaluates ALL runs in a + * caller-supplied AppConfig against a sample set, in dry-run mode, with no + * idempotency writes and no real Reddit side-effects. Used by the config-editor + * Impact tab so the preview reflects the WHOLE config (not a single extracted rule). */ -import type { Rule, Item, Author } from '../shared/types'; +import type { Rule, Item, Author, AppConfig, Action } from '../shared/types'; import { parseConfig } from './config'; import { runRule } from './runRule'; +import { runRun } from './runRun'; +import { runAction } from './runAction'; +import { runWithTimeout, RunTimeoutError } from '../lib/timeout'; export type SimulationSample = { item: Item; author: Author }; @@ -122,6 +130,101 @@ export async function simulateRule( }; } +/** + * Evaluate a caller-supplied AppConfig against a set of samples in full dry-run + * mode. Used by the config-editor /simulate-live endpoint so the Impact tab + * previews the WHOLE config (runs + checks + actions) rather than a single + * extracted rule. + * + * Dry-run contract: + * - Every action is forced to dryRun: true before dispatch. + * - bypassIdempotency: true skips reserveAction/commitAction Redis writes so + * Impact-tab polls never dirty the idempotency store. + * - No real Reddit side-effects are ever executed. + * + * firedCount = number of samples where at least one run triggered at least one + * action. A sample that triggers multiple runs counts as 1. + * + * Mirrors the core loop of dryRunActivity but accepts an in-memory AppConfig + * instead of reading from configStore, so it evaluates the editor's unsaved + * buffer rather than the last-published config. + */ +export async function simulateFullConfig( + config: AppConfig, + samples: SimulationSample[], + sub: string +): Promise { + let firedCount = 0; + let erroredCount = 0; + let firstError: string | undefined; + const breakdown: SimulationBreakdown[] = []; + + // Use rev 0 for the ActionContext: bypassIdempotency=true means the rev value + // is never used in idempotency key generation (reserveAction is skipped). + // Pass the in-memory config so dryRun=true propagates as the global gate. + const ctxConfig: AppConfig = { ...config, dryRun: true }; + + for (const sample of samples) { + let triggered = false; + let errored = false; + try { + for (const run of config.runs) { + let result: Awaited>; + try { + result = await runWithTimeout(runRun(run, sample.item, sample.author, sub), run.name); + } catch (err) { + const isTimeout = err instanceof RunTimeoutError; + const msg = err instanceof Error ? err.message : String(err); + console.error( + `[cm/simulateFullConfig] runRun ${isTimeout ? 'timed out' : 'threw'} — skipping run:`, + run.name, + err + ); + if (firstError === undefined) firstError = msg.slice(0, 200); + // Count this sample as errored once (even if multiple runs err). + errored = true; + continue; + } + if (!result.triggered) continue; + + // At least one run triggered; mark this sample as fired. + triggered = true; + + // Dispatch actions in dry-run mode to verify the full pipeline fires + // without executing any real Reddit side-effects. + for (const action of result.actions) { + const forcedDryRun: Action = { ...action, dryRun: true }; + await runAction(forcedDryRun, { + item: sample.item, + author: sample.author, + subredditName: sub, + rev: 0, + config: ctxConfig, + bypassIdempotency: true, + }); + } + } + } catch (err) { + errored = true; + const msg = err instanceof Error ? err.message : String(err); + if (firstError === undefined) firstError = msg.slice(0, 200); + } + + if (errored) erroredCount++; + if (triggered) firedCount++; + breakdown.push({ activityId: sample.item.id, triggered, errored }); + } + + return { + ok: true, + totalSamples: samples.length, + firedCount, + erroredCount, + ...(firstError !== undefined ? { firstError } : {}), + breakdown, + }; +} + /** * Format the simulation result as a toast-friendly multi-line string. * Mods see the percent + the 3 sample IDs that would have fired (for context). diff --git a/src/lib/resolveOpenaiKey.ts b/src/lib/resolveOpenaiKey.ts new file mode 100644 index 0000000..d4047f0 --- /dev/null +++ b/src/lib/resolveOpenaiKey.ts @@ -0,0 +1,15 @@ +import { settings } from '@devvit/web/server'; +import { getOpenaiKey } from '../state/apiKeyStore'; + +/** Resolve the OpenAI key for a sub: encrypted-Redis key first, then the + * plaintext Devvit subreddit-setting fallback. Returns '' when neither set. + * + * Note: getOpenaiKey swallows Redis read errors and returns null, so a Redis + * outage silently falls through to the settings fallback rather than throwing. + * Callers see '' only when both sources are genuinely absent. */ +export async function resolveOpenaiKey(sub: string): Promise { + const fromRedis = await getOpenaiKey(sub); + // getOpenaiKey returns null when absent; '' can't come from it (trimmed at write time). + if (fromRedis) return fromRedis; + return ((await settings.get('openai_api_key')) ?? '').trim(); +} diff --git a/src/routes/api.ts b/src/routes/api.ts index 8bbfbd4..08f17cc 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -29,9 +29,12 @@ import { checkCircuit, recordFailure, recordSuccess } from '../lib/circuitBreake import { log } from '../lib/log'; import { isTransientOpenaiError } from '../lib/openaiErrors'; import { readStatsSnapshot } from '../state/statsRollup'; +import { configEditor } from './configEditor'; export const api = new Hono(); +api.route('/config', configEditor); + api.get('/recent', async (c) => { if (c.req.query('demo') === '1') { log.info('cm/api/recent', 'demo=1 — serving synthetic fixtures (not real ZSET)'); diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts new file mode 100644 index 0000000..0d2abb5 --- /dev/null +++ b/src/routes/configEditor.ts @@ -0,0 +1,189 @@ +/** + * Config-editor route group, mounted at /api/config (Task 3). + * + * GET /raw: returns the current wiki page content + revisionId so the + * editor client can seed its CodeMirror buffer. On a fresh sub + * with no wiki page yet, returns the default YAML template so + * the mod starts from something valid rather than a blank slate. + * Auth-gated: mods only. + * + * Later tasks will add /validate, /simulate-live, /explain, /save to this + * same sub-app. + */ + +import { Hono } from 'hono'; +import { reddit, redis } from '@devvit/web/server'; +import { WIKI_PAGE } from '../core/configSource'; +import { requireModerator } from '../lib/requireModerator'; +import { DEFAULT_CONFIG_YAML } from '../config/default-config'; +import { log } from '../lib/log'; +import { parseConfig } from '../core/config'; +import { getRecentSample } from '../core/recentSample'; +import { simulateFullConfig } from '../core/simulateRule'; +import { checkRateLimit } from '../lib/ratelimit'; +import { explainRule } from '../core/explainRule'; +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; +import { publish } from '../state/configStore'; +import { K } from '../state/keys'; +import { logModActivity } from '../state/modActivity'; + +export const configEditor = new Hono(); + +function isNotFound(err: unknown): boolean { + const m = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return m.includes('not found') || m.includes('404') || m.includes('does not exist') || m.includes('no such page'); +} + +configEditor.get('/raw', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ error: auth.error }, auth.status); + try { + const page = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + return c.json({ content: page.content, revisionId: page.revisionId, isDefaultTemplate: false }); + } catch (err) { + if (isNotFound(err)) { + return c.json({ content: DEFAULT_CONFIG_YAML, revisionId: null, isDefaultTemplate: true }); + } + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/raw', 'wiki read failed', { err: msg, sub: auth.sub }); + return c.json({ error: `wiki unavailable: ${msg}` }, 503); + } +}); + +configEditor.post('/validate', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string') return c.json({ ok: false, error: 'text required' }, 400); + const parsed = parseConfig(text); + if (parsed.ok) return c.json({ ok: true, format: parsed.format }); + return c.json({ ok: false, errors: parsed.errors }); +}); + +configEditor.post('/simulate-live', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + const rl = await checkRateLimit('simulate-live', auth.sub, 120, 60); + if (!rl.allowed) return c.json({ ok: false, error: 'Slow down a moment, then keep editing.' }, 429); + + // Parse the full editor config first. A mid-edit invalid config is not an + // error in the 4xx sense; the Impact tab shows the parse message while the + // mod keeps typing. Return {ok:false} with a 200 so simulateLiveSafe (client) + // surfaces it as an inline status string rather than an error banner. + const parsed = parseConfig(text); + if (!parsed.ok) { + return c.json({ ok: false, error: 'config invalid (fix to preview impact)' }); + } + + let result; + try { + const samples = await getRecentSample(auth.sub); + result = await simulateFullConfig(parsed.config, samples, auth.sub); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Simulation unavailable: ${msg}` }, 503); + } + return c.json(result); +}); + +configEditor.post('/explain', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + // TODO: per-sub circuit breaker (v2); per-user cap is the v1 cost gate. + const rl = await checkRateLimit('explain', `${auth.sub}:${auth.username}`, 10, 3600); + if (rl.degraded) return c.json({ ok: false, error: 'Rate-limit subsystem degraded. Retry in ~60s.' }, 503); + if (!rl.allowed) return c.json({ ok: false, error: `Your limit: ${rl.count}/${rl.max} this hour.` }, 429); + try { + const apiKey = await resolveOpenaiKey(auth.sub); + if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); + const result = await explainRule(text, apiKey); + if (!result.ok) return c.json({ ok: false, error: result.error }, 500); + return c.json({ ok: true, explanation: result.value }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Explain unavailable: ${msg}` }, 503); + } +}); + +configEditor.post('/save', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + + const { text, baseRevisionId } = await c.req.json<{ + text?: string; + baseRevisionId?: string | null; + }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + + // Gate 1: never write an invalid config to the live moderation wiki. + const parsed = parseConfig(text); + if (!parsed.ok) return c.json({ ok: false, error: 'config invalid', errors: parsed.errors }, 400); + + // Gate 2: optimistic lock. Re-read the current wiki rev; if it moved since + // the editor loaded, refuse so we never silently clobber a concurrent edit. + try { + const current = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + if (baseRevisionId && current.revisionId !== baseRevisionId) { + return c.json( + { + ok: false, + error: 'The wiki changed since you opened the editor. Reload to merge.', + conflict: true, + }, + 409 + ); + } + } catch (err) { + if (!isNotFound(err)) { + const errMsg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Could not verify current wiki state: ${errMsg}` }, 503); + } + // not-found means the wiki page does not exist yet; allow the first-ever create. + } + + let written; + try { + written = await reddit.updateWikiPage({ + subredditName: auth.sub, + page: WIKI_PAGE, + content: text, + reason: `Edited via ContextMod Observatory by u/${auth.username}`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/save', 'updateWikiPage failed', { err: msg, sub: auth.sub }); + return c.json({ ok: false, error: `Wiki write failed: ${msg}` }, 502); + } + + const rev = await publish(parsed.config, auth.sub); + + // stamp the new wiki rev so the 5-min cron does not re-publish an identical config (best-effort). + try { + if (written?.revisionId) await redis.set(K.cfgLastWikiRev(auth.sub), written.revisionId); + } catch { + /* stamping is best-effort; the cron self-heals next tick */ + } + + const ruleCount = parsed.config.runs + .flatMap((r) => r.checks) + .flatMap((ch) => ch.rules).length; + + await logModActivity(auth.sub, { + ts: Date.now(), + actor: auth.username, + kind: 'edit-config', + detail: `${ruleCount} rules @ rev ${rev}`, + }); + + return c.json({ ok: true, rev, ruleCount }); +}); diff --git a/src/routes/forms.ts b/src/routes/forms.ts index acebcef..e9753c4 100644 --- a/src/routes/forms.ts +++ b/src/routes/forms.ts @@ -20,41 +20,25 @@ import { dryRunActivity } from '../core/dryRunActivity'; import { normalizePost, normalizeComment, + asPayloadTimestamp, type PostSubmitPayload, type CommentSubmitPayload, } from '../shared/normalize'; import * as configStore from '../state/configStore'; -import { simulateRule, formatSimulationToast, type SimulationSample } from '../core/simulateRule'; +import { simulateRule, formatSimulationToast } from '../core/simulateRule'; +import { getRecentSample } from '../core/recentSample'; import { explainRule, formatExplainToast } from '../core/explainRule'; -import { settings } from '@devvit/web/server'; -import { setOpenaiKey, getOpenaiKey } from '../state/apiKeyStore'; +import { setOpenaiKey } from '../state/apiKeyStore'; import { requireModerator } from '../lib/requireModerator'; import { checkRateLimit } from '../lib/ratelimit'; import { checkCircuit, recordFailure, recordSuccess } from '../lib/circuitBreaker'; -import { type Result, ok, err } from '../lib/result'; import { log } from '../lib/log'; import { isTransientOpenaiError } from '../lib/openaiErrors'; - -/** - * Wave V hotfix — resolve OpenAI API key with fallback chain: - * 1. Redis (preferred — set via "ContextMod: Set OpenAI API key" mod menu) - * 2. Devvit subreddit setting (fallback for mods who prefer settings UI) - * - * Devvit CLI for global-scope settings is broken (Unimplemented RPC). - * Subreddit-scope settings don't allow isSecret. Redis is the cleanest path. - */ -async function resolveOpenaiKey(sub: string): Promise { - const fromRedis = await getOpenaiKey(sub); - if (fromRedis) return fromRedis; - const fromSettings = ((await settings.get('openai_api_key')) ?? '').trim(); - return fromSettings; -} +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; import type { AppConfig } from '../shared/types'; export const forms = new Hono(); -const SIMULATION_SAMPLE_LIMIT = 25; - /** * Map requireModerator() failure to user-facing toast text. * @@ -101,13 +85,6 @@ interface FetchedComment { createdAt?: number | Date | string; } -function asPayloadTimestamp(t?: number | Date | string): number | string | undefined { - if (t == null) return undefined; - if (typeof t === 'number') return t; - if (typeof t === 'string') return t; - return t.getTime(); -} - forms.post('/test-rules-submit', async (c) => { // Live-playtest 2026-05-16 revealed Devvit form submit envelope is FLAT: // `{thingId: '...'}`, NOT `{values: {thingId: '...'}}` per the doc convention @@ -264,79 +241,14 @@ forms.post('/simulate-rule-submit', async (c) => { try { const sub = await reddit.getCurrentSubreddit(); - // Reuse the live AppConfig snapshot for needsAuthorEnrichment decisions - // — same enrichment path live rules use, so simulation matches reality. - const snapshot = await configStore.getCurrentRev(sub.name); - const config: AppConfig = snapshot?.config ?? { - runs: [], - needsAuthorEnrichment: false, - }; - - // reddit.getNewPosts returns a Listing; .all() flattens to an array. - // AD Tier-1 #1: fetchRecentPostsSafe now returns Result — propagate - // reddit-api failure to toast w/ failure phase instead of "fired 0/0". - const recentResult = await fetchRecentPostsSafe(sub.name); - if (!recentResult.ok) { - return c.json({ - showToast: `Simulation failed (reddit-api): ${recentResult.error}`, - }); - } - const recent = recentResult.value; - const samples: SimulationSample[] = []; - let skipped = 0; - let firstSkipError: string | null = null; - for (const post of recent) { - try { - const payload: PostSubmitPayload = { - post: { - id: post.id, - title: post.title, - selftext: post.body ?? '', - url: post.url ?? '', - authorId: post.authorId ?? '', - score: post.score ?? 0, - isSelf: !!post.url?.includes(sub.name), - nsfw: !!post.nsfw, - locked: !!post.locked, - stickied: !!post.stickied, - createdAt: asPayloadTimestamp(post.createdAt), - }, - author: { name: post.authorName ?? '', id: post.authorId ?? '' }, - } as PostSubmitPayload; - const normalized = await normalizePost(payload, config); - samples.push({ item: normalized.item, author: normalized.author }); - } catch (perPostErr) { - // AD Tier-1 #2 + HIGH #4: count skipped samples + capture the FIRST - // failure message so the toast shows a real cause instead of a - // bare "normalize error" the mod can't act on. - skipped += 1; - if (firstSkipError === null) { - firstSkipError = - perPostErr instanceof Error ? perPostErr.message : String(perPostErr); - } - log.warn('cm/forms/simulate-rule-submit', 'skipped sample', { err: perPostErr }); - } - } - - // AD HIGH #3: when every sample failed, simulateRule returns - // totalSamples=0 and formatSimulationToast says "No recent posts to - // simulate against." That's misleading — there WERE recent posts, - // they all failed normalize. Surface the dedicated "aborted" toast - // with the first error instead of a contradictory base + suffix. - if (recent.length > 0 && skipped === recent.length) { - const detail = firstSkipError ? firstSkipError.slice(0, 100) : 'unknown'; - return c.json({ - showToast: `Simulation aborted: every sample failed to normalize (first: ${detail})`, - }); - } - + // getRecentSample fetches + caches the last N posts (60 s TTL) so the + // future live-impact editor endpoint can reuse the same sample without + // re-hitting Reddit on every keystroke. Per-post normalize errors are + // logged + skipped inside getRecentSample; reddit-api errors throw so + // the outer catch below classifies them as the reddit-api phase. + const samples = await getRecentSample(sub.name); const result = await simulateRule(ruleJson5, samples, sub.name); - const baseToast = formatSimulationToast(result); - const suffix = - skipped > 0 - ? ` (${skipped}/${recent.length} skipped — first: ${(firstSkipError ?? '').slice(0, 60)})` - : ''; - return c.json({ showToast: `${baseToast}${suffix}` }); + return c.json({ showToast: formatSimulationToast(result) }); } catch (e) { // Wave U WARN fix (Codex CR3 #7): prefix toast w/ failure phase so mod // knows whether to retry (network/reddit), fix their rule (parse), or @@ -353,20 +265,6 @@ forms.post('/simulate-rule-submit', async (c) => { } }); -interface RedditPostLike { - id?: string; - title?: string; - body?: string; - url?: string; - authorId?: string; - authorName?: string; - score?: number; - nsfw?: boolean; - locked?: boolean; - stickied?: boolean; - createdAt?: number | Date | string; -} - /** * Wave S Phase S5 — AI rule explainer. * Reads openai_api_key from app settings + calls OpenAI chat completions. @@ -488,42 +386,3 @@ forms.post('/set-openai-key-submit', async (c) => { } }); -interface RedditListingLike { - all?: () => Promise | T[]; -} - -/** - * AD Tier-1 bug #1 fix — previously returned `[]` on any failure path, which - * caused the simulator to report "fired 0/0" indistinguishably from a real - * "no rule triggers fired" result. Now returns a discriminated Result so the - * caller can surface the actual failure phase in the toast (judges + mods - * deserve "Reddit API unavailable — try again" not a silent zero). - */ -async function fetchRecentPostsSafe( - subredditName: string -): Promise> { - try { - const redditAny = reddit as unknown as { - getNewPosts?: (opts: { - subredditName: string; - limit: number; - pageSize: number; - }) => Promise>; - }; - if (typeof redditAny.getNewPosts !== 'function') { - return err('reddit.getNewPosts unavailable in this Devvit runtime'); - } - const listing = await redditAny.getNewPosts({ - subredditName, - limit: SIMULATION_SAMPLE_LIMIT, - pageSize: SIMULATION_SAMPLE_LIMIT, - }); - if (!listing) return err('reddit.getNewPosts returned empty listing'); - const all = typeof listing.all === 'function' ? await listing.all() : []; - return ok(all.slice(0, SIMULATION_SAMPLE_LIMIT)); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - log.warn('cm/forms/simulate-rule-submit', 'fetchRecentPostsSafe failed', { err: e }); - return err(`reddit-api: ${msg}`); - } -} diff --git a/src/schema/app.schema.json b/src/schema/app.schema.json index 5a09820..7db94b8 100644 --- a/src/schema/app.schema.json +++ b/src/schema/app.schema.json @@ -6,15 +6,23 @@ "additionalProperties": false, "required": ["runs"], "properties": { - "dryRun": { "type": "boolean" }, - "needsAuthorEnrichment": { "type": "boolean" }, + "dryRun": { + "type": "boolean", + "description": "Global dry-run flag: when true, all actions are simulated and logged but never executed." + }, + "needsAuthorEnrichment": { + "type": "boolean", + "description": "Set at parse time by inspecting rule kinds. Gates the expensive getUserByUsername call." + }, "namedRules": { "type": "object", - "additionalProperties": { "$ref": "#/definitions/Rule" } + "additionalProperties": { "$ref": "#/definitions/Rule" }, + "description": "Named reusable rules that checks can reference via a NamedRuleRef." }, "runs": { "type": "array", - "items": { "$ref": "#/definitions/Run" } + "items": { "$ref": "#/definitions/Run" }, + "description": "Ordered list of runs to evaluate against each incoming post or comment." } }, "definitions": { @@ -23,10 +31,14 @@ "additionalProperties": false, "required": ["name", "checks"], "properties": { - "name": { "type": "string" }, + "name": { + "type": "string", + "description": "Unique label for this run, used in logs and goto references." + }, "checks": { "type": "array", - "items": { "$ref": "#/definitions/Check" } + "items": { "$ref": "#/definitions/Check" }, + "description": "Ordered list of checks evaluated in sequence within this run." } } }, @@ -35,16 +47,27 @@ "additionalProperties": false, "required": ["name", "combinator", "rules"], "properties": { - "name": { "type": "string" }, - "combinator": { "enum": ["AND", "OR", "NOT"] }, + "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." + }, "rules": { "type": "array", - "items": { "$ref": "#/definitions/Rule" } + "items": { "$ref": "#/definitions/Rule" }, + "description": "Rules evaluated together using the check's combinator." + }, + "filters": { + "$ref": "#/definitions/FilterSpec", + "description": "Optional author and item predicates that gate whether this check runs at all." }, - "filters": { "$ref": "#/definitions/FilterSpec" }, "actions": { "type": "array", - "items": { "$ref": "#/definitions/Action" } + "items": { "$ref": "#/definitions/Action" }, + "description": "Actions executed in order when this check triggers." }, "postBehavior": { "oneOf": [ @@ -55,7 +78,8 @@ "required": ["goto"], "properties": { "goto": { "type": "string" } } } - ] + ], + "description": "What to do after this check: 'next' continues to the next check, 'stop' halts the run, or {goto} jumps to a named check." } } }, @@ -76,35 +100,72 @@ "type": "object", "additionalProperties": false, "required": ["kind", "pattern"], + "description": "Matches a post's title, body, or URL against a regular expression.", "properties": { - "kind": { "const": "regex" }, - "name": { "type": "string" }, - "pattern": { "type": "string" }, - "flags": { "type": "string" }, - "target": { "enum": ["title", "body", "url"] } + "kind": { + "const": "regex", + "description": "Identifies this rule as a regex match rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "pattern": { + "type": "string", + "description": "Regular expression source string to match against the target field." + }, + "flags": { + "type": "string", + "description": "Optional regex flags such as 'i' for case-insensitive or 'm' for multiline." + }, + "target": { + "enum": ["title", "body", "url"], + "description": "Which field to match against: title, body, or url. Defaults to title." + } } }, "AuthorRule": { "type": "object", "additionalProperties": false, "required": ["kind", "filter"], + "description": "Triggers based on properties of the post or comment author.", "properties": { - "kind": { "const": "author" }, - "name": { "type": "string" }, - "filter": { "$ref": "#/definitions/AuthorFilter" } + "kind": { + "const": "author", + "description": "Identifies this rule as an author attribute rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "filter": { + "$ref": "#/definitions/AuthorFilter", + "description": "Author attribute predicates that must be satisfied for this rule to trigger." + } } }, "RuleSetRule": { "type": "object", "additionalProperties": false, "required": ["kind", "combinator", "rules"], + "description": "Combines nested rules with AND, OR, or NOT logic.", "properties": { - "kind": { "const": "ruleset" }, - "name": { "type": "string" }, - "combinator": { "enum": ["AND", "OR", "NOT"] }, + "kind": { + "const": "ruleset", + "description": "Identifies this rule as a nested rule-set combinator." + }, + "name": { + "type": "string", + "description": "Optional label for this rule set, used in logs." + }, + "combinator": { + "enum": ["AND", "OR", "NOT"], + "description": "AND requires all nested rules to trigger, OR requires any, NOT requires none." + }, "rules": { "type": "array", - "items": { "$ref": "#/definitions/Rule" } + "items": { "$ref": "#/definitions/Rule" }, + "description": "Nested rules evaluated together using the combinator." } } }, @@ -112,121 +173,347 @@ "type": "object", "additionalProperties": false, "required": ["kind", "name"], + "description": "Reference to a reusable rule defined in the top-level namedRules map.", "properties": { - "kind": { "const": "named" }, - "name": { "type": "string" } + "kind": { + "const": "named", + "description": "Identifies this as a reference to a named rule." + }, + "name": { + "type": "string", + "description": "Key into the top-level namedRules map to look up." + } } }, "RepostRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers when the same URL has already been submitted within the configured window.", "properties": { - "kind": { "const": "repost" }, - "name": { "type": "string" }, - "windowDays": { "type": "number", "minimum": 1, "maximum": 365 } + "kind": { + "const": "repost", + "description": "Identifies this rule as a URL-deduplication repost rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "windowDays": { + "type": "number", + "minimum": 1, + "maximum": 365, + "description": "How many days back to check for duplicate URLs. Defaults to 30." + } } }, "HistoryRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers based on an author's recent post/comment counts or karma thresholds. Any supplied threshold triggers the rule (OR semantics).", "properties": { - "kind": { "const": "history" }, - "name": { "type": "string" }, - "postCountLt": { "type": "number", "minimum": 0, "maximum": 100 }, - "postCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountLt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "linkKarmaLt": { "type": "number" }, - "linkKarmaGt": { "type": "number" }, - "commentKarmaLt": { "type": "number" }, - "commentKarmaGt": { "type": "number" }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "history", + "description": "Identifies this rule as an author history rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "postCountLt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count is less than this value." + }, + "postCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count is greater than this value." + }, + "commentCountLt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count is less than this value." + }, + "commentCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count is greater than this value." + }, + "linkKarmaLt": { + "type": "number", + "description": "Triggers when the author's link karma is less than this value." + }, + "linkKarmaGt": { + "type": "number", + "description": "Triggers when the author's link karma is greater than this value." + }, + "commentKarmaLt": { + "type": "number", + "description": "Triggers when the author's comment karma is less than this value." + }, + "commentKarmaGt": { + "type": "number", + "description": "Triggers when the author's comment karma is greater than this value." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count posts and comments within the last N seconds. Defaults to unlimited." + } } }, "AttributionRule": { "type": "object", "additionalProperties": false, "required": ["kind", "domains", "domainPercent"], + "description": "Triggers when a configured fraction of the author's recent posts link to specific domains.", "properties": { - "kind": { "const": "attribution" }, - "name": { "type": "string" }, - "domains": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }, - "domainPercent": { "type": "number", "minimum": 0, "maximum": 100 }, - "minPosts": { "type": "number", "minimum": 1 }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "attribution", + "description": "Identifies this rule as a domain attribution rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "domains": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "List of domain substrings to match against each post's domain (case-insensitive)." + }, + "domainPercent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the matching domain percentage of the author's recent posts is at or above this value (0 to 100)." + }, + "minPosts": { + "type": "number", + "minimum": 1, + "description": "Minimum number of recent posts required before evaluating the rule. Defaults to 5." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count posts within the last N seconds. Defaults to unlimited." + } } }, "RecentActivityRule": { "type": "object", "additionalProperties": false, "required": ["kind", "subreddits"], + "description": "Triggers when the author has posted or commented in specified subreddits above configured thresholds.", "properties": { - "kind": { "const": "recentActivity" }, - "name": { "type": "string" }, - "subreddits": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }, - "postCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "recentActivity", + "description": "Identifies this rule as a recent subreddit activity rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "subreddits": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "List of subreddit names to check the author's activity in (case-insensitive)." + }, + "postCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count in the target subreddits exceeds this value." + }, + "commentCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count in the target subreddits exceeds this value." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count activity within the last N seconds. Defaults to unlimited." + } } }, "ImageRepostRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers when an image post's perceptual hash is too similar to a recently seen image in the same subreddit.", "properties": { - "kind": { "const": "imageRepost" }, - "name": { "type": "string" }, - "hammingThreshold": { "type": "number", "minimum": 0, "maximum": 256 }, - "windowDays": { "type": "number", "minimum": 1, "maximum": 365 } + "kind": { + "const": "imageRepost", + "description": "Identifies this rule as a perceptual image-repost detection rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "hammingThreshold": { + "type": "number", + "minimum": 0, + "maximum": 256, + "description": "Maximum bit difference between two image hashes to consider them the same image. Defaults to 8." + }, + "windowDays": { + "type": "number", + "minimum": 1, + "maximum": 365, + "description": "How many days back to check for matching image hashes. Defaults to 30." + } } }, "FilterSpec": { "type": "object", "additionalProperties": false, + "description": "Optional gate predicates applied before a check's rules run.", "properties": { - "authorIs": { "$ref": "#/definitions/AuthorFilter" }, - "itemIs": { "$ref": "#/definitions/ItemFilter" } + "authorIs": { + "$ref": "#/definitions/AuthorFilter", + "description": "Author predicates that must all match for the check to proceed." + }, + "itemIs": { + "$ref": "#/definitions/ItemFilter", + "description": "Item predicates that must all match for the check to proceed." + } } }, "AuthorFilter": { "type": "object", "additionalProperties": false, + "description": "Predicate set evaluated against the post or comment author.", "properties": { - "nameIn": { "type": "array", "items": { "type": "string" } }, - "nameNotIn": { "type": "array", "items": { "type": "string" } }, - "flairTextIn": { "type": "array", "items": { "type": "string" } }, - "flairTextNotIn": { "type": "array", "items": { "type": "string" } }, - "ageMinSec": { "type": "number" }, - "ageMaxSec": { "type": "number" }, - "linkKarmaMin": { "type": "number" }, - "linkKarmaMax": { "type": "number" }, - "commentKarmaMin": { "type": "number" }, - "commentKarmaMax": { "type": "number" }, - "isMod": { "type": "boolean" }, - "isContributor": { "type": "boolean" }, - "verified": { "type": "boolean" }, - "shadowBanned": { "type": "boolean" } + "nameIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's username is in this exact-match list." + }, + "nameNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's username is NOT in this exact-match list." + }, + "flairTextIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's flair text is one of these values." + }, + "flairTextNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's flair text is not one of these values." + }, + "ageMinSec": { + "type": "number", + "description": "Matches when the author's account age is at least this many seconds." + }, + "ageMaxSec": { + "type": "number", + "description": "Matches when the author's account age is at most this many seconds." + }, + "linkKarmaMin": { + "type": "number", + "description": "Matches when the author's link karma is at least this value." + }, + "linkKarmaMax": { + "type": "number", + "description": "Matches when the author's link karma is at most this value." + }, + "commentKarmaMin": { + "type": "number", + "description": "Matches when the author's comment karma is at least this value." + }, + "commentKarmaMax": { + "type": "number", + "description": "Matches when the author's comment karma is at most this value." + }, + "isMod": { + "type": "boolean", + "description": "When true, matches moderators only. When false, matches non-moderators only." + }, + "isContributor": { + "type": "boolean", + "description": "When true, matches approved contributors only. When false, matches non-contributors only." + }, + "verified": { + "type": "boolean", + "description": "When true, matches authors with a verified email only." + }, + "shadowBanned": { + "type": "boolean", + "description": "When true, matches shadow-banned accounts only." + } } }, "ItemFilter": { "type": "object", "additionalProperties": false, + "description": "Predicate set evaluated against the post or comment itself.", "properties": { - "over18": { "type": "boolean" }, - "locked": { "type": "boolean" }, - "stickied": { "type": "boolean" }, - "removed": { "type": "boolean" }, - "approved": { "type": "boolean" }, - "isSelf": { "type": "boolean" }, - "scoreMin": { "type": "number" }, - "scoreMax": { "type": "number" }, - "linkFlairTextIn": { "type": "array", "items": { "type": "string" } }, - "linkFlairTextNotIn": { "type": "array", "items": { "type": "string" } }, - "titleMatches": { "type": "string" }, - "bodyMatches": { "type": "string" }, - "urlMatches": { "type": "string" } + "over18": { + "type": "boolean", + "description": "When true, matches NSFW items only. When false, matches SFW items only." + }, + "locked": { + "type": "boolean", + "description": "When true, matches locked items only. When false, matches unlocked items only." + }, + "stickied": { + "type": "boolean", + "description": "When true, matches stickied items only. When false, matches non-stickied items only." + }, + "removed": { + "type": "boolean", + "description": "When true, matches removed items only. When false, matches non-removed items only." + }, + "approved": { + "type": "boolean", + "description": "When true, matches approved items only. When false, matches non-approved items only." + }, + "isSelf": { + "type": "boolean", + "description": "When true, matches self-text posts only. When false, matches link posts only." + }, + "scoreMin": { + "type": "number", + "description": "Matches when the item's score is at least this value." + }, + "scoreMax": { + "type": "number", + "description": "Matches when the item's score is at most this value." + }, + "linkFlairTextIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the post's link flair text is one of these values." + }, + "linkFlairTextNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the post's link flair text is not one of these values." + }, + "titleMatches": { + "type": "string", + "description": "Regex source string matched against the post title." + }, + "bodyMatches": { + "type": "string", + "description": "Regex source string matched against the post or comment body." + }, + "urlMatches": { + "type": "string", + "description": "Regex source string matched against the post URL." + } } }, "Action": { @@ -235,82 +522,168 @@ "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Removes the post or comment from the subreddit.", "properties": { - "kind": { "const": "remove" }, - "isSpam": { "type": "boolean" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "remove", + "description": "Identifies this action as a removal action." + }, + "isSpam": { + "type": "boolean", + "description": "When true, marks the removal as a spam removal." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Approves the post or comment.", "properties": { - "kind": { "const": "approve" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "approve", + "description": "Identifies this action as an approval action." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind", "template"], + "description": "Posts a moderator comment using the provided template text.", "properties": { - "kind": { "const": "comment" }, - "template": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "comment", + "description": "Identifies this action as a comment action." + }, + "template": { + "type": "string", + "description": "Text of the comment to post. Supports template variables." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Locks the post or comment to prevent further replies.", "properties": { - "kind": { "const": "lock" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "lock", + "description": "Identifies this action as a lock action." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind", "reason"], + "description": "Reports the post or comment to the moderators with a reason.", "properties": { - "kind": { "const": "report" }, - "reason": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "report", + "description": "Identifies this action as a report action." + }, + "reason": { + "type": "string", + "description": "The report reason shown to moderators." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Bans the author from the subreddit, optionally with a duration and message.", "properties": { - "kind": { "const": "ban" }, - "duration": { "type": "number" }, - "reason": { "type": "string" }, - "note": { "type": "string" }, - "message": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "ban", + "description": "Identifies this action as a ban action." + }, + "duration": { + "type": "number", + "description": "Ban duration in days. Use 0 for a permanent ban." + }, + "reason": { + "type": "string", + "description": "The mod-visible reason for the ban." + }, + "note": { + "type": "string", + "description": "Internal mod note attached to the ban, not shown to the user." + }, + "message": { + "type": "string", + "description": "Message sent to the banned user explaining the ban." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Sets the author's user flair on the subreddit.", "properties": { - "kind": { "const": "userFlair" }, - "text": { "type": "string" }, - "cssClass": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "userFlair", + "description": "Identifies this action as a user flair assignment action." + }, + "text": { + "type": "string", + "description": "Flair text to apply to the author." + }, + "cssClass": { + "type": "string", + "description": "CSS class for the flair styling." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Marks the post or comment as moderator-distinguished, optionally stickying it.", "properties": { - "kind": { "const": "distinguish" }, - "sticky": { "type": "boolean" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "distinguish", + "description": "Identifies this action as a mod-distinguish action." + }, + "sticky": { + "type": "boolean", + "description": "When true, pins the distinguished comment to the top of the thread (post-level comments only). Defaults to true." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } } ] diff --git a/src/shared/normalize.ts b/src/shared/normalize.ts index 95f6a89..f37b241 100644 --- a/src/shared/normalize.ts +++ b/src/shared/normalize.ts @@ -240,6 +240,21 @@ async function enrichAuthor(name: string, id: string, needsEnrichment: boolean): } // Public API. + +/** + * M1: single source of truth for converting a createdAt value (number | Date | string) + * to the number | string shape that PostSubmitPayload and CommentSubmitPayload accept. + * Previously duplicated in recentSample.ts and forms.ts. + */ +export function asPayloadTimestamp( + t?: number | Date | string +): number | string | undefined { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return t.getTime(); +} + export interface NormalizedActivity { item: Item; author: Author; diff --git a/src/state/modActivity.ts b/src/state/modActivity.ts index ef2e9a7..fbd5560 100644 --- a/src/state/modActivity.ts +++ b/src/state/modActivity.ts @@ -18,7 +18,8 @@ export type ModActivityKind = | 'simulate-rule' | 'explain-rule' | 'mute-rule' - | 'unmute-rule'; + | 'unmute-rule' + | 'edit-config'; export interface ModActivity { ts: number; @@ -53,6 +54,7 @@ function isValidModActivity(o: unknown): o is ModActivity { 'explain-rule', 'mute-rule', 'unmute-rule', + 'edit-config', ]; if (!valid.includes(m.kind as ModActivityKind)) return false; if (m.detail !== undefined && typeof m.detail !== 'string') return false; diff --git a/tests/client/config-api.test.ts b/tests/client/config-api.test.ts new file mode 100644 index 0000000..5bd725d --- /dev/null +++ b/tests/client/config-api.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchConfigRawSafe, saveConfigSafe, simulateLiveSafe, explainConfigSafe } from '../../src/client/lib/api'; + +beforeEach(() => { vi.restoreAllMocks(); }); + +describe('config api helpers', () => { + it('fetchConfigRawSafe returns content on 200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data.content).toBe('runs: []'); + }); + + it('saveConfigSafe surfaces a 409 conflict', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'wiki changed', conflict: true }), + { status: 409, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(false); + }); + + it('saveConfigSafe success returns rev and ruleCount', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, rev: 2, ruleCount: 3 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) { + expect(r.data.rev).toBe(2); + expect(r.data.ruleCount).toBe(3); + } + }); + + it('simulateLiveSafe body-level failure (200 ok:false)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'bad rule' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await simulateLiveSafe('runs: []'); + expect(r.ok).toBe(false); + }); + + it('fetchConfigRawSafe 503 returns ok:false', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'wiki unavailable' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(false); + }); + + it('explainConfigSafe no-key 400 returns ok:false with OpenAI key message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }))); + const r = await explainConfigSafe('runs: []'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/OpenAI key/); + }); + + it('explainConfigSafe success returns explanation string', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, explanation: 'Removes crypto spam.' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await explainConfigSafe('runs: []'); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data).toBe('Removes crypto spam.'); + }); +}); diff --git a/tests/client/config-editor.test.tsx b/tests/client/config-editor.test.tsx new file mode 100644 index 0000000..431f79d --- /dev/null +++ b/tests/client/config-editor.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +/** + * ConfigEditor tests — CodeMirror 6 component. + * + * jsdom env required: EditorView mounts a real DOM tree. CM6 v6 supports jsdom + * for unit tests; the editor creates a `.cm-editor` wrapper div and injects + * content into `.cm-content` / `.cm-line` children. + * + * Value-sync test strategy: CM6 renders each doc line as a `.cm-line` span. + * After a dispatch the DOM updates synchronously (CM6 batch-commits inside the + * same microtask as the dispatch). We query `.cm-line` text to confirm the new + * value is reflected in the editor DOM. + */ +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ConfigEditor } from '../../src/client/components/ConfigEditor'; + +describe('ConfigEditor', () => { + it('mounts and renders the initial document', () => { + const { container } = render( + {}} /> + ); + expect(container.querySelector('.cm-editor')).toBeTruthy(); + expect(container.textContent).toContain('runs'); + }); + + it('syncs an external value change into the editor', () => { + const { container, rerender } = render( + {}} /> + ); + act(() => { + rerender( {}} />); + }); + expect(container.textContent).toContain('[a]'); + }); +}); diff --git a/tests/client/config-workbench.test.tsx b/tests/client/config-workbench.test.tsx new file mode 100644 index 0000000..b53fb21 --- /dev/null +++ b/tests/client/config-workbench.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; + +const fetchConfigRawSafe = vi.fn(); +const saveConfigSafe = vi.fn(); +const validateConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + fetchConfigRawSafe: () => fetchConfigRawSafe(), + saveConfigSafe: (...a: unknown[]) => saveConfigSafe(...a), + validateConfigSafe: (...a: unknown[]) => validateConfigSafe(...a), + // PreviewPane (now rendered inside ConfigWorkbench) calls these; stub them + // so they silently succeed and don't cause unhandled-rejection noise. + simulateLiveSafe: async () => ({ + ok: true, + empty: false, + data: { totalSamples: 0, firedCount: 0, erroredCount: 0 }, + }), + explainConfigSafe: async () => ({ ok: true, empty: false, data: '' }), +})); + +import { ConfigWorkbench } from '../../src/client/components/ConfigWorkbench'; + +afterEach(() => cleanup()); + +beforeEach(() => { + vi.clearAllMocks(); + fetchConfigRawSafe.mockResolvedValue({ ok: true, empty: false, data: { content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false } }); + validateConfigSafe.mockResolvedValue({ ok: true }); + saveConfigSafe.mockResolvedValue({ ok: true, empty: false, data: { rev: 1, ruleCount: 0 } }); +}); + +describe('ConfigWorkbench', () => { + it('loads config then saves', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(saveConfigSafe).toHaveBeenCalled()); + }); + + it('shows subreddit in header', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + expect(screen.getByText(/r\/testsub/i)).toBeTruthy(); + }); + + it('calls onClose when Close button clicked', async () => { + const onClose = vi.fn(); + render(); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const closeBtn = await screen.findByRole('button', { name: /close editor/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); + + it('refreshes baseRev after save (calls fetchConfigRawSafe twice)', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalledTimes(1)); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalledTimes(2)); + }); + + it('shows load error if fetchConfigRawSafe fails', async () => { + fetchConfigRawSafe.mockResolvedValue({ ok: false, error: 'Network error' }); + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + expect(await screen.findByText(/load failed/i)).toBeTruthy(); + }); + + it('shows conflict reload button on wiki-changed error', async () => { + saveConfigSafe.mockResolvedValue({ ok: false, error: 'wiki changed beneath you' }); + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(screen.getByRole('button', { name: /reload/i })).toBeTruthy()); + }); +}); diff --git a/tests/client/preview-pane.test.tsx b/tests/client/preview-pane.test.tsx new file mode 100644 index 0000000..eed277b --- /dev/null +++ b/tests/client/preview-pane.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; + +// Mock the API helpers BEFORE importing PreviewPane so the module-level +// mock is in place when PreviewPane's module is first evaluated. +const simulateLiveSafe = vi.fn(); +const explainConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + simulateLiveSafe: (...a: unknown[]) => simulateLiveSafe(...a), + explainConfigSafe: (...a: unknown[]) => explainConfigSafe(...a), +})); + +import { PreviewPane } from '../../src/client/components/PreviewPane'; + +afterEach(() => cleanup()); + +beforeEach(() => { + vi.clearAllMocks(); + simulateLiveSafe.mockResolvedValue({ + ok: true, + empty: false, + data: { totalSamples: 10, firedCount: 3, erroredCount: 0 }, + }); + explainConfigSafe.mockResolvedValue({ + ok: true, + empty: false, + data: 'This config matches posts with runs: [a].', + }); +}); + +describe('PreviewPane', () => { + it('renders all three tabs', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs.length).toBe(3); + const labels = tabs.map((t) => t.textContent?.trim()); + expect(labels).toContain('Impact'); + expect(labels).toContain('Explain'); + expect(labels).toContain('Diff'); + }); + + it('defaults to the Impact tab selected', () => { + render(); + const impactTab = screen.getByRole('tab', { name: /impact/i }); + expect(impactTab.getAttribute('aria-selected')).toBe('true'); + }); + + it('Explain tab: clicking "Explain with AI" shows AI explanation', async () => { + render(); + + // Switch to the Explain tab + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + + // The Explain tab should now be visible with the button + const explainBtn = await screen.findByRole('button', { name: /explain with ai/i }); + fireEvent.click(explainBtn); + + // Wait for the async explain call to resolve and the text to appear + await waitFor(() => + expect(screen.getByText(/This config matches posts with runs/i)).toBeTruthy() + ); + expect(explainConfigSafe).toHaveBeenCalledWith('runs: [a]'); + }); + + it('Explain tab: shows loading placeholder "..." while waiting', async () => { + // Use a never-resolving promise so we can observe the interim state + explainConfigSafe.mockImplementation(() => new Promise(() => {})); + + render(); + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + + const explainBtn = await screen.findByRole('button', { name: /explain with ai/i }); + fireEvent.click(explainBtn); + + // "..." should appear immediately as the loading indicator + expect(screen.getByText('...')).toBeTruthy(); + }); + + it('Diff tab: shows changed lines when text differs from currentText', async () => { + render(); + + // Switch to the Diff tab + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + + // The diff should show the old line removed and new line added + // simpleDiff('runs: []', 'runs: [a]') produces del + add entries + await waitFor(() => { + // Look for deletion and addition markers in the rendered diff + const pre = document.querySelector('pre'); + expect(pre).toBeTruthy(); + const content = pre?.textContent ?? ''; + // Should contain both - and + markers for the changed line + expect(content).toContain('-'); + expect(content).toContain('+'); + }); + }); + + it('Diff tab: shows "No changes." when text matches currentText', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + expect(screen.getByText('No changes.')).toBeTruthy(); + }); + + it('Impact tab: shows error message when simulateLiveSafe returns ok:false', async () => { + simulateLiveSafe.mockResolvedValue({ ok: false, error: 'simulation failed' }); + render(); + + await waitFor( + () => { + expect(screen.getByText('simulation failed')).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it('switching tabs away from Impact and back does not leave stale debounce running', async () => { + render(); + + // Start on Impact, switch away, switch back + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + fireEvent.click(screen.getByRole('tab', { name: /impact/i })); + + // After switching back, the component should still render without errors + expect(screen.getByRole('tab', { name: /impact/i }).getAttribute('aria-selected')).toBe('true'); + }); +}); diff --git a/tests/core/recentSample.test.ts b/tests/core/recentSample.test.ts new file mode 100644 index 0000000..76350f4 --- /dev/null +++ b/tests/core/recentSample.test.ts @@ -0,0 +1,220 @@ +/** + * Task 2: getRecentSample — cached recent-post helper for live-impact preview. + * + * Mocks @devvit/web/server so the test runs in pure node without any Devvit + * runtime. We test the two caching branches: cache hit (no Reddit call) and + * cache miss (fetch + write through). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getNewPosts = vi.fn(); +const redisGet = vi.fn(); +const redisSet = vi.fn(); + +vi.mock('@devvit/web/server', () => ({ + reddit: { getNewPosts: (o: unknown) => getNewPosts(o) }, + redis: { + get: (k: string) => redisGet(k), + set: (k: string, v: string, o?: unknown) => redisSet(k, v, o), + }, +})); + +// normalizePost is used inside getRecentSample — stub it so we don't need the +// full Reddit enrichment chain. The stub passes id + isSelf through from the +// payload so I2-related assertions can inspect the normalized item. +vi.mock('../../src/shared/normalize', () => ({ + normalizePost: vi.fn( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => ({ + item: { id: p.post.id, isSelf: p.post.isSelf ?? false }, + author: { name: p.author.name }, + }) + ), + // M1: asPayloadTimestamp is now exported from normalize; mock re-exports it + // as a pass-through so recentSample.ts can import it without error. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, +})); + +// configStore: getCurrentRev returns a snapshot with a minimal config. +vi.mock('../../src/state/configStore', () => ({ + getCurrentRev: vi.fn(async () => ({ + rev: 0, + config: { runs: [], needsAuthorEnrichment: false }, + })), +})); + +import { getRecentSample } from '../../src/core/recentSample'; +import { normalizePost } from '../../src/shared/normalize'; + +describe('getRecentSample', () => { + beforeEach(() => { + getNewPosts.mockReset(); + redisGet.mockReset(); + redisSet.mockReset(); + // Reset normalizePost to the default pass-through between tests so M3's + // per-throw mock doesn't bleed into other test cases. + vi.mocked(normalizePost).mockImplementation( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => ({ + item: { id: p.post.id, isSelf: p.post.isSelf ?? false }, + author: { name: p.author.name }, + }) + ); + }); + + it('returns the cached sample without hitting reddit', async () => { + redisGet.mockResolvedValue( + JSON.stringify([{ item: { id: 't3_x' }, author: { name: 'a' } }]) + ); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect(getNewPosts).not.toHaveBeenCalled(); + }); + + it('fetches + caches on a cache miss', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_abc', + title: 'hello', + body: null, + url: 'https://reddit.com/r/sub/comments/abc', + authorName: 'alice', + authorId: 't2_alice', + score: 5, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(getNewPosts).toHaveBeenCalledOnce(); + expect(redisSet).toHaveBeenCalledOnce(); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ item: { id: 't3_abc' }, author: { name: 'alice' } }); + }); + + it('returns empty array when reddit returns no posts', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ all: async () => [] }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(0); + expect(redisSet).toHaveBeenCalledOnce(); + }); + + it('still returns data when cache write fails', async () => { + redisGet.mockResolvedValue(null); + redisSet.mockRejectedValue(new Error('redis down')); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_def', + title: 'test', + body: null, + url: 'https://reddit.com/r/sub/comments/def', + authorName: 'bob', + authorId: 't2_bob', + score: 1, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + }); + + // I2: the real isSelf field must take precedence over the URL heuristic. + // Post has isSelf:true but a URL that does NOT contain the sub name, so the + // URL heuristic would wrongly yield false. The normalized item must carry + // isSelf:true because the real field was present. + it('I2: uses real isSelf field when present, not URL heuristic', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_a', + title: 'image post', + body: null, + // URL contains no sub name — heuristic would return false. + url: 'https://i.redd.it/x.jpg', + isSelf: true, + authorName: 'carol', + authorId: 't2_carol', + score: 3, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + // normalizePost stub passes isSelf from the payload onto item.isSelf, so + // if the real field was used the item should be marked as a self post. + expect((out[0].item as { isSelf?: boolean }).isSelf).toBe(true); + }); + + // M3: per-post normalize-error skip. When the first post causes normalizePost + // to throw and the second is valid, the returned array must contain only the + // valid one (length 1) without the function itself throwing. + it('M3: skips a post that causes normalizePost to throw, returns the rest', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + // First post — will trigger a normalizePost throw. + { + id: 't3_bad', + title: 'malformed', + body: null, + url: '', + authorName: 'x', + authorId: 't2_x', + score: 0, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + // Second post — valid, must survive. + { + id: 't3_good', + title: 'ok', + body: null, + url: 'https://reddit.com/r/sub/comments/good', + authorName: 'y', + authorId: 't2_y', + score: 2, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + + // Make normalizePost throw on the first call, succeed on the second. + let callCount = 0; + vi.mocked(normalizePost).mockImplementation( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => { + callCount++; + if (callCount === 1) throw new Error('malformed id'); + return { item: { id: p.post.id, isSelf: false }, author: { name: p.author.name } }; + } + ); + + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect((out[0].item as { id: string }).id).toBe('t3_good'); + }); +}); diff --git a/tests/core/simulate-full-config.test.ts b/tests/core/simulate-full-config.test.ts new file mode 100644 index 0000000..5d3fb32 --- /dev/null +++ b/tests/core/simulate-full-config.test.ts @@ -0,0 +1,262 @@ +/** + * Safety regression: simulateFullConfig MUST never fire real Reddit mutations. + * + * The structural guarantee lives in runAction: when bypassIdempotency=true the + * function returns dry-run before the action-handler switch ever runs. + * simulateFullConfig always passes bypassIdempotency=true AND forces dryRun=true + * on the config, so the combined gate is redundant by design. + * + * This test locks that guarantee: a rule that DOES trigger (firedCount >= 1) + * must leave every Reddit mutation spy uncalled. Without this regression test, + * a future refactor that accidentally removes either gate would pass all + * existing tests while silently allowing real removes / bans / comments during + * Impact-tab previews. + * + * Reddit mutation spies asserted: + * Direct calls: remove, approve, submitComment, report, banUser, setUserFlair + * Indirect (via model object): getPostById(…).lock, getPostById(…).distinguish + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.hoisted: declarations run before vi.mock factory hoisting so spies can be +// referenced inside the factory closure without temporal-dead-zone errors. +const { + spyRemove, + spyApprove, + spySubmitComment, + spyReport, + spyBanUser, + spySetUserFlair, + spyPostLock, + spyPostDistinguish, + spyCommentLock, + spyCommentDistinguish, + spyGetPostById, + spyGetCommentById, +} = vi.hoisted(() => { + const spyPostLock = vi.fn().mockResolvedValue(undefined); + const spyPostDistinguish = vi.fn().mockResolvedValue(undefined); + const spyCommentLock = vi.fn().mockResolvedValue(undefined); + const spyCommentDistinguish = vi.fn().mockResolvedValue(undefined); + + const mockPost = { lock: spyPostLock, distinguish: spyPostDistinguish }; + const mockComment = { lock: spyCommentLock, distinguish: spyCommentDistinguish }; + + return { + spyRemove: vi.fn().mockResolvedValue(undefined), + spyApprove: vi.fn().mockResolvedValue(undefined), + spySubmitComment: vi.fn().mockResolvedValue(undefined), + spyReport: vi.fn().mockResolvedValue(undefined), + spyBanUser: vi.fn().mockResolvedValue(undefined), + spySetUserFlair: vi.fn().mockResolvedValue(undefined), + spyPostLock, + spyPostDistinguish, + spyCommentLock, + spyCommentDistinguish, + spyGetPostById: vi.fn().mockResolvedValue(mockPost), + spyGetCommentById: vi.fn().mockResolvedValue(mockComment), + }; +}); + +vi.mock('@devvit/web/server', () => ({ + reddit: { + remove: spyRemove, + approve: spyApprove, + submitComment: spySubmitComment, + report: spyReport, + banUser: spyBanUser, + setUserFlair: spySetUserFlair, + getPostById: spyGetPostById, + getCommentById: spyGetCommentById, + }, + redis: { + // muteSet (isRuleMuted) reads from redis; return null so no rules are muted. + hGet: vi.fn().mockResolvedValue(null), + hGetAll: vi.fn().mockResolvedValue({}), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(0), + zAdd: vi.fn().mockResolvedValue(0), + zRemRangeByRank: vi.fn().mockResolvedValue(0), + }, +})); + +import { simulateFullConfig, type SimulationSample } from '../../src/core/simulateRule'; +import type { Item, Author, AppConfig } from '../../src/shared/types'; + +// --- Shared fixtures (mirror pattern from simulate-rule.test.ts) --- + +const ITEM: Item = { + id: 't3_safety', + title: 'buy cheap crypto now', + body: 'unlimited free crypto giveaway click here', + url: 'https://example.com', + author: 'spammer99', + age: 60, + score: 1, + isSelf: true, + over18: false, + removed: false, + approved: false, + locked: false, + stickied: false, + linkFlairText: null, +}; + +const AUTHOR: Author = { + name: 'spammer99', + id: 't2_spammer99', + age: 86400 * 2, + linkKarma: 0, + commentKarma: 0, + flairText: null, + isMod: false, + isContributor: false, + verified: false, + shadowBanned: false, +}; + +const SAMPLE: SimulationSample = { item: ITEM, author: AUTHOR }; + +/** + * AppConfig whose rule WILL trigger on SAMPLE. + * dryRun is intentionally NOT set here — the test proves that it is + * simulateFullConfig's own forcing (not the config flag) that keeps + * actions from executing. + */ +const CONFIG: AppConfig = { + // No dryRun: true — absence proves simulateFullConfig's forcing is responsible. + runs: [ + { + name: 'spam-guard', + checks: [ + { + name: 'crypto-spam', + combinator: 'OR', + rules: [ + { + kind: 'regex', + name: 'crypto-keyword', + pattern: 'crypto|giveaway', + flags: 'i', + target: 'title', + }, + ], + // Three action kinds cover remove, comment, and ban mutation paths. + actions: [ + { kind: 'remove', isSpam: true }, + { kind: 'comment', template: 'Removed for spam.' }, + { kind: 'ban', reason: 'spam', duration: 3 }, + ], + }, + ], + }, + ], +}; + +const ALL_MUTATION_SPIES = [ + spyRemove, + spyApprove, + spySubmitComment, + spyReport, + spyBanUser, + spySetUserFlair, + spyPostLock, + spyPostDistinguish, + spyCommentLock, + spyCommentDistinguish, +]; + +beforeEach(() => { + ALL_MUTATION_SPIES.forEach((s) => s.mockClear()); +}); + +describe('simulateFullConfig dry-run safety', () => { + it('triggers the rule (firedCount >= 1) but fires ZERO Reddit mutations', async () => { + const result = await simulateFullConfig(CONFIG, [SAMPLE], 'testsub'); + + // 1. The call must succeed. + expect(result.ok).toBe(true); + + if (!result.ok) return; // narrow for TS — line above already asserted + + // 2. The rule must have actually triggered — otherwise the "no mutations" + // assertion is vacuous (a rule that never triggered would trivially + // pass it even if the gate were broken). + expect(result.firedCount).toBeGreaterThanOrEqual(1); + expect(result.totalSamples).toBe(1); + expect(result.erroredCount).toBe(0); + + // 3. Core safety assertion: every Reddit mutation spy is uncalled. + // If the dry-run early-return in runAction is ever removed or bypassed, + // one or more of these will flip to toHaveBeenCalled() and the test fails. + expect(spyRemove).not.toHaveBeenCalled(); + expect(spyApprove).not.toHaveBeenCalled(); + expect(spySubmitComment).not.toHaveBeenCalled(); + expect(spyReport).not.toHaveBeenCalled(); + expect(spyBanUser).not.toHaveBeenCalled(); + expect(spySetUserFlair).not.toHaveBeenCalled(); + expect(spyPostLock).not.toHaveBeenCalled(); + expect(spyPostDistinguish).not.toHaveBeenCalled(); + expect(spyCommentLock).not.toHaveBeenCalled(); + expect(spyCommentDistinguish).not.toHaveBeenCalled(); + }); + + it('scales to multiple samples — all trigger, still zero mutations', async () => { + const samples: SimulationSample[] = [ + { item: { ...ITEM, id: 't3_s1', title: 'buy crypto now' }, author: AUTHOR }, + { item: { ...ITEM, id: 't3_s2', title: 'free giveaway crypto' }, author: AUTHOR }, + { item: { ...ITEM, id: 't3_s3', title: 'crypto millionaire secret' }, author: AUTHOR }, + ]; + + const result = await simulateFullConfig(CONFIG, samples, 'testsub'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.firedCount).toBe(3); + expect(result.erroredCount).toBe(0); + + ALL_MUTATION_SPIES.forEach((spy) => { + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it('non-matching sample does not trigger and still fires zero mutations', async () => { + const quietSample: SimulationSample = { + item: { ...ITEM, id: 't3_quiet', title: 'a nice poem about flowers', body: 'lovely day' }, + author: AUTHOR, + }; + + const result = await simulateFullConfig(CONFIG, [quietSample], 'testsub'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.firedCount).toBe(0); + expect(result.erroredCount).toBe(0); + + ALL_MUTATION_SPIES.forEach((spy) => { + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it('mixed batch: some trigger, some do not, zero mutations throughout', async () => { + const samples: SimulationSample[] = [ + { item: { ...ITEM, id: 't3_hit1', title: 'free crypto giveaway' }, author: AUTHOR }, + { item: { ...ITEM, id: 't3_miss1', title: 'my garden update' }, author: AUTHOR }, + { item: { ...ITEM, id: 't3_hit2', title: 'crypto pump incoming' }, author: AUTHOR }, + ]; + + const result = await simulateFullConfig(CONFIG, samples, 'testsub'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.firedCount).toBe(2); + expect(result.totalSamples).toBe(3); + expect(result.erroredCount).toBe(0); + + ALL_MUTATION_SPIES.forEach((spy) => { + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/e2e/config-editor.spec.ts b/tests/e2e/config-editor.spec.ts new file mode 100644 index 0000000..017df6e --- /dev/null +++ b/tests/e2e/config-editor.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +/** + * Phase 5 — Config editor E2E: light happy-path smoke against demo mode. + * + * Runs against `?demo=1` via the mock-server which now stubs all five + * editor API endpoints (raw/validate/simulate-live/explain/save). + * + * Coverage: + * - "Edit config" button opens the workbench overlay + * - CodeMirror editor is visible (.cm-editor) + * - All three preview tabs are present (Impact / Explain / Diff) + * - Impact tab eventually shows the fire-rate text from simulateLiveSafe + * + * Intentionally light: no full save round-trip assertion (the mock save + * returns ok:true but the post-save reload would race the debounced validate; + * tab-visible + impact-text are the must-have assertions). + */ + +test.describe('Config editor workbench', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?demo=1'); + // Dismiss onboarding tour so it does not obscure the Edit config button + await page.evaluate(() => localStorage.setItem('cm-tour-seen-v1', '1')); + await page.reload(); + }); + + test('opens workbench with CodeMirror editor visible', async ({ page }) => { + // Wait for the dashboard to settle (stat cards imply data loaded) + await expect(page.getByText('Actions today').first()).toBeVisible(); + + // Click the Edit config button in the ActionBar + const editBtn = page.getByRole('button', { name: /edit config/i }); + await expect(editBtn).toBeVisible(); + await editBtn.click(); + + // The workbench overlay must be present + await expect(page.locator('.cm-workbench')).toBeVisible(); + + // CodeMirror must have rendered its editor container + await expect(page.locator('.cm-editor')).toBeVisible({ timeout: 10_000 }); + }); + + test('workbench shows three preview tabs: Impact, Explain, Diff', async ({ page }) => { + await expect(page.getByText('Actions today').first()).toBeVisible(); + + await page.getByRole('button', { name: /edit config/i }).click(); + await expect(page.locator('.cm-editor')).toBeVisible({ timeout: 10_000 }); + + // All three tabs must be in the tab list + const tabList = page.getByRole('tablist'); + await expect(tabList.getByRole('tab', { name: /impact/i })).toBeVisible(); + await expect(tabList.getByRole('tab', { name: /explain/i })).toBeVisible(); + await expect(tabList.getByRole('tab', { name: /diff/i })).toBeVisible(); + }); + + test('Impact tab renders fire-rate text from mock simulate-live', async ({ page }) => { + await expect(page.getByText('Actions today').first()).toBeVisible(); + + await page.getByRole('button', { name: /edit config/i }).click(); + await expect(page.locator('.cm-editor')).toBeVisible({ timeout: 10_000 }); + + // Impact is the default tab; the debounce fires after 700 ms. + // The mock returns firedCount:3, totalSamples:25 -> "Would fire on 3/25" + await expect(page.getByText(/would fire on/i)).toBeVisible({ timeout: 5_000 }); + }); + + test('Close editor button hides workbench', async ({ page }) => { + await expect(page.getByText('Actions today').first()).toBeVisible(); + + await page.getByRole('button', { name: /edit config/i }).click(); + await expect(page.locator('.cm-editor')).toBeVisible({ timeout: 10_000 }); + + await page.getByRole('button', { name: /close editor/i }).click(); + await expect(page.locator('.cm-workbench')).not.toBeVisible(); + }); +}); diff --git a/tests/lib/resolveOpenaiKey.test.ts b/tests/lib/resolveOpenaiKey.test.ts new file mode 100644 index 0000000..d109924 --- /dev/null +++ b/tests/lib/resolveOpenaiKey.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getOpenaiKey = vi.fn(); +const settingsGet = vi.fn(); +vi.mock('../../src/state/apiKeyStore', () => ({ getOpenaiKey: (s: string) => getOpenaiKey(s) })); +vi.mock('@devvit/web/server', () => ({ settings: { get: (k: string) => settingsGet(k) } })); + +import { resolveOpenaiKey } from '../../src/lib/resolveOpenaiKey'; + +describe('resolveOpenaiKey', () => { + beforeEach(() => { getOpenaiKey.mockReset(); settingsGet.mockReset(); }); + + it('prefers the Redis key', async () => { + getOpenaiKey.mockResolvedValue('sk-redis'); + expect(await resolveOpenaiKey('sub')).toBe('sk-redis'); + expect(settingsGet).not.toHaveBeenCalled(); + }); + + it('falls back to settings, trimmed', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(' sk-settings '); + expect(await resolveOpenaiKey('sub')).toBe('sk-settings'); + }); + + it('returns empty string when neither source has a key', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(undefined); + expect(await resolveOpenaiKey('sub')).toBe(''); + }); +}); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts new file mode 100644 index 0000000..958a660 --- /dev/null +++ b/tests/routes/config-editor.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getWikiPage = vi.fn(); +const updateWikiPage = vi.fn(); +const requireModeratorMock = vi.fn(); +const getRecentSample = vi.fn(); +const simulateFullConfig = vi.fn(); +const explainRule = vi.fn(); +const resolveOpenaiKey = vi.fn(); +const publish = vi.fn(); +const logModActivity = vi.fn(); + +// Named redis fn handles so individual tests can override per-call behavior. +const redisGet = vi.fn(async () => null); +const redisSet = vi.fn(async () => 'OK'); +const redisIncrBy = vi.fn(async () => 1); +const redisExpire = vi.fn(async () => undefined); + +vi.mock('@devvit/web/server', () => ({ + reddit: { + getWikiPage: (s: string, p: string) => getWikiPage(s, p), + updateWikiPage: (o: unknown) => updateWikiPage(o), + }, + redis: { + get: (...args: unknown[]) => redisGet(...args), + set: (...args: unknown[]) => redisSet(...args), + incrBy: (...args: unknown[]) => redisIncrBy(...args), + expire: (...args: unknown[]) => redisExpire(...args), + }, + settings: { get: vi.fn() }, +})); +vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); +vi.mock('../../src/core/recentSample', () => ({ getRecentSample: (s: string) => getRecentSample(s) })); +vi.mock('../../src/core/simulateRule', () => ({ simulateFullConfig: (...a: unknown[]) => simulateFullConfig(...a) })); +vi.mock('../../src/core/explainRule', () => ({ explainRule: (...a: unknown[]) => explainRule(...a) })); +vi.mock('../../src/lib/resolveOpenaiKey', () => ({ resolveOpenaiKey: (s: string) => resolveOpenaiKey(s) })); +vi.mock('../../src/state/configStore', () => ({ + publish: (...a: unknown[]) => publish(...a), + getCurrentRev: vi.fn(), + getRecentRevs: vi.fn(), +})); +vi.mock('../../src/state/modActivity', () => ({ + logModActivity: (...a: unknown[]) => logModActivity(...a), +})); + +import { configEditor } from '../../src/routes/configEditor'; + +const MOD = { ok: true, sub: 'testsub', username: 'mod1' }; +const NON_MOD = { ok: false, status: 403, error: 'not a moderator of this sub' }; + +async function get(path: string) { + const res = await configEditor.request(new Request(`http://x${path}`)); + return { status: res.status, body: await res.json() }; +} + +async function post(path: string, body: unknown) { + const res = await configEditor.request(new Request(`http://x${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +describe('POST /validate', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); }); + it('accepts a valid config', async () => { + const r = await post('/validate', { text: 'runs: []' }); + expect(r.body.ok).toBe(true); + }); + it('reports errors for an invalid config', async () => { + const r = await post('/validate', { text: 'runs:\n kind: badvalue' }); + expect(r.body.ok).toBe(false); + expect(r.body.errors).toBeDefined(); + }); + it('rejects non-mods with 403', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await post('/validate', { text: 'runs: []' }); + expect(r.status).toBe(403); + expect(r.body.ok).toBe(false); + }); +}); + +describe('POST /simulate-live', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + getRecentSample.mockResolvedValue([]); + // Default: incrBy returns 1 (well under 120 cap) — allowed. + redisIncrBy.mockResolvedValue(1); + }); + + it('runs the full config and returns {ok:true, firedCount, totalSamples}', async () => { + // simulateFullConfig is what the route now delegates to after parsing the full config. + simulateFullConfig.mockResolvedValue({ ok: true, totalSamples: 25, firedCount: 7, erroredCount: 0, breakdown: [] }); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, firedCount: 7, totalSamples: 25 }); + }); + + it('returns {ok:false} with 200 when the config text is invalid', async () => { + // An invalid config (mid-edit) should return {ok:false} with a 200 so the + // client treats it as a normal "config invalid" inline status, not an error banner. + // Use a YAML root that is a bare string scalar — parseConfig rejects non-object roots. + simulateFullConfig.mockClear(); + const r = await post('/simulate-live', { text: '"this is not a config"' }); + expect(r.status).toBe(200); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/config invalid/); + // simulateFullConfig must NOT have been called when the config is unparseable. + expect(simulateFullConfig).not.toHaveBeenCalled(); + }); + + it('returns 400 when text is missing', async () => { + const r = await post('/simulate-live', {}); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + }); + + it('returns 429 when rate-limited (incrBy exceeds cap of 120)', async () => { + // checkRateLimit: allowed = count <= max; 121 > 120, so allowed = false. + redisIncrBy.mockResolvedValueOnce(121); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.status).toBe(429); + expect(r.body.ok).toBe(false); + }); + + it('returns 503 when getRecentSample throws', async () => { + getRecentSample.mockRejectedValueOnce(new Error('Redis timeout')); + // simulateFullConfig would not be reached since getRecentSample throws first. + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.status).toBe(503); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/Simulation unavailable/); + }); +}); + +describe('POST /explain', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + resolveOpenaiKey.mockResolvedValue('sk-x'); + // Default: incrBy returns 1 (under 10 cap) — allowed, not degraded. + redisIncrBy.mockResolvedValue(1); + }); + + it('returns the explanation', async () => { + explainRule.mockResolvedValue({ ok: true, value: 'This rule removes crypto spam.' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, explanation: 'This rule removes crypto spam.' }); + }); + + it('returns 400 when text is missing', async () => { + const r = await post('/explain', {}); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + }); + + it('returns 400 when no OpenAI key is set', async () => { + resolveOpenaiKey.mockResolvedValueOnce(''); + explainRule.mockResolvedValue({ ok: true, value: 'irrelevant' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/No OpenAI key/); + }); + + it('returns 503 when rate-limit subsystem is degraded (incrBy throws)', async () => { + // checkRateLimit catches the throw and returns { allowed: true, degraded: true }. + // The /explain handler checks rl.degraded before rl.allowed and returns 503. + redisIncrBy.mockRejectedValueOnce(new Error('Redis connection refused')); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(503); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/degraded/i); + }); + + it('returns 429 when rate-limited (incrBy exceeds cap of 10)', async () => { + // checkRateLimit: allowed = count <= max; 11 > 10, so allowed = false. + redisIncrBy.mockResolvedValueOnce(11); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(429); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/limit/i); + }); + + it('returns 500 when explainRule returns {ok: false}', async () => { + explainRule.mockResolvedValueOnce({ ok: false, error: 'OpenAI returned 500' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(500); + expect(r.body.ok).toBe(false); + }); +}); + +describe('GET /raw', () => { + beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); + + it('returns wiki content + revisionId for a mod', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-1' }); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }); + }); + + it('rejects non-mods', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await get('/raw'); + expect(r.status).toBe(403); + }); + + it('returns the default template on a missing page', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('404 not found')); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body.isDefaultTemplate).toBe(true); + expect(typeof r.body.content).toBe('string'); + }); + + it('returns 503 on a non-404 wiki error', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('internal error')); + const r = await get('/raw'); + expect(r.status).toBe(503); + }); +}); + +describe('POST /save', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + updateWikiPage.mockReset(); + publish.mockReset(); + getWikiPage.mockReset(); + logModActivity.mockReset(); + }); + + it('rejects an invalid config without writing', async () => { + const r = await post('/save', { text: 'runs: "bad"', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(400); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('409s when the wiki moved since load', async () => { + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-2' }); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(409); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('saves a valid config and publishes', async () => { + getWikiPage.mockResolvedValue({ content: 'old', revisionId: 'rev-1' }); + updateWikiPage.mockResolvedValue({ revisionId: 'rev-2' }); + publish.mockResolvedValue(0); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(200); + expect(updateWikiPage).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + expect(r.body).toMatchObject({ ok: true, rev: 0, ruleCount: 0 }); + }); +}); diff --git a/tests/routes/forms-openai-key.test.ts b/tests/routes/forms-openai-key.test.ts index 446856a..0ffffb4 100644 --- a/tests/routes/forms-openai-key.test.ts +++ b/tests/routes/forms-openai-key.test.ts @@ -52,6 +52,13 @@ vi.mock('../../src/core/dryRunActivity', () => ({ dryRunActivity: vi.fn() })); vi.mock('../../src/shared/normalize', () => ({ normalizePost: vi.fn(), normalizeComment: vi.fn(), + // M1: asPayloadTimestamp moved to shared/normalize — must be present in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/state/configStore', () => ({ getCurrentRev: vi.fn() })); vi.mock('../../src/core/simulateRule', () => ({ diff --git a/tests/routes/forms-simulate-rule.test.ts b/tests/routes/forms-simulate-rule.test.ts index 636a457..7e02dcc 100644 --- a/tests/routes/forms-simulate-rule.test.ts +++ b/tests/routes/forms-simulate-rule.test.ts @@ -52,6 +52,13 @@ vi.mock('../../src/shared/normalize', () => ({ author: { name: 'u', id: 't2_u' }, })), normalizeComment: vi.fn(), + // M1: asPayloadTimestamp moved to shared/normalize — must be present in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/core/dryRunActivity', () => ({ dryRunActivity: vi.fn() })); vi.mock('../../src/core/explainRule', () => ({ diff --git a/tests/routes/forms-test-rules.test.ts b/tests/routes/forms-test-rules.test.ts index e358f6d..2b0c80a 100644 --- a/tests/routes/forms-test-rules.test.ts +++ b/tests/routes/forms-test-rules.test.ts @@ -37,6 +37,13 @@ vi.mock('../../src/core/dryRunActivity', () => ({ vi.mock('../../src/shared/normalize', () => ({ normalizePost: (...a: unknown[]) => normalizePost(...a), normalizeComment: (...a: unknown[]) => normalizeComment(...a), + // M1: asPayloadTimestamp moved to shared/normalize — must be included in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/state/configStore', () => ({ getCurrentRev: (...a: unknown[]) => getCurrentRev(...a), diff --git a/tests/schema/descriptions.test.ts b/tests/schema/descriptions.test.ts new file mode 100644 index 0000000..36fcfb6 --- /dev/null +++ b/tests/schema/descriptions.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import schema from '../../src/schema/app.schema.json'; + +describe('schema descriptions', () => { + it('documents the top-level runs property', () => { + const runs = (schema as { properties?: { runs?: { description?: string } } }).properties?.runs; + expect(typeof runs?.description).toBe('string'); + expect((runs?.description ?? '').length).toBeGreaterThan(0); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ec4e158..d042b5f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,15 @@ export default defineConfig({ include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], exclude: ['tests/e2e/**', 'tests/bench/**', '**/__snapshots__/**'], globals: false, + // codemirror-json-schema ships ESM/CJS files with extensionless sub-imports + // (e.g. `from "./features/completion"`) that Node ESM resolution rejects. + // server.deps.inline forces Vite's bundler to handle the package instead of + // native Node, where extensionless imports resolve correctly. + server: { + deps: { + inline: ['codemirror-json-schema'], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'json-summary', 'html'],