app/lib/health.server.ts
269 lines and 29 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| checkConfig | 31 to 39 | 7 | 88% | WatchCRAP 7.1 | No data: mutation testing has not run on this repo yet |
| checkOwner | 41 to 50 | 3 | 75% | OkCRAP 3.1 | No data: mutation testing has not run on this repo yet |
| checkAccessKeys | 52 to 64 | 6 | 88% | WatchCRAP 6.1 | No data: mutation testing has not run on this repo yet |
| checkSite | 76 to 96 | 6 | 92% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:87> | 87 to 88 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:91> | 91 to 91 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:91> | 91 to 91 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| checkBackup | 99 to 107 | 5 | 83% | WatchCRAP 5.1 | No data: mutation testing has not run on this repo yet |
| checkNovels | 110 to 124 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| <anonymous:118> | 118 to 119 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| checkAiPublishEmails | 127 to 145 | 4 | 83% | OkCRAP 4.1 | No data: mutation testing has not run on this repo yet |
| <anonymous:139> | 139 to 139 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| checkServiceAccount | 155 to 175 | 5 | 94% | WatchCRAP 5 | No data: mutation testing has not run on this repo yet |
| <anonymous:166> | 166 to 167 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| checkDriveFile | 178 to 193 | 6 | 92% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:186> | 186 to 187 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| checkSocialTemplates | 196 to 210 | 6 | 83% | WatchCRAP 6.2 | No data: mutation testing has not run on this repo yet |
| <anonymous:204> | 204 to 204 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| runChecks | 212 to 229 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:214> | 214 to 214 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:221> | 221 to 221 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| runHealth | 236 to 288 | 10 | 90% | WatchCRAP 10.1 | No data: mutation testing has not run on this repo yet |
| <anonymous:239> | 239 to 239 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:247> | 247 to 247 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:252> | 252 to 255 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:256> | 256 to 256 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:261> | 261 to 261 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:267> | 267 to 267 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| message | 290 to 292 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports app/db/schema.ts, app/lib/google/oauth.server.ts, app/lib/google/service-account.server.ts, app/lib/novels/repo.server.ts, app/lib/sites.server.ts, app/lib/social/queue.server.ts, app/lib/timed-fetch.server.ts. Imported by workers/app.ts.
Source
// Carrel's own health check, run by the cron trigger. It emails Dustin only when a check changes
// state, like the site's watchdog, so a standing failure sends one message, not one every 15 minutes.
//
// It checks the settings the gate needs, the database and its Owner, the Access signing keys, and
// each site: its key, and whether the site still conforms to the site-api contract Carrel was built
// against, the GitHub App for the writing repository, that every AI publish reached Dustin's inbox,
// Google (the service account key's age and whether Google still takes it, and Dustin's drive.file
// grant), and any template social post that went out, which Dustin should know about (design
// decision 5, third step).
import { backupHealth } from "@dustinedwards/d1-dump";
import { runConformance } from "@dustinedwards/site-api/conformance";
import { accessIssuer } from "@dustinedwards/site-runtime/access";
import { eq, isNull } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { aiPublications, googleKeysSeen, googleTokens, healthState, people } from "~/db/schema";
import { oauthConnection, userAccessToken } from "~/lib/google/oauth.server";
import { saAccessToken, saConnection } from "~/lib/google/service-account.server";
import { installationToken, NOVELS_REPO, novelsConnection } from "~/lib/novels/repo.server";
import { SITE_IDS, siteConnection, siteEntry, type SiteId } from "~/lib/sites.server";
import { unseenTemplates } from "~/lib/social/queue.server";
import { timedFetch } from "~/lib/timed-fetch.server";
export type CheckResult = { name: string; ok: boolean; detail: string };
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
const PLACEHOLDER = /example\.(com|cloudflareaccess\.com)/i;
async function checkConfig(env: Env): Promise<CheckResult> {
const missing: string[] = [];
if (!env.ACCESS_AUD?.trim()) missing.push("ACCESS_AUD");
if (!env.ACCESS_TEAM_DOMAIN?.trim() || PLACEHOLDER.test(env.ACCESS_TEAM_DOMAIN)) missing.push("ACCESS_TEAM_DOMAIN");
if (!env.ALERT_EMAIL?.trim() || PLACEHOLDER.test(env.ALERT_EMAIL)) missing.push("ALERT_EMAIL");
return missing.length === 0
? { name: "config", ok: true, detail: "Access and alert settings are present." }
: { name: "config", ok: false, detail: `Missing or placeholder: ${missing.join(", ")}.` };
}
async function checkOwner(env: Env): Promise<CheckResult> {
try {
const owners = await drizzle(env.DB).select({ id: people.id }).from(people).where(eq(people.isOwner, true)).all();
return owners.length === 1
? { name: "owner", ok: true, detail: "The database answers and has its Owner." }
: { name: "owner", ok: false, detail: "The database has no Owner, so nobody can use Carrel. Run npm run seed:owner." };
} catch (error) {
return { name: "owner", ok: false, detail: `The database did not answer: ${message(error)}` };
}
}
async function checkAccessKeys(env: Env, fetcher: Fetch): Promise<CheckResult> {
const url = `${accessIssuer(env.ACCESS_TEAM_DOMAIN) ?? ""}/cdn-cgi/access/certs`;
try {
const response = await fetcher(url);
if (!response.ok) return { name: "access-keys", ok: false, detail: `${url} answered ${response.status}.` };
const body = (await response.json()) as { keys?: unknown[] };
return Array.isArray(body.keys) && body.keys.length > 0
? { name: "access-keys", ok: true, detail: "Access signing keys are published." }
: { name: "access-keys", ok: false, detail: `${url} lists no signing keys.` };
} catch (error) {
return { name: "access-keys", ok: false, detail: `${url} could not be read: ${message(error)}` };
}
}
/**
* A site with no key is not connected yet, which is not a failure: its stage has not arrived. With a
* key, the conformance suite runs, including its writes, each of which a conforming site refuses. On
* a site that offers media (site-api v0.2.0) that includes the media checks: limits declared, list in
* shape, a delete of an unknown id and an upload of an undeclared type both refused. On a site that
* declares the mentions group (v0.5.0) it includes the mentions checks: the list in shape, writes without
* the key refused, a decision and a delete of an unknown id refused, a stale version refused on the first
* real mention, and a sweep with no change id refused; a site without the group must answer 501. The suite's real
* upload round trip stays off: the health check never puts a file into a site's storage.
*/
async function checkSite(env: Env, id: SiteId, fetcher: Fetch): Promise<CheckResult> {
const name = `site-${id}`;
const site = siteEntry(id).name;
const connection = siteConnection(env, id);
if (connection.state === "not-connected") return { name, ok: true, detail: `Not connected: ${connection.detail}` };
if (connection.state === "misconfigured") return { name, ok: false, detail: connection.detail };
try {
const report = await runConformance({
baseUrl: connection.origin,
key: connection.key,
probeMediaUpload: false,
fetch: ((input: RequestInfo | URL, init?: RequestInit) =>
fetcher(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, init)) as typeof fetch,
});
if (report.ok) return { name, ok: true, detail: `${site} conforms (${report.checks[0]?.detail ?? "meta ok"}).` };
const failed = report.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
return { name, ok: false, detail: `${site} does not conform. ${failed.join(" | ")}` };
} catch (error) {
return { name, ok: false, detail: `${site} could not be checked: ${message(error)}` };
}
}
/** The newest complete D1 dump must be under 26 hours old (d1-dump's default). No complete dump at all fails too. */
async function checkBackup(env: Env, now: Date): Promise<CheckResult> {
try {
const { last_ok, age_hours, warning } = await backupHealth(env.BACKUPS, { now });
if (last_ok === null || warning) return { name: "backup", ok: false, detail: warning ?? "No complete D1 dump exists yet." };
return { name: "backup", ok: true, detail: `The newest complete D1 dump finished ${last_ok}, ${age_hours} hours ago.` };
} catch (error) {
return { name: "backup", ok: false, detail: `The backup bucket could not be read: ${message(error)}` };
}
}
/** Not set up yet is not a failure, as with a site; set up, the App must still get a token for the repository. */
async function checkNovels(env: Env, fetcher: Fetch): Promise<CheckResult> {
const connection = novelsConnection(env);
if (connection.state !== "connected") {
return connection.state === "not-connected"
? { name: "writing", ok: true, detail: `Not connected: ${connection.detail}` }
: { name: "writing", ok: false, detail: connection.detail };
}
try {
await installationToken(connection.appId, connection.privateKey, ((input: RequestInfo | URL, init?: RequestInit) =>
fetcher(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, init)) as typeof fetch);
return { name: "writing", ok: true, detail: `The GitHub App can write to ${NOVELS_REPO}.` };
} catch (error) {
return { name: "writing", ok: false, detail: `The GitHub App could not reach ${NOVELS_REPO}: ${message(error)}` };
}
}
/** Decision 2c: every AI publish emails Dustin. One whose email failed stays a failure until it is sent. */
async function checkAiPublishEmails(env: Env): Promise<CheckResult> {
try {
const unsent = await drizzle(env.DB)
.select({ changeId: aiPublications.changeId, itemId: aiPublications.itemId, client: aiPublications.client, error: aiPublications.emailError })
.from(aiPublications)
.where(isNull(aiPublications.emailedAt))
.all();
if (unsent.length === 0) return { name: "ai-publish-email", ok: true, detail: "Every AI publish was emailed." };
return {
name: "ai-publish-email",
ok: false,
detail: `${unsent.length} AI publish${unsent.length === 1 ? " was" : "es were"} not emailed: ${unsent
.map((u) => `${u.itemId} by ${u.client} (${u.error ?? "no error recorded"})`)
.join("; ")}.`,
};
} catch (error) {
return { name: "ai-publish-email", ok: false, detail: `The record could not be read: ${message(error)}` };
}
}
/** Google recommends rotating service account keys at least every 90 days. */
const KEY_MAX_AGE_DAYS = 90;
/**
* The service account key: absent is "not connected" (its stage's setup is Dustin's), a key Google
* refuses (deleted, disabled, expired) fails, and so does one older than KEY_MAX_AGE_DAYS. A key file
* carries no creation date, so age counts from the first time this check saw the key.
*/
async function checkServiceAccount(env: Env, fetcher: Fetch, now: Date): Promise<CheckResult> {
const connection = saConnection(env);
if (connection.state === "not-connected") return { name: "google-service-account", ok: true, detail: `Not connected: ${connection.detail}` };
if (connection.state === "misconfigured") return { name: "google-service-account", ok: false, detail: connection.detail };
const { key } = connection;
const d = drizzle(env.DB);
await d.insert(googleKeysSeen).values({ privateKeyId: key.privateKeyId, clientEmail: key.clientEmail, firstSeenAt: now.toISOString() }).onConflictDoNothing();
const seen = await d.select({ firstSeenAt: googleKeysSeen.firstSeenAt }).from(googleKeysSeen).where(eq(googleKeysSeen.privateKeyId, key.privateKeyId)).get();
const days = Math.floor((now.getTime() - Date.parse(seen!.firstSeenAt)) / 86_400_000);
const id = key.privateKeyId.slice(0, 8);
try {
await saAccessToken(key, ((input: RequestInfo | URL, init?: RequestInit) =>
fetcher(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, init)) as typeof fetch);
} catch (error) {
return { name: "google-service-account", ok: false, detail: `Key ${id} (${key.clientEmail}) no longer works: ${message(error)} Create a new key and set GOOGLE_SA_KEY.` };
}
if (days > KEY_MAX_AGE_DAYS) {
return { name: "google-service-account", ok: false, detail: `Key ${id} is ${days} days old, past ${KEY_MAX_AGE_DAYS}. Create a new key, set GOOGLE_SA_KEY, then delete the old one in Google Cloud.` };
}
return { name: "google-service-account", ok: true, detail: `Key ${id} (${key.clientEmail}) works; ${days} days old.` };
}
/** Dustin's drive.file grant, if he has connected it: Google must still refresh it. */
async function checkDriveFile(env: Env, fetcher: Fetch): Promise<CheckResult> {
const connection = await oauthConnection(env);
if (connection.state === "not-configured") return { name: "google-drive-file", ok: true, detail: `Not connected: ${connection.detail}` };
if (connection.state === "misconfigured") return { name: "google-drive-file", ok: false, detail: connection.detail };
const grants = await drizzle(env.DB).select({ personId: googleTokens.personId }).from(googleTokens).all();
if (grants.length === 0) return { name: "google-drive-file", ok: true, detail: "Not connected: no one has connected Google for Send to Docs yet." };
try {
for (const g of grants) {
await userAccessToken(env, g.personId, ((input: RequestInfo | URL, init?: RequestInit) =>
fetcher(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, init)) as typeof fetch);
}
return { name: "google-drive-file", ok: true, detail: "The drive.file grant refreshes." };
} catch (error) {
return { name: "google-drive-file", ok: false, detail: message(error) };
}
}
/** A template post went out: the routine failed or hit its cap. Fails until Dustin marks it seen. */
async function checkSocialTemplates(env: Env): Promise<CheckResult> {
try {
const unseen = await unseenTemplates(env.DB);
if (unseen.length === 0) return { name: "social-templates", ok: true, detail: "No template post is waiting to be seen." };
return {
name: "social-templates",
ok: false,
detail: `${unseen.length} template post${unseen.length === 1 ? "" : "s"} went out because the routine did not draft ${unseen.length === 1 ? "it" : "them"}: ${unseen
.map((u) => `${u.accountKey}: "${u.text}"`)
.join("; ")}. Mark ${unseen.length === 1 ? "it" : "them"} seen in Carrel's social page.`,
};
} catch (error) {
return { name: "social-templates", ok: false, detail: `The social record could not be read: ${message(error)}` };
}
}
export async function runChecks(
env: Env,
fetcher: Fetch = (url, init) => timedFetch(url, init),
now: Date = new Date(),
): Promise<CheckResult[]> {
return Promise.all([
checkConfig(env),
checkOwner(env),
checkAccessKeys(env, fetcher),
...SITE_IDS.map((id) => checkSite(env, id, fetcher)),
checkNovels(env, fetcher),
checkAiPublishEmails(env),
checkBackup(env, now),
checkServiceAccount(env, fetcher, now),
checkDriveFile(env, fetcher),
checkSocialTemplates(env),
]);
}
/**
* Runs the checks, emails when any check changed state, and only then records the new states. If
* the email fails, nothing is recorded, so the next run tries again rather than losing the alert.
* A check seen for the first time counts as a change only when it fails.
*/
export async function runHealth(
env: Env,
fetcher?: Fetch,
now: () => Date = () => new Date(),
): Promise<{ results: CheckResult[]; emailed: boolean }> {
const results = await runChecks(env, fetcher, now());
const db = drizzle(env.DB);
let previous: Map<string, boolean>;
try {
const rows = await db.select().from(healthState).all();
previous = new Map(rows.map((r) => [r.checkName, r.ok]));
} catch {
previous = new Map();
}
const changed = results.filter((r) => {
const before = previous.get(r.name);
return before === undefined ? !r.ok : before !== r.ok;
});
const unseen = results.filter((r) => !previous.has(r.name));
if (changed.length === 0 && unseen.length === 0) return { results, emailed: false };
let emailed = false;
if (changed.length > 0) {
const failing = results.filter((r) => !r.ok);
try {
await env.EMAIL.send({
from: env.ALERT_FROM,
to: env.ALERT_EMAIL,
subject: failing.length === 0 ? "Carrel: all checks pass" : `Carrel: ${failing.length} check${failing.length === 1 ? "" : "s"} failing`,
text: results.map((r) => `${r.ok ? "ok " : "FAIL"} ${r.name}: ${r.detail}`).join("\n"),
});
emailed = true;
} catch (error) {
console.error(JSON.stringify({ health: "email-failed", error: message(error) }));
return { results, emailed: false };
}
}
const at = now().toISOString();
try {
for (const r of [...new Set([...changed, ...unseen])]) {
await db
.insert(healthState)
.values({ checkName: r.name, ok: r.ok, detail: r.detail, changedAt: at })
.onConflictDoUpdate({ target: healthState.checkName, set: { ok: r.ok, detail: r.detail, changedAt: at } });
}
} catch (error) {
console.error(JSON.stringify({ health: "record-failed", error: message(error) }));
}
return { results, emailed };
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}