diff --git a/.github/workflows/deploy-freshness.yml b/.github/workflows/deploy-freshness.yml
new file mode 100644
index 0000000..af3dd85
--- /dev/null
+++ b/.github/workflows/deploy-freshness.yml
@@ -0,0 +1,48 @@
+name: Deploy freshness
+
+# OPS-A guard: prove the LIVE site matches main HEAD. The daily sync commits
+# fresh data and relies on a workflow-triggering push token to fire pages.yml
+# (see sync-stars.yml's OPS-A invariant). If that ever breaks, the commit lands
+# but no deploy runs and the site silently freezes on stale data with no failed
+# run to notice. This job turns that invisible failure into a red scheduled run.
+on:
+ schedule:
+ # A few hours after the 05:23 sync + its deploy, so a healthy deploy has
+ # settled. A one-off drift can be a deploy in flight; a persistent red here
+ # is the silent-freeze signal.
+ - cron: '41 9 * * *'
+ workflow_dispatch:
+
+# Strictly read-only: this monitor never writes to the repo, needs no Pages or
+# id-token permission, and uses NO secret — it reads committed data and fetches
+# only the PUBLIC published artifact.
+permissions:
+ contents: read
+
+concurrency:
+ group: deploy-freshness
+ cancel-in-progress: true
+
+jobs:
+ freshness:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: main
+
+ - uses: pnpm/action-setup@v6
+ - uses: actions/setup-node@v5
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ # Compares the public live dataset-meta.json against main HEAD's committed
+ # copy. Exits non-zero on drift (GitHub notifies on a failed scheduled run)
+ # or if the live site is unreachable/malformed — we never conclude "fresh"
+ # from a site we could not read. The live URL is derived from
+ # GITHUB_REPOSITORY; pass --url to override for a custom domain.
+ - name: check deploy freshness
+ run: pnpm exec tsx packages/deploy/src/cli.ts freshness --data .
diff --git a/.gitignore b/.gitignore
index 55f2805..2e033a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ classifier-state.json
.claude/
.idea/
.vscode/
+
+# Playwright MCP browser session artifacts
+.playwright-mcp/
diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html
index 35c6bfd..1dbd4b0 100644
--- a/apps/dashboard/index.html
+++ b/apps/dashboard/index.html
@@ -5,10 +5,13 @@
+ packages/deploy/src/verify.ts before loosening any directive. NB:
+ frame-ancestors is intentionally omitted — it (like X-Frame-Options) is
+ ignored when delivered via , so clickjacking protection is simply
+ not available on Pages; keeping it here only logged a console error. -->
StarLedger
diff --git a/docs/P3-ai-spec.md b/docs/P3-ai-spec.md
index d59334e..2ba0fd0 100644
--- a/docs/P3-ai-spec.md
+++ b/docs/P3-ai-spec.md
@@ -458,17 +458,32 @@ with no secret/backend and acceptable size + latency — see
runs produced and merged PRs #17, #18, and #20. Each changed only the public
artifact pair, passed CI plus the structural and provenance gates, and deployed
through Pages. The public artifact bytes and metadata hashes were verified against
-`main`; the current published coverage is five annotations out of 492 canonical
-repositories. The third run exercised the configured `max_total_per_run: 3`
-budget without exceeding it.
-
-**Final operational closeout remains pending.** `verify-ai-provenance` remains
-required alongside `verify-agent-artifacts` and CI on `main`. Continue bounded,
-manual backfill with exactly one executor enabled. Before P3 is fully closed,
-visually confirm the public dashboard's category/tag facets and secondary summary,
-then run the executor unchanged after the current collection is fully accounted
-for and prove it produces neither an artifact PR nor byte churn. Existing fixture
-and no-op tests support this behavior but do not replace the required live replay.
+`main`; the published coverage was five annotations out of the 492 canonical
+repositories present at the time — still five today, now against 550, as no
+executor has run since. The third run exercised the configured
+`max_total_per_run: 3` budget without exceeding it.
+
+**Operational closeout — status (updated 2026-07-08).** `verify-ai-provenance`
+remains required alongside `verify-agent-artifacts` and CI on `main`. Continue
+bounded, manual backfill with exactly one executor enabled.
+
+- ✅ **Public-dashboard visual confirmation — DONE.** Verified live at
+ `https://f-e-u-e-r.github.io/starledger/` under the new strict CSP: React
+ mounts and the stylesheet applies (the CSP does not break the app), and the
+ category/tag facets, AI badge, secondary summary, AI tags, the "N of M
+ enriched" counter (`5 of 550`), and AI-aware search all render.
+- ✅ **No-churn replay, deterministic half — DONE (offline).**
+ `classifier verify-artifacts` passes on the committed pair, re-serializing the
+ committed annotations is byte-identical, and the meta `annotations_sha256` +
+ `annotation_count` match. So the assembler produces zero byte churn on an
+ unchanged set.
+- ⏳ **No-churn replay, live half — PENDING (credentialed).** Running
+ `classifier plan --current ai-annotations.json` against live README OIDs at the
+ current base (to prove the planner emits zero jobs) needs a `STAR_SYNC_TOKEN`
+ and is the owner's step; the offline checks above support but do not replace it.
+
+Existing fixture and no-op tests support this behavior but do not replace the
+required live planner replay.
`pnpm p3-gate` is the aggregate gate: typecheck, lint, format, the full test suite
(AI schema drift, fingerprint/planner, injection fixtures, structural + provenance
@@ -478,8 +493,9 @@ build, and generated-schema drift.
## P3 exit conditions
The contract, gate, and implementation conditions below are met and tested. Live
-artifact publication is also verified; the visible-dashboard check and no-churn
-replay remain pending the operational closeout above.
+artifact publication is verified, and (2026-07-08) the visible-dashboard check is
+DONE and the deterministic no-churn replay is verified offline; only the live
+credentialed planner replay remains pending the operational closeout above.
- canonical stars remain untouched by AI;
- jobs are generated only by trusted deterministic code;
@@ -490,9 +506,11 @@ replay remain pending the operational closeout above.
- the dashboard works fully without AI (fail-soft) and fails closed only on
canonical data;
- public AI artifact deployment is hash-verified against `main`;
-- live AI facets, secondary summaries, and AI-aware search still need visual
- confirmation on the public dashboard;
-- an unchanged live run produces no artifact churn after bounded backfill;
+- live AI facets, secondary summaries, and AI-aware search are visually confirmed
+ on the public dashboard (2026-07-08);
+- an unchanged run produces no artifact churn: proven offline via the
+ deterministic assembler replay; the live credentialed planner replay is still
+ pending;
- semantic search is explicitly deferred by ADR-001 (lexical search shipped).
## Subsequent milestones
@@ -512,5 +530,6 @@ replay remain pending the operational closeout above.
- **P3.5 (ADR and live artifact publication delivered; final closeout pending):**
vector search is deferred and lexical search shipped. Three validated Claude
Routine artifact PRs have merged and deployed five annotations under bounded
- budgets. Public dashboard visual confirmation and the post-backfill no-churn
- replay remain pending.
+ budgets. Public dashboard visual confirmation is DONE (2026-07-08) and the
+ deterministic no-churn replay is verified offline; only the live credentialed
+ planner replay remains pending.
diff --git a/packages/deploy/src/cli.ts b/packages/deploy/src/cli.ts
index 6e945a5..5be0fc0 100644
--- a/packages/deploy/src/cli.ts
+++ b/packages/deploy/src/cli.ts
@@ -1,7 +1,8 @@
-import { existsSync, readFileSync } from 'node:fs';
+import { appendFileSync, existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { argv, env, exit } from 'node:process';
import { verifyDatasetIntegrity } from './dataset';
+import { evaluateDeployFreshness } from './freshness';
import { writeFixtureDataset } from './fixture';
import {
DATASET_META_FILE,
@@ -26,6 +27,17 @@ function derivedBase(): string {
return env.GITHUB_ACTIONS && repo ? `/${repo}/` : '/';
}
+/** Best-effort append to the GitHub Actions run summary (no-op off CI). */
+function appendStepSummary(markdown: string): void {
+ const path = env.GITHUB_STEP_SUMMARY;
+ if (!path) return;
+ try {
+ appendFileSync(path, markdown + '\n');
+ } catch {
+ /* the summary is diagnostic only; never let it fail the check */
+ }
+}
+
async function main(): Promise {
const cmd = argv[2];
const dist = flag('dist') ?? DEFAULT_DIST;
@@ -75,9 +87,42 @@ async function main(): Promise {
console.log(`[deploy] wrote fixture dataset (${r.repoCount} repos) → ${out}`);
break;
}
+ case 'freshness': {
+ // OPS-A: compare the PUBLIC live dataset-meta.json against main HEAD's
+ // VERIFIED fingerprint (evaluateDeployFreshness re-hashes stars.json so a
+ // stale committed meta can't self-certify). Read-only; needs no secret.
+ // Exit 3 on drift so the scheduled run fails visibly (a one-off can be a
+ // deploy in flight; a persistent red is a silent freeze). Exit 1 (via
+ // main().catch) if the dataset/live site is unverifiable — never "fresh".
+ const outcome = await evaluateDeployFreshness({
+ dataDir: data,
+ url: flag('url'),
+ repoSlug: env.GITHUB_REPOSITORY,
+ });
+ if (outcome.status === 'fresh') {
+ console.log(
+ `[deploy] freshness OK: live site matches main HEAD (${outcome.expectedSha.slice(0, 12)}…)`,
+ );
+ break;
+ }
+ console.error(
+ `[deploy] DRIFT: live site is serving ${outcome.liveSha.slice(0, 12)}… but main HEAD is ` +
+ `${outcome.expectedSha.slice(0, 12)}… — deploy drift (OPS-A)`,
+ );
+ appendStepSummary(
+ `### ⚠️ Deploy freshness drift (OPS-A)\n\n` +
+ `- live \`stars_sha256\`: \`${outcome.liveSha}\`\n` +
+ `- main HEAD \`stars_sha256\`: \`${outcome.expectedSha}\`\n\n` +
+ `The published site does not match main HEAD. A single occurrence can be a ` +
+ `deploy in flight; a persistent drift is a silent freeze — see the OPS-A ` +
+ `invariant in \`.github/workflows/sync-stars.yml\`.`,
+ );
+ exit(3);
+ break;
+ }
default:
console.error(
- 'usage: deploy [--data dir] [--dist dir] [--base /x/] [--out dir]',
+ 'usage: deploy [--data dir] [--dist dir] [--base /x/] [--out dir] [--url URL]',
);
exit(2);
}
diff --git a/packages/deploy/src/freshness.ts b/packages/deploy/src/freshness.ts
new file mode 100644
index 0000000..5d2e5eb
--- /dev/null
+++ b/packages/deploy/src/freshness.ts
@@ -0,0 +1,141 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { DatasetMetaSchema } from '@starred/schema';
+import { verifyDatasetIntegrity } from './dataset';
+import { DATASET_META_FILE, STARS_FILE } from './stage';
+
+/**
+ * Deploy-freshness guard (OPS-A). The daily sync commits fresh data to main and
+ * relies on a workflow-triggering push token to fire pages.yml. If that
+ * invariant ever breaks (e.g. someone swaps the App token for GITHUB_TOKEN), the
+ * commit still lands but NO deploy runs — the live site silently freezes on
+ * stale data with no failed run to notice. This guard makes that failure
+ * OBSERVABLE by comparing the live site's published fingerprint against main
+ * HEAD. It is strictly read-only: no writes, no secret, only the public artifact.
+ */
+
+export interface FreshnessResult {
+ status: 'fresh' | 'drift';
+ /** stars_sha256 the live site is currently serving. */
+ liveSha: string;
+ /** stars_sha256 committed at main HEAD — what the site SHOULD be serving. */
+ expectedSha: string;
+}
+
+/**
+ * Parse a fetched dataset-meta.json body and return its stars_sha256, validated
+ * against the canonical schema. Throws (fail-closed) on malformed JSON or a body
+ * that does not satisfy the committed contract: a monitor that cannot read the
+ * live fingerprint must alarm, never silently conclude "fresh".
+ */
+export function parseLiveStarsSha(liveMetaText: string): string {
+ let raw: unknown;
+ try {
+ raw = JSON.parse(liveMetaText);
+ } catch {
+ throw new Error('live dataset-meta.json is not valid JSON');
+ }
+ const parsed = DatasetMetaSchema.safeParse(raw);
+ if (!parsed.success) {
+ throw new Error('live dataset-meta.json failed dataset-meta schema validation');
+ }
+ return parsed.data.stars_sha256;
+}
+
+/** Pure comparison of the live fingerprint against the expected (main HEAD) one. */
+export function compareFreshness(liveSha: string, expectedSha: string): FreshnessResult {
+ return { status: liveSha === expectedSha ? 'fresh' : 'drift', liveSha, expectedSha };
+}
+
+export interface CheckFreshnessOptions {
+ /** Absolute URL of the live dataset-meta.json, e.g. https://.github.io//dataset-meta.json. */
+ url: string;
+ /** The stars_sha256 committed at main HEAD — what the live site should be serving. */
+ expectedSha: string;
+ /** Injectable for tests; defaults to the global fetch. */
+ fetchImpl?: typeof fetch;
+}
+
+/**
+ * Fetch the live dataset-meta.json and compare its fingerprint to main HEAD. A
+ * network failure or non-200 THROWS — the monitor cannot conclude "fresh" from
+ * an unreachable site, and a false "fresh" would defeat the guard's whole point.
+ */
+export async function checkFreshness(opts: CheckFreshnessOptions): Promise {
+ const doFetch = opts.fetchImpl ?? fetch;
+ let res: Response;
+ try {
+ res = await doFetch(opts.url);
+ } catch (err) {
+ throw new Error(
+ `could not reach the live site (${opts.url}): ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+ if (!res.ok) {
+ throw new Error(`live dataset-meta.json → HTTP ${res.status} (${opts.url})`);
+ }
+ const liveSha = parseLiveStarsSha(await res.text());
+ return compareFreshness(liveSha, opts.expectedSha);
+}
+
+/**
+ * Public URL of the live dataset-meta.json for a project Pages site, or undefined
+ * when the slug is missing/malformed. Pages subdomains are the owner login
+ * lowercased; the repo path keeps its case. Pure so it is unit-testable without
+ * the environment.
+ */
+export function deriveLiveMetaUrl(repoSlug: string | undefined): string | undefined {
+ if (!repoSlug || !repoSlug.includes('/')) return undefined;
+ const [owner, name] = repoSlug.split('/');
+ if (!owner || !name) return undefined;
+ return `https://${owner.toLowerCase()}.github.io/${name}/${DATASET_META_FILE}`;
+}
+
+export interface EvaluateFreshnessOptions {
+ /** Directory holding main HEAD's stars.json + dataset-meta.json. */
+ dataDir: string;
+ /** Live URL override; when absent it is derived from repoSlug. */
+ url?: string;
+ /** Repo slug ("owner/repo") for URL derivation when url is absent. */
+ repoSlug?: string;
+ /** Injectable for tests; defaults to the global fetch. */
+ fetchImpl?: typeof fetch;
+}
+
+export interface FreshnessOutcome extends FreshnessResult {
+ /** The live URL that was actually checked. */
+ url: string;
+}
+
+/**
+ * The full freshness evaluation used by the CLI/workflow. It derives main HEAD's
+ * VERIFIED fingerprint by re-hashing stars.json and confirming dataset-meta
+ * agrees (verifyDatasetIntegrity) — so a stale or corrupt committed meta cannot
+ * "self-certify" the site as fresh against itself — then resolves the live URL,
+ * fetches, and compares. Every failure mode (missing data, an inconsistent local
+ * dataset, an underivable URL, an unreachable/malformed live artifact) THROWS,
+ * fail-closed: the monitor never reports "fresh" from a state it could not verify.
+ */
+export async function evaluateDeployFreshness(
+ opts: EvaluateFreshnessOptions,
+): Promise {
+ const starsPath = join(opts.dataDir, STARS_FILE);
+ const metaPath = join(opts.dataDir, DATASET_META_FILE);
+ if (!existsSync(starsPath) || !existsSync(metaPath)) {
+ throw new Error(
+ `canonical dataset (stars.json + dataset-meta.json) not found in ${opts.dataDir}`,
+ );
+ }
+ const expectedSha = verifyDatasetIntegrity(
+ readFileSync(starsPath, 'utf8'),
+ readFileSync(metaPath, 'utf8'),
+ ).sha256;
+ const url = opts.url ?? deriveLiveMetaUrl(opts.repoSlug);
+ if (!url) {
+ throw new Error('could not derive the live URL; pass --url ');
+ }
+ const result = await checkFreshness({ url, expectedSha, fetchImpl: opts.fetchImpl });
+ return { ...result, url };
+}
diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts
index 698e606..5adea8b 100644
--- a/packages/deploy/src/verify.ts
+++ b/packages/deploy/src/verify.ts
@@ -22,25 +22,108 @@ function assetUrls(html: string): string[] {
.filter((u) => u.includes('/assets/'));
}
-/** Directives that must survive into the built index.html (SEC-B). */
-const REQUIRED_CSP_DIRECTIVES = ["default-src 'none'", "script-src 'self'"];
+/**
+ * The EXACT Content-Security-Policy the built index.html must ship (SEC-B). The
+ * deploy pins the WHOLE policy — every directive and its exact source list — so
+ * that a dropped directive, a weakened source (e.g. adding `'unsafe-inline'`), an
+ * extra/meta-ineffective directive (`frame-ancestors`), or a swap to the
+ * non-enforcing `Content-Security-Policy-Report-Only` all fail the gate rather
+ * than silently shipping. Change this map deliberately if the policy legitimately
+ * evolves. Source lists are compared order-insensitively.
+ */
+const EXPECTED_CSP: Record = {
+ 'default-src': ["'none'"],
+ 'script-src': ["'self'"],
+ 'style-src': ["'self'"],
+ 'img-src': ["'self'", 'data:'],
+ 'font-src': ["'self'"],
+ 'connect-src': ["'self'"],
+ 'base-uri': ["'none'"],
+ 'form-action': ["'none'"],
+};
+
+/**
+ * Parse `name="value"` attributes from a single tag string (lowercased names,
+ * first occurrence wins — the order browsers honor). The leading `(?:^|\s)`
+ * requires a real attribute-name boundary, so a decoy like `data-http-equiv`
+ * is captured under its own name and never mistaken for `http-equiv`.
+ */
+function parseTagAttributes(tag: string): Map {
+ const attrs = new Map();
+ for (const m of tag.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g)) {
+ const name = m[1]!.toLowerCase();
+ if (!attrs.has(name)) attrs.set(name, m[2] ?? '');
+ }
+ return attrs;
+}
+
+/**
+ * Parse a CSP policy string into a directive→sources map (directive names
+ * lowercased). A DUPLICATE directive is rejected: browsers enforce only the
+ * first occurrence, so a duplicate could hide a weakened first directive behind
+ * a pinned-looking second one.
+ */
+function parseCspPolicy(policy: string): Map {
+ const map = new Map();
+ for (const part of policy.split(';')) {
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
+ const name = tokens.shift()?.toLowerCase();
+ if (!name) continue;
+ if (map.has(name)) {
+ throw new Error(
+ `Content-Security-Policy has a duplicate "${name}" directive (SEC-B): browsers enforce only the first, so a duplicate can hide a weakened policy`,
+ );
+ }
+ map.set(name, tokens);
+ }
+ return map;
+}
/**
- * Assert the Content-Security-Policy meta survived the build (SEC-B). GitHub
- * Pages cannot send a CSP response header, so this meta is the only backstop;
- * if a tooling change ever drops it (or weakens the load-bearing directives),
- * the deploy must fail here rather than silently shipping an unprotected page.
+ * Assert the built index.html ships EXACTLY the pinned Content-Security-Policy
+ * (SEC-B). GitHub Pages cannot send a CSP response header, so this meta is the
+ * only backstop; the deploy must fail here rather than ship a missing, weakened,
+ * report-only, or console-erroring policy. HTML comments are stripped first (a
+ * commented-out CSP is not browser-enforced); the policy is read from a SINGLE
+ * `` tag whose `http-equiv` equals `Content-Security-Policy` exactly (so
+ * a `-Report-Only` suffix or a later unrelated tag cannot satisfy the check), and
+ * every directive's source list must match the pin exactly.
*/
function assertContentSecurityPolicy(html: string): void {
- if (!/http-equiv=["']?Content-Security-Policy["']?/i.test(html)) {
+ const active = html.replace(//g, '');
+ let policyText: string | undefined;
+ for (const tag of active.match(/]*>/gi) ?? []) {
+ const attrs = parseTagAttributes(tag);
+ if (attrs.get('http-equiv')?.toLowerCase() === 'content-security-policy') {
+ policyText = attrs.get('content'); // undefined ⇒ the CSP meta has no content
+ break;
+ }
+ }
+ if (policyText === undefined) {
+ throw new Error(
+ 'index.html is missing an enforcing Content-Security-Policy meta with a content attribute (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop',
+ );
+ }
+ const actual = parseCspPolicy(policyText);
+
+ const extra = [...actual.keys()].filter((name) => !(name in EXPECTED_CSP));
+ if (extra.length > 0) {
throw new Error(
- 'index.html is missing the Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop',
+ `Content-Security-Policy has unexpected directive(s) "${extra.join(', ')}" (SEC-B): update the pinned policy deliberately (header-only directives such as frame-ancestors are ineffective in a )`,
);
}
- for (const directive of REQUIRED_CSP_DIRECTIVES) {
- if (!html.includes(directive)) {
+ for (const [name, want] of Object.entries(EXPECTED_CSP)) {
+ const got = actual.get(name);
+ if (!got) {
+ throw new Error(
+ `Content-Security-Policy is missing the required "${name}" directive (SEC-B)`,
+ );
+ }
+ const wantSorted = [...want].sort().join(' ');
+ const gotSorted = [...got].sort().join(' ');
+ if (wantSorted !== gotSorted) {
throw new Error(
- `Content-Security-Policy is missing the required "${directive}" directive (SEC-B)`,
+ `Content-Security-Policy directive "${name}" must be exactly "${want.join(' ')}" but is "${got.join(' ')}" (SEC-B): no weakening (e.g. 'unsafe-inline') is allowed`,
);
}
}
diff --git a/packages/deploy/tests/freshness.test.ts b/packages/deploy/tests/freshness.test.ts
new file mode 100644
index 0000000..0cc5766
--- /dev/null
+++ b/packages/deploy/tests/freshness.test.ts
@@ -0,0 +1,173 @@
+import { mkdtempSync, readFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+import { writeFixtureDataset } from '../src/fixture';
+import {
+ checkFreshness,
+ compareFreshness,
+ deriveLiveMetaUrl,
+ evaluateDeployFreshness,
+ parseLiveStarsSha,
+} from '../src/freshness';
+
+const SHA_A = 'a'.repeat(64);
+const SHA_B = 'b'.repeat(64);
+
+const validLiveMeta = JSON.stringify({
+ schema_version: '1.0',
+ dataset_generated_at: '2026-07-07T00:00:00.000Z',
+ stars_sha256: SHA_A,
+ repo_count: 550,
+});
+
+/** A fetch stub returning a fixed body/status; enough of Response for the guard. */
+function mockFetch(body: string, ok = true, status = 200): typeof fetch {
+ return (async () => ({ ok, status, text: async () => body })) as unknown as typeof fetch;
+}
+
+const URL = 'https://example.github.io/repo/dataset-meta.json';
+
+describe('deploy freshness guard (OPS-A)', () => {
+ it('MATCH: live fingerprint equals main HEAD → fresh', async () => {
+ const r = await checkFreshness({
+ url: URL,
+ expectedSha: SHA_A,
+ fetchImpl: mockFetch(validLiveMeta),
+ });
+ expect(r.status).toBe('fresh');
+ expect(r.liveSha).toBe(SHA_A);
+ });
+
+ it('MISMATCH: live differs from main HEAD → drift (both fingerprints surfaced)', async () => {
+ const r = await checkFreshness({
+ url: URL,
+ expectedSha: SHA_B,
+ fetchImpl: mockFetch(validLiveMeta),
+ });
+ expect(r.status).toBe('drift');
+ expect(r.liveSha).toBe(SHA_A);
+ expect(r.expectedSha).toBe(SHA_B);
+ });
+
+ it('MISSING live meta: HTTP 404 → throws (cannot conclude fresh)', async () => {
+ await expect(
+ checkFreshness({
+ url: URL,
+ expectedSha: SHA_A,
+ fetchImpl: mockFetch('not found', false, 404),
+ }),
+ ).rejects.toThrow(/HTTP 404/);
+ });
+
+ it('MALFORMED live meta: invalid JSON → throws', async () => {
+ await expect(
+ checkFreshness({ url: URL, expectedSha: SHA_A, fetchImpl: mockFetch('{not json') }),
+ ).rejects.toThrow(/not valid JSON/);
+ });
+
+ it('MALFORMED live meta: valid JSON but fails the dataset-meta schema → throws', () => {
+ expect(() => parseLiveStarsSha(JSON.stringify({ stars_sha256: 'short' }))).toThrow(
+ /schema validation/,
+ );
+ });
+
+ it('NETWORK failure: fetch rejects → throws (never silently "fresh")', async () => {
+ const failing = (async () => {
+ throw new Error('ECONNREFUSED');
+ }) as unknown as typeof fetch;
+ await expect(
+ checkFreshness({ url: URL, expectedSha: SHA_A, fetchImpl: failing }),
+ ).rejects.toThrow(/could not reach/);
+ });
+
+ it('compareFreshness is pure', () => {
+ expect(compareFreshness(SHA_A, SHA_A).status).toBe('fresh');
+ expect(compareFreshness(SHA_A, SHA_B).status).toBe('drift');
+ });
+});
+
+describe('deriveLiveMetaUrl', () => {
+ it('derives a project Pages URL with the owner login lowercased', () => {
+ expect(deriveLiveMetaUrl('F-e-u-e-r/starledger')).toBe(
+ 'https://f-e-u-e-r.github.io/starledger/dataset-meta.json',
+ );
+ });
+
+ it('returns undefined for a missing or malformed slug', () => {
+ expect(deriveLiveMetaUrl(undefined)).toBeUndefined();
+ expect(deriveLiveMetaUrl('noslash')).toBeUndefined();
+ expect(deriveLiveMetaUrl('/repo')).toBeUndefined();
+ expect(deriveLiveMetaUrl('owner/')).toBeUndefined();
+ });
+});
+
+describe('evaluateDeployFreshness (CLI flow, verified main-HEAD fingerprint)', () => {
+ const url = 'https://example.github.io/repo/dataset-meta.json';
+
+ function fixtureDir(): { dir: string; sha: string } {
+ const dir = mkdtempSync(join(tmpdir(), 'freshness-cli-'));
+ writeFixtureDataset(dir);
+ const sha = (
+ JSON.parse(readFileSync(join(dir, 'dataset-meta.json'), 'utf8')) as {
+ stars_sha256: string;
+ }
+ ).stars_sha256;
+ return { dir, sha };
+ }
+
+ function liveMeta(sha: string): string {
+ return JSON.stringify({
+ schema_version: '1.0',
+ dataset_generated_at: '2026-07-07T00:00:00.000Z',
+ stars_sha256: sha,
+ repo_count: 1,
+ });
+ }
+
+ it('FRESH: live fingerprint equals the re-hashed main-HEAD dataset', async () => {
+ const { dir, sha } = fixtureDir();
+ const outcome = await evaluateDeployFreshness({
+ dataDir: dir,
+ url,
+ fetchImpl: mockFetch(liveMeta(sha)),
+ });
+ expect(outcome.status).toBe('fresh');
+ expect(outcome.expectedSha).toBe(sha);
+ expect(outcome.url).toBe(url);
+ });
+
+ it('DRIFT: live fingerprint differs from main HEAD', async () => {
+ const { dir, sha } = fixtureDir();
+ const outcome = await evaluateDeployFreshness({
+ dataDir: dir,
+ url,
+ fetchImpl: mockFetch(liveMeta('f'.repeat(64))),
+ });
+ expect(outcome.status).toBe('drift');
+ expect(outcome.expectedSha).toBe(sha);
+ expect(outcome.liveSha).toBe('f'.repeat(64));
+ });
+
+ it('throws when the committed dataset is absent (fail-closed)', async () => {
+ const empty = mkdtempSync(join(tmpdir(), 'freshness-empty-'));
+ await expect(evaluateDeployFreshness({ dataDir: empty, url })).rejects.toThrow(/not found/);
+ });
+
+ it('throws when the URL cannot be derived and none is passed', async () => {
+ const { dir } = fixtureDir();
+ await expect(evaluateDeployFreshness({ dataDir: dir, repoSlug: undefined })).rejects.toThrow(
+ /could not derive the live URL/,
+ );
+ });
+
+ it('propagates an unreachable live site (never silently "fresh")', async () => {
+ const { dir } = fixtureDir();
+ const failing = (async () => {
+ throw new Error('ECONNREFUSED');
+ }) as unknown as typeof fetch;
+ await expect(
+ evaluateDeployFreshness({ dataDir: dir, url, fetchImpl: failing }),
+ ).rejects.toThrow(/could not reach/);
+ });
+});
diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts
index c54aa27..7a1aaec 100644
--- a/packages/deploy/tests/verify.test.ts
+++ b/packages/deploy/tests/verify.test.ts
@@ -8,7 +8,7 @@ import { staticSmoke, verifyBuiltArtifact } from '../src/verify';
// Mirror the production index.html CSP meta so the fixture exercises SEC-B.
const CSP_META =
- "";
+ "";
function builtDist(base = '/repo/') {
const root = mkdtempSync(join(tmpdir(), 'verify-'));
@@ -72,4 +72,115 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => {
/Content-Security-Policy/,
);
});
+
+ it('SEC-B: a CSP that reintroduces the meta-ineffective frame-ancestors is rejected', () => {
+ const root = mkdtempSync(join(tmpdir(), 'verify-'));
+ const dataDir = join(root, 'data');
+ const distDir = join(root, 'dist');
+ mkdirSync(dataDir);
+ mkdirSync(join(distDir, 'assets'), { recursive: true });
+ writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n');
+ const cspWithFrameAncestors =
+ "";
+ writeFileSync(
+ join(distDir, 'index.html'),
+ `${cspWithFrameAncestors}`,
+ );
+ writeFixtureDataset(dataDir);
+ stageDashboardData({ dataDir, distDir });
+ expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/frame-ancestors/);
+ });
+
+ it('SEC-B: a commented-out CSP meta is rejected (the browser does not enforce it)', () => {
+ const root = mkdtempSync(join(tmpdir(), 'verify-'));
+ const dataDir = join(root, 'data');
+ const distDir = join(root, 'dist');
+ mkdirSync(dataDir);
+ mkdirSync(join(distDir, 'assets'), { recursive: true });
+ writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n');
+ // Well-formed except the CSP meta is commented out — an inert, unenforced policy.
+ writeFileSync(
+ join(distDir, 'index.html'),
+ ``,
+ );
+ writeFixtureDataset(dataDir);
+ stageDashboardData({ dataDir, distDir });
+ expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(
+ /Content-Security-Policy/,
+ );
+ });
+
+ it('SEC-B: directives cannot be borrowed from a later meta tag (split-tag bypass)', () => {
+ const root = mkdtempSync(join(tmpdir(), 'verify-'));
+ const dataDir = join(root, 'data');
+ const distDir = join(root, 'dist');
+ mkdirSync(dataDir);
+ mkdirSync(join(distDir, 'assets'), { recursive: true });
+ writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n');
+ // The CSP meta itself has NO content; a later decoy meta carries directives.
+ // The browser enforces nothing from the empty CSP meta, so this must fail.
+ const splitTag =
+ '';
+ writeFileSync(
+ join(distDir, 'index.html'),
+ `${splitTag}`,
+ );
+ writeFixtureDataset(dataDir);
+ stageDashboardData({ dataDir, distDir });
+ expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/content attribute/);
+ });
+
+ // Build a staged dist whose contains exactly `headHtml`, for CSP tests.
+ function distWithHead(headHtml: string): string {
+ const root = mkdtempSync(join(tmpdir(), 'verify-'));
+ const dataDir = join(root, 'data');
+ const distDir = join(root, 'dist');
+ mkdirSync(dataDir);
+ mkdirSync(join(distDir, 'assets'), { recursive: true });
+ writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n');
+ writeFileSync(
+ join(distDir, 'index.html'),
+ `${headHtml}`,
+ );
+ writeFixtureDataset(dataDir);
+ stageDashboardData({ dataDir, distDir });
+ return distDir;
+ }
+
+ it('SEC-B: a non-enforcing Content-Security-Policy-Report-Only meta is rejected', () => {
+ const reportOnly = CSP_META.replace(
+ 'http-equiv="Content-Security-Policy"',
+ 'http-equiv="Content-Security-Policy-Report-Only"',
+ );
+ expect(() =>
+ verifyBuiltArtifact({ distDir: distWithHead(reportOnly), base: '/repo/' }),
+ ).toThrow(/enforcing Content-Security-Policy/);
+ });
+
+ it("SEC-B: a weakened script-src (adds 'unsafe-inline') is rejected", () => {
+ const weakened = CSP_META.replace("script-src 'self'", "script-src 'self' 'unsafe-inline'");
+ expect(() => verifyBuiltArtifact({ distDir: distWithHead(weakened), base: '/repo/' })).toThrow(
+ /unsafe-inline/,
+ );
+ });
+
+ it('SEC-B: decoy data-* attributes do not satisfy the CSP check', () => {
+ const decoy = CSP_META.replace('http-equiv=', 'data-http-equiv=').replace(
+ 'content=',
+ 'data-content=',
+ );
+ expect(() => verifyBuiltArtifact({ distDir: distWithHead(decoy), base: '/repo/' })).toThrow(
+ /enforcing Content-Security-Policy/,
+ );
+ });
+
+ it('SEC-B: a duplicate directive (weakened first, pinned second) is rejected', () => {
+ const dup = CSP_META.replace(
+ "script-src 'self'",
+ "script-src 'self' 'unsafe-inline'; script-src 'self'",
+ );
+ expect(() => verifyBuiltArtifact({ distDir: distWithHead(dup), base: '/repo/' })).toThrow(
+ /duplicate "script-src"/,
+ );
+ });
});