Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
247 changes: 198 additions & 49 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,17 @@ async function isCallerModerator(): Promise<boolean> {
// Try the Redis cache first
const cacheKey = keys.modlist(subredditName);
let mods: string[] | null = null;
let redisOk = true;
try {
const cached = await redis.get(cacheKey);
if (cached) mods = JSON.parse(cached);
console.log(`[vibe-mod] mod check: redis cache ${mods ? 'hit' : 'miss'}`);
} catch (err) {
redisOk = false;
console.warn('[vibe-mod] mod check: redis.get(modlist) threw:', describeErr(err));
}

let redditOk = true;
if (!mods) {
try {
const list = await reddit.getModerators({ subredditName });
Expand All @@ -147,11 +150,28 @@ async function isCallerModerator(): Promise<boolean> {
console.warn('[vibe-mod] mod check: cache write failed (non-fatal):', describeErr(err));
}
} catch (err) {
redditOk = false;
console.warn('[vibe-mod] mod check: getModerators threw:', describeErr(err));
return false;
}
}

// Resilient fallback (reddit/devvit#258 work-around): if BOTH redis.get AND
// reddit.getModerators throw, we can't enumerate the mod list ourselves — but
// Devvit's gateway already filtered this request by `forUserType:"moderator"`
// (see devvit.json menu.items). The gateway is the security boundary; trust
// it as fallback so menus open even while the plugin RPC sidecar is broken.
// Logged loudly so the fallback is auditable. Removed once #258 is fixed.
if (!mods) {
if (!redisOk && !redditOk) {
console.warn(
`[vibe-mod] mod check: plugin RPC unreachable for both redis and reddit — falling back to gateway-side forUserType:"moderator" filter; trusting ${username}`,
);
return true;
}
console.warn('[vibe-mod] mod check: could not resolve mod list — refusing');
return false;
}
Comment on lines +158 to +173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

서버 측 모더레이터 인증이 게이트웨이 신뢰로 다운그레이드되어 FIND-03 가드가 약화됩니다.

이 파일 105-109행 주석은 forUserType: "moderator"가 "UI 힌트일 뿐 서버 시행이 아니며 모든 폼/메뉴 핸들러는 반드시 isCallerModerator()를 호출하고 false면 거부해야 한다"고 명시하고 있습니다 (FIND-03). 그런데 새 폴백 분기(164-173행)는 Redis와 Reddit RPC가 둘 다 실패하면 forUserType 필터링을 신뢰해 true를 반환합니다. 이렇게 되면 두 RPC를 (의도적이든 일시적이든) 실패시킬 수 있는 호출자가 보안 검사를 우회할 수 있어, 사실상 이 가드가 닫고자 했던 인증 격차가 RPC 장애 윈도우 동안 다시 열립니다.

reddit/devvit#258 회피책으로서 의도는 이해되지만, 권한이 더 낮은 동작(예: 폼은 열어 두되 모든 쓰기 핸들러는 여전히 fail-closed로 거부, 또는 폴백 모드에 한해 ban/mute 같은 가드 액션 비활성화)으로 격리하는 것을 권장합니다. 최소한 폴백을 거친 호출은 별도 플래그로 표시해 다운스트림 권한 결정에서 추적/제한할 수 있게 해 두면 게이트웨이 우회 위험이 줄어듭니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` around lines 158 - 173, The current fallback returns
true trusting the gateway filter when both redisOk and redditOk are false, which
reopens the FIND-03 gap; change this by NOT returning true from the mods check:
instead set a clearly named fallback flag (e.g., gatewayFallback = true) in the
same scope when (!redisOk && !redditOk) and still return false for moderator
checks, ensuring callers of isCallerModerator() and any downstream authorization
(ban/mute/write handlers) see and can act on that gatewayFallback flag to allow
UI-only behavior but enforce fail-closed on privileged actions; update call
sites that use the mods boolean (and any handlers that previously relied on a
true result) to consult gatewayFallback and reject or restrict elevated
operations accordingly, and preserve the logged warning including username for
auditability.


const isMod = mods.includes(username);
console.log(`[vibe-mod] mod check: ${username} ∈ mods? ${isMod}`);
return isMod;
Expand Down Expand Up @@ -211,14 +231,24 @@ app.post('/internal/menu/compose-rule', async (c) => {
}

const subredditName = getCurrentSubredditName();
const dailyCount = Number((await redis.get(keys.compileCount(subredditName, todayKey()))) ?? '0');
// Best-effort daily-count read — render "—" if plugin RPC is unreachable
// (reddit/devvit#258) so the form still opens. Quota enforcement still
// happens server-side in compose-rule-submit, where we fail-closed if we
// can't confirm the count.
let dailyCountDisplay = '—';
try {
const raw = await redis.get(keys.compileCount(subredditName, todayKey()));
dailyCountDisplay = String(Number(raw ?? '0'));
} catch (err) {
console.warn('[vibe-mod] compose-rule: redis.get(dailyCount) threw — showing "—":', describeErr(err));
}

return c.json<UiResponse>({
showForm: {
name: 'ruleComposerForm',
form: {
title: `Compose rule for r/${subredditName}`,
description: `Compiles used today: ${dailyCount} / ${LIMITS.COMPILE_RATE_LIMIT_PER_DAY}.\nYour rule will be saved as a draft. Dry-run preview runs automatically.`,
description: `Compiles used today: ${dailyCountDisplay} / ${LIMITS.COMPILE_RATE_LIMIT_PER_DAY}.\nYour rule will be saved as a draft. Dry-run preview runs automatically.`,
acceptLabel: 'Compile + Preview',
cancelLabel: 'Cancel',
fields: [
Expand Down Expand Up @@ -261,12 +291,28 @@ app.post('/internal/form/compose-rule-submit', async (c) => {

const subredditName = getCurrentSubredditName();

// Rate limit (sub-scoped)
// Rate limit (sub-scoped). All plugin RPC here is wrapped in try/catch
// because reddit/devvit#258 makes every Devvit plugin call throw
// "Error: undefined undefined: undefined". An unwrapped throw at this
// layer 500s the whole handler — the user sees nothing happen when they
// click "Compile + Preview". Fail-open on the quota check (skip enforcement
// if we can't read the counter), fail-closed on the key check (require
// explicit BYOK absence to fall through). See claudedocs/2026-05-13-...
const todayCounterKey = keys.compileCount(subredditName, todayKey());
const todayCount = Number((await redis.get(todayCounterKey)) ?? '0');
let todayCount = 0;
try {
todayCount = Number((await redis.get(todayCounterKey)) ?? '0');
} catch (err) {
console.warn('[vibe-mod] submit: redis.get(todayCount) threw — skipping quota:', describeErr(err));
}
Comment on lines +325 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Redis 읽기 실패 후 카운터 증가가 일일 할당량을 1로 재설정합니다.

todayCountredis.get 실패 시 0으로 시작합니다(325-330행). 그 뒤 506-514행에서 redis.set(todayCounterKey, String(todayCount + 1))을 호출하면, 만약 일시적인 RPC 장애 동안 읽기가 한 번 실패한 후 쓰기가 곧바로 성공하는 경우, 누적된 실제 카운트(예: 47)가 "1"로 덮어써져 일일 할당량이 사실상 초기화됩니다. 또한 비-원자성이라 동시 컴파일에서 증가 손실이 발생할 수 있습니다.

읽기가 실패했다면 증가도 건너뛰거나(quota를 보수적으로 fail-closed), 가능하면 redis.incr + expire로 전환해 read/modify/write 레이스와 RPC 부분 실패로 인한 덮어쓰기를 모두 회피하는 것이 안전합니다.

♻️ 제안 변경
-  let todayCount = 0;
+  let todayCount = 0;
+  let todayCountReadOk = true;
   try {
     todayCount = Number((await redis.get(todayCounterKey)) ?? '0');
   } catch (err) {
+    todayCountReadOk = false;
     console.warn('[vibe-mod] submit: redis.get(todayCount) threw — skipping quota:', describeErr(err));
   }
@@
-  if (!usingBYOK) {
+  if (!usingBYOK && todayCountReadOk) {
     try {
-      await redis.set(todayCounterKey, String(todayCount + 1));
-      await redis.expire(todayCounterKey, 86_400);
+      // Prefer atomic increment to avoid losing concurrent compiles and to
+      // avoid clobbering the counter when an earlier read failed.
+      await redis.incrBy(todayCounterKey, 1);
+      await redis.expire(todayCounterKey, 86_400);
     } catch (err) {
       console.warn('[vibe-mod] submit: redis.set(todayCount) threw — quota not incremented:', describeErr(err));
     }
   }

Also applies to: 506-514

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/index.ts` around lines 325 - 330, The current flow reads
todayCount via redis.get into todayCount and on read failure defaults to 0, then
later does redis.set(todayCounterKey, String(todayCount + 1)) which can
overwrite a correct counter (symbols: todayCount, redis.get(todayCounterKey),
redis.set(todayCounterKey, String(todayCount + 1))) — change the increment logic
to avoid read-modify-write races and RPC partial failures: on read error, either
skip incrementing (fail-closed) or, preferably, replace the read+set with an
atomic redis.incr(todayCounterKey) and ensure ttl via
redis.expire(todayCounterKey, ttlSeconds) (or use SET with INCR and EXPIRE
semantics) so concurrent requests and read failures cannot reset the counter;
apply the same change for the other occurrence referenced (the block around
redis.set in the 506-514 region).


// Check if BYOK key is present (skip quota for BYOK)
const subOverrideKey = (await settings.get('subredditOpenaiApiKey')) as string;
let subOverrideKey = '';
try {
subOverrideKey = ((await settings.get('subredditOpenaiApiKey')) as string) ?? '';
} catch (err) {
console.warn('[vibe-mod] submit: settings.get(subredditOpenaiApiKey) threw — assuming no BYOK:', describeErr(err));
}
const usingBYOK = !!subOverrideKey?.trim();

if (!usingBYOK && todayCount >= LIMITS.COMPILE_RATE_LIMIT_PER_DAY) {
Expand All @@ -286,14 +332,23 @@ app.post('/internal/form/compose-rule-submit', async (c) => {
compiled = result.json;
tokensIn = result.tokensIn;
tokensOut = result.tokensOut;
} catch {
// Don't leak the error message — it could echo back compile context to the user.
return c.json<UiResponse>({
showToast: {
text: 'Compiler offline. Your draft is saved. Try again in a minute.',
appearance: 'neutral',
},
});
} catch (err) {
// Don't leak the error message — it could echo back compile context.
// Distinguish three failure shapes so the moderator sees an actionable
// message: plugin RPC blocked (Devvit platform bug), key not configured,
// or OpenAI/network error.
const msg = String((err as Error)?.message ?? err);
let userMsg: string;
if (msg === 'no_key_plugin_rpc') {
userMsg =
'Compiler offline: Devvit plugin RPC is unreachable (reddit/devvit#258, OPEN platform bug). settings.get(openaiApiKey) cannot return your key right now, so the compile cannot run. Once Reddit ships the platform fix, this same flow will produce the dry-run preview.';
} else if (msg === 'no_key') {
userMsg = 'No OpenAI API key configured. Run `npx devvit settings set openaiApiKey` and try again.';
} else {
userMsg = 'Compiler offline. Try again in a minute.';
}
console.warn('[vibe-mod] submit: callOpenAI threw:', describeErr(err));
return c.json<UiResponse>({ showToast: { text: userMsg, appearance: 'neutral' } });
}

// Clarification path — sends user back to form with answer field, NOT concatenation.
Expand Down Expand Up @@ -367,14 +422,31 @@ app.post('/internal/form/compose-rule-submit', async (c) => {
});
}

// Append to draft bundle (sub-scoped key)
// Append to draft bundle (sub-scoped key). All plugin RPC here is
// best-effort — see top of handler for the reddit/devvit#258 rationale.
// We've already produced a valid compiled `validated` rule above; even if
// persistence fails the user still sees the compile-success toast and the
// rule object below.
const draftKey = keys.rulesDraft(subredditName);
const draftJson = await redis.get(draftKey);
let draftJson: string | undefined;
try {
draftJson = (await redis.get(draftKey)) ?? undefined;
} catch (err) {
console.warn('[vibe-mod] submit: redis.get(draft) threw — starting fresh:', describeErr(err));
}

let llmModel = 'gpt-5.4-mini';
try {
llmModel = ((await settings.get('openaiModel')) as string) || 'gpt-5.4-mini';
} catch (err) {
console.warn('[vibe-mod] submit: settings.get(openaiModel) threw — using default:', describeErr(err));
}

const draft: RuleBundleType = safeParseBundle(draftJson, 'compose/draft') ?? {
schemaVersion: '1.0.0',
bundleVersion: 0,
compiledAt: Date.now(),
llmModel: ((await settings.get('openaiModel')) as string) || 'gpt-5.4-mini',
llmModel,
llmTokensIn: 0,
llmTokensOut: 0,
rules: [],
Expand All @@ -395,25 +467,52 @@ app.post('/internal/form/compose-rule-submit', async (c) => {
draft.llmTokensIn += tokensIn;
draft.llmTokensOut += tokensOut;

await redis.set(draftKey, JSON.stringify(draft));
let persisted = true;
try {
await redis.set(draftKey, JSON.stringify(draft));
} catch (err) {
persisted = false;
console.warn('[vibe-mod] submit: redis.set(draft) threw — rule NOT persisted:', describeErr(err));
}

// Increment daily compile counter (sub-scoped, BYOK skipped)
// Increment daily compile counter (sub-scoped, BYOK skipped) — best-effort.
if (!usingBYOK) {
await redis.set(todayCounterKey, String(todayCount + 1));
await redis.expire(todayCounterKey, 86_400);
try {
await redis.set(todayCounterKey, String(todayCount + 1));
await redis.expire(todayCounterKey, 86_400);
} catch (err) {
console.warn('[vibe-mod] submit: redis.set(todayCount) threw — quota not incremented:', describeErr(err));
}
}

// Kick off dry-run replay job
await scheduler.runJob({
name: 'dry-run-replay',
runAt: new Date(),
data: { ruleId: validated.id, subredditName },
});
// Kick off dry-run replay job — best-effort. If scheduler is unreachable
// the rule is still compiled; the user just doesn't get the dry-run preview.
let dryRunQueued = true;
try {
await scheduler.runJob({
name: 'dry-run-replay',
runAt: new Date(),
data: { ruleId: validated.id, subredditName },
});
} catch (err) {
dryRunQueued = false;
console.warn('[vibe-mod] submit: scheduler.runJob(dry-run) threw — no preview:', describeErr(err));
}

// Honest user-facing toast — say what actually happened rather than promising
// a dashboard view that won't render if persistence failed.
const lines = [`Compiled rule "${validated.name}".`];
if (persisted && dryRunQueued) {
lines.push('Dry-run started — check Dashboard in 30s.');
} else if (persisted) {
lines.push('Saved as draft (dry-run preview unavailable).');
} else {
lines.push('Plugin RPC unreachable — rule compiled but not persisted (reddit/devvit#258).');
}
return c.json<UiResponse>({
showToast: {
text: `Compiled rule "${validated.name}". Dry-run started — check Dashboard in 30s.`,
appearance: 'success',
text: lines.join(' '),
appearance: persisted ? 'success' : 'neutral',
},
});
});
Expand All @@ -428,34 +527,61 @@ app.post('/internal/menu/dashboard', async (c) => {
}

const subredditName = getCurrentSubredditName();
const active = safeParseBundle(await redis.get(keys.rulesActive(subredditName)), 'dashboard/active');
const draft = safeParseBundle(await redis.get(keys.rulesDraft(subredditName)), 'dashboard/draft');
// All redis reads here are best-effort (reddit/devvit#258). If plugin RPC
// is unreachable, the dashboard still opens with a banner explaining the
// bug instead of 500-ing into a "click does nothing" experience.
let rpcOk = true;
let active: RuleBundleType | null = null;
let draft: RuleBundleType | null = null;
try {
active = safeParseBundle(await redis.get(keys.rulesActive(subredditName)), 'dashboard/active');
draft = safeParseBundle(await redis.get(keys.rulesDraft(subredditName)), 'dashboard/draft');
} catch (err) {
rpcOk = false;
console.warn('[vibe-mod] dashboard: redis.get(rules) threw:', describeErr(err));
}

const auditKey = keys.audit(subredditName);
const recentIds = await redis.zRange(auditKey, 0, 19, { by: 'rank', reverse: true });
const recent: Array<Record<string, string>> = [];
for (const m of recentIds) {
const h = await redis.hGetAll(keys.auditEntry(subredditName, m.member));
recent.push({ ...h, id: String(m.member) });
try {
const auditKey = keys.audit(subredditName);
const recentIds = await redis.zRange(auditKey, 0, 19, { by: 'rank', reverse: true });
for (const m of recentIds) {
const h = await redis.hGetAll(keys.auditEntry(subredditName, m.member));
recent.push({ ...h, id: String(m.member) });
}
} catch (err) {
rpcOk = false;
console.warn('[vibe-mod] dashboard: redis.zRange(audit) threw:', describeErr(err));
}

// Dry-run results for the draft rules (written by /internal/scheduler/dry-run-replay).
const dryRunLines: string[] = [];
for (const r of draft?.rules ?? []) {
const raw = await redis.get(keys.dryrun(subredditName, r.id));
if (!raw) continue;
const d = JSON.parse(raw) as DryRunResult;
if (d.status === 'ok') {
dryRunLines.push(
` ${r.id}: would match ${d.matched.length}/${d.sampledPosts} recent post(s)` +
(d.matched.length ? ` → ${[...new Set(d.matched.flatMap((m) => m.would))].join(', ')}` : ''),
);
} else {
dryRunLines.push(` ${r.id}: ${d.note ?? 'dry-run unavailable'}`);
try {
const raw = await redis.get(keys.dryrun(subredditName, r.id));
if (!raw) continue;
const d = JSON.parse(raw) as DryRunResult;
if (d.status === 'ok') {
dryRunLines.push(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This catch block logs the error but doesn't set rpcOk = false. This is inconsistent with the other error handling blocks in this function. If a redis call fails here, it's an indicator of the same underlying RPC issue, and the UI should probably reflect that by showing the warning banner. Consider setting rpcOk = false; here as well.

      rpcOk = false;
      console.warn('[vibe-mod] dashboard: redis.get(dryrun/' + r.id + ') threw:', describeErr(err));

` ${r.id}: would match ${d.matched.length}/${d.sampledPosts} recent post(s)` +
(d.matched.length ? ` → ${[...new Set(d.matched.flatMap((m) => m.would))].join(', ')}` : ''),
);
} else {
dryRunLines.push(` ${r.id}: ${d.note ?? 'dry-run unavailable'}`);
}
} catch (err) {
console.warn(`[vibe-mod] dashboard: redis.get(dryrun/${r.id}) threw:`, describeErr(err));
}
}

const summary = [
...(rpcOk
? []
: [
'⚠ Plugin RPC unreachable (reddit/devvit#258 — OPEN platform bug).',
'Persistence is offline; this view reflects what redis would return.',
'',
]),
`Active rules: ${active?.rules.length ?? 0}`,
`Draft rules: ${draft?.rules.length ?? 0}`,
`Recent actions: ${recent.length}`,
Expand Down Expand Up @@ -915,12 +1041,35 @@ async function callOpenAI(
clarificationAnswer?: string,
): Promise<{ json: unknown; tokensIn: number; tokensOut: number }> {
// BYOK preference: sub-scope override key beats developer global key.
const subKey = (await settings.get('subredditOpenaiApiKey')) as string;
const globalKey = (await settings.get('openaiApiKey')) as string;
// settings.get currently throws \`undefined undefined: undefined\` whenever
// Devvit's plugin RPC sidecar is unreachable (reddit/devvit#258). Treat
// those throws as "key unavailable" so callers can surface the platform
// bug to the user instead of seeing the generic "Try again in a minute"
// toast. Distinct error code (\`no_key_plugin_rpc\`) lets the submit
// handler branch on the cause.
let subKey = '';
let globalKey = '';
try {
subKey = ((await settings.get('subredditOpenaiApiKey')) as string) ?? '';
} catch (err) {
console.warn('[vibe-mod] callOpenAI: settings.get(subredditOpenaiApiKey) threw:', describeErr(err));
throw new Error('no_key_plugin_rpc');
}
try {
globalKey = ((await settings.get('openaiApiKey')) as string) ?? '';
} catch (err) {
console.warn('[vibe-mod] callOpenAI: settings.get(openaiApiKey) threw:', describeErr(err));
throw new Error('no_key_plugin_rpc');
}
const apiKey = (subKey?.trim() || globalKey || '').trim();
if (!apiKey) throw new Error('no_key');

const model = ((await settings.get('openaiModel')) as string) || 'gpt-5.4-mini';
let model = 'gpt-5.4-mini';
try {
model = ((await settings.get('openaiModel')) as string) || 'gpt-5.4-mini';
} catch (err) {
console.warn('[vibe-mod] callOpenAI: settings.get(openaiModel) threw — using default:', describeErr(err));
}

const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
{ role: 'system', content: VIBE_MOD_SYSTEM_PROMPT },
Expand Down