Files
com.gregkilmer.www/services/content/src/rules.ts
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

162 lines
6.4 KiB
TypeScript

/**
* The rules every change runs, in a fixed order. Pure functions: the same
* change against the same accepted state always gets the same answer, and no
* model decides anything.
*
* The rules live here in code, not in the vault, so no agent can loosen them.
*/
import { notePath, typeDef, type CheckedNote } from "@gk/schema";
import type { Author, HeldReason, StoredNote } from "./store.ts";
export interface RuleSettings {
/** A note with at least this much body text… */
textLossMinChars: number;
/** …is held when an edit keeps less than this share of it. */
textLossKeepRatio: number;
/** An agent making more changes than this inside the window is held. */
agentBatchLimit: number;
agentBatchWindowMinutes: number;
/** Note types an agent may publish without review. */
agentAutoPublish: string[];
/** Email addresses that may appear in notes. */
allowedEmails: string[];
}
export const defaultRules: RuleSettings = {
textLossMinChars: 400,
textLossKeepRatio: 0.5,
agentBatchLimit: 10,
agentBatchWindowMinutes: 10,
agentAutoPublish: ["status", "library-item"],
allowedEmails: [],
};
export interface Proposed {
path: string;
name: string;
slug: string;
text: string;
author: Author;
checked: CheckedNote;
}
export interface RuleContext {
/** The accepted version at this path, if any. */
current: StoredNote | null;
/** Another live note with the same file name, if any. */
nameTakenBy: string | null;
/** Another live note of the same type with the same slug, if any. */
slugTakenBy: string | null;
/** The agent's changes inside the batch window, before this one. */
recentAgentChanges: number;
settings: RuleSettings;
}
/**
* Authors held to the agent rules. A sync job writes as `sync` and may do
* nothing an agent cannot: the same contact, publish, edit and batch limits.
*/
export const isLimited = (a: Author) => a === "agent" || a === "sync";
export type Verdict = { decision: "accept" } | { decision: "hold"; reasons: HeldReason[] };
/** Strings that look like credentials. Matching is on new text only. */
const SECRET_PATTERNS: [string, RegExp][] = [
["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
["an AWS key", /\bAKIA[0-9A-Z]{16}\b/],
["a GitHub token", /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/],
["a Slack token", /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/],
["an API key", /\bsk-[A-Za-z0-9_-]{20,}\b/],
["a bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/],
["a password or key value", /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["']?[^\s"']{8,}/i],
];
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
const PHONE = /(?:\+?1[\s.-]?)?\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g;
function newMatches(re: RegExp, text: string, before: string): string[] {
const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`;
const g = new RegExp(re.source, flags);
const old = new Set(before.match(g) ?? []);
return [...new Set(text.match(g) ?? [])].filter((m) => !old.has(m));
}
const bodyOf = (text: string) => text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
export function evaluate(p: Proposed, ctx: RuleContext): Verdict {
const reasons: HeldReason[] = [];
const { current, settings } = ctx;
const live = current && !current.removed ? current : null;
// 1. The note must be valid. Nothing else is judged on a broken note.
if (!p.checked.ok) {
return {
decision: "hold",
reasons: [
{
code: "invalid",
message: p.checked.problems.map((x) => (x.field ? `${x.field}: ${x.message}` : x.message)).join("; "),
},
],
};
}
const data = p.checked.data;
// 2. Settings notes live at one fixed path, so there is only ever one.
if (typeDef(p.checked.type)?.singleton && notePath(data) !== p.path) {
reasons.push({ code: "misplaced", message: `A ${p.checked.type} note belongs at ${notePath(data)}` });
}
// 3. Names and slugs must stay unambiguous.
if (ctx.nameTakenBy) {
reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${ctx.nameTakenBy}` });
}
if (ctx.slugTakenBy) {
reasons.push({ code: "duplicate-slug", message: `Another ${p.checked.type} already uses the address "${p.slug}": ${ctx.slugTakenBy}` });
}
// 4. A large loss of text in one edit.
if (live) {
const before = bodyOf(live.text).length;
const after = bodyOf(p.text).length;
if (before >= settings.textLossMinChars && after < before * settings.textLossKeepRatio) {
reasons.push({
code: "text-loss",
message: `The text shrank from ${before} to ${after} characters in one edit`,
});
}
}
// 5. Anything that looks like a secret, or contact details from an agent.
const before = live?.text ?? "";
for (const [what, re] of SECRET_PATTERNS) {
if (newMatches(re, p.text, before).length) reasons.push({ code: "secret", message: `The note now contains what looks like ${what}` });
}
const who = p.author === "sync" ? "A sync" : "An agent";
if (isLimited(p.author)) {
const allowed = new Set(settings.allowedEmails.map((e) => e.toLowerCase()));
const emails = newMatches(EMAIL, p.text, before).filter((e) => !allowed.has(e.toLowerCase()));
if (emails.length) reasons.push({ code: "contact", message: `${who} added an email address: ${emails.join(", ")}` });
if (newMatches(PHONE, p.text, before).length) reasons.push({ code: "contact", message: `${who} added a phone number` });
}
// 6. Agents stay inside their lane.
if (isLimited(p.author)) {
// A sync may change notes it wrote itself; anything else needs agentEdits.
if (live && live.author !== p.author && live.data.agentEdits !== true) {
reasons.push({ code: "agent-edit", message: `${who} changed a note you wrote, which needs your review` });
}
const publishing = data.publish === true && !(live?.publish ?? false);
if (publishing && !settings.agentAutoPublish.includes(p.checked.type)) {
reasons.push({ code: "agent-publish", message: `${who} wants to publish a ${p.checked.type}, which needs your approval` });
}
if (ctx.recentAgentChanges >= settings.agentBatchLimit) {
reasons.push({
code: "batch",
message: `More than ${settings.agentBatchLimit} ${p.author} changes in ${settings.agentBatchWindowMinutes} minutes`,
});
}
}
return reasons.length ? { decision: "hold", reasons } : { decision: "accept" };
}