From 9ca6a0c1cd207c9bd3a6d7091c367b5a26251f76 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 23 Aug 2026 19:57:22 -0400 Subject: [PATCH] feat: add dynamic OG image for reports --- db/schema.ts | 34 ++ netlify/_shared/og-image.ts | 5 + netlify/edge-functions/report-social-meta.ts | 74 +++ netlify/functions/_shared/og-cards.ts | 482 ++++++++++++++++++ netlify/functions/_shared/report-social.ts | 69 +++ netlify/functions/report-og.ts | 94 ++++ .../edge-functions/report-social-meta.test.ts | 43 ++ netlify/tests/functions/report-social.test.ts | 155 ++++++ netlify/tests/report-og.test.ts | 178 +++++++ package.json | 1 + pnpm-lock.yaml | 148 ++++++ 11 files changed, 1283 insertions(+) create mode 100644 netlify/_shared/og-image.ts create mode 100644 netlify/edge-functions/report-social-meta.ts create mode 100644 netlify/functions/_shared/og-cards.ts create mode 100644 netlify/functions/_shared/report-social.ts create mode 100644 netlify/functions/report-og.ts create mode 100644 netlify/tests/edge-functions/report-social-meta.test.ts create mode 100644 netlify/tests/functions/report-social.test.ts create mode 100644 netlify/tests/report-og.test.ts diff --git a/db/schema.ts b/db/schema.ts index e246f6e..0e65013 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -14,6 +14,40 @@ export const ReportRowSchema = v.object({ }); export type ReportRow = v.InferOutput; +const ReportSocialCountSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +const ReportSocialByLevelSchema = v.object({ + stagedPublish: ReportSocialCountSchema, + trustedPublisher: ReportSocialCountSchema, + provenance: ReportSocialCountSchema, + none: ReportSocialCountSchema, +}); +const ReportSocialTrustSchema = v.object({ + total: ReportSocialCountSchema, + byLevel: ReportSocialByLevelSchema, +}); +const reportSocialFields = { + id: v.string(), + orgs: v.string(), + createdAt: v.nullable(v.date()), + trust: v.nullable(ReportSocialTrustSchema), +}; + +export const ReportSocialBaseRowSchema = v.object(reportSocialFields); +export type ReportSocialBaseRow = v.InferOutput; + +export const ReportSocialRowSchema = v.object({ + ...reportSocialFields, + history: v.array( + v.object({ + id: v.string(), + capturedAt: v.date(), + total: ReportSocialCountSchema, + byLevel: ReportSocialByLevelSchema, + }), + ), +}); +export type ReportSocialRow = v.InferOutput; + // Schedule state is joined at read time rather than stored on the report row. export const SharedReportRowSchema = v.object({ id: v.string(), diff --git a/netlify/_shared/og-image.ts b/netlify/_shared/og-image.ts new file mode 100644 index 0000000..1b60342 --- /dev/null +++ b/netlify/_shared/og-image.ts @@ -0,0 +1,5 @@ +export const OG_LOGICAL_WIDTH = 1_200; +export const OG_LOGICAL_HEIGHT = 630; +export const OG_DEVICE_PIXEL_RATIO = 2; +export const OG_WIDTH = OG_LOGICAL_WIDTH * OG_DEVICE_PIXEL_RATIO; +export const OG_HEIGHT = OG_LOGICAL_HEIGHT * OG_DEVICE_PIXEL_RATIO; diff --git a/netlify/edge-functions/report-social-meta.ts b/netlify/edge-functions/report-social-meta.ts new file mode 100644 index 0000000..01e53df --- /dev/null +++ b/netlify/edge-functions/report-social-meta.ts @@ -0,0 +1,74 @@ +import type { Config, Context } from "@netlify/edge-functions"; +import { OG_HEIGHT, OG_WIDTH } from "../_shared/og-image.ts"; + +const SOCIAL_META_BLOCK = /[\s\S]*?/; +const TRAILING_SLASH = /\/$/; + +function escapeAttribute(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +export function reportSocialMeta(requestUrl: URL): string { + const canonicalUrl = new URL(requestUrl.pathname, requestUrl.origin).toString(); + const encodedId = requestUrl.pathname.slice("/report/".length).replace(TRAILING_SLASH, ""); + const imageUrl = new URL(`/og/report/${encodedId}`, requestUrl.origin); + const title = "npm supply-chain audit report"; + const description = "A read-only npm supply-chain audit snapshot on npm.report."; + + return [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n "); +} + +export function injectReportSocialMeta(html: string, requestUrl: URL): string { + return html.replace(SOCIAL_META_BLOCK, reportSocialMeta(requestUrl)); +} + +export default async (request: Request, context: Context): Promise => { + const response = await context.next(); + if (!response.ok || !response.headers.get("content-type")?.includes("text/html")) return response; + + const html = injectReportSocialMeta(await response.text(), new URL(request.url)); + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + headers.delete("etag"); + + return new Response(html, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +export const config: Config = { + path: "/report/:id", +}; diff --git a/netlify/functions/_shared/og-cards.ts b/netlify/functions/_shared/og-cards.ts new file mode 100644 index 0000000..304280c --- /dev/null +++ b/netlify/functions/_shared/og-cards.ts @@ -0,0 +1,482 @@ +import type { ReportSocialRow } from "#db/schema"; +import { OG_LOGICAL_HEIGHT, OG_LOGICAL_WIDTH } from "#server/og-image"; + +export const OG_LOGO_SOURCE = "app-logo"; + +export const OG_CARD_STYLES = ` + * { box-sizing: border-box; } + .card { + position: relative; + display: flex; + width: ${OG_LOGICAL_WIDTH}px; + height: ${OG_LOGICAL_HEIGHT}px; + overflow: hidden; + background: #0d1117; + color: #d7dde5; + font-family: "IBM Plex Sans"; + } + .wordmark { + display: flex; + align-items: center; + color: #d7dde5; + font-family: "IBM Plex Mono"; + font-size: 31px; + font-weight: 600; + letter-spacing: -0.7px; + line-height: 1; + } + .wordmark img { + width: 31px; + height: 31px; + margin: 2px 5px 0 8px; + } + .home-card { + flex-direction: column; + padding: 58px 64px 52px; + } + .home-rule { + position: absolute; + top: 119px; + left: 64px; + width: 1072px; + height: 1px; + background: #2b3543; + } + .home-copy { + position: absolute; + top: 184px; + left: 64px; + display: flex; + width: 720px; + flex-direction: column; + gap: 21px; + } + .home-title { + font-size: 61px; + font-weight: 600; + letter-spacing: -1.8px; + line-height: 1.06; + } + .home-subtitle { + display: flex; + align-items: baseline; + gap: 11px; + color: #95a1b0; + font-family: "IBM Plex Mono"; + font-size: 25px; + letter-spacing: -0.3px; + line-height: 1.35; + } + .home-caret { + flex: 0 0 auto; + color: #d7dde5; + } + .home-mark { + position: absolute; + top: 165px; + right: 52px; + width: 310px; + height: 310px; + } + .home-spectrum { + position: absolute; + right: 64px; + bottom: 52px; + left: 64px; + display: flex; + height: 13px; + overflow: hidden; + border: 1px solid #2b3543; + border-radius: 3px; + background: #151b24; + } + .home-spectrum span:nth-child(1) { width: 16%; background: #c9a0dc; } + .home-spectrum span:nth-child(2) { width: 24%; background: #d8a657; } + .home-spectrum span:nth-child(3) { width: 28%; background: #5fb3b3; } + .home-spectrum span:nth-child(4) { width: 32%; background: #e07a5f; } + .report-card { + flex-direction: column; + padding: 50px 56px 48px; + } + .report-card::after { + position: absolute; + inset: 18px; + border: 1px solid #20272f; + border-radius: 4px; + content: ""; + } + .report-top { + display: flex; + align-items: center; + justify-content: space-between; + } + .snapshot { + display: flex; + align-items: baseline; + gap: 7px; + color: #a08049; + font-family: "IBM Plex Mono"; + font-size: 18px; + font-weight: 400; + letter-spacing: 0.3px; + line-height: 1; + white-space: nowrap; + } + .snapshot-packages { + color: #d8a657; + font-weight: 600; + } + .audit-title { + display: flex; + width: 100%; + min-width: 0; + margin-top: 8px; + align-items: center; + gap: 16px; + font-family: "IBM Plex Mono"; + font-size: 50px; + line-height: 1; + } + .audit-caret { + flex: 0 0 auto; + color: #95a1b0; + font-weight: 400; + } + .audit-org { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + color: #d7dde5; + font-size: inherit; + font-weight: 600; + letter-spacing: -0.5px; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; + } + .trend { + position: absolute; + top: 204px; + right: 56px; + left: 56px; + display: flex; + height: 203px; + flex-direction: column; + gap: 5px; + } + .trend-head, + .trend-legend, + .trend-key, + .trend-dates { + display: flex; + align-items: center; + } + .trend-head { + justify-content: space-between; + } + .trend-title, + .trend-legend, + .trend-dates { + color: #758296; + font-family: "IBM Plex Mono"; + font-size: 12px; + line-height: 1; + } + .trend-title { + color: #95a1b0; + } + .trend-legend { + gap: 17px; + } + .trend-key { + gap: 6px; + } + .trend-key-line { + width: 18px; + border-top: 2px solid; + } + .trend-key-line--strong { border-color: #8fbf7f; } + .trend-key-line--any { border-color: #5fb3b3; border-top-style: dashed; } + .trend-key-line--none { border-color: #e07a5f; border-top-style: dotted; } + .trend svg { + display: block; + width: 100%; + height: 162px; + } + .trend-dates { + justify-content: space-between; + } + .trust-block { + position: absolute; + right: 56px; + bottom: 48px; + left: 56px; + display: flex; + flex-direction: column; + } + .strong-summary { + display: flex; + width: 530px; + height: 16px; + align-items: center; + justify-content: center; + gap: 9px; + color: #8fbf7f; + font-family: "IBM Plex Mono"; + font-size: 12px; + font-weight: 600; + line-height: 1; + } + .strong-summary-line { + display: flex; + width: 100%; + justify-content: center; + padding-bottom: 3px; + border-bottom: 1px solid #8fbf7f; + } + .distribution { + display: flex; + height: 14px; + margin-top: 12px; + overflow: hidden; + border: 1px solid #2b3543; + border-radius: 3px; + background: #151b24; + } + .metrics { + display: flex; + margin-top: 7px; + gap: 12px; + } + .metric { + display: flex; + width: 259px; + height: 111px; + padding: 14px 18px 13px; + border: 1px solid #2b3543; + border-radius: 4px; + background: #11161e; + flex-direction: column; + justify-content: space-between; + } + .metric-label { + color: #95a1b0; + font-family: "IBM Plex Mono"; + font-size: 15px; + font-weight: 400; + letter-spacing: 1.2px; + line-height: 1.25; + text-transform: uppercase; + } + .metric-values { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + color: #d7dde5; + } + .metric-count { + color: inherit; + font-family: "IBM Plex Mono"; + font-size: 42px; + font-weight: 600; + line-height: 1; + } + .metric-percent { + color: inherit; + font-family: "IBM Plex Mono"; + font-size: 28px; + font-weight: 400; + line-height: 1; + } + .metric--staged .metric-values { color: #c9a0dc; } + .metric--trusted .metric-values { color: #d8a657; } + .metric--provenance .metric-values { color: #5fb3b3; } + .metric--risk { border-color: rgba(224, 122, 95, 0.4); } + .metric--risk .metric-values { color: #e07a5f; } + .plain-mark { + position: absolute; + right: 56px; + bottom: 48px; + width: 190px; + height: 190px; + } + .plain-rule { + position: absolute; + right: 285px; + bottom: 62px; + left: 56px; + height: 2px; + background: #d8a657; + } +`; + +const TRUST_LEVELS = [ + { + key: "stagedPublish", + label: "Staged publish", + color: "#c9a0dc", + variant: "staged", + }, + { + key: "trustedPublisher", + label: "Trusted publisher", + color: "#d8a657", + variant: "trusted", + }, + { + key: "provenance", + label: "Provenance only", + color: "#5fb3b3", + variant: "provenance", + }, + { key: "none", label: "No trust signal", color: "#e07a5f", variant: "risk" }, +] as const; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function wordmark(): string { + return `
npmreport
`; +} + +function percentage(count: number, total: number): string { + const value = total > 0 ? (count / total) * 100 : 0; + return `${value.toFixed(Number.isInteger(value) ? 0 : 1)}%`; +} + +function widthPercentage(count: number, total: number): string { + return total > 0 ? `${(count / total) * 100}%` : "0%"; +} + +function shortDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function auditedOn(date: Date | null): string { + if (!date) return "audited"; + const iso = date.toISOString(); + return `audited on ${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`; +} + +function snapshotHtml(report: ReportSocialRow): string { + const packages = report.trust + ? `${report.trust.total.toLocaleString("en-US")} packages` + : ""; + return `${packages}${auditedOn(report.createdAt)}`; +} + +function trendHtml(history: ReportSocialRow["history"]): string { + if (history.length <= 1) return ""; + + const width = 1088; + const plotTop = 3; + const plotBottom = 61; + const firstTime = history[0].capturedAt.getTime(); + const lastTime = history.at(-1)!.capturedAt.getTime(); + const xAt = (index: number): number => { + if (lastTime <= firstTime) return (index / (history.length - 1)) * width; + return ((history[index].capturedAt.getTime() - firstTime) / (lastTime - firstTime)) * width; + }; + const yAt = (value: number): number => plotTop + (1 - value / 100) * (plotBottom - plotTop); + const line = (count: (point: ReportSocialRow["history"][number]) => number): string => + history + .map((point, index) => { + const value = point.total > 0 ? (count(point) / point.total) * 100 : 0; + return `${xAt(index).toFixed(1)},${yAt(value).toFixed(1)}`; + }) + .join(" "); + + const strong = line((point) => point.byLevel.stagedPublish + point.byLevel.trustedPublisher); + const any = line((point) => point.total - point.byLevel.none); + const none = line((point) => point.byLevel.none); + const grid = [0, 50, 100] + .map( + (value) => + ``, + ) + .join(""); + + return `
+
+ Progress over time +
+ Strong trust + Any trust + No trust signal +
+
+ ${grid} + + + + +
${shortDate(history[0].capturedAt)}${shortDate(history.at(-1)!.capturedAt)}
+
`; +} + +export function homeCardHtml(): string { + return `
+ ${wordmark()} +
+
+
Supply-chain trust signals
for npm orgs.
+
>Audit, visualize, share, track over time.
+
+ +
+
`; +} + +export function reportCardHtml(report: ReportSocialRow): string { + const orgLabel = report.orgs.length > 96 ? `${report.orgs.slice(0, 95)}…` : report.orgs; + const safeOrgs = escapeHtml(orgLabel); + const titleSize = orgLabel.length > 36 ? 30 : orgLabel.length > 20 ? 40 : 50; + const title = `
+ >${safeOrgs} +
`; + + if (!report.trust) { + return `
+
${wordmark()}
${snapshotHtml(report)}
+ ${title} +
+
`; + } + + const { total, byLevel } = report.trust; + const distribution = TRUST_LEVELS.map( + (level) => + ``, + ).join(""); + const strongTrust = byLevel.stagedPublish + byLevel.trustedPublisher; + const strongSummary = `
Strong trust · ${strongTrust.toLocaleString("en-US")} · ${percentage(strongTrust, total)}
`; + const metrics = TRUST_LEVELS.map((level) => { + const count = byLevel[level.key]; + return `
+
${level.label}
+
${count.toLocaleString("en-US")}${percentage(count, total)}
+
`; + }).join(""); + + return `
+
${wordmark()}
${snapshotHtml(report)}
+ ${title} + ${trendHtml(report.history)} +
${strongSummary}
${metrics}
${distribution}
+
`; +} diff --git a/netlify/functions/_shared/report-social.ts b/netlify/functions/_shared/report-social.ts new file mode 100644 index 0000000..b22c4fc --- /dev/null +++ b/netlify/functions/_shared/report-social.ts @@ -0,0 +1,69 @@ +import { getDb } from "#db/index"; +import { + parseRows, + ReportSocialBaseRowSchema, + ReportTrustHistoryRowSchema, + type ReportSocialRow, +} from "#db/schema"; + +export async function getReportSocialData(id: string): Promise { + const db = getDb(); + const [row] = parseRows( + ReportSocialBaseRowSchema, + await db.sql` + SELECT + id, + orgs, + created_at AS "createdAt", + CASE + WHEN payload ? 'trust' THEN jsonb_build_object( + 'total', payload #> '{trust,summary,total}', + 'byLevel', payload #> '{trust,summary,byLevel}' + ) + ELSE NULL + END AS trust + FROM reports + WHERE id = ${id} + `, + ); + if (!row) return null; + if (!row.trust) return { ...row, history: [] }; + + const history = parseRows( + ReportTrustHistoryRowSchema, + await db.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 + WHERE org_key = ( + SELECT org_key + FROM report_trust_history + WHERE report_id = ${id} + ) + ORDER BY captured_at ASC + LIMIT 100 + `, + ).map((point) => ({ + id: point.reportId, + capturedAt: point.capturedAt, + total: point.total, + byLevel: { + stagedPublish: point.stagedPublish, + trustedPublisher: point.trustedPublisher, + provenance: point.provenance, + none: point.none, + }, + })); + + return { ...row, history }; +} diff --git a/netlify/functions/report-og.ts b/netlify/functions/report-og.ts new file mode 100644 index 0000000..86d42af --- /dev/null +++ b/netlify/functions/report-og.ts @@ -0,0 +1,94 @@ +import type { Config } from "@netlify/functions"; +import { readFile } from "node:fs/promises"; +import { render } from "takumi-js"; +import wasmUrl from "takumi-js/wasm-url"; +import type { ReportSocialRow } from "#db/schema"; +import { OG_DEVICE_PIXEL_RATIO, OG_HEIGHT, OG_WIDTH } from "#server/og-image"; +import { homeCardHtml, OG_CARD_STYLES, OG_LOGO_SOURCE, reportCardHtml } from "#node/og-cards"; +import { getReportSocialData } from "#node/report-social"; + +export { homeCardHtml, reportCardHtml } from "#node/og-cards"; + +const REPORT_IMAGE_PATH = /^\/og\/report\/([^/]+)$/; +const assets = Promise.all([ + readFile(new URL("../../src/components/logo.svg", import.meta.url)), + readFile( + new URL( + "../../node_modules/@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2", + import.meta.url, + ), + ), + readFile( + new URL( + "../../node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2", + import.meta.url, + ), + ), + readFile( + new URL( + "../../node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-600-normal.woff2", + import.meta.url, + ), + ), + readFile(wasmUrl), +]); + +function reportIdFromUrl(url: URL): string | null { + const match = REPORT_IMAGE_PATH.exec(url.pathname); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +async function renderCard(html: string): Promise> { + const [logo, plexSans, plexMono400, plexMono600, wasm] = await assets; + return render(html, { + width: OG_WIDTH, + height: OG_HEIGHT, + devicePixelRatio: OG_DEVICE_PIXEL_RATIO, + format: "png", + stylesheets: [OG_CARD_STYLES], + images: [{ src: OG_LOGO_SOURCE, data: logo }], + fonts: [ + { name: "IBM Plex Sans", data: plexSans }, + { name: "IBM Plex Mono", data: plexMono400, weight: 400 }, + { name: "IBM Plex Mono", data: plexMono600, weight: 600 }, + ], + module: wasm, + }); +} + +export function renderHomeCard(): Promise> { + return renderCard(homeCardHtml()); +} + +export function renderReportCard(report: ReportSocialRow): Promise> { + return renderCard(reportCardHtml(report)); +} + +export default async (request: Request): Promise => { + if (request.method !== "GET") return new Response("Method not allowed", { status: 405 }); + + const id = reportIdFromUrl(new URL(request.url)); + if (!id) return new Response("Not found", { status: 404 }); + + const report = await getReportSocialData(id); + if (!report) return new Response("Not found", { status: 404 }); + + const image = await renderReportCard(report); + + return new Response(image, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=0, must-revalidate", + "Netlify-CDN-Cache-Control": "public, durable, max-age=86400, stale-while-revalidate=604800", + }, + }); +}; + +export const config: Config = { + path: "/og/report/:id", +}; diff --git a/netlify/tests/edge-functions/report-social-meta.test.ts b/netlify/tests/edge-functions/report-social-meta.test.ts new file mode 100644 index 0000000..f823f90 --- /dev/null +++ b/netlify/tests/edge-functions/report-social-meta.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + injectReportSocialMeta, + reportSocialMeta, +} from "../../edge-functions/report-social-meta.ts"; + +describe("report social metadata", () => { + it("points report previews at the report-specific PNG", () => { + const meta = reportSocialMeta( + new URL("https://deploy-preview-12--npm-report.netlify.app/report/acme-2026-08-23-abc?x=1"), + ); + + expect(meta).toContain( + 'property="og:image" content="https://deploy-preview-12--npm-report.netlify.app/og/report/acme-2026-08-23-abc"', + ); + expect(meta).toContain( + 'property="og:url" content="https://deploy-preview-12--npm-report.netlify.app/report/acme-2026-08-23-abc"', + ); + expect(meta).toContain('name="twitter:card" content="summary_large_image"'); + expect(meta).not.toContain("?x=1"); + }); + + it("replaces the social metadata marker", () => { + const html = ` + + + + `; + const result = injectReportSocialMeta( + html, + new URL("https://npm.report/report/acme-2026-08-23-abc"), + ); + + expect(result).not.toContain(""); + expect(result).not.toContain("og-home.png"); + expect(result).toContain('property="og:image:width" content="2400"'); + expect(result).toContain('property="og:image:height" content="1260"'); + expect(result).toContain( + 'rel="canonical" href="https://npm.report/report/acme-2026-08-23-abc"', + ); + }); +}); diff --git a/netlify/tests/functions/report-social.test.ts b/netlify/tests/functions/report-social.test.ts new file mode 100644 index 0000000..c6e44cb --- /dev/null +++ b/netlify/tests/functions/report-social.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import type { DatabaseConnection } from "@netlify/database"; +import type { NetlifyDB } from "@netlify/database-dev"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { serializeJson } from "#db/schema"; +import { resetTestDatabase, startTestDatabase, stopTestDatabase } from "../database.js"; + +let database: DatabaseConnection; +let local: NetlifyDB; +let getReportSocialData: typeof import("#node/report-social").getReportSocialData; + +async function insertTrustHistory( + reportId: string, + capturedAt: Date, + byLevel: { + stagedPublish: number; + trustedPublisher: number; + provenance: number; + none: number; + }, +): Promise { + await database.sql` + INSERT INTO report_trust_history ( + report_id, + org_key, + orgs_json, + captured_at, + total, + staged_publish, + trusted_publisher, + provenance, + none, + deprecated, + failure_count + ) VALUES ( + ${reportId}, + ${"acme"}, + ${serializeJson(["acme"])}::jsonb, + ${capturedAt}, + ${60}, + ${byLevel.stagedPublish}, + ${byLevel.trustedPublisher}, + ${byLevel.provenance}, + ${byLevel.none}, + ${0}, + ${0} + ) + `; +} + +beforeAll(async () => { + const started = await startTestDatabase(); + local = started.local; + vi.stubEnv("NETLIFY_DB_URL", started.connectionString); + vi.stubEnv("NETLIFY_DB_DRIVER", "server"); + vi.resetModules(); + database = (await import("#db/index")).getDb(); + ({ getReportSocialData } = await import("#node/report-social")); +}); + +beforeEach(async () => { + await resetTestDatabase(database); +}); + +afterAll(async () => { + await stopTestDatabase(local, database); + vi.unstubAllEnvs(); +}); + +describe("getReportSocialData", () => { + it("projects trust metrics and the matching all-package history without loading report rows", async () => { + const payload = { + trust: { + rows: [{ pkg: "intentionally-not-selected" }], + summary: { + total: 60, + byLevel: { + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }, + }, + }, + failures: [], + }; + const createdAt = new Date("2026-08-23T12:34:56.000Z"); + const previousAt = new Date("2026-08-22T12:34:56.000Z"); + await database.sql` + INSERT INTO reports (id, orgs, scope_label, payload, created_at) + VALUES + ( + ${"acme-previous"}, + ${"acme"}, + ${"ALL org packages"}, + ${serializeJson({ failures: [] })}::jsonb, + ${previousAt} + ), + ( + ${"acme-report"}, + ${"acme"}, + ${"ALL org packages"}, + ${serializeJson(payload)}::jsonb, + ${createdAt} + ) + `; + const previousLevels = { + stagedPublish: 7, + trustedPublisher: 14, + provenance: 14, + none: 25, + }; + const currentLevels = { + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }; + await insertTrustHistory("acme-previous", previousAt, previousLevels); + await insertTrustHistory("acme-report", createdAt, currentLevels); + + await expect(getReportSocialData("acme-report")).resolves.toEqual({ + id: "acme-report", + orgs: "acme", + createdAt, + trust: { + total: 60, + byLevel: currentLevels, + }, + history: [ + { id: "acme-previous", capturedAt: previousAt, total: 60, byLevel: previousLevels }, + { id: "acme-report", capturedAt: createdAt, total: 60, byLevel: currentLevels }, + ], + }); + }); + + it("returns a report without invented trust metrics when no trust audit exists", async () => { + await database.sql` + INSERT INTO reports (id, orgs, scope_label, payload) + VALUES ( + ${"manual-report"}, + ${"acme"}, + ${"last 12 months"}, + ${serializeJson({ manual: { rows: [] }, failures: [] })}::jsonb + ) + `; + + await expect(getReportSocialData("manual-report")).resolves.toMatchObject({ + id: "manual-report", + orgs: "acme", + trust: null, + history: [], + }); + }); +}); diff --git a/netlify/tests/report-og.test.ts b/netlify/tests/report-og.test.ts new file mode 100644 index 0000000..e595e6b --- /dev/null +++ b/netlify/tests/report-og.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getReportSocialData = vi.hoisted(() => + vi.fn(), +); + +vi.mock("#node/report-social", () => ({ getReportSocialData })); + +import handler, { + config, + homeCardHtml, + renderHomeCard, + reportCardHtml, +} from "../functions/report-og.js"; + +beforeEach(() => { + getReportSocialData.mockResolvedValue({ + id: "acme-2026-08-23-0123456789abcdef", + orgs: "acme", + createdAt: new Date("2026-08-23T12:34:56.000Z"), + trust: { + total: 60, + byLevel: { + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }, + }, + history: [ + { + id: "acme-2026-08-22-abcdef", + capturedAt: new Date("2026-08-22T12:34:56.000Z"), + total: 60, + byLevel: { + stagedPublish: 7, + trustedPublisher: 14, + provenance: 14, + none: 25, + }, + }, + { + id: "acme-2026-08-23-0123456789abcdef", + capturedAt: new Date("2026-08-23T12:34:56.000Z"), + total: 60, + byLevel: { + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }, + }, + ], + }); +}); + +describe("report OG image function", () => { + it("uses the app header copy on the homepage card", () => { + const html = homeCardHtml(); + + expect(html).toContain("Supply-chain trust signals"); + expect(html).toContain("for npm orgs."); + expect(html).toContain("Audit, visualize, share, track over time."); + }); + + it("renders the homepage card at the social-image dimensions", async () => { + const image = new Uint8Array(await renderHomeCard()); + const png = new DataView(image.buffer, image.byteOffset, image.byteLength); + + expect([...image.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + expect(png.getUint32(16)).toBe(2_400); + expect(png.getUint32(20)).toBe(1_260); + }); + + it("renders a PNG for a stored report", async () => { + const response = await handler( + new Request("https://npm.report/og/report/acme-2026-08-23-0123456789abcdef"), + ); + const image = new Uint8Array(await response.arrayBuffer()); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(response.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate"); + expect(response.headers.get("netlify-cdn-cache-control")).toContain("durable"); + expect([...image.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + expect(image.byteLength).toBeGreaterThan(1_000); + const png = new DataView(image.buffer, image.byteOffset, image.byteLength); + expect(png.getUint32(16)).toBe(2_400); + expect(png.getUint32(20)).toBe(1_260); + expect(getReportSocialData).toHaveBeenCalledWith("acme-2026-08-23-0123456789abcdef"); + }); + + it("returns 404 for malformed and unknown report ids", async () => { + const malformed = await handler(new Request("https://npm.report/og/report/%")); + expect(malformed.status).toBe(404); + + getReportSocialData.mockResolvedValueOnce(null); + const missing = await handler(new Request("https://npm.report/og/report/missing")); + expect(missing.status).toBe(404); + }); + + it("rejects methods other than GET", async () => { + const response = await handler( + new Request("https://npm.report/og/report/acme", { method: "POST" }), + ); + expect(response.status).toBe(405); + expect(getReportSocialData).not.toHaveBeenCalled(); + }); + + it("escapes report data before rendering HTML", () => { + const html = reportCardHtml({ + id: "unsafe", + orgs: 'acme ', + createdAt: null, + trust: null, + history: [], + }); + expect(html).toContain("acme <script>alert("nope")</script>"); + expect(html).toContain(' { + const html = reportCardHtml({ + id: "acme", + orgs: "acme", + createdAt: new Date("2026-08-23T12:34:56.000Z"), + trust: { + total: 60, + byLevel: { + stagedPublish: 8, + trustedPublisher: 16, + provenance: 15, + none: 21, + }, + }, + history: [], + }); + + expect(html).toContain("Staged publish"); + expect(html).toContain("13.3%"); + expect(html).toContain("Trusted publisher"); + expect(html).toContain("26.7%"); + expect(html).toContain("Provenance only"); + expect(html).toContain("25%"); + expect(html).toContain("No trust signal"); + expect(html).toContain("35%"); + expect(html).toContain('class="strong-summary"'); + expect(html).toContain("24 · 40%"); + expect(html).toContain("60 packages"); + expect(html).toContain("audited on 2026-08-23 12:34 UTC"); + }); + + it("includes the trust graph only for a multi-point history", () => { + const base = { + id: "acme", + orgs: "acme", + createdAt: new Date("2026-08-23T12:34:56.000Z"), + trust: { + total: 2, + byLevel: { stagedPublish: 0, trustedPublisher: 1, provenance: 0, none: 1 }, + }, + }; + const point = { + id: "acme", + capturedAt: new Date("2026-08-23T12:34:56.000Z"), + total: 2, + byLevel: { stagedPublish: 0, trustedPublisher: 1, provenance: 0, none: 1 }, + }; + + expect(reportCardHtml({ ...base, history: [point, point] })).toContain("Progress over time"); + expect(reportCardHtml({ ...base, history: [point] })).not.toContain("Progress over time"); + }); + + it("owns the report image route", () => { + expect(config.path).toBe("/og/report/:id"); + }); +}); diff --git a/package.json b/package.json index 0b4d7e8..df61859 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@sveltejs/vite-plugin-svelte": "^7.3.0", "packumeta": "0.4.1", "svelte": "^5.56.10", + "takumi-js": "^2.10.0", "valibot": "^1.4.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65b35e2..cc27589 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: svelte: specifier: ^5.56.10 version: 5.56.10(@typescript-eslint/types@8.66.0) + takumi-js: + specifier: ^2.10.0 + version: 2.10.0 valibot: specifier: ^1.4.2 version: 1.4.2(typescript@6.0.3) @@ -1766,6 +1769,88 @@ packages: svelte: ^5.46.4 vite: ^8.0.0-beta.7 || ^8.0.0 + '@takumi-rs/core-darwin-arm64@2.10.0': + resolution: {integrity: sha512-6FipAj/BzRZeJbuDdeOavI93r83jw3jZTYgW/LAtn8syYjxB2Ayxx2hY4HJKdZK2KDVHvkr7ZENEbWqGtSAdqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@takumi-rs/core-darwin-x64@2.10.0': + resolution: {integrity: sha512-2FbQZr3l7N66/PHBaVZBj8HEVVVAGvVzJaVtBCenUQDRnsaE0F7VkFovllYpuVHwpnIuYH3qTXZL90mB/Weoeg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@takumi-rs/core-linux-arm64-gnu@2.10.0': + resolution: {integrity: sha512-n1L/ZBfJ0ctCjwhBdL+5RMIRZOUQiKplmxUT774ofgVDsxIuXz84NF/mqcPWMfIPU5qtCEcHysUUbXZirEYK+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-arm64-musl@2.10.0': + resolution: {integrity: sha512-Mju5+Rj4xv/M+CFtWzvmkcziPFl5QTYAXpb0Y7mRn7vQfRCv06gh628a8XVrkCu45RsyTR535GRUM0oV9n3WGQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-linux-x64-gnu@2.10.0': + resolution: {integrity: sha512-fqzgmp60yefxVfqzRw0n6VOeE6jHEI7zHgPUMzxs7bF8SfrX+8uagvzBog5aHrSB5AefhsVboWgqlx9Wx+S0Yw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@takumi-rs/core-linux-x64-musl@2.10.0': + resolution: {integrity: sha512-wx0Poj7dSIEczsyXdeo4XvDHJaCxiJiXwojsVbQQ3LwLRlLN3wRYxW4Ujx090XFXgy/WQ4Rip0/CgATs3h/zQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@takumi-rs/core-win32-arm64-msvc@2.10.0': + resolution: {integrity: sha512-1n5Dorkx3tq5Z/NVSB+HcopiLaMF60kf8ncVMG/pHaui1JdmWyJb6lDud7PrH3354XwEkVHhdR/9yEVezhGL4Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@takumi-rs/core-win32-x64-msvc@2.10.0': + resolution: {integrity: sha512-FnjkXva7vdaZO7TvF/Z8cs1UXjf9XdgCv3CIo4QHPx4NgxaTZqbod+K3xH3FFpZjQhnWLoHvTDRntWiLdUzpag==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@takumi-rs/core@2.10.0': + resolution: {integrity: sha512-+2/648UWDgl4UrRKORYp9GVl5epWV90uxAxSbPSgB+bY/kqYpYekEKvq/76NOjjb7HldvO2IMmzSu6Y60C1KkQ==} + engines: {node: '>=18'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + + '@takumi-rs/helpers@2.10.0': + resolution: {integrity: sha512-cPQNj/16DASgMaIA07ZrjNQ14Z+7lWiVPZQVz5S1kLAhVlCf9qg2D2vge/+yglHrdaL556U22wDWNUyFId5a+A==} + engines: {node: '>=18'} + peerDependencies: + preact: ^10.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + preact: + optional: true + react: + optional: true + + '@takumi-rs/wasm@2.10.0': + resolution: {integrity: sha512-euimMzmiWeSDuWYRTCMx6V3aBMj3zAjIwx47StvDWuQKK3eDBaqKbrE1VCe9MHH4l1Mne1ZQpLUmLQeJwmZ/2w==} + engines: {node: '>=18'} + peerDependencies: + csstype: '*' + peerDependenciesMeta: + csstype: + optional: true + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4096,6 +4181,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + takumi-js@2.10.0: + resolution: {integrity: sha512-SdaoNkuifjgJgWbsNBdm94mkgxw36iGeaUGFxsEk+HfkJBHW4LOCdaq4Bz9p7pQlVXlkxLj633uOWE4A1GRc2Q==} + engines: {node: '>=18'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -5887,6 +5976,55 @@ snapshots: vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@takumi-rs/core-darwin-arm64@2.10.0': + optional: true + + '@takumi-rs/core-darwin-x64@2.10.0': + optional: true + + '@takumi-rs/core-linux-arm64-gnu@2.10.0': + optional: true + + '@takumi-rs/core-linux-arm64-musl@2.10.0': + optional: true + + '@takumi-rs/core-linux-x64-gnu@2.10.0': + optional: true + + '@takumi-rs/core-linux-x64-musl@2.10.0': + optional: true + + '@takumi-rs/core-win32-arm64-msvc@2.10.0': + optional: true + + '@takumi-rs/core-win32-x64-msvc@2.10.0': + optional: true + + '@takumi-rs/core@2.10.0': + dependencies: + '@takumi-rs/helpers': 2.10.0 + optionalDependencies: + '@takumi-rs/core-darwin-arm64': 2.10.0 + '@takumi-rs/core-darwin-x64': 2.10.0 + '@takumi-rs/core-linux-arm64-gnu': 2.10.0 + '@takumi-rs/core-linux-arm64-musl': 2.10.0 + '@takumi-rs/core-linux-x64-gnu': 2.10.0 + '@takumi-rs/core-linux-x64-musl': 2.10.0 + '@takumi-rs/core-win32-arm64-msvc': 2.10.0 + '@takumi-rs/core-win32-x64-msvc': 2.10.0 + transitivePeerDependencies: + - preact + - react + + '@takumi-rs/helpers@2.10.0': {} + + '@takumi-rs/wasm@2.10.0': + dependencies: + '@takumi-rs/helpers': 2.10.0 + transitivePeerDependencies: + - preact + - react + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -8478,6 +8616,16 @@ snapshots: symbol-tree@3.2.4: {} + takumi-js@2.10.0: + dependencies: + '@takumi-rs/core': 2.10.0 + '@takumi-rs/helpers': 2.10.0 + '@takumi-rs/wasm': 2.10.0 + transitivePeerDependencies: + - csstype + - preact + - react + tar-stream@3.2.0: dependencies: b4a: 1.8.1 -- 2.51.2