diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index deeaae7..771620f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,19 @@ The dev server runs the Vite app at `http://localhost:5173`. The Netlify Vite pl platform locally, wiring the `/api/*` routes for the audit stream, report links, and daily package-trust tracking API, hooking up Netlify Database, etc. +To populate the local database with a repeatable 28-day trust history for the fictional `acme` +organization, leave the dev server running and run: + +```bash +pnpm run db:seed:local +``` + +Then enter `acme` in the organization field or open `/report/dev-example-acme-2026-07-19`. Daily +tracking is already enabled for the seeded org, so the shared report shows the disabled tracking +state. The seed +uses Netlify's local database connection and is kept outside `netlify/database/migrations`, so it is +never applied by a deploy. + ## Before changing behaviour - Preserve partial-failure visibility. A failed or rate-limited fetch must be recorded and surfaced, diff --git a/db/seed-local.ts b/db/seed-local.ts new file mode 100644 index 0000000..5d16ced --- /dev/null +++ b/db/seed-local.ts @@ -0,0 +1,250 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + EXAMPLE_ORG, + EXAMPLE_TOTAL_PACKAGES, + EXAMPLE_TRUST_HISTORY, +} from "../src/lib/exampleTrustHistory.ts"; + +function snapshotValues(): string { + return EXAMPLE_TRUST_HISTORY.map( + ({ capturedAt, strong, any }, index) => + `(${index}, '${capturedAt}'::timestamp, ${strong}, ${any})`, + ).join(",\n "); +} + +function snapshotsCte(): string { + return ` + snapshots (snapshot_index, captured_at, strong_count, any_count) AS ( + VALUES + ${snapshotValues()} + ), + prepared AS ( + SELECT + snapshot_index, + captured_at, + strong_count, + any_count, + strong_count / 3 AS staged_publish, + strong_count - (strong_count / 3) AS trusted_publisher, + any_count - strong_count AS provenance, + ${EXAMPLE_TOTAL_PACKAGES} - any_count AS none + FROM snapshots + )`; +} + +export function buildExampleSeedSql(): string { + const values = snapshotsCte(); + const reportId = `'dev-example-${EXAMPLE_ORG}-' || to_char(captured_at, 'YYYY-MM-DD')`; + const latestCapturedAt = EXAMPLE_TRUST_HISTORY.at(-1)!.capturedAt; + const latestReportId = `dev-example-${EXAMPLE_ORG}-${latestCapturedAt.slice(0, 10)}`; + + return `BEGIN; + +WITH${values} +INSERT INTO reports (id, orgs, scope_label, payload, created_at) +SELECT + ${reportId}, + '${EXAMPLE_ORG}', + 'ALL org packages', + jsonb_build_object( + 'trust', jsonb_build_object( + 'rows', ( + SELECT jsonb_agg( + jsonb_build_object( + 'pkg', format('@${EXAMPLE_ORG}/package-%s', lpad(package_number::text, 2, '0')), + 'latestPublish', to_char( + captured_at - make_interval(days => package_number % 21), + 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"' + ), + 'version', format('1.%s.%s', snapshot_index, package_number), + 'level', CASE + WHEN package_number <= staged_publish THEN 'stagedPublish' + WHEN package_number <= strong_count THEN 'trustedPublisher' + WHEN package_number <= any_count THEN 'provenance' + ELSE 'none' + END, + 'provenance', package_number > strong_count AND package_number <= any_count, + 'trustedPublisher', package_number > staged_publish AND package_number <= strong_count, + 'stagedPublish', package_number <= staged_publish, + 'publisher', CASE + WHEN package_number <= staged_publish THEN 'release-bot' + WHEN package_number <= strong_count THEN 'github-actions' + WHEN package_number <= any_count THEN 'ci' + ELSE format('maintainer-%s', (package_number % 4) + 1) + END, + 'deprecated', package_number > ${EXAMPLE_TOTAL_PACKAGES - 3}, + 'downloads', 1250000 - package_number * 13000 + ) ORDER BY package_number + ) + FROM generate_series(1, ${EXAMPLE_TOTAL_PACKAGES}) AS packages(package_number) + ), + 'summary', jsonb_build_object( + 'scopeLabel', 'ALL org packages', + 'orgs', jsonb_build_array('${EXAMPLE_ORG}'), + 'total', ${EXAMPLE_TOTAL_PACKAGES}, + 'provenance', provenance, + 'trustedPublisher', trusted_publisher, + 'stagedPublish', staged_publish, + 'deprecated', 3, + 'byLevel', jsonb_build_object( + 'stagedPublish', staged_publish, + 'trustedPublisher', trusted_publisher, + 'provenance', provenance, + 'none', none + ) + ) + ), + 'failures', '[]'::jsonb + ) || CASE + WHEN captured_at = '${latestCapturedAt}'::timestamp THEN jsonb_build_object( + 'manual', jsonb_build_object( + 'rows', jsonb_build_array( + jsonb_build_object( + 'when', '2026-07-18T08:42:10.000Z', + 'who', 'release-admin', + 'ref', '@${EXAMPLE_ORG}/package-41@1.27.41' + ), + jsonb_build_object( + 'when', '2026-07-11T16:05:33.000Z', + 'who', 'maintainer-2', + 'ref', '@${EXAMPLE_ORG}/package-58@1.26.58' + ), + jsonb_build_object( + 'when', '2026-06-29T13:20:04.000Z', + 'who', 'release-admin', + 'ref', '@${EXAMPLE_ORG}/package-17@1.26.17' + ) + ), + 'totalScanned', 184, + 'bots', jsonb_build_array('GitHub Actions'), + 'byPublisher', jsonb_build_array( + jsonb_build_object('who', 'release-admin', 'count', 2), + jsonb_build_object('who', 'maintainer-2', 'count', 1) + ) + ), + 'external', jsonb_build_object( + 'rows', jsonb_build_array( + jsonb_build_object('user', 'former-contractor', 'pkg', '@${EXAMPLE_ORG}/package-52'), + jsonb_build_object('user', 'former-contractor', 'pkg', '@${EXAMPLE_ORG}/package-59'), + jsonb_build_object('user', 'community-maintainer', 'pkg', '@${EXAMPLE_ORG}/package-44') + ), + 'distinctUsers', 2, + 'byUser', jsonb_build_array( + jsonb_build_object('user', 'former-contractor', 'count', 2), + jsonb_build_object('user', 'community-maintainer', 'count', 1) + ) + ) + ) + ELSE '{}'::jsonb + END, + captured_at +FROM prepared +ON CONFLICT (id) DO UPDATE SET + orgs = EXCLUDED.orgs, + scope_label = EXCLUDED.scope_label, + payload = EXCLUDED.payload, + created_at = EXCLUDED.created_at; + +WITH${values} +INSERT INTO report_trust_history ( + report_id, + org_key, + orgs_json, + captured_at, + total, + staged_publish, + trusted_publisher, + provenance, + none, + deprecated, + failure_count +) +SELECT + ${reportId}, + '${EXAMPLE_ORG}', + jsonb_build_array('${EXAMPLE_ORG}'), + captured_at, + ${EXAMPLE_TOTAL_PACKAGES}, + staged_publish, + trusted_publisher, + provenance, + none, + 3, + 0 +FROM prepared +ON CONFLICT (report_id) DO UPDATE SET + org_key = EXCLUDED.org_key, + orgs_json = EXCLUDED.orgs_json, + captured_at = EXCLUDED.captured_at, + total = EXCLUDED.total, + staged_publish = EXCLUDED.staged_publish, + trusted_publisher = EXCLUDED.trusted_publisher, + provenance = EXCLUDED.provenance, + none = EXCLUDED.none, + deprecated = EXCLUDED.deprecated, + failure_count = EXCLUDED.failure_count; + +INSERT INTO report_rerun_schedules ( + org_key, + orgs_json, + enabled, + next_run_at, + last_run_at, + last_report_id, + last_error, + consecutive_failures, + updated_at +) VALUES ( + '${EXAMPLE_ORG}', + jsonb_build_array('${EXAMPLE_ORG}'), + true, + now() + interval '1 day', + '${latestCapturedAt}'::timestamp, + '${latestReportId}', + NULL, + 0, + now() +) +ON CONFLICT (org_key) DO UPDATE SET + orgs_json = EXCLUDED.orgs_json, + enabled = EXCLUDED.enabled, + next_run_at = EXCLUDED.next_run_at, + last_run_at = EXCLUDED.last_run_at, + last_report_id = EXCLUDED.last_report_id, + last_error = EXCLUDED.last_error, + consecutive_failures = EXCLUDED.consecutive_failures, + updated_at = EXCLUDED.updated_at; + +COMMIT;`; +} + +function main(): void { + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const result = spawnSync( + pnpm, + ["dlx", "netlify@latest", "database", "connect", "--json", "--query", buildExampleSeedSql()], + { encoding: "utf8", maxBuffer: 2 * 1024 * 1024 }, + ); + + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Netlify Database seed failed").trim()); + } + + console.log( + `Seeded ${EXAMPLE_TRUST_HISTORY.length} local ${EXAMPLE_ORG} trust snapshots. ` + + `Daily tracking is enabled. Open /report/dev-example-${EXAMPLE_ORG}-2026-07-19 ` + + `or enter "${EXAMPLE_ORG}" in the app.`, + ); +} + +const entry = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (import.meta.url === entry) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/netlify/tests/functions/local-seed.test.ts b/netlify/tests/functions/local-seed.test.ts new file mode 100644 index 0000000..f8f986f --- /dev/null +++ b/netlify/tests/functions/local-seed.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import type { DatabaseConnection } from "@netlify/database"; +import type { NetlifyDB } from "@netlify/database-dev"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + parseRows, + ReportRerunScheduleRowSchema, + ReportRowSchema, + ReportTrustHistoryRowSchema, +} from "../../../db/schema.js"; +import { buildExampleSeedSql } from "../../../db/seed-local.js"; +import { EXAMPLE_TRUST_HISTORY } from "../../../src/lib/exampleTrustHistory.js"; +import { parseOrNull, AuditResultSchema } from "../../../src/lib/schemas.js"; +import { resetTestDatabase, startTestDatabase, stopTestDatabase } from "../database.js"; + +let database: DatabaseConnection; +let local: NetlifyDB; + +beforeAll(async () => { + const started = await startTestDatabase(); + local = started.local; + const { getDatabase } = await import("@netlify/database"); + database = getDatabase({ connectionString: started.connectionString }); +}); + +beforeEach(async () => resetTestDatabase(database)); + +afterAll(async () => stopTestDatabase(local, database)); + +describe("local example data", () => { + it("seeds a repeatable, internally consistent trust history", async () => { + await database.pool.query(buildExampleSeedSql()); + await database.pool.query(buildExampleSeedSql()); + + const reports = parseRows( + ReportRowSchema, + await database.sql` + SELECT + id, + orgs, + scope_label AS "scopeLabel", + payload, + created_at AS "createdAt" + FROM reports + ORDER BY created_at ASC + `, + ); + const history = parseRows( + ReportTrustHistoryRowSchema, + await database.sql` + SELECT + report_id AS "reportId", + org_key AS "orgKey", + orgs_json AS orgs, + captured_at AS "capturedAt", + total, + staged_publish AS "stagedPublish", + trusted_publisher AS "trustedPublisher", + provenance, + none, + deprecated, + failure_count AS "failureCount" + FROM report_trust_history + ORDER BY captured_at ASC + `, + ); + const schedules = parseRows( + ReportRerunScheduleRowSchema, + await database.sql` + SELECT + org_key AS "orgKey", + orgs_json AS orgs, + enabled, + next_run_at AS "nextRunAt", + last_run_at AS "lastRunAt", + last_report_id AS "lastReportId", + last_error AS "lastError", + consecutive_failures AS "consecutiveFailures" + FROM report_rerun_schedules + `, + ); + + expect(reports).toHaveLength(EXAMPLE_TRUST_HISTORY.length); + expect(history).toHaveLength(EXAMPLE_TRUST_HISTORY.length); + expect(schedules).toHaveLength(1); + expect(schedules[0]).toMatchObject({ + orgKey: "acme", + orgs: ["acme"], + enabled: true, + lastReportId: "dev-example-acme-2026-07-19", + consecutiveFailures: 0, + }); + for (const point of history) { + expect(point.stagedPublish + point.trustedPublisher + point.provenance + point.none).toBe( + point.total, + ); + } + for (let index = 1; index < history.length; index++) { + const previous = history[index - 1]; + const current = history[index]; + expect(current.stagedPublish + current.trustedPublisher).toBeGreaterThanOrEqual( + previous.stagedPublish + previous.trustedPublisher, + ); + expect(current.total - current.none).toBeGreaterThanOrEqual(previous.total - previous.none); + } + + expect(history.at(-1)).toMatchObject({ + total: 60, + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }); + expect( + history.slice(20, 22).map((point) => ({ + day: point.capturedAt.toISOString().slice(0, 10), + strong: point.stagedPublish + point.trustedPublisher, + any: point.total - point.none, + })), + ).toEqual([ + { day: "2026-07-12", strong: 19, any: 31 }, + { day: "2026-07-13", strong: 22, any: 36 }, + ]); + + const latest = reports.at(-1); + const payload = latest ? parseOrNull(AuditResultSchema, latest.payload) : null; + expect(payload?.trust?.rows).toHaveLength(60); + expect(payload?.trust?.summary.byLevel).toEqual({ + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }); + expect(payload?.manual).toMatchObject({ + totalScanned: 184, + bots: ["GitHub Actions"], + byPublisher: [ + { who: "release-admin", count: 2 }, + { who: "maintainer-2", count: 1 }, + ], + }); + expect(payload?.manual?.rows).toHaveLength(3); + expect(payload?.external).toMatchObject({ + distinctUsers: 2, + byUser: [ + { user: "former-contractor", count: 2 }, + { user: "community-maintainer", count: 1 }, + ], + }); + expect(payload?.external?.rows).toHaveLength(3); + }); +}); diff --git a/package.json b/package.json index eedc787..09a209c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "vite build", "build:analyze": "rm -rf .sonda/ && SONDA=true vite build", + "db:seed:local": "node --experimental-strip-types db/seed-local.ts", "dev": "vite", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/src/components/SamplePreview.svelte b/src/components/SamplePreview.svelte index 7d6cae5..3aaea89 100644 --- a/src/components/SamplePreview.svelte +++ b/src/components/SamplePreview.svelte @@ -1,5 +1,8 @@
@@ -101,31 +73,23 @@ Example report -
+
- +

Every package owned by acme, tracked daily.

+