CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (push) Successful in 11s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
masterplan-lock / check (pull_request) Successful in 29s
CI (SIAX Cloud) / security (push) Successful in 32s
CI (SIAX Cloud) / quality (pull_request) Successful in 45s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 46s
CI (SIAX Cloud) / quality (push) Successful in 1m1s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m16s
Pre-existing main-breakage: FAS 36-drift-konsolideringen (64e63cd) tog bort
siax.repo.json men doctor och siax-doctor.test laest den fortfarande -> ENOENT i
quality-jobbet. Doctor laeser nu v3-manifestet (samma fackliga faltar);
operationalState korrigerad 'down' -> 'running' sa att den overensstammer med
evidence/operational-state.json (live-proba 2026-09-22). Doctor 0/55 failures.
609 lines
28 KiB
JavaScript
609 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
// siax-doctor (BACKLOG P1-19, control AGENT-04): "Repot är
|
|
// self-diagnostiserande: `pnpm siax:doctor` kör hela standarden lokalt och
|
|
// skriver ut avvikelser med kontroll-ID."
|
|
//
|
|
// This is a repo-local, zero-dependency subset -- NOT the published
|
|
// @siax/doctor package from sax3l/siax-standard. That package was
|
|
// deliberately not taken on as a dependency here: at the time this was
|
|
// written it wasn't verified reachable/stable from this repo's frozen
|
|
// lockfile install, and a doctor script that can silently fail to install
|
|
// is worse than no doctor script. This checks the same class of
|
|
// machine-checkable facts using only Node's built-ins -- no network in the
|
|
// default mode, no registry.
|
|
//
|
|
// =============================================================================
|
|
// 2026-09-07 -- WHY THIS SCRIPT WAS REWRITTEN: it validated PRESENCE, never TRUTH
|
|
// =============================================================================
|
|
// The previous version's manifest checks were all `requireJsonField`, whose
|
|
// entire body is "does this key exist". Measured consequence: running it with
|
|
// siax.repo.json `operationalState: "running"` (false at the time -- every
|
|
// declared host answered 402/404) and with `"down"` (true) produced 39/40 and
|
|
// byte-identical output. A descriptor that asserted a live service therefore
|
|
// survived every gate this repo had.
|
|
//
|
|
// DESIGN, and what is deliberately NOT done:
|
|
//
|
|
// 1. OFFLINE (default, and the only thing anything automated runs).
|
|
// Everything here is a pure function of the repo's own bytes, so it is
|
|
// deterministic and cannot go red because a network was missing. Three
|
|
// kinds of check:
|
|
// (a) VALUE checks -- enums, formats, patterns. `operationalState` must
|
|
// be one of the standard's five states, `gitHost` must literally be
|
|
// git.siax.io, `node` must match the fleet's server pattern, every
|
|
// declared fqdn must be a bare well-formed hostname.
|
|
// (b) INTERNAL CONSISTENCY -- the repo's files must agree with each
|
|
// other. Every prod hostname in siax.repo.json must actually appear
|
|
// in this repo's own deploy/contract config (siax.config.json
|
|
// `environments.*.domain`, contracts/openapi.yaml `servers:`), and
|
|
// vice versa. A hostname nothing deploys is a hostname nobody owns.
|
|
// (c) SUBSTANTIATION -- the one that closes the historical hole.
|
|
// Liveness is not knowable offline. But whether a liveness CLAIM is
|
|
// BACKED is. `operationalState` must be matched by a dated probe
|
|
// record in evidence/operational-state.json whose results agree
|
|
// with it: "running" requires every prod probe to be a 2xx,
|
|
// "down" requires none of them to be. Claiming "running" next to a
|
|
// recorded 402 now fails. The record's own `ok` flags are re-derived
|
|
// from `httpStatus`, so a hand-edited `ok: true` next to a 402 fails
|
|
// too.
|
|
//
|
|
// 2. LIVE (`--live`, or SIAX_DOCTOR_LIVE=1) -- opt-in, for humans and for a
|
|
// scheduled job that HAS network. Actually probes the declared prod hosts,
|
|
// compares reality to the declared state, and checks that the security
|
|
// headers next.config.mjs promises are really on the wire.
|
|
// `--write-evidence` rewrites evidence/operational-state.json from that
|
|
// probe. This is NOT wired into the blocking CI path on purpose: a gate
|
|
// that needs the public internet fails for reasons that have nothing to do
|
|
// with the change under review, and people learn to ignore it.
|
|
//
|
|
// Exit code 0 = every check passed. Exit code 1 = at least one check failed
|
|
// (see the printed control-ID to find the BACKLOG/standard item).
|
|
// `--json` prints a machine-readable result instead of the table; that is what
|
|
// tests/repo/siax-doctor.test.ts consumes so this script is enforced by the
|
|
// blocking "CI (SIAX Cloud) / quality" job without any workflow change.
|
|
// =============================================================================
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// ROOT is the repo being diagnosed. SIAX_DOCTOR_ROOT lets a test point the
|
|
// SAME checks at a fixture tree, so the negative cases ("operationalState says
|
|
// running while the recorded probes say 402") are proven by the suite on every
|
|
// run -- not just once, in a transcript, by whoever wrote the check.
|
|
const ROOT = process.env.SIAX_DOCTOR_ROOT
|
|
? process.env.SIAX_DOCTOR_ROOT
|
|
: join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
const argv = process.argv.slice(2);
|
|
const WANT_JSON = argv.includes("--json");
|
|
const WANT_LIVE = argv.includes("--live") || process.env.SIAX_DOCTOR_LIVE === "1";
|
|
const WANT_WRITE_EVIDENCE = argv.includes("--write-evidence");
|
|
const PROBE_TIMEOUT_MS = Number(process.env.SIAX_DOCTOR_TIMEOUT_MS || 15000);
|
|
|
|
/**
|
|
* How stale a liveness claim may be before it stops counting as evidence.
|
|
* Only enforced for states that assert the service is up ("running"/"degraded")
|
|
* -- a repo that is honestly "down" does not decay into a lie by sitting still.
|
|
*/
|
|
const MAX_EVIDENCE_AGE_DAYS = 90;
|
|
|
|
/** @type {{id: string, label: string, check: () => boolean | string}[]} */
|
|
const checks = [];
|
|
|
|
/** @type {{id: string, label: string, run: () => Promise<boolean | string>}[]} */
|
|
const liveChecks = [];
|
|
|
|
function file(path) {
|
|
return join(ROOT, path);
|
|
}
|
|
|
|
function readJson(path) {
|
|
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
|
|
try {
|
|
return { value: JSON.parse(readFileSync(file(path), "utf8")) };
|
|
} catch (e) {
|
|
return { error: `invalid JSON in ${path}: ${e.message}` };
|
|
}
|
|
}
|
|
|
|
function readText(path) {
|
|
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
|
|
return { value: readFileSync(file(path), "utf8") };
|
|
}
|
|
|
|
function requireFile(id, label, path) {
|
|
checks.push({
|
|
id,
|
|
label,
|
|
check: () => (existsSync(file(path)) ? true : `missing: ${path}`),
|
|
});
|
|
}
|
|
|
|
function requireJsonField(id, label, path, fieldPath) {
|
|
checks.push({
|
|
id,
|
|
label,
|
|
check: () => {
|
|
const { value: data, error } = readJson(path);
|
|
if (error) return error;
|
|
let cur = data;
|
|
for (const key of fieldPath.split(".")) {
|
|
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
|
|
cur = cur[key];
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The check the old `requireJsonField` should have been: presence AND a
|
|
* predicate on the value. `expectation` is prose printed on failure.
|
|
*/
|
|
function requireJsonValue(id, label, path, fieldPath, predicate, expectation) {
|
|
checks.push({
|
|
id,
|
|
label,
|
|
check: () => {
|
|
const { value: data, error } = readJson(path);
|
|
if (error) return error;
|
|
let cur = data;
|
|
for (const key of fieldPath.split(".")) {
|
|
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
|
|
cur = cur[key];
|
|
}
|
|
if (predicate(cur)) return true;
|
|
return `${path} "${fieldPath}" = ${JSON.stringify(cur)} -- expected ${expectation}`;
|
|
},
|
|
});
|
|
}
|
|
|
|
function requireTextMatch(id, label, path, pattern, patternLabel) {
|
|
checks.push({
|
|
id,
|
|
label,
|
|
check: () => {
|
|
const { value: text, error } = readText(path);
|
|
if (error) return error;
|
|
return pattern.test(text) ? true : `${path} does not contain ${patternLabel}`;
|
|
},
|
|
});
|
|
}
|
|
|
|
function check(id, label, fn) {
|
|
checks.push({ id, label, check: fn });
|
|
}
|
|
|
|
// -- REPO-* / DOC-* : required root files --------------------------------
|
|
requireFile("DOC-01", "README.md exists", "README.md");
|
|
requireFile("REPO-08", "LICENSE exists", "LICENSE");
|
|
requireFile("REPO-13", ".editorconfig exists", ".editorconfig");
|
|
checks.push({
|
|
id: "REPO-15/P0-19",
|
|
label: "no committed .env (real secrets)",
|
|
check: () => (existsSync(file(".env")) ? "a real .env file is committed -- should never happen" : true),
|
|
});
|
|
requireFile("P1-15", "CODEOWNERS exists", "CODEOWNERS");
|
|
requireFile("P1-3", "CHANGELOG.md exists", "CHANGELOG.md");
|
|
requireFile("SEC-*", "SECURITY.md exists", "SECURITY.md");
|
|
requireFile("AGENT-01", "AGENTS.md exists", "AGENTS.md");
|
|
requireFile("DOC-01", "CONTRIBUTING.md exists", "CONTRIBUTING.md");
|
|
|
|
// -- DOC-02/DOC-04 : ARCHITECTURE.md at the exact standard path -----------
|
|
requireFile("DOC-02", "docs/architecture/ARCHITECTURE.md exists at the standard path", "docs/architecture/ARCHITECTURE.md");
|
|
requireTextMatch(
|
|
"DOC-02/v2.0",
|
|
"ARCHITECTURE.md has the v2.0 '## 5. Ytor och paritet' section",
|
|
"docs/architecture/ARCHITECTURE.md",
|
|
/^## 5\. Ytor och paritet/m,
|
|
"'## 5. Ytor och paritet'",
|
|
);
|
|
requireTextMatch(
|
|
"DOC-04",
|
|
"ARCHITECTURE.md has a 'Senast verifierad mot kod' date field",
|
|
"docs/architecture/ARCHITECTURE.md",
|
|
/Senast verifierad mot kod:\*\*\s*\d{4}-\d{2}-\d{2}/,
|
|
"a dated 'Senast verifierad mot kod' field",
|
|
);
|
|
|
|
// -- P1-1 / REPO-03/04/06 : manifests, PRESENCE ---------------------------
|
|
requireFile("P1-1/REPO-04", "siax.repo.json exists", "siax.repo.v3.json");
|
|
for (const field of ["repo", "category", "subcategory", "appId", "owner", "lifecycle", "gitHost", "node", "domains", "consumes", "consumedBy", "replacesSaaS", "children", "operationalState"]) {
|
|
requireJsonField("P1-1/REPO-04", `siax.repo.json has required field "${field}"`, "siax.repo.v3.json", field);
|
|
}
|
|
requireFile("P1-1/REPO-06", "siax.config.json exists", "siax.config.json");
|
|
for (const field of ["framework", "build", "start", "port", "healthcheck", "environments"]) {
|
|
requireJsonField("P1-1/REPO-06", `siax.config.json has required field "${field}"`, "siax.config.json", field);
|
|
}
|
|
|
|
// -- REPO-04/value : manifests, VALUES (not just presence) -----------------
|
|
// Enums mirror schema/siax.repo.schema.json (standard v2.0). Kept literal here
|
|
// on purpose: the doctor must work offline, and fetching the schema over the
|
|
// network is the exact dependency this script refuses to take on.
|
|
const OPERATIONAL_STATES = ["planned", "building", "running", "degraded", "down"];
|
|
const LIFECYCLES = ["active", "archive", "retire", "external"];
|
|
const CATEGORIES = ["platform", "extension", "business", "consultant", "customer", "mobile"];
|
|
const ENVS = ["dev", "test", "staging", "prod"];
|
|
|
|
requireJsonValue(
|
|
"REPO-04/GOV-06", 'siax.repo.json "operationalState" is one of the standard\'s five states',
|
|
"siax.repo.v3.json", "operationalState",
|
|
(v) => OPERATIONAL_STATES.includes(v), `one of ${OPERATIONAL_STATES.join("|")}`,
|
|
);
|
|
requireJsonValue(
|
|
"REPO-04/GOV-06", 'siax.repo.json "lifecycle" is a standard lifecycle',
|
|
"siax.repo.v3.json", "lifecycle",
|
|
(v) => LIFECYCLES.includes(v), `one of ${LIFECYCLES.join("|")}`,
|
|
);
|
|
requireJsonValue(
|
|
"REPO-04/GOV-01", 'siax.repo.json "category" is a standard category',
|
|
"siax.repo.v3.json", "category",
|
|
(v) => CATEGORIES.includes(v), `one of ${CATEGORIES.join("|")}`,
|
|
);
|
|
requireJsonValue(
|
|
"REPO-04", 'siax.repo.json "gitHost" is git.siax.io (schema const)',
|
|
"siax.repo.v3.json", "gitHost",
|
|
(v) => v === "git.siax.io", '"git.siax.io"',
|
|
);
|
|
requireJsonValue(
|
|
"REPO-04", 'siax.repo.json "node" is null or a real fleet node',
|
|
"siax.repo.v3.json", "node",
|
|
(v) => v === null || (typeof v === "string" && /^server(5|6|7|8|9|10|11)$/.test(v)),
|
|
"null, or server5..server11",
|
|
);
|
|
requireJsonValue(
|
|
"REPO-06", 'siax.config.json "port" is a plausible TCP port',
|
|
"siax.config.json", "port",
|
|
(v) => Number.isInteger(v) && v > 0 && v < 65536, "an integer in 1..65535",
|
|
);
|
|
requireJsonValue(
|
|
"REPO-06/CD-04", 'siax.config.json "healthcheck.path" is an absolute path',
|
|
"siax.config.json", "healthcheck.path",
|
|
(v) => typeof v === "string" && v.startsWith("/"), "a path starting with /",
|
|
);
|
|
|
|
// A bare hostname: labels of [a-z0-9-], at least one dot, no scheme/port/path.
|
|
const FQDN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
|
|
|
|
check("NET-01", "every siax.repo.json domain entry is well-formed", () => {
|
|
const { value: repo, error } = readJson("siax.repo.v3.json");
|
|
if (error) return error;
|
|
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
|
const problems = [];
|
|
for (const [i, d] of repo.domains.entries()) {
|
|
if (!d || typeof d !== "object") { problems.push(`domains[${i}] is not an object`); continue; }
|
|
if (typeof d.fqdn !== "string" || !FQDN_RE.test(d.fqdn)) {
|
|
problems.push(`domains[${i}].fqdn=${JSON.stringify(d.fqdn)} is not a bare lowercase hostname (no scheme, port or path)`);
|
|
}
|
|
if (!ENVS.includes(d.env)) problems.push(`domains[${i}].env=${JSON.stringify(d.env)} not in ${ENVS.join("|")}`);
|
|
if (typeof d.purpose !== "string" || d.purpose.trim() === "") problems.push(`domains[${i}].purpose is empty`);
|
|
}
|
|
const seen = new Set();
|
|
for (const d of repo.domains) {
|
|
if (d && typeof d.fqdn === "string") {
|
|
if (seen.has(d.fqdn)) problems.push(`duplicate fqdn ${d.fqdn}`);
|
|
seen.add(d.fqdn);
|
|
}
|
|
}
|
|
return problems.length === 0 ? true : problems.join("; ");
|
|
});
|
|
|
|
/** Hostnames this repo's own deploy/contract config actually points at. */
|
|
function hostsInDeployConfig() {
|
|
const hosts = new Set();
|
|
const { value: cfg } = readJson("siax.config.json");
|
|
if (cfg && cfg.environments && typeof cfg.environments === "object") {
|
|
for (const env of Object.values(cfg.environments)) {
|
|
if (env && typeof env.domain === "string") hosts.add(env.domain.toLowerCase());
|
|
}
|
|
}
|
|
const { value: openapi } = readText("contracts/openapi.yaml");
|
|
if (openapi) {
|
|
// `servers:` entries are `- url: https://host`. Deliberately a narrow match:
|
|
// any https URL in the contract counts as a host this repo points at.
|
|
for (const m of openapi.matchAll(/^\s*-?\s*url:\s*["']?https?:\/\/([a-z0-9.-]+)/gim)) {
|
|
hosts.add(m[1].toLowerCase());
|
|
}
|
|
}
|
|
return hosts;
|
|
}
|
|
|
|
check("NET-01/REPO-06", "every declared prod hostname appears in this repo's own deploy/contract config", () => {
|
|
const { value: repo, error } = readJson("siax.repo.v3.json");
|
|
if (error) return error;
|
|
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
|
|
const configured = hostsInDeployConfig();
|
|
const orphans = repo.domains
|
|
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
|
.map((d) => d.fqdn.toLowerCase())
|
|
.filter((f) => !configured.has(f));
|
|
return orphans.length === 0
|
|
? true
|
|
: `declared but nothing in siax.config.json/contracts/openapi.yaml points at: ${orphans.join(", ")}`;
|
|
});
|
|
|
|
check("REPO-06/NET-01", "every siax.config.json environment domain is declared in siax.repo.json domains", () => {
|
|
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
|
if (e1) return e1;
|
|
const { value: cfg, error: e2 } = readJson("siax.config.json");
|
|
if (e2) return e2;
|
|
const declared = new Map(
|
|
(Array.isArray(repo.domains) ? repo.domains : [])
|
|
.filter((d) => d && typeof d.fqdn === "string")
|
|
.map((d) => [d.fqdn.toLowerCase(), d.env]),
|
|
);
|
|
const problems = [];
|
|
for (const [envName, env] of Object.entries(cfg.environments ?? {})) {
|
|
if (!env || typeof env.domain !== "string") continue;
|
|
const fqdn = env.domain.toLowerCase();
|
|
if (!declared.has(fqdn)) {
|
|
problems.push(`siax.config.json environments.${envName}.domain=${fqdn} is not in siax.repo.json domains`);
|
|
} else if (declared.get(fqdn) !== envName) {
|
|
problems.push(`${fqdn} is env=${declared.get(fqdn)} in siax.repo.json but environments.${envName} in siax.config.json`);
|
|
}
|
|
}
|
|
return problems.length === 0 ? true : problems.join("; ");
|
|
});
|
|
|
|
// -- GOV-06 : operationalState must be SUBSTANTIATED, not merely present ----
|
|
const EVIDENCE_PATH = "evidence/operational-state.json";
|
|
requireFile("GOV-06", `${EVIDENCE_PATH} exists (substantiates operationalState)`, EVIDENCE_PATH);
|
|
|
|
check("GOV-06", "the recorded probe evidence is internally coherent", () => {
|
|
const { value: ev, error } = readJson(EVIDENCE_PATH);
|
|
if (error) return error;
|
|
const problems = [];
|
|
if (!Array.isArray(ev.probes) || ev.probes.length === 0) problems.push("no probes recorded");
|
|
const when = Date.parse(ev.checkedAt ?? "");
|
|
if (Number.isNaN(when)) problems.push(`checkedAt=${JSON.stringify(ev.checkedAt)} is not a parseable timestamp`);
|
|
else if (when > Date.now() + 86_400_000) problems.push(`checkedAt=${ev.checkedAt} is in the future`);
|
|
for (const [i, p] of (ev.probes ?? []).entries()) {
|
|
if (!p || typeof p !== "object") { problems.push(`probes[${i}] is not an object`); continue; }
|
|
if (typeof p.fqdn !== "string" || !FQDN_RE.test(p.fqdn)) problems.push(`probes[${i}].fqdn is not a hostname`);
|
|
if (!Number.isInteger(p.httpStatus) && p.httpStatus !== null) problems.push(`probes[${i}].httpStatus must be an integer or null (unreachable)`);
|
|
if (typeof p.ok !== "boolean") problems.push(`probes[${i}].ok must be a boolean`);
|
|
// `ok` is not allowed to disagree with the status it sits next to.
|
|
const derived = Number.isInteger(p.httpStatus) && p.httpStatus >= 200 && p.httpStatus < 400;
|
|
if (typeof p.ok === "boolean" && p.ok !== derived) {
|
|
problems.push(`probes[${i}] (${p.fqdn}) records ok=${p.ok} next to httpStatus=${JSON.stringify(p.httpStatus)}`);
|
|
}
|
|
}
|
|
return problems.length === 0 ? true : problems.join("; ");
|
|
});
|
|
|
|
check("GOV-06", "every declared prod hostname has a probe result on record", () => {
|
|
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
|
if (e1) return e1;
|
|
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
|
if (e2) return e2;
|
|
const probed = new Set((ev.probes ?? []).map((p) => String(p?.fqdn ?? "").toLowerCase()));
|
|
const missing = (Array.isArray(repo.domains) ? repo.domains : [])
|
|
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
|
.map((d) => d.fqdn.toLowerCase())
|
|
.filter((f) => !probed.has(f));
|
|
return missing.length === 0 ? true : `no probe recorded for: ${missing.join(", ")}`;
|
|
});
|
|
|
|
check("GOV-06", "operationalState agrees with the recorded probe results", () => {
|
|
const { value: repo, error: e1 } = readJson("siax.repo.v3.json");
|
|
if (e1) return e1;
|
|
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
|
|
if (e2) return e2;
|
|
|
|
const state = repo.operationalState;
|
|
if (ev.operationalState !== state) {
|
|
return `siax.repo.json says "${state}" but ${EVIDENCE_PATH} was recorded for "${ev.operationalState}" -- re-probe (\`node scripts/siax-doctor.mjs --live --write-evidence\`) instead of editing one side`;
|
|
}
|
|
|
|
const prodFqdns = new Set(
|
|
(Array.isArray(repo.domains) ? repo.domains : [])
|
|
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
|
.map((d) => d.fqdn.toLowerCase()),
|
|
);
|
|
const prodProbes = (ev.probes ?? []).filter((p) => prodFqdns.has(String(p?.fqdn ?? "").toLowerCase()));
|
|
const up = prodProbes.filter((p) => p.ok === true);
|
|
const down = prodProbes.filter((p) => p.ok !== true);
|
|
const describe = (list) => list.map((p) => `${p.fqdn}=${p.httpStatus ?? "unreachable"}`).join(", ");
|
|
|
|
// Staleness only bites states that ASSERT the service is up. A repo that is
|
|
// honestly "down" does not become a liar by not being re-probed.
|
|
if (state === "running" || state === "degraded") {
|
|
const ageDays = (Date.now() - Date.parse(ev.checkedAt ?? "")) / 86_400_000;
|
|
if (Number.isFinite(ageDays) && ageDays > MAX_EVIDENCE_AGE_DAYS) {
|
|
return `operationalState "${state}" is backed only by evidence ${Math.round(ageDays)} days old (limit ${MAX_EVIDENCE_AGE_DAYS}) -- an unrefreshed liveness claim is an unproven one`;
|
|
}
|
|
}
|
|
|
|
switch (state) {
|
|
case "running":
|
|
// The exact historical defect: "running" while every host answered 402/404.
|
|
return down.length === 0
|
|
? true
|
|
: `operationalState "running" but the recorded probes say otherwise: ${describe(down)}`;
|
|
case "degraded":
|
|
return up.length > 0
|
|
? true
|
|
: `operationalState "degraded" implies something still answers, but no prod host does: ${describe(down)}`;
|
|
case "down":
|
|
return up.length === 0
|
|
? true
|
|
: `operationalState "down" but these prod hosts answered: ${describe(up)}`;
|
|
case "planned":
|
|
case "building":
|
|
return up.length === 0
|
|
? true
|
|
: `operationalState "${state}" but these prod hosts already answer: ${describe(up)} -- that is "running"/"degraded"`;
|
|
default:
|
|
return `unknown operationalState ${JSON.stringify(state)}`;
|
|
}
|
|
});
|
|
|
|
// -- SEC-06 / P0-13 : the security headers CHANGELOG.md claims must exist ----
|
|
// Text-level tripwire so `pnpm siax:doctor` alone catches the claim going
|
|
// stale. tests/security/security-headers.test.ts is the strict version (it
|
|
// evaluates the config and asserts each value).
|
|
check("SEC-06/P0-13", "next.config.mjs actually sets the security headers CHANGELOG.md claims", () => {
|
|
const { value: text, error } = readText("next.config.mjs");
|
|
if (error) return error;
|
|
if (!/async\s+headers\s*\(/.test(text)) return "next.config.mjs has no async headers()";
|
|
const missing = ["Content-Security-Policy", "Strict-Transport-Security", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"]
|
|
.filter((h) => !text.includes(h));
|
|
return missing.length === 0 ? true : `next.config.mjs never mentions: ${missing.join(", ")}`;
|
|
});
|
|
|
|
// -- AGENT-02/03 : prompts -------------------------------------------------
|
|
requireFile("AGENT-02", "prompts/000-INDEX.md exists", "prompts/000-INDEX.md");
|
|
|
|
// -- DX-03 : devcontainer --------------------------------------------------
|
|
requireFile("DX-03", ".devcontainer/devcontainer.json exists", ".devcontainer/devcontainer.json");
|
|
|
|
// -- P1-2 : ADRs -------------------------------------------------------------
|
|
requireFile("P1-2", "docs/architecture/decisions/ has at least one ADR", "docs/architecture/decisions/README.md");
|
|
|
|
// -- CI-14 : contract ---------------------------------------------------------
|
|
requireFile("A4/DOC", "contracts/openapi.yaml exists", "contracts/openapi.yaml");
|
|
|
|
// -- CD-04/OBS-04 : health/ready ---------------------------------------------
|
|
requireFile("P0-3/CD-04", "app/api/health/route.ts exists", "app/api/health/route.ts");
|
|
requireFile("P0-3/OBS-04", "app/api/ready/route.ts exists (readiness, not just liveness)", "app/api/ready/route.ts");
|
|
|
|
// =============================================================================
|
|
// LIVE MODE (opt-in) -- the half that needs the world
|
|
// =============================================================================
|
|
const REQUIRED_LIVE_HEADERS = [
|
|
"content-security-policy",
|
|
"strict-transport-security",
|
|
"x-frame-options",
|
|
"x-content-type-options",
|
|
"referrer-policy",
|
|
];
|
|
|
|
async function probe(url) {
|
|
const ac = new AbortController();
|
|
const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
|
try {
|
|
const res = await fetch(url, { method: "GET", redirect: "manual", signal: ac.signal });
|
|
return { url, httpStatus: res.status, ok: res.status >= 200 && res.status < 400, headers: res.headers };
|
|
} catch (e) {
|
|
return { url, httpStatus: null, ok: false, headers: null, note: `unreachable: ${e.message}` };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function prodTargets() {
|
|
const { value: repo } = readJson("siax.repo.v3.json");
|
|
const { value: cfg } = readJson("siax.config.json");
|
|
const healthPath = cfg?.healthcheck?.path ?? "/";
|
|
return (Array.isArray(repo?.domains) ? repo.domains : [])
|
|
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
|
|
.map((d) => ({ fqdn: d.fqdn, env: d.env, url: `https://${d.fqdn}${healthPath}` }));
|
|
}
|
|
|
|
if (WANT_LIVE) {
|
|
liveChecks.push({
|
|
id: "GOV-06/live",
|
|
label: "live probe of every prod host agrees with siax.repo.json operationalState",
|
|
run: async () => {
|
|
const { value: repo, error } = readJson("siax.repo.v3.json");
|
|
if (error) return error;
|
|
const results = await Promise.all(prodTargets().map((t) => probe(t.url).then((r) => ({ ...t, ...r }))));
|
|
globalThis.__siaxDoctorLiveResults = results;
|
|
const up = results.filter((r) => r.ok);
|
|
const summary = results.map((r) => `${r.fqdn}=${r.httpStatus ?? "unreachable"}`).join(", ");
|
|
switch (repo.operationalState) {
|
|
case "running":
|
|
return up.length === results.length ? true : `declared "running", live probe says: ${summary}`;
|
|
case "degraded":
|
|
return up.length > 0 ? true : `declared "degraded", but nothing answered: ${summary}`;
|
|
case "down":
|
|
case "planned":
|
|
case "building":
|
|
return up.length === 0 ? true : `declared "${repo.operationalState}", but these answered: ${summary}`;
|
|
default:
|
|
return `unknown operationalState ${JSON.stringify(repo.operationalState)}`;
|
|
}
|
|
},
|
|
});
|
|
|
|
liveChecks.push({
|
|
id: "SEC-06/live",
|
|
label: "a reachable prod host really serves the security headers (P0-13)",
|
|
run: async () => {
|
|
const results = globalThis.__siaxDoctorLiveResults ?? [];
|
|
// Only responses THIS app actually produced are in scope. A 402/404 from
|
|
// a disabled Vercel deployment has no CSP either, but that is not a
|
|
// header defect this repo can fix -- it is a liveness fact, already
|
|
// carried by GOV-06/live above and recorded in
|
|
// evidence/operational-state.json `securityHeadersObserved`. Failing
|
|
// here as well would make this check permanently red for a reason no
|
|
// change to next.config.mjs could clear, which is how gates get ignored.
|
|
const serving = results.filter((r) => r.headers && r.ok);
|
|
if (serving.length === 0) return true;
|
|
const problems = [];
|
|
for (const r of serving) {
|
|
const missing = REQUIRED_LIVE_HEADERS.filter((h) => !r.headers.get(h));
|
|
if (missing.length) problems.push(`${r.fqdn} (HTTP ${r.httpStatus}) is missing: ${missing.join(", ")}`);
|
|
}
|
|
return problems.length === 0 ? true : problems.join("; ");
|
|
},
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
const results = [];
|
|
for (const { id, label, check: fn } of checks) {
|
|
const outcome = fn();
|
|
results.push({ id, label, mode: "offline", pass: outcome === true, detail: outcome === true ? null : outcome });
|
|
}
|
|
for (const { id, label, run } of liveChecks) {
|
|
const outcome = await run();
|
|
results.push({ id, label, mode: "live", pass: outcome === true, detail: outcome === true ? null : outcome });
|
|
}
|
|
|
|
if (WANT_LIVE && WANT_WRITE_EVIDENCE) {
|
|
const { value: repo } = readJson("siax.repo.v3.json");
|
|
const live = globalThis.__siaxDoctorLiveResults ?? [];
|
|
const existing = readJson(EVIDENCE_PATH).value ?? {};
|
|
const next = {
|
|
...existing,
|
|
control: "GOV-06",
|
|
operationalState: repo?.operationalState,
|
|
checkedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
method: `scripts/siax-doctor.mjs --live --write-evidence (fetch, ${PROBE_TIMEOUT_MS}ms timeout)`,
|
|
probes: live.map((r) => ({
|
|
fqdn: r.fqdn,
|
|
env: r.env,
|
|
url: r.url,
|
|
httpStatus: r.httpStatus,
|
|
ok: r.ok,
|
|
note: r.note ?? (r.headers ? `server: ${r.headers.get("server") ?? "unknown"}` : ""),
|
|
})),
|
|
securityHeadersObserved: Object.fromEntries(
|
|
REQUIRED_LIVE_HEADERS.map((h) => [h, live.find((r) => r.headers)?.headers?.get(h) ?? null]),
|
|
),
|
|
};
|
|
writeFileSync(file(EVIDENCE_PATH), `${JSON.stringify(next, null, 2)}\n`);
|
|
if (!WANT_JSON) console.log(`\nwrote ${EVIDENCE_PATH} from the live probe.\n`);
|
|
}
|
|
|
|
const failures = results.filter((r) => !r.pass);
|
|
|
|
if (WANT_JSON) {
|
|
console.log(JSON.stringify({ total: results.length, failed: failures.length, live: WANT_LIVE, results }, null, 2));
|
|
} else {
|
|
console.log("siax-doctor -- 0pt (repo-local subset, see scripts/siax-doctor.mjs header)");
|
|
console.log(WANT_LIVE ? "mode: offline + LIVE probe\n" : "mode: offline only (add --live to probe declared hosts)\n");
|
|
for (const r of results) {
|
|
const tag = r.mode === "live" ? "~" : " ";
|
|
if (r.pass) console.log(` ${tag}[PASS] ${r.id.padEnd(16)} ${r.label}`);
|
|
else console.log(` ${tag}[FAIL] ${r.id.padEnd(16)} ${r.label} -- ${r.detail}`);
|
|
}
|
|
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
|
|
if (failures.length > 0) {
|
|
console.log(`${failures.length} check(s) failed -- see control-IDs above and cross-reference docs/compliance/BACKLOG.md.`);
|
|
}
|
|
}
|
|
|
|
if (failures.length > 0) process.exit(1);
|