mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
e3b0cf60c5
- 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
162 lines
6.4 KiB
TypeScript
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" };
|
|
}
|