app/lib/search/ask-guard.server.ts
142 lines and 13 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| normalizeQuestion | 38 to 44 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| questionKey | 46 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| checkAskRate | 62 to 71 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| reserveAskBudget | 77 to 88 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readAskBudget | 90 to 94 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| resetAskBudget | 96 to 99 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readCachedAnswer | 101 to 110 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| dropCachedAnswer | 113 to 115 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| writeCachedAnswer | 117 to 127 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readCachedDrift | 130 to 136 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| writeCachedDrift | 138 to 142 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| dropCachedDrift | 145 to 147 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| invalidateAnswerCache | 153 to 166 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/lib/bytes.mjs, app/lib/rate-limit.mjs, app/lib/search/ask-pacing.mjs. Imported by app/lib/admin/posts-actions.server.ts, app/lib/search/ask.server.ts, app/routes/admin.posts._index.tsx, app/routes/admin.tsx, app/routes/search.ask.ts.
Source
// Every generated answer bills Workers AI and the route is public. Nothing reaches the model until
// rate, cache and daily budget have passed, and budget sits AFTER the cache so a hit costs nothing.
import { toHex } from "~/lib/bytes.mjs";
import { limitHit } from "~/lib/rate-limit.mjs";
import { pacedAllowance, secondsPerPacedUnit } from "~/lib/search/ask-pacing.mjs";
/** Per IP per minute: more than a reading human asks, far less than a loop wants. */
const ASK_RATE_LIMIT = 5;
export const ASK_RATE_LIMIT_PERIOD_SECONDS = 60;
/** A ceiling on the bill: a number a real reader never reaches and a scraper will. */
const DAILY_ANSWER_BUDGET = 200;
/** Without a burst the first question after UTC midnight is refused: the paced share is zero at 00:00:01. */
const ASK_BURST_ALLOWANCE = 25;
/** How long a cached answer stays valid. Also the ceiling on staleness. */
const ANSWER_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7;
const ANSWER_CACHE_PREFIX = "ask:answer:";
/**
* TTL cache, not compute-on-write: the expected set moves with the clock as scheduled posts go live.
* KV refuses a TTL under 60 seconds; five minutes of an under-reported badge harms nobody.
*/
const DRIFT_CACHE_KEY = "ask:drift";
/** Stated here and repeated to the reader in the badge's own title text. */
export const DRIFT_CACHE_TTL_SECONDS = 300;
export interface CachedAnswer {
answer: string;
chunks: unknown[];
}
/** No stemming or stopword dropping: differently worded questions must not share an answer. */
function normalizeQuestion(question: string): string {
return question
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/[?!.,;:]+$/g, "")
.trim();
}
export async function questionKey(question: string): Promise<string> {
const normalized = normalizeQuestion(question);
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(normalized),
);
return `${ANSWER_CACHE_PREFIX}${toHex(digest)}`;
}
interface GuardVerdict {
ok: boolean;
retryAfter?: number;
reason?: "rate" | "budget" | "unprotected";
}
/** Fails closed without the binding, so removing it disables Ask rather than unprotecting it. */
export async function checkAskRate(env: Env, ip: string): Promise<GuardVerdict> {
// Keyed by IP alone, not IP plus question: the point is to cap how often one caller can spend.
const verdict = await limitHit(env, `ip:${ip}`, ASK_RATE_LIMIT, ASK_RATE_LIMIT_PERIOD_SECONDS);
if (verdict.status === "ok") return { ok: true };
return {
ok: false,
reason: verdict.status === "unavailable" ? "unprotected" : "rate",
retryAfter: verdict.retryAfterSeconds,
};
}
/**
* Only on a cache miss. Reserve-then-generate, so two concurrent requests cannot both pass on one read;
* never refunded on failure, since under-counting would make the ceiling a suggestion.
*/
export async function reserveAskBudget(env: Env): Promise<GuardVerdict> {
const budget = env.ASK_BUDGET.get(env.ASK_BUDGET.idFromName("global"));
// Paced, so exhausting the ceiling denies Ask for minutes rather than until UTC midnight.
const ceiling = pacedAllowance(DAILY_ANSWER_BUDGET, ASK_BURST_ALLOWANCE, new Date());
const { ok } = await budget.consume(ceiling);
if (!ok) {
// Not seconds-until-midnight: under pacing that would be wrong by up to a day.
return { ok: false, reason: "budget", retryAfter: secondsPerPacedUnit(DAILY_ANSWER_BUDGET) };
}
return { ok: true };
}
export async function readAskBudget(env: Env): Promise<{ day: string; count: number; limit: number }> {
const budget = env.ASK_BUDGET.get(env.ASK_BUDGET.idFromName("global"));
const { day, count } = await budget.peek();
return { day, count, limit: DAILY_ANSWER_BUDGET };
}
export async function resetAskBudget(env: Env): Promise<void> {
const budget = env.ASK_BUDGET.get(env.ASK_BUDGET.idFromName("global"));
await budget.reset();
}
export async function readCachedAnswer(
env: Env,
key: string,
): Promise<CachedAnswer | null> {
const cached = await env.APP_KV.get(key, "json");
if (!cached || typeof cached !== "object") return null;
const value = cached as Partial<CachedAnswer>;
if (typeof value.answer !== "string" || !Array.isArray(value.chunks)) return null;
return { answer: value.answer, chunks: value.chunks };
}
/** For an entry whose citations are no longer public. A delete by name is consistent; list is not. */
export async function dropCachedAnswer(env: Env, key: string): Promise<void> {
await env.APP_KV.delete(key);
}
export async function writeCachedAnswer(
env: Env,
key: string,
value: CachedAnswer,
): Promise<void> {
// An empty answer is a failed generation; caching it would serve the failure for a week.
if (value.answer.trim().length === 0) return;
await env.APP_KV.put(key, JSON.stringify(value), {
expirationTtl: ANSWER_CACHE_TTL_SECONDS,
});
}
/** Null, not zero, on a miss: zero means the index agrees, and a miss must not render a clean badge. */
export async function readCachedDrift(env: Env): Promise<number | null> {
const cached = await env.APP_KV.get(DRIFT_CACHE_KEY, "json");
if (!cached || typeof cached !== "object") return null;
const value = (cached as { drift?: unknown }).drift;
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return null;
return value;
}
export async function writeCachedDrift(env: Env, drift: number): Promise<void> {
await env.APP_KV.put(DRIFT_CACHE_KEY, JSON.stringify({ drift }), {
expirationTtl: DRIFT_CACHE_TTL_SECONDS,
});
}
/** A delete rather than a write: the caller knows the count is stale, not what it became. */
export async function dropCachedDrift(env: Env): Promise<void> {
await env.APP_KV.delete(DRIFT_CACHE_KEY);
}
/**
* Invalidated rather than keyed by generation, which would cost a second KV read on every request.
* KV list is eventually consistent, so an answer written just before a sync can survive until its TTL.
*/
export async function invalidateAnswerCache(env: Env): Promise<number> {
let cursor: string | undefined;
let deleted = 0;
for (;;) {
const page = await env.APP_KV.list({ prefix: ANSWER_CACHE_PREFIX, cursor });
for (const key of page.keys) {
await env.APP_KV.delete(key.name);
deleted += 1;
}
if (page.list_complete) break;
cursor = page.cursor;
}
return deleted;
}