Skip to content
Docs

Contributing

Add a site check

Add a check to the shared site scan, as an SEO & GEO site health rule or a Security page or site check, with its data source, severity, fix text, grouping, tests, demo data and docs.

Accessibility, SEO & GEO and Security share one crawl: each page is loaded once in a real Chromium and every module’s checks run on the result. This page shows how to add a check to that scan: where the data a check needs comes from, how to add an SEO & GEO site health rule or a Security check, how findings are grouped and scored, and what to test and document. It also explains why accessibility checks work differently.

Warning

Checks only read what any visitor’s browser receives. Open a Discussion before you add a check that needs a new kind of request (a new file path, a DNS lookup, a third-party service), and never add one that sends payloads, submits forms, clicks, tries passwords or crawls beyond the site’s own pages. See Rules for every check.

How a check runs

The scan code is in packages/core/src/a11y/scan.ts. For each page:

  1. checkPage() asks the scanner backend to open the page with scanRequest(). Both backends (Electron in the desktop app, Playwright in browser development mode and tests) run the same in-page script, built in core by runScript() in a11y/scanner.ts.
  2. The backend returns a ScanResponse: the page’s status, the in-page script’s PageResult, and some of the main document’s response headers.
  3. storePageResult() stores the accessibility results, then, in the same transaction, calls storePageSeo() (seo/site.ts), storePageSecurity() (security/findings.ts) and storePageContent() (a11y/content.ts). Each replaces that page’s previous findings.

When the scan ends (a full scan, a page check, a recheck, or a stopped scan), finishScan() calls finishSeo() for the cross-page and site-level SEO checks, then finishSecurity(), which runs the Security site probes on full scans only (probes: scope === "full") and folds every occurrence into findings.

From a11y/scanner.ts:

export type ScanResponse = {
  httpStatus: number | null;
  finalUrl: string;
  result: PageResult;
  /** The page's X-Robots-Tag header, if any. */
  xRobotsTag?: string | null;
  /** A JPEG of the page (base64) when it had issues and the request asked for one, with its size. */
  screenshot?: { data: string; width: number; height: number } | null;
  /** The main document's security-relevant response headers (`RESPONSE_HEADERS`), lowercase names; Set-Cookie lines joined with "\n". */
  headers?: Record<string, string>;
};

Pages that answer HTTP 400 or higher, redirect off the site or are blocked by robots.txt never reach storePageResult(). They’re marked failed or skipped in site_pages, which is how finishSeo() finds broken links.

Where the data comes from

Pick the cheapest source that has what your check needs:

Source What it has Read it in
The in-page script PageResult: title, lang, links, axe results, seo (SeoFacts), security (SecurityFacts), the main text, images pageFindings() (SEO), pageSecurityFindings() (Security), pageContent() (content quality)
Response headers The headers named in RESPONSE_HEADERS, as response.headers; X-Robots-Tag as response.xRobotsTag pageSecurityFindings(); pageSeo() for X-Robots-Tag
Stored pages Every page’s row in site_pages: state, HTTP status, seo, links, in_sitemap finishSeo(), for checks across pages (duplicates, broken links, orphans)
A site-level request Files and answers that aren’t a crawled page siteFiles() and aiCrawlerAccess() in seo/site.ts; siteSecurityProbes() in security/probes.ts

Adding a fact to the in-page script

If your check needs something from the DOM that PageResult doesn’t have yet, add a field to SeoFacts or SecurityFacts in a11y/scanner.ts and fill it in runScript(). The existing facts show the pattern:

const security = {
  // …
  forms: [...document.forms].slice(0, 20).map((f) => ({ action: abs(f.getAttribute("action") || location.href), hasPassword: !!f.querySelector('input[type="password" i]') })),
  scripts: [...document.querySelectorAll("script[src]")].slice(0, 50).map((s) => ({ src: abs(s.getAttribute("src") || ""), integrity: !!s.getAttribute("integrity") })).filter((s) => s.src),
  generator: meta('meta[name="generator" i]'),
  directoryListing: /^(Index of \\/|Directory listing for \\/)/.test(document.title || ""),
  // …
};
  • Cap every list and string (.slice(0, 20), limit(text, 200)). The result is serialized as JSON and sent over IPC for every page.
  • Only read the DOM. No clicks, no form submission, no extra requests from the page.
  • runScript() returns a template string, so a regular expression’s backslashes are doubled (\\/, \\s), as in the excerpt.
  • Keep the decision in core. Return raw facts and judge them in the check function (JSON-LD is returned as text and parsed by readStructuredData()), so the logic can be unit-tested without a browser.

Both backends run the script unchanged, so nothing changes in apps/desktop. Run the desktop smoke test anyway: it’s what proves the Electron scanner returns the new field.

Adding a response header

Add the header’s lowercase name to RESPONSE_HEADERS in a11y/scanner.ts. pickHeaders() keeps only those names: the Playwright backend calls it on the page’s headers, and the Electron main process sends every header of the main document, which apps/desktop-web/src/lib/scanner.ts filters with pickHeaders() too. Repeated headers are joined with ", ", and Set-Cookie lines with "\n".

Accessibility

There are no pluggable accessibility rules. The page checks are axe-core’s own rules, run unmodified at a pinned version (4.14.0). Startica doesn’t add rules to axe or rewrite its results. What you can change:

  • Which axe rules run comes from the site’s profile: profileTags() in a11y/scanner.ts (WCAG 2.2 A and AA by default; AAA and best practices when the site turns them on). A missing or wrong axe rule is a report to axe-core upstream.
  • How results become issues is in a11y/results.ts: findings() turns each failing element into an occurrence, fingerprint() groups the same rule on the same element markup across pages (ids, data- and style attributes and query strings are stripped by normalizeHtml()), and pageScore() weighs failures by impact (critical 10, serious 5, moderate 2, minor 1).
  • Content quality (A5) is Startica’s own code, in a11y/content.ts. pageContent() produces findings of three fixed kinds, stored in site_findings: spelling, readability and rule. The rule kind runs the owner’s own content rules (text contains or missing, selector exists or missing, links to a domain), which are data on sites.content_rules, set on the Content screen. They aren’t code you add.
  • PDF checks are listed in DOCUMENT_RULES in a11y/documents.ts and produced by documentProblems() from what analyzePdf() reads with pdf.js. A new PDF check is an entry there, a line in documentProblems(), and a field in DocumentChecks (packages/db/src/schema.ts) if it needs something analyzePdf() doesn’t read yet.

A new kind of content finding means a new value in the site_findings.kind enum, a new tab on the Content screen (apps/desktop-web/src/app/(app)/accessibility/content/page.tsx) and new tests in a11y/content.test.ts. That’s a feature rather than a check, so start with a Discussion.

Add an SEO & GEO rule

Site health rules are in packages/core/src/seo/. Findings are stored in seo_findings and replaced on every scan; they have no open or fixed state of their own.

Step 1: Describe the rule

Add an entry to SEO_RULES in seo/rules.ts. Its key is the rule id stored in seo_findings.rule, and its text is shown as written on the Site health screen and in Opportunities:

export type SeoSeverity = "high" | "medium" | "low" | "info";
export type SeoCategory = "indexing" | "basics" | "links" | "structured" | "ai";

export type SeoRule = { title: string; severity: SeoSeverity; category: SeoCategory; why: string; fix: string };

export const SEO_RULES = {
  // …
  "h1-missing": {
    title: "No main heading (h1)",
    severity: "medium",
    category: "basics",
    why: "The h1 tells search engines, AI models and screen readers what the page is about.",
    fix: "Add one h1 that names the page's topic.",
  },
  // …
} satisfies Record<string, SeoRule>;

Pick the severity by what happens if the problem stays:

Severity Use it when Points off a page’s score
high It’s likely to keep pages out of search results or AI answers 15
medium It hurts how pages are shown or understood 7
low It’s a smaller improvement 2
info It’s a fact worth knowing, often intended (noindex, canonical-elsewhere) 0

The weights are SEVERITY_WEIGHT. A page scores 100 minus its findings’ weights (seoPageScore()). The site score is the average page score, less two thirds of the weight of each site-wide finding (no page_id) and each broken-internal-link, at most 30 points. Every rule that isn’t info also becomes an opportunity on SEO & GEO → Opportunities automatically (opportunities/collect.ts); info rules are listed under Good to know on Site health.

Step 2: Produce it

A check on one page goes in pageFindings() in seo/checks.ts. It gets the page’s address, title, whether the sitemap lists it, its PageSeo and its raw JSON-LD blocks, and returns drafts. It’s a pure function:

export function pageFindings(page: { url: string; title: string | null; inSitemap: boolean }, seo: PageSeo, jsonLd: string[]): SeoFindingDraft[] {
  const out: SeoFindingDraft[] = [];
  const add = (rule: SeoRuleId, detail: Record<string, unknown> = {}, groupKey: string = rule) => out.push({ rule, groupKey, detail });
  // …
  if (seo.h1.length === 0) add("h1-missing");
  else if (seo.h1.length > 1) add("h1-multiple", { headings: seo.h1 });
  // …
  for (const gap of data.incomplete) add("jsonld-incomplete", gap, `jsonld-incomplete:${gap.type}`);
  return out;
}

If the check needs a new fact, add it to PageSeo (packages/db/src/schema.ts) and fill it in pageSeo(), which builds it from the in-page SeoFacts. PageSeo is stored as JSON in site_pages.seo, so a new field needs no migration, but pages scanned before the change won’t have it until they’re scanned again.

A check across pages or on the whole site goes in finishSeo() in seo/site.ts, using its add(pageId, rule, detail, groupKey) helper with null as the page for a site-wide finding:

if (!pages.some((p) => p.inSitemap)) add(null, "sitemap-missing");

Then add the rule to SITE_RULES at the bottom of the file. finishSeo() deletes only those rules before writing new ones, so a rule missing from the list piles up a copy on every scan.

A check that needs a request goes in siteFiles() next to llms.txt, robots.txt and the soft-404 probe. Use fetchText(): it sends Startica’s scan user agent, times out after 15 seconds, and returns null for reserved test hosts (.example, .test, .invalid), so the demo’s fieldnote.example never reaches the network. Decide what null means for your check. Sites often answer every unknown address with their app’s HTML page, so don’t treat a 200 as proof that a file exists (isTextFile() checks for a non-HTML answer).

Step 3: Show it

The Site health screen (apps/desktop-web/src/app/(app)/seo/health/page.tsx) shows every rule’s title, why and fix with no changes. Two things need code:

  • A new category needs an entry in CATEGORIES on that page, or its findings won’t be listed.
  • Details (a length, the headings found, the crawlers blocked) are shown by detailLine() for page findings and in RuleCard for grouped ones. Add a line for your rule if its detail says something the title doesn’t.

Add a Security check

Security checks are in packages/core/src/security/. They run only for sites whose ownership is verified: storePageSecurity() and finishSecurity() return straight away otherwise. Each check writes occurrences (security_occurrences), and finishSecurity() folds them into findings (security_findings) with a lifecycle that survives scans.

Step 1: Describe the rule

Add an entry to RULES in security/rules.ts:

export type SecurityRule = {
  title: string;
  severity: SecuritySeverity;
  category: SecurityCategory;
  /** Checked on every page (grouped into one finding listing the pages), or once for the site. */
  scope: "page" | "site";
  why: string;
  fix: string;
  /** OWASP Top 10 (2021) category, when one fits. */
  owasp?: OwaspId;
  /** Related controls, for an auditor's reference (never a claim of conformance). */
  controls: ControlId[];
};

A real one:

// --- Exposed files (one quiet GET each, on verified sites only)
"exposed-git": {
  title: "The site's Git folder is public",
  severity: "critical",
  category: "exposure",
  scope: "site",
  why: "Anyone can download the source code and its history, often with passwords and keys that were committed at some point.",
  fix: "Block /.git/ in the server settings (or deploy without it), then change any secret that was ever in the repository.",
  owasp: "A05",
  controls: LEAK,
},
  • scope decides what Recheck does on the finding: a site finding enqueues security_recheck, which runs the site probes again; a page finding starts a page check on its pages.
  • owasp and controls appear in the report as references for the owner’s auditor. Use the existing ids in OWASP and CONTROLS, or the shared lists (TRANSPORT, CONFIG, LEAK).
  • category groups findings on the screens. A new one needs a label in CATEGORY_LABEL; the type makes that a compile error if you forget.

Severity, and the points each open finding takes off the site’s 100-point score (SEVERITY_WEIGHT, counted once per finding, not per page):

Severity Use it when Points
critical Something sensitive is exposed right now 30
high Visitors’ data can be read or changed on the way 15
medium A protection most sites should have is missing 7
low It’s hardening worth doing 2
info It’s worth knowing; not counted 0

Severity also drives what happens next: critical and high findings become Today moves, and the issue tracker’s automatic setting opens issues at or above the severity the owner picked.

Step 2a: A page check

Page checks go in pageSecurityFindings() in security/checks.ts, a pure function over the page’s address, its response headers and its SecurityFacts. Nothing in checks.ts makes a request. The cookie checks show the pattern, including the group key:

for (const c of parseCookies(headers["set-cookie"])) {
  if (https && !c.secure) add("cookie-not-secure", { cookie: c.name }, `cookie-not-secure:${c.name}`);
  if (!c.httpOnly) add("cookie-not-httponly", { cookie: c.name }, `cookie-not-httponly:${c.name}`);
  if (c.sameSite === null) add("cookie-no-samesite", { cookie: c.name }, `cookie-no-samesite:${c.name}`);
}

storePageSecurity() calls it with response.headers and response.result.security and stores the drafts as the page’s occurrences. If you need a header that isn’t there yet, see Adding a response header.

Step 2b: A site check

Site checks run once per full scan (and on Recheck) in siteSecurityProbes() in security/probes.ts. Keep the request in probes.ts and the decision in a pure function in checks.ts, as the exposed-file checks do:

export const EXPOSED_FILES = [
  { kind: "git", path: "/.git/HEAD", rule: "exposed-git" },
  { kind: "env", path: "/.env", rule: "exposed-env" },
  { kind: "ds_store", path: "/.DS_Store", rule: "exposed-ds-store" },
] as const satisfies readonly { kind: "git" | "env" | "ds_store"; path: string; rule: SecurityRuleId }[];
for (const file of EXPOSED_FILES) {
  const target = new URL(file.path, url.origin).toString();
  if (exposedFile(file.kind, await probe(target))) drafts.push({ rule: file.rule, groupKey: file.rule, detail: { url: target } });
}

exposedFile() recognizes each file by its content, not by a 200: a site that answers every path with its HTML page must not be reported. Do the same for any file you look for.

Use the helpers that are already there:

  • probe(url, follow?) is a GET with Startica’s scan user agent and a 10-second timeout that keeps at most the first 64 KB. It doesn’t follow redirects unless asked, because a redirected file isn’t exposed.
  • Reserved hosts (.example, .test, .invalid) return before any request, so the demo makes none.
  • Local hosts (isPrivateHost()) skip HTTPS, TLS, the redirect and email checks. Skip yours there too if it doesn’t apply to a site on the owner’s own computer or network.

Site-level drafts are stored with no page and replaced each time the probes run.

Step 3: Show it

The Findings list, a finding’s page and the Markdown report take the title, why, fix, area, OWASP category and controls from the rule (findingInfo() in security/report.ts). A finding’s details are shown as one line by describeDetail() in the same file, which reads a fixed set of keys, among them cookie, header and value, host, generator, url, urls, maxAge, protocol, validTo, error, domain, problems and policy. Reuse those keys in your detail where they fit; otherwise add a case to describeDetail().

Groups and fingerprints

A problem the owner fixes once should be one finding, however many pages show it. Each module groups differently:

Module Grouped by Lifecycle
Accessibility fingerprint(kind, ruleId, html): the axe rule plus the element’s normalized markup Open → fixed when no page has it; reopens if it comes back; ignore decisions are kept by fingerprint
SEO & GEO group_key on each finding; findings are replaced each scan None: the latest scan’s findings are the list
Security group_key: one finding per key across all pages Open → fixed when no longer seen; reopens; accepted risk and false positive survive scans

For SEO and Security the group key defaults to the rule id, which is right when the fix is the same everywhere (a missing header is usually set once at the server or CDN). Add the distinguishing value when there can be several separate fixes: the cookie name (cookie-not-secure:session), the script’s host (third-party-script-no-sri:cdn.fieldnote.example), the header and its value (server-version:x-powered-by:standin/1.2), the duplicated text (title-duplicate:<title>), the broken address (broken:<url>) or the JSON-LD type (jsonld-incomplete:Product).

Keep the key stable between scans: lowercase it, cut long values to a fixed length (the server-version key uses value.slice(0, 120).toLowerCase()), and never put a page address in a page check’s key, or every page becomes its own finding. A Security finding keeps the detail of the first occurrence folded into it, so put page-specific details in each occurrence; a finding’s page lists its pages with their own details (findingPages()).

Rules for every check

These come from ARCHITECTURE.md at the repo root (sections 3, 9.1, 10.1, 11.1 and 11.3) and apply to every check:

  • Never claim a result. Findings are “found by automated checks”. Never call a site “compliant”, “certified”, “secure” or “pentested” in a rule’s text, a detail line, a report or the docs. A site with no findings has “no problems found by the checks”. The Security report test fails if the report says “compliant”, “certified” or “pentested”.
  • Defensive only, for Security. Read what any visitor’s browser receives: pages, response headers, cookies, certificates, public DNS and a few well-known file paths. Plain GETs, a TLS handshake and DNS lookups only. No exploit payloads, fuzzing, form submission, login attempts, or crawling beyond the site’s own pages.
  • Polite requests. The crawl loads two pages at a time, at most one a second per site. Page checks add no requests; they read what the crawl already loaded. Site-level requests run once at the end of a scan: keep them to a handful of GETs with a timeout, a size cap and Startica’s scan user agent, and only to the site being scanned. Security has two approved exceptions (the public data services in exposure monitoring, and a GET of each third-party script the site’s own pages load); any new destination needs a Discussion and the owner’s go-ahead.
  • Verified sites only, for Security. Don’t run a security check from anywhere that skips isVerified().
  • Startica suggests, the owner fixes. A check never changes the site, its DNS or its files.

Writing the text

  • title names the problem as the owner sees it: “Cookie readable by scripts (no HttpOnly)”, “Page is only in the sitemap: nothing links to it”.
  • why says what goes wrong for visitors, search engines or the owner if it stays, in one or two sentences. security.test.ts checks that every Security rule’s why has more than 20 characters and its fix more than 10.
  • fix says what to change, with the exact header, value or file where there is one (“Send Strict-Transport-Security: max-age=31536000”). Name the usual place to change it (server, CDN, framework) without assuming one vendor.
  • info fixes start with “Nothing, if that’s intended” when the fact is often a choice.
  • Plain words, no jargon the title doesn’t explain, and no real companies or products in examples: use Fieldnote and fieldnote.example.

Test it

Unit tests

Test the pure function with small hand-built inputs:

  • SEO page checks in seo/seo.test.ts, with its seo() helper for a PageSeo:

    const rules = (title: string | null, over = {}, inSitemap = false) => pageFindings({ url: "https://fieldnote.example/pricing", title, inSitemap }, seo(over), []).map((f) => f.rule);
    expect(rules("Pricing: Fieldnote", { robots: "noindex, follow" }, true)).toEqual(["noindex-in-sitemap"]);

    Add your rule’s case and one where it must not fire. The “site files” test shows how to test a site-level request: it starts two local node:http servers (a single-page app that answers every path with HTML, and a well-behaved site) and checks siteFiles() against both.

  • Security page checks in security/security.test.ts, with pageSecurityFindings() over a header object and noFacts, comparing group keys. Start from good, the header set with no findings, and change one thing. Site checks test the pure function (exposedFile(), parseSecurityTxt(), tlsFindings()) with fixed inputs and a fixed NOW. Add a case to “scores open findings once each, by severity” if your severity changes the expected numbers.

Unit tests make no requests except to servers they start on 127.0.0.1.

The stand-in site

packages/core/src/a11y/standin/server.mjs is a small static site with planted problems (see Testing). Plant a case for your check:

  • In a page: edit one of the HTML files. Keep /clean.html free of every problem: it’s the page that must score 100 and send every security header.
  • In a response header: change standinHeaders(path).
  • In a site-level file: add a branch in the request handler, as /.git/HEAD and /robots.txt have.

Tests can serve a fixed version of a page without touching the files, through startStandin()'s transform(path, html) and headers(path, planted) options. That’s how security.test.ts checks that a fixed cookie closes its findings.

Then update every place that expects the stand-in’s exact results:

Where Expects
packages/core/src/a11y/scan.test.ts The exact SEO findings (rule@path), and the 7 accessibility issue groups
packages/core/src/security/security.test.ts The exact open Security group keys with page counts (11), and the score
apps/desktop-web/scripts/smoke.mjs 7 open accessibility issues, a few Site health titles, and 11 open Security findings (12 if the monitor has already run)
apps/desktop/scripts/smoke.mjs 7 accessibility issues and exactly 11 open Security findings, through the Electron scanner
apps/docs/content/contributing/testing.md The stand-in’s planted problems and the counts

Demo data

The demo product’s site, fieldnote.example, is seeded through the same code as a real scan: seedDemoAccessibility() in packages/core/src/demo/accessibility.ts calls storePageResult() for each page with a hand-built PageResult and response headers, then finishScan(). So:

  • A new page check runs on the demo by itself. Check what it reports there and change the demo’s result(), security() or headers() if it should look different.
  • A site-level check sees no responses, because nothing is requested for a reserved host. Seed what the request would have found: seedDemoSecurity() inserts site-level security_occurrences (security-txt-missing, dmarc-none) before folding them, and the SEO seed inserts its ai-training-blocked finding directly.

packages/core/src/demo/seed.test.ts expects the demo’s open Security group keys and its security score, so update it with the demo. To look at the result, load the demo into an isolated data folder with STARTICA_DATA_DIR="$HOME/startica-demo" yarn seed:demo --force (see Development setup).

Document it

In the same pull request:

  • Add the check to the user-facing list: Security checks (the table for its area, with its severity) or Site health (check, severity, when it’s reported, how to fix). Content and PDF checks are described in Content and documents.
  • Update the stand-in tables and counts in Testing.
  • Update the module’s section in ARCHITECTURE.md at the repo root (10.2 or 11.2) if it lists the checks.

Before you open a pull request

Run the checks from the repo root:

yarn typecheck && yarn lint && yarn test && yarn smoke:desktop
yarn workspace @startica/desktop smoke
yarn workspace @startica/docs check

yarn test includes the end-to-end scan tests, which skip themselves when Playwright’s Chromium isn’t installed; install it (yarn workspace @startica/desktop-web playwright install chromium) so they run. The desktop smoke test is the only one that scans through the Electron scanner, and it expects the stand-in’s exact Security count. The docs check fails on a page missing from the sidebar or a broken internal link.

In the pull request, say what the check reads, which requests it adds (if any), and what it reports on the stand-in and on the demo.

Search the docs