Skip to the content

Capsid menu

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

The functions of health.server.ts
FunctionLinesComplexityCoveredCRAPMutation
checkConfig31 to 39788%WatchCRAP 7.1No data: mutation testing has not run on this repo yet
checkOwner41 to 50375%OkCRAP 3.1No data: mutation testing has not run on this repo yet
checkAccessKeys52 to 64688%WatchCRAP 6.1No data: mutation testing has not run on this repo yet
checkSite76 to 96692%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:87>87 to 883100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:91>91 to 911100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:91>91 to 911100%OkCRAP 1No data: mutation testing has not run on this repo yet
checkBackup99 to 107583%WatchCRAP 5.1No data: mutation testing has not run on this repo yet
checkNovels110 to 1244100%OkCRAP 4No data: mutation testing has not run on this repo yet
<anonymous:118>118 to 1193100%OkCRAP 3No data: mutation testing has not run on this repo yet
checkAiPublishEmails127 to 145483%OkCRAP 4.1No data: mutation testing has not run on this repo yet
<anonymous:139>139 to 1392100%OkCRAP 2No data: mutation testing has not run on this repo yet
checkServiceAccount155 to 175594%WatchCRAP 5No data: mutation testing has not run on this repo yet
<anonymous:166>166 to 1673100%OkCRAP 3No data: mutation testing has not run on this repo yet
checkDriveFile178 to 193692%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:186>186 to 1873100%OkCRAP 3No data: mutation testing has not run on this repo yet
checkSocialTemplates196 to 210683%WatchCRAP 6.2No data: mutation testing has not run on this repo yet
<anonymous:204>204 to 2041100%OkCRAP 1No data: mutation testing has not run on this repo yet
runChecks212 to 2291100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:214>214 to 21410%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:221>221 to 2211100%OkCRAP 1No data: mutation testing has not run on this repo yet
runHealth236 to 2881090%WatchCRAP 10.1No data: mutation testing has not run on this repo yet
<anonymous:239>239 to 2391100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:247>247 to 2471100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:252>252 to 2552100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:256>256 to 2561100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:261>261 to 2611100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:267>267 to 2672100%OkCRAP 2No data: mutation testing has not run on this repo yet
message290 to 2922100%OkCRAP 2No 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);
}