From cf4ec64f19dad17bff794b1b152fcf80f4f40ef6 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:30:45 -0700 Subject: [PATCH 1/7] feat(web): personless PostHog capture for the desktop telemetry routes Sends one event per call with GeoIP off, no person profile and a fresh distinct id, after the response has gone. Does nothing until DESKTOP_TELEMETRY_POSTHOG_KEY is set. --- .../desktop-telemetry/desktop-dimensions.ts | 50 +++++++++++ .../desktop-telemetry/personless-capture.ts | 57 +++++++++++++ .../record-after-response.ts | 11 +++ .../lib/desktop-telemetry/request-country.ts | 10 +++ .../personless-capture.test.ts | 83 +++++++++++++++++++ 5 files changed, 211 insertions(+) create mode 100644 surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts create mode 100644 surfsense_web/lib/desktop-telemetry/personless-capture.ts create mode 100644 surfsense_web/lib/desktop-telemetry/record-after-response.ts create mode 100644 surfsense_web/lib/desktop-telemetry/request-country.ts create mode 100644 surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts diff --git a/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts new file mode 100644 index 0000000000..bea3545157 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts @@ -0,0 +1,50 @@ +/** + * App version, platform and OS as the desktop app reports them + * (docs/contracts/05-desktop-telemetry.md). Both routes accept only these + * spellings, so nothing free-form reaches an event. + */ + +export const DESKTOP_PLATFORMS = [ + "win32-x64", + "win32-arm64", + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-arm64", +] as const; + +const FIXED_OS_NAMES = new Set(["windows-10", "windows-11", "linux"]); +const MACOS_MAJOR = /^macos-[1-9][0-9]$/; + +// Plain x.y.z only: a pre-release tag is free text and could carry a name. +export const APP_VERSION = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/; + +export function isDesktopPlatform(value: unknown): value is (typeof DESKTOP_PLATFORMS)[number] { + return typeof value === "string" && (DESKTOP_PLATFORMS as readonly string[]).includes(value); +} + +export function isDesktopOs(value: unknown): value is string { + return typeof value === "string" && (FIXED_OS_NAMES.has(value) || MACOS_MAJOR.test(value)); +} + +export function isAppVersion(value: unknown): value is string { + return typeof value === "string" && APP_VERSION.test(value); +} + +export type UpdateCheckDimensions = { + app_version: string; + platform: string; + os: string; +}; + +/** Reads the three X-SurfSense-* headers, folding anything unexpected into one bucket. */ +export function readUpdateCheckDimensions(headers: Headers): UpdateCheckDimensions { + const version = headers.get("x-surfsense-version"); + const platform = headers.get("x-surfsense-platform"); + const os = headers.get("x-surfsense-os"); + return { + app_version: isAppVersion(version) ? version : "invalid", + platform: isDesktopPlatform(platform) ? platform : "other", + os: isDesktopOs(os) ? os : "other", + }; +} diff --git a/surfsense_web/lib/desktop-telemetry/personless-capture.ts b/surfsense_web/lib/desktop-telemetry/personless-capture.ts new file mode 100644 index 0000000000..f6484e01c6 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/personless-capture.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "node:crypto"; +import { PostHog, type PostHogOptions } from "posthog-node"; + +/** An event with no person behind it; every value is an enum, flag or fixed format. */ +export type PersonlessEvent = { + event: string; + properties: Record; +}; + +export type CapturePersonless = (event: PersonlessEvent) => Promise; + +type CaptureConfig = { + apiKey: string | undefined; + host: string | undefined; + fetch?: PostHogOptions["fetch"]; +}; + +/** + * Sends one event straight away, with GeoIP off and no person profile, so + * PostHog keeps neither a location from our server's address nor a person + * (docs/contracts/05-desktop-telemetry.md). Never throws: posthog-node logs + * and drops a failed send. + */ +export function createPersonlessCapture({ apiKey, host, fetch }: CaptureConfig): CapturePersonless { + if (!apiKey) return async () => {}; + + const client = new PostHog(apiKey, { + host: host || undefined, + disableGeoip: true, + flushAt: 1, + flushInterval: 0, + requestTimeout: 5000, + fetchRetryCount: 1, + fetch, + }); + + return async ({ event, properties }) => { + await client.captureImmediate({ + // A fresh id per event: there is no one to count twice and nothing to join on. + distinctId: randomUUID(), + event, + properties: { ...properties, $process_person_profile: false }, + disableGeoip: true, + }); + }; +} + +let captureFromEnv: CapturePersonless | undefined; + +/** The routes' capture. Does nothing until DESKTOP_TELEMETRY_POSTHOG_KEY is set. */ +export function capturePersonlessEvent(event: PersonlessEvent): Promise { + captureFromEnv ??= createPersonlessCapture({ + apiKey: process.env.DESKTOP_TELEMETRY_POSTHOG_KEY, + host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + }); + return captureFromEnv(event); +} diff --git a/surfsense_web/lib/desktop-telemetry/record-after-response.ts b/surfsense_web/lib/desktop-telemetry/record-after-response.ts new file mode 100644 index 0000000000..d74b692d8a --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/record-after-response.ts @@ -0,0 +1,11 @@ +import { after } from "next/server"; +import { capturePersonlessEvent, type PersonlessEvent } from "./personless-capture"; + +/** + * Captures once the response has gone, so PostHog being slow or down never + * delays an update check or a usage report. On Vercel, after() keeps the + * function alive until the send settles. + */ +export function recordAfterResponse(event: PersonlessEvent): void { + after(() => capturePersonlessEvent(event)); +} diff --git a/surfsense_web/lib/desktop-telemetry/request-country.ts b/surfsense_web/lib/desktop-telemetry/request-country.ts new file mode 100644 index 0000000000..0d1795d67e --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/request-country.ts @@ -0,0 +1,10 @@ +const COUNTRY_CODE = /^[A-Z]{2}$/; + +/** + * Country from the header Vercel derives at its edge, so the address itself + * is never read here (docs/contracts/05-desktop-telemetry.md). + */ +export function requestCountry(headers: Headers): string { + const country = headers.get("x-vercel-ip-country"); + return country && COUNTRY_CODE.test(country) ? country : "unknown"; +} diff --git a/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts b/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts new file mode 100644 index 0000000000..7786d11046 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/personless-capture.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { gunzipSync } from "node:zlib"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/personless-capture.test.ts + +import { createPersonlessCapture } from "../../../lib/desktop-telemetry/personless-capture"; + +type Sent = { url: string; body: { batch?: Array> } }; + +function postHogDouble() { + const sent: Sent[] = []; + const fetch = async ( + url: string, + options: { body?: string | Blob; headers?: Record } + ) => { + let raw: Buffer; + if (typeof options.body === "string") raw = Buffer.from(options.body); + else raw = Buffer.from(await (options.body as Blob).arrayBuffer()); + const gzipped = options.headers?.["Content-Encoding"] === "gzip"; + sent.push({ url, body: JSON.parse((gzipped ? gunzipSync(raw) : raw).toString("utf8")) }); + return { + status: 200, + text: async () => "{}", + json: async () => ({}), + headers: { get: () => null }, + }; + }; + return { + sent, + fetch: fetch as unknown as Parameters[0]["fetch"], + }; +} + +const EVENT = { + event: "desktop_update_check", + properties: { + file: "stable.yml", + app_version: "2.2.4", + platform: "linux-x64", + os: "linux", + country: "FR", + }, +}; + +test("with no key nothing is sent", async () => { + const { sent, fetch } = postHogDouble(); + await createPersonlessCapture({ apiKey: undefined, host: undefined, fetch })(EVENT); + await createPersonlessCapture({ apiKey: "", host: "https://ph.example", fetch })(EVENT); + assert.equal(sent.length, 0); +}); + +test("an event is sent before the capture resolves, personless and without GeoIP", async () => { + const { sent, fetch } = postHogDouble(); + await createPersonlessCapture({ apiKey: "phc_test", host: "https://ph.example", fetch })(EVENT); + + assert.equal(sent.length, 1); + assert.ok(sent[0].url.startsWith("https://ph.example/"), sent[0].url); + const [event] = sent[0].body.batch ?? []; + const properties = event.properties as Record; + assert.equal(event.event, "desktop_update_check"); + for (const [key, value] of Object.entries(EVENT.properties)) { + assert.equal(properties[key], value, key); + } + assert.equal(properties.$process_person_profile, false); + assert.equal(properties.$geoip_disable, true); + assert.equal("$ip" in properties, false); + assert.equal("ip" in event, false); +}); + +test("no two events share a distinct id, so nothing can be joined across them", async () => { + const { sent, fetch } = postHogDouble(); + const capture = createPersonlessCapture({ + apiKey: "phc_test", + host: "https://ph.example", + fetch, + }); + await capture(EVENT); + await capture(EVENT); + const ids = sent.map((request) => request.body.batch?.[0].distinct_id); + assert.equal(ids.length, 2); + assert.notEqual(ids[0], ids[1]); +}); From 573c2bb76414d9db2478b51acf8663874395cda9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:30:46 -0700 Subject: [PATCH 2/7] feat(web): serve the desktop update feed at /api/updates Fetches the latest release's channel file from GitHub, pins every file to that release's download URL and answers 502 on anything unexpected, so the app falls back to GitHub. latest*.yml stays a 404. Each check records a personless desktop_update_check. --- surfsense_web/app/api/updates/[file]/route.ts | 12 + .../update-feed/channel-files.ts | 7 + .../update-feed/latest-github-manifest.ts | 29 ++ .../update-feed/pin-manifest-to-release.ts | 114 +++++++ .../update-feed/serve-update-manifest.ts | 51 ++++ .../fixtures/stable-linux.yml | 12 + .../desktop-telemetry/fixtures/stable-mac.yml | 11 + .../desktop-telemetry/fixtures/stable.yml | 8 + .../desktop-telemetry/update-feed.test.ts | 287 ++++++++++++++++++ 9 files changed, 531 insertions(+) create mode 100644 surfsense_web/app/api/updates/[file]/route.ts create mode 100644 surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts create mode 100644 surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts create mode 100644 surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts create mode 100644 surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts create mode 100644 surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml create mode 100644 surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml create mode 100644 surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml create mode 100644 surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts diff --git a/surfsense_web/app/api/updates/[file]/route.ts b/surfsense_web/app/api/updates/[file]/route.ts new file mode 100644 index 0000000000..a88923a169 --- /dev/null +++ b/surfsense_web/app/api/updates/[file]/route.ts @@ -0,0 +1,12 @@ +import { recordAfterResponse } from "@/lib/desktop-telemetry/record-after-response"; +import { serveUpdateManifest } from "@/lib/desktop-telemetry/update-feed/serve-update-manifest"; + +// Each check must reach the handler to be counted. The GitHub fetch inside +// keeps its own 15-minute cache. +export const dynamic = "force-dynamic"; + +/** The desktop update feed at updates.surfsense.com (docs/contracts/05-desktop-telemetry.md). */ +export async function GET(request: Request, { params }: { params: Promise<{ file: string }> }) { + const { file } = await params; + return serveUpdateManifest(request, file, { fetchManifest: fetch, record: recordAfterResponse }); +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts b/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts new file mode 100644 index 0000000000..6ce964730a --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/channel-files.ts @@ -0,0 +1,7 @@ +// The four 2.x channel files. latest*.yml belongs to the legacy 0.0.x bridge +// on GitHub and must never be served from here. +const CHANNEL_FILE = /^stable(-mac|-linux(-arm64)?)?\.yml$/; + +export function isChannelFile(file: string): boolean { + return CHANNEL_FILE.test(file); +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts new file mode 100644 index 0000000000..6c199150c1 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts @@ -0,0 +1,29 @@ +const LATEST_DOWNLOAD = "https://github.com/MODSetter/SurfSense/releases/latest/download"; +// Real manifests are about 500 bytes. +const MAX_MANIFEST_CHARS = 64 * 1024; + +export type FetchManifest = ( + url: string, + init: RequestInit & { next?: { revalidate?: number } } +) => Promise; + +/** + * The channel file of the latest published release. "latest" only moves on + * publish, so a draft merged to main early is never served, and 15 minutes of + * caching keeps our own fetches out of GitHub's download counts. + */ +export async function fetchLatestGithubManifest( + file: string, + fetchManifest: FetchManifest +): Promise { + const response = await fetchManifest(`${LATEST_DOWNLOAD}/${file}`, { + redirect: "follow", + next: { revalidate: 900 }, + // Well inside the app's 10 s, so it still has time to fall back to GitHub. + signal: AbortSignal.timeout(5000), + }); + if (response.status !== 200) throw new Error(`GitHub answered ${response.status}`); + const text = await response.text(); + if (text.length > MAX_MANIFEST_CHARS) throw new Error("manifest is too large"); + return text; +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts b/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts new file mode 100644 index 0000000000..3b04f8c2d4 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/pin-manifest-to-release.ts @@ -0,0 +1,114 @@ +import { APP_VERSION } from "../desktop-dimensions"; + +const RELEASE_DOWNLOAD = "https://github.com/MODSetter/SurfSense/releases/download"; +const RELEASE_FILE = /^SurfSense[-A-Za-z0-9._]*\.(exe|zip|dmg|AppImage|deb)$/; + +const SHA512 = /^[A-Za-z0-9+/]{86}==$/; +const BYTE_COUNT = /^[0-9]{1,13}$/; +const ISO_DATE = /^(['"]?)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,3})?Z\1$/; +const PERCENTAGE = /^(100|[1-9]?[0-9])(\.[0-9]+)?$/; + +// Every line electron-builder writes for our releases, value shapes included. +// With no YAML parser here, a line outside this list could hide a url (a flow +// mapping, a quoted key, a folded scalar), so it fails the whole manifest and +// the app falls back to GitHub. +const TOP_LEVEL_LINE = /^([A-Za-z][A-Za-z0-9]*):(?: (.*))?$/; +const FILE_START_LINE = /^ {2}- url: (.*)$/; +const FILE_FIELD_LINE = /^ {4}([A-Za-z][A-Za-z0-9]*): (.*)$/; + +const TOP_LEVEL_VALUES = new Map([ + ["version", APP_VERSION], + ["files", /^$/], + ["path", /^\S+$/], + ["sha512", SHA512], + ["releaseDate", ISO_DATE], + ["stagingPercentage", PERCENTAGE], +]); + +const FILE_FIELD_VALUES = new Map([ + ["sha512", SHA512], + ["size", BYTE_COUNT], + ["blockMapSize", BYTE_COUNT], + ["isAdminRightsRequired", /^(true|false)$/], +]); + +type Line = + | { kind: "top"; key: string; value: string } + | { kind: "file-start"; url: string } + | { kind: "file-field"; key: string; value: string }; + +function parseLine(line: string, number: number): Line { + const fileStart = FILE_START_LINE.exec(line); + if (fileStart) return { kind: "file-start", url: fileStart[1] }; + + const fileField = FILE_FIELD_LINE.exec(line); + if (fileField && FILE_FIELD_VALUES.get(fileField[1])?.test(fileField[2])) { + return { kind: "file-field", key: fileField[1], value: fileField[2] }; + } + + const topLevel = TOP_LEVEL_LINE.exec(line); + const value = topLevel?.[2] ?? ""; + if (topLevel && TOP_LEVEL_VALUES.get(topLevel[1])?.test(value)) { + return { kind: "top", key: topLevel[1], value }; + } + + throw new Error(`line ${number} is not part of an update manifest`); +} + +/** Checks the nesting and that no key repeats, which the app's YAML parser would reject. */ +function checkStructure(lines: Line[]): string { + const topKeys = new Set(); + let inFiles = false; + let fileKeys: Set | null = null; + let fileCount = 0; + + for (const line of lines) { + if (line.kind === "top") { + if (topKeys.has(line.key)) throw new Error(`${line.key} appears twice`); + topKeys.add(line.key); + inFiles = line.key === "files"; + fileKeys = null; + } else if (line.kind === "file-start") { + if (!inFiles) throw new Error("a file entry sits outside files:"); + fileKeys = new Set(["url"]); + fileCount += 1; + } else { + if (!fileKeys) throw new Error(`${line.key} sits outside a file entry`); + if (fileKeys.has(line.key)) throw new Error(`${line.key} appears twice in one file entry`); + fileKeys.add(line.key); + } + } + + const version = lines.find((line) => line.kind === "top" && line.key === "version"); + if (!version || version.kind !== "top") throw new Error("no version"); + if (fileCount === 0) throw new Error("no files"); + return version.value; +} + +/** Accepts a bare release file name, or the exact versioned URL for it. */ +function releaseFileUrl(value: string, version: string): string { + const prefix = `${RELEASE_DOWNLOAD}/v${version}/`; + const name = value.startsWith(prefix) ? value.slice(prefix.length) : value; + if (!RELEASE_FILE.test(name)) throw new Error("a file is not a SurfSense release file"); + return `${prefix}${name}`; +} + +/** + * Points every url: and path: at the GitHub release for the manifest's own + * version, leaving hashes, sizes and dates as they are. On Linux the sha512 + * here is the only check on an update, so anything unexpected throws and the + * route answers 502 rather than serve it (docs/contracts/05-desktop-telemetry.md). + */ +export function pinManifestToRelease(manifest: string): string { + const body = manifest.endsWith("\n") ? manifest.slice(0, -1) : manifest; + const lines = body.split("\n").map((line, index) => parseLine(line, index + 1)); + const version = checkStructure(lines); + + const pinned = lines.map((line) => { + if (line.kind === "file-start") return ` - url: ${releaseFileUrl(line.url, version)}`; + if (line.kind === "file-field") return ` ${line.key}: ${line.value}`; + if (line.key === "path") return `path: ${releaseFileUrl(line.value, version)}`; + return line.value === "" ? `${line.key}:` : `${line.key}: ${line.value}`; + }); + return `${pinned.join("\n")}\n`; +} diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts new file mode 100644 index 0000000000..80cb079cf7 --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts @@ -0,0 +1,51 @@ +import { readUpdateCheckDimensions } from "../desktop-dimensions"; +import type { PersonlessEvent } from "../personless-capture"; +import { requestCountry } from "../request-country"; +import { isChannelFile } from "./channel-files"; +import { type FetchManifest, fetchLatestGithubManifest } from "./latest-github-manifest"; +import { pinManifestToRelease } from "./pin-manifest-to-release"; + +export type UpdateFeedDeps = { + fetchManifest: FetchManifest; + record: (event: PersonlessEvent) => void; +}; + +const NO_STORE = { "Cache-Control": "no-store" }; + +/** + * GET /api/updates/ (docs/contracts/05-desktop-telemetry.md, part A). + * Every failure is a 502, which sends the app to GitHub directly, so this + * route can be down or wrong without blocking an update. Reads only the + * X-SurfSense-* headers and the country header: never the address or the + * User-Agent. + */ +export async function serveUpdateManifest( + request: Request, + file: string, + deps: UpdateFeedDeps +): Promise { + if (!isChannelFile(file)) return new Response("Not found\n", { status: 404, headers: NO_STORE }); + + // Counted whatever GitHub answers: the app checked either way. + deps.record({ + event: "desktop_update_check", + properties: { + file, + ...readUpdateCheckDimensions(request.headers), + country: requestCountry(request.headers), + }, + }); + + try { + const manifest = pinManifestToRelease( + await fetchLatestGithubManifest(file, deps.fetchManifest) + ); + return new Response(manifest, { + status: 200, + headers: { "Content-Type": "text/yaml", ...NO_STORE }, + }); + } catch (error) { + console.warn(`[updates] ${file} not served: ${error instanceof Error ? error.message : error}`); + return new Response("Bad gateway\n", { status: 502, headers: NO_STORE }); + } +} diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml new file mode 100644 index 0000000000..da10152c8d --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-linux.yml @@ -0,0 +1,12 @@ +version: 2.2.3 +files: + - url: SurfSense.AppImage + sha512: 2AdhuIu3+eyrEhjwTIvu/6aO/CG9Zs/PEmeP/RP18oD8iwgYBFeGJJMuCw68AEl1j6/tIINDHWf2bwK/BzIvwA== + size: 1462521628 + blockMapSize: 1519408 + - url: SurfSense.deb + sha512: SI2pnGuRFgukWx2XsgvnvUxS0eKJyjUFOPDtVBXd1v3Z2QzCICp6XA1fuHZzd6ZzoJQt+OY8DGyO87wTzMag0Q== + size: 1345595320 +path: SurfSense.AppImage +sha512: 2AdhuIu3+eyrEhjwTIvu/6aO/CG9Zs/PEmeP/RP18oD8iwgYBFeGJJMuCw68AEl1j6/tIINDHWf2bwK/BzIvwA== +releaseDate: '2026-10-21T10:05:26.481Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml new file mode 100644 index 0000000000..f85baf0f7c --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable-mac.yml @@ -0,0 +1,11 @@ +version: 2.2.3 +files: + - url: SurfSense-arm64.zip + sha512: GV/g0Q1MRkWRmgvD7RRNrwEOAiiMIRp1cGmgRc7lWpGg8xWnKKVLc1CZD58EDB8p2Pt/LgmsAMghezrM8skupg== + size: 1338360154 + - url: SurfSense-arm64.dmg + sha512: gbtr+hbisU89CNAT9kI6xKIt51ra6DiOH5Im4ybC4ivuL8dFYXGzLFEUVAK3B5Du55eHq/WXSI2ugIu/WnSexg== + size: 1357516813 +path: SurfSense-arm64.zip +sha512: GV/g0Q1MRkWRmgvD7RRNrwEOAiiMIRp1cGmgRc7lWpGg8xWnKKVLc1CZD58EDB8p2Pt/LgmsAMghezrM8skupg== +releaseDate: '2026-10-21T10:15:37.088Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml new file mode 100644 index 0000000000..fd632dd122 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/fixtures/stable.yml @@ -0,0 +1,8 @@ +version: 2.2.3 +files: + - url: SurfSense-Setup.exe + sha512: W4tisJ3ctecrvGbkA6ibMQqWgAhjFNexPOOPtBa602DSzi7eO6zU4sbeF/hYaXVPJksOuUmczyUaa4trrVbvHQ== + size: 1216797720 +path: SurfSense-Setup.exe +sha512: W4tisJ3ctecrvGbkA6ibMQqWgAhjFNexPOOPtBa602DSzi7eO6zU4sbeF/hYaXVPJksOuUmczyUaa4trrVbvHQ== +releaseDate: '2026-10-21T10:03:17.403Z' diff --git a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts new file mode 100644 index 0000000000..040a3d1acb --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { mock, test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/update-feed.test.ts + +import type { PersonlessEvent } from "../../../lib/desktop-telemetry/personless-capture"; +import type { FetchManifest } from "../../../lib/desktop-telemetry/update-feed/latest-github-manifest"; +import { serveUpdateManifest } from "../../../lib/desktop-telemetry/update-feed/serve-update-manifest"; + +// Fetched once from releases/latest/download on 21 Oct 2026 (v2.2.3). +const fixture = (name: string) => readFileSync(path.join(__dirname, "fixtures", name), "utf8"); +const PINNED = "https://github.com/MODSetter/SurfSense/releases/download/v2.2.3/"; + +const CLIENT_HEADERS = { + "x-surfsense-version": "2.2.4", + "x-surfsense-platform": "win32-x64", + "x-surfsense-os": "windows-11", + "x-vercel-ip-country": "DE", + "x-forwarded-for": "203.0.113.7", + "x-real-ip": "203.0.113.7", + "x-vercel-forwarded-for": "203.0.113.7", + "user-agent": "electron-builder secret-agent", + cookie: "session=abc", +}; + +type Call = { url: string; init: Parameters[1] }; + +function upstream(body: string | (() => Promise), status = 200) { + const calls: Call[] = []; + const fetchManifest: FetchManifest = async (url, init) => { + calls.push({ url, init }); + return typeof body === "function" ? body() : new Response(body, { status }); + }; + return { calls, fetchManifest }; +} + +async function check( + file: string, + manifest: string | (() => Promise), + { + status = 200, + headers = CLIENT_HEADERS, + }: { status?: number; headers?: Record } = {} +) { + const events: PersonlessEvent[] = []; + const { calls, fetchManifest } = upstream(manifest, status); + const request = new Request(`https://updates.surfsense.com/api/updates/${file}?noCache=1`, { + headers, + }); + const response = await serveUpdateManifest(request, file, { + fetchManifest, + record: (event) => events.push(event), + }); + return { response, body: await response.text(), events, calls }; +} + +mock.method(console, "warn", () => {}); + +for (const file of ["stable.yml", "stable-mac.yml", "stable-linux.yml"]) { + test(`${file} from GitHub is served with every file pinned to its release`, async () => { + const original = fixture(file); + const { response, body } = await check(file, original); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/yaml"); + assert.equal(response.headers.get("cache-control"), "no-store"); + + const before = original.trimEnd().split("\n"); + const after = body.trimEnd().split("\n"); + assert.equal(after.length, before.length); + for (const [index, line] of after.entries()) { + const match = /^(\s*- url|path): (.*)$/.exec(line); + if (match) { + const name = before[index].split(": ")[1]; + assert.equal(match[2], `${PINNED}${name}`, line); + } else { + // sha512, size, blockMapSize, releaseDate and version pass through untouched. + assert.equal(line, before[index]); + } + } + }); +} + +test("stable-linux-arm64.yml is a channel file too", async () => { + const { response } = await check("stable-linux-arm64.yml", fixture("stable-linux.yml")); + assert.equal(response.status, 200); +}); + +test("a manifest already pinned to its own release is served as it is", async () => { + const pinned = (await check("stable.yml", fixture("stable.yml"))).body; + const { response, body } = await check("stable.yml", pinned); + assert.equal(response.status, 200); + assert.equal(body, pinned); +}); + +test("stagingPercentage passes through untouched", async () => { + const staged = `${fixture("stable.yml")}stagingPercentage: 25\n`; + const { response, body } = await check("stable.yml", staged); + assert.equal(response.status, 200); + assert.match(body, /\nstagingPercentage: 25\n$/); +}); + +test("GitHub's latest release is fetched with nothing from the app's request", async () => { + const { calls } = await check("stable-mac.yml", fixture("stable-mac.yml")); + assert.equal(calls.length, 1); + assert.equal( + calls[0].url, + "https://github.com/MODSetter/SurfSense/releases/latest/download/stable-mac.yml" + ); + assert.equal(calls[0].init.redirect, "follow"); + assert.equal(calls[0].init.next?.revalidate, 900); + assert.equal(calls[0].init.headers, undefined); +}); + +for (const file of [ + "latest.yml", + "latest-mac.yml", + "latest-linux.yml", + "stable.yaml", + "stable-windows.yml", + "stable-linux-arm.yml", + "STABLE.yml", + "beta.yml", + "stable.yml.exe", + "../stable.yml", + "stable.yml/..", + "", +]) { + test(`${JSON.stringify(file)} is not a channel file: 404, no fetch, nothing recorded`, async () => { + const { response, events, calls } = await check(file, fixture("stable.yml")); + assert.equal(response.status, 404); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(calls.length, 0); + assert.equal(events.length, 0); + }); +} + +const stable = fixture("stable.yml"); +const HOSTILE: Record = { + "an absolute url off GitHub": stable.replace( + "url: SurfSense-Setup.exe", + "url: https://evil.example/SurfSense-Setup.exe" + ), + "a GitHub url for another release": stable.replace( + "url: SurfSense-Setup.exe", + "url: https://github.com/MODSetter/SurfSense/releases/download/v2.2.2/SurfSense-Setup.exe" + ), + "a GitHub url for another repository": stable.replace( + "path: SurfSense-Setup.exe", + "path: https://github.com/someone/SurfSense/releases/download/v2.2.3/SurfSense-Setup.exe" + ), + "path traversal in a url": stable.replace( + "url: SurfSense-Setup.exe", + "url: ../../../evil/SurfSense-Setup.exe" + ), + "path traversal after the name": stable.replace( + "path: SurfSense-Setup.exe", + "path: SurfSense/../../x/SurfSense-Setup.exe" + ), + "a file that is not an installer": stable.replace( + "url: SurfSense-Setup.exe", + "url: SurfSense-Setup.sh" + ), + "a file not named SurfSense": stable.replace("path: SurfSense-Setup.exe", "path: Setup.exe"), + "a quoted url": stable.replace( + "url: SurfSense-Setup.exe", + "url: 'https://evil.example/SurfSense-Setup.exe'" + ), + "a missing version": stable.replace("version: 2.2.3\n", ""), + "a version that is not x.y.z": stable.replace("version: 2.2.3", "version: 2.2.3-evil"), + "two versions": `version: 9.9.9\n${stable}`, + "two paths": `${stable}path: SurfSense-Setup.exe\n`, + "a flow mapping file entry": stable.replace( + " - url: SurfSense-Setup.exe", + " - {url: 'https://evil.example/x.exe', sha512: x}" + ), + "a folded url": stable.replace( + " - url: SurfSense-Setup.exe", + " - url: SurfSense-Setup.exe\n https://evil.example/x.exe" + ), + "an unknown key": `${stable}releaseNotes: hello\n`, + "a comment": `# hi\n${stable}`, + "a document marker": `---\n${stable}`, + "windows line endings": stable.replace(/\n/g, "\r\n"), + "a file field outside files:": stable.replace("releaseDate:", " size: 1\nreleaseDate:"), + "no files at all": "version: 2.2.3\npath: SurfSense-Setup.exe\n", + "an empty body": "", + "an HTML error page": "Not Found", +}; + +for (const [name, manifest] of Object.entries(HOSTILE)) { + test(`a manifest with ${name} is a 502`, async () => { + const { response, body } = await check("stable.yml", manifest); + assert.equal(response.status, 502); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.doesNotMatch(body, /evil/); + }); +} + +test("GitHub not answering 200 is a 502", async () => { + for (const status of [404, 403, 500, 503]) { + const { response } = await check("stable.yml", "Not Found", { status }); + assert.equal(response.status, 502, String(status)); + } +}); + +test("GitHub failing outright is a 502", async () => { + const { response } = await check("stable.yml", () => Promise.reject(new Error("timeout"))); + assert.equal(response.status, 502); +}); + +test("a huge upstream body is a 502", async () => { + const { response } = await check("stable.yml", stable + "#".repeat(70 * 1024)); + assert.equal(response.status, 502); +}); + +test("each check records one desktop_update_check with exactly the contract's fields", async () => { + const { events } = await check("stable.yml", stable); + assert.deepEqual(events, [ + { + event: "desktop_update_check", + properties: { + file: "stable.yml", + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + country: "DE", + }, + }, + ]); +}); + +test("a check is recorded even when GitHub fails, since the app still checked", async () => { + const { events } = await check("stable-mac.yml", "nope", { status: 500 }); + assert.equal(events.length, 1); + assert.equal(events[0].properties.file, "stable-mac.yml"); +}); + +test("headers outside the contract fold into invalid, other and unknown", async () => { + const { events } = await check("stable-linux.yml", fixture("stable-linux.yml"), { + headers: { + "x-surfsense-version": "2.2.4-beta+alice", + "x-surfsense-platform": "freebsd-x64", + "x-surfsense-os": "windows-7", + "x-vercel-ip-country": "not a country", + }, + }); + assert.deepEqual(events[0].properties, { + file: "stable-linux.yml", + app_version: "invalid", + platform: "other", + os: "other", + country: "unknown", + }); +}); + +test("a check with no SurfSense headers still counts, as invalid, other and unknown", async () => { + const { events } = await check("stable.yml", stable, { headers: {} }); + assert.deepEqual(events[0].properties, { + file: "stable.yml", + app_version: "invalid", + platform: "other", + os: "other", + country: "unknown", + }); +}); + +test("every macOS major is accepted as its own OS", async () => { + for (const os of ["macos-11", "macos-15", "macos-26"]) { + const { events } = await check("stable-mac.yml", fixture("stable-mac.yml"), { + headers: { "x-surfsense-os": os, "x-surfsense-platform": "darwin-arm64" }, + }); + assert.equal(events[0].properties.os, os); + assert.equal(events[0].properties.platform, "darwin-arm64"); + } +}); + +test("the recorded event carries no IP address, User-Agent or cookie", async () => { + const { events } = await check("stable.yml", stable); + const recorded = JSON.stringify(events); + assert.doesNotMatch(recorded, /203\.0\.113\.7/); + assert.doesNotMatch(recorded, /secret-agent|electron-builder/); + assert.doesNotMatch(recorded, /session=abc/); + assert.doesNotMatch(recorded, /"\$?ip"/); +}); From ef78d6664aa3357024af9eb1a089a5597c89ebdb Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:30:46 -0700 Subject: [PATCH 3/7] feat(web): accept opt-in desktop usage reports at /api/usage Validates schema 1 strictly (400 on an unknown key or value, 413 over 4 KB, 415 for anything but JSON) and records a flattened, personless desktop_usage_report. --- surfsense_web/app/api/usage/route.ts | 9 + .../usage-report/read-text-within.ts | 25 ++ .../usage-report/receive-usage-report.ts | 53 ++++ .../usage-report/usage-event-properties.ts | 25 ++ .../usage-report/usage-report-schema.ts | 56 ++++ .../desktop-telemetry/usage-report.test.ts | 263 ++++++++++++++++++ 6 files changed, 431 insertions(+) create mode 100644 surfsense_web/app/api/usage/route.ts create mode 100644 surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts create mode 100644 surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts create mode 100644 surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts create mode 100644 surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts create mode 100644 surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts diff --git a/surfsense_web/app/api/usage/route.ts b/surfsense_web/app/api/usage/route.ts new file mode 100644 index 0000000000..9df46a79cc --- /dev/null +++ b/surfsense_web/app/api/usage/route.ts @@ -0,0 +1,9 @@ +import { recordAfterResponse } from "@/lib/desktop-telemetry/record-after-response"; +import { receiveUsageReport } from "@/lib/desktop-telemetry/usage-report/receive-usage-report"; + +export const dynamic = "force-dynamic"; + +/** Opt-in usage reports at usage.surfsense.com (docs/contracts/05-desktop-telemetry.md). */ +export async function POST(request: Request) { + return receiveUsageReport(request, { record: recordAfterResponse }); +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts b/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts new file mode 100644 index 0000000000..d30cdd494f --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/read-text-within.ts @@ -0,0 +1,25 @@ +/** + * The body as UTF-8 text, or null as soon as it passes `limitBytes`. Stops + * reading there, so an oversized or endless body costs no more than the limit. + * Throws on invalid UTF-8. + */ +export async function readTextWithin(request: Request, limitBytes: number): Promise { + const declared = Number(request.headers.get("content-length")); + if (Number.isFinite(declared) && declared > limitBytes) return null; + if (!request.body) return ""; + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limitBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts b/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts new file mode 100644 index 0000000000..0469807fcb --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/receive-usage-report.ts @@ -0,0 +1,53 @@ +import type { PersonlessEvent } from "../personless-capture"; +import { requestCountry } from "../request-country"; +import { readTextWithin } from "./read-text-within"; +import { usageEventProperties } from "./usage-event-properties"; +import { usageReportSchema } from "./usage-report-schema"; + +export type UsageReportDeps = { + record: (event: PersonlessEvent) => void; +}; + +// The contract's cap; a real report is well under 1 KB. +const MAX_REPORT_BYTES = 4096; + +function reply(status: number, error?: string): Response { + if (!error) return new Response(null, { status, headers: { "Cache-Control": "no-store" } }); + return Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } }); +} + +function isJsonContentType(contentType: string | null): boolean { + return contentType?.split(";")[0].trim().toLowerCase() === "application/json"; +} + +/** + * POST /api/usage (docs/contracts/05-desktop-telemetry.md, part B). Records + * only a report that passes the schema exactly. Reads only the body and the + * country header: never the address, the User-Agent or a cookie. + */ +export async function receiveUsageReport( + request: Request, + deps: UsageReportDeps +): Promise { + if (!isJsonContentType(request.headers.get("content-type"))) { + return reply(415, "content_type_must_be_json"); + } + + let body: unknown; + try { + const text = await readTextWithin(request, MAX_REPORT_BYTES); + if (text === null) return reply(413, "report_too_large"); + body = JSON.parse(text); + } catch { + return reply(400, "invalid_json"); + } + + const report = usageReportSchema.safeParse(body); + if (!report.success) return reply(400, "invalid_report"); + + deps.record({ + event: "desktop_usage_report", + properties: usageEventProperties(report.data, requestCountry(request.headers)), + }); + return reply(204); +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts b/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts new file mode 100644 index 0000000000..3c550838bd --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/usage-event-properties.ts @@ -0,0 +1,25 @@ +import type { UsageReport } from "./usage-report-schema"; + +/** + * desktop_usage_report's properties: the report flattened one level + * (active_first, hardware_gpu, week_chat_turns, ...) plus the country. + * A section the report leaves null adds no properties. + */ +export function usageEventProperties( + report: UsageReport, + country: string +): Record { + const { active, hardware, week, ...top } = report; + const properties: Record = { ...top }; + for (const [section, values] of [ + ["active", active], + ["hardware", hardware], + ["week", week], + ] as const) { + for (const [key, value] of Object.entries(values ?? {})) { + properties[`${section}_${key}`] = value; + } + } + properties.country = country; + return properties; +} diff --git a/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts b/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts new file mode 100644 index 0000000000..d43437993d --- /dev/null +++ b/surfsense_web/lib/desktop-telemetry/usage-report/usage-report-schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { APP_VERSION, DESKTOP_PLATFORMS, isDesktopOs } from "../desktop-dimensions"; + +const ISO_WEEK = /^20[0-9]{2}-W(0[1-9]|[1-4][0-9]|5[0-3])$/; +const TURNS = z.enum(["0", "1-9", "10-49", "50+", "unknown"]); + +/** + * Usage report schema 1 (docs/contracts/05-desktop-telemetry.md, part B). + * Strict all the way down: every value is an enum, flag or fixed format, so + * an unknown key or value is refused rather than stored. + */ +export const usageReportSchema = z + .strictObject({ + schema: z.literal(1), + app_version: z.string().regex(APP_VERSION), + platform: z.enum(DESKTOP_PLATFORMS), + os: z.string().refine(isDesktopOs), + ui_language: z.enum(["en", "ja", "de", "other"]), + install_week: z.string().regex(ISO_WEEK), + active: z.strictObject({ + first: z.boolean(), + week: z.boolean(), + month: z.boolean(), + }), + hardware: z + .strictObject({ + ram_gb: z.enum(["<8", "8-15", "16-31", "32-63", "64+"]), + gpu: z.enum(["nvidia", "amd", "intel", "apple", "none", "unknown"]), + vram_gb: z.enum(["0", "<4", "4-7", "8-11", "12-23", "24+", "unknown"]), + }) + .nullable(), + week: z + .strictObject({ + chat_turns: TURNS, + agent_turns: TURNS, + model_source: z.enum(["none", "local", "remote", "both", "unknown"]), + sources: z.enum(["0", "1-9", "10-99", "100-999", "1000+", "unknown"]), + studio_outputs: z.enum(["0", "1-4", "5+", "unknown"]), + plugin_calls: z.enum(["0", "1-9", "10+", "unknown"]), + onboarding: z.enum([ + "not_started", + "model_chosen", + "model_ready", + "first_source", + "first_answer", + "unknown", + ]), + }) + .nullable(), + }) + // Hardware rides only on an install's first report and the week only on the + // first report of a week, so neither can become a daily trail. + .refine((report) => report.hardware === null || report.active.first, { path: ["hardware"] }) + .refine((report) => report.week === null || report.active.week, { path: ["week"] }); + +export type UsageReport = z.infer; diff --git a/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts b/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts new file mode 100644 index 0000000000..1e6b40d8a1 --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/usage-report.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/usage-report.test.ts + +import type { PersonlessEvent } from "../../../lib/desktop-telemetry/personless-capture"; +import { receiveUsageReport } from "../../../lib/desktop-telemetry/usage-report/receive-usage-report"; + +// The contract's own example: a first report of the week, not the install's first. +const REPORT = { + schema: 1, + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + ui_language: "en", + install_week: "2026-W43", + active: { first: false, week: true, month: false }, + hardware: null, + week: { + chat_turns: "10-49", + agent_turns: "0", + model_source: "local", + sources: "10-99", + studio_outputs: "1-4", + plugin_calls: "0", + onboarding: "first_answer", + }, +}; + +const FIRST_REPORT = { + ...REPORT, + active: { first: true, week: true, month: true }, + hardware: { ram_gb: "16-31", gpu: "nvidia", vram_gb: "8-11" }, +}; + +const PRIVATE_HEADERS = { + "x-forwarded-for": "203.0.113.7", + "x-real-ip": "203.0.113.7", + "x-vercel-forwarded-for": "203.0.113.7", + "user-agent": "SurfSense secret-agent", + cookie: "session=abc", +}; + +async function post( + body: unknown, + { + contentType = "application/json", + headers = {}, + }: { contentType?: string | null; headers?: Record } = {} +) { + const events: PersonlessEvent[] = []; + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { + ...(contentType ? { "content-type": contentType } : {}), + "x-vercel-ip-country": "JP", + ...PRIVATE_HEADERS, + ...headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + const response = await receiveUsageReport(request, { record: (event) => events.push(event) }); + return { response, events }; +} + +/** A copy of REPORT with one value replaced at a dotted path. */ +function withValue(pathName: string, value: unknown) { + const copy = structuredClone(REPORT) as Record; + const keys = pathName.split("."); + let target = copy; + for (const key of keys.slice(0, -1)) target = target[key] as Record; + target[keys[keys.length - 1]] = value; + return copy; +} + +test("a valid report is a 204 with no body and nothing cached", async () => { + const { response } = await post(REPORT); + assert.equal(response.status, 204); + assert.equal(await response.text(), ""); + assert.equal(response.headers.get("cache-control"), "no-store"); +}); + +test("a valid report records one desktop_usage_report, flattened, with the country", async () => { + const { events } = await post(REPORT); + assert.deepEqual(events, [ + { + event: "desktop_usage_report", + properties: { + schema: 1, + app_version: "2.2.4", + platform: "win32-x64", + os: "windows-11", + ui_language: "en", + install_week: "2026-W43", + active_first: false, + active_week: true, + active_month: false, + week_chat_turns: "10-49", + week_agent_turns: "0", + week_model_source: "local", + week_sources: "10-99", + week_studio_outputs: "1-4", + week_plugin_calls: "0", + week_onboarding: "first_answer", + country: "JP", + }, + }, + ]); +}); + +test("an install's first report carries its hardware", async () => { + const { response, events } = await post(FIRST_REPORT); + assert.equal(response.status, 204); + assert.equal(events[0].properties.hardware_ram_gb, "16-31"); + assert.equal(events[0].properties.hardware_gpu, "nvidia"); + assert.equal(events[0].properties.hardware_vram_gb, "8-11"); +}); + +test("a mid-week report with neither section records only the flags", async () => { + const { response, events } = await post({ + ...REPORT, + active: { first: false, week: false, month: false }, + week: null, + }); + assert.equal(response.status, 204); + const keys = Object.keys(events[0].properties); + assert.equal( + keys.some((key) => key.startsWith("week_") || key.startsWith("hardware_")), + false + ); +}); + +test("without the country header the country is unknown", async () => { + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(REPORT), + }); + const events: PersonlessEvent[] = []; + await receiveUsageReport(request, { record: (event) => events.push(event) }); + assert.equal(events[0].properties.country, "unknown"); +}); + +test("the recorded event carries no IP address, User-Agent or cookie", async () => { + const { events } = await post(FIRST_REPORT); + const recorded = JSON.stringify(events); + assert.doesNotMatch(recorded, /203\.0\.113\.7/); + assert.doesNotMatch(recorded, /secret-agent/); + assert.doesNotMatch(recorded, /session=abc/); + assert.doesNotMatch(recorded, /"\$?ip"/); +}); + +const UNKNOWN_KEYS: Record = { + "at the top": { ...REPORT, install_id: "abc" }, + "in active": withValue("active.day", true), + "in week": withValue("week.prompt", "hello"), + "in hardware": { ...FIRST_REPORT, hardware: { ...FIRST_REPORT.hardware, cpu: "i9" } }, + "named __proto__": `{"__proto__": {"x": 1}, ${JSON.stringify(REPORT).slice(1)}`, +}; + +for (const [where, body] of Object.entries(UNKNOWN_KEYS)) { + test(`an unknown key ${where} is a 400 and is not recorded`, async () => { + const { response, events } = await post(body); + assert.equal(response.status, 400); + assert.equal(events.length, 0); + }); +} + +const BAD_VALUES: Array<[string, unknown]> = [ + ["schema", 2], + ["app_version", "2.2.4-beta"], + ["app_version", "latest"], + ["platform", "win32-ia32"], + ["os", "windows-7"], + ["os", "macos-"], + ["ui_language", "fr"], + ["install_week", "2026-43"], + ["install_week", "2026-W54"], + ["active.first", "yes"], + ["week.chat_turns", "5"], + ["week.chat_turns", 5], + ["week.model_source", "openai"], + ["week.sources", "10-49"], + ["week.studio_outputs", "1-9"], + ["week.plugin_calls", "10-49"], + ["week.onboarding", "done"], + ["week", "busy"], + ["hardware", { ram_gb: "16-31", gpu: "nvidia", vram_gb: "8-11" }], +]; + +for (const [pathName, value] of BAD_VALUES) { + test(`${pathName} = ${JSON.stringify(value)} is a 400`, async () => { + const { response, events } = await post(withValue(pathName, value)); + assert.equal(response.status, 400); + assert.equal(events.length, 0); + }); +} + +test("a missing key is a 400", async () => { + const { hardware: _hardware, ...withoutHardware } = REPORT; + const { response } = await post(withoutHardware); + assert.equal(response.status, 400); +}); + +test("week sent when it is not the week's first report is a 400", async () => { + const { response } = await post({ + ...REPORT, + active: { first: false, week: false, month: true }, + }); + assert.equal(response.status, 400); +}); + +test("a body that is not JSON is a 400", async () => { + for (const body of ["", "{", "null", "[]", "1"]) { + const { response } = await post(body); + assert.equal(response.status, 400, JSON.stringify(body)); + } +}); + +test("a body over 4 KB is a 413", async () => { + const padded = `${JSON.stringify(REPORT)}${" ".repeat(4097)}`; + const { response, events } = await post(padded); + assert.equal(response.status, 413); + assert.equal(events.length, 0); +}); + +test("a body over 4 KB with no Content-Length is a 413 and is not read to the end", async () => { + let pulled = 0; + const endless = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(new Uint8Array(1024).fill(32)); + }, + }); + const request = new Request("https://usage.surfsense.com/api/usage", { + method: "POST", + headers: { "content-type": "application/json" }, + body: endless, + duplex: "half", + } as RequestInit); + const response = await receiveUsageReport(request, { record: () => {} }); + assert.equal(response.status, 413); + assert.ok(pulled < 10, `pulled ${pulled} chunks`); +}); + +test("a body of exactly 4 KB is accepted", async () => { + const json = JSON.stringify(REPORT); + const { response } = await post(`${json}${" ".repeat(4096 - json.length)}`); + assert.equal(response.status, 204); +}); + +test("a body that is not JSON content is a 415", async () => { + for (const contentType of [null, "text/plain", "application/x-www-form-urlencoded"]) { + const { response, events } = await post(REPORT, { contentType }); + assert.equal(response.status, 415, String(contentType)); + assert.equal(events.length, 0); + } +}); + +test("JSON with a charset parameter is accepted", async () => { + const { response } = await post(REPORT, { contentType: "application/json; charset=utf-8" }); + assert.equal(response.status, 204); +}); From 3054b3851e86bebed08b314470fd7ce9e7d868ac Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:30:47 -0700 Subject: [PATCH 4/7] test(web): pin that the sunset proxy never runs for the desktop routes --- .../sunset-passthrough.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts diff --git a/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts b/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts new file mode 100644 index 0000000000..17a093953b --- /dev/null +++ b/surfsense_web/tests/unit/desktop-telemetry/sunset-passthrough.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Run with: pnpm exec tsx --test tests/unit/desktop-telemetry/sunset-passthrough.test.ts + +import { config } from "../../../proxy"; + +// The sunset proxy sends every non-public path to /sunset on the cloud +// deployment. The desktop routes are not public pages, so they rely on the +// matcher never running the proxy for them: a redirect would cost every +// update check its count and every usage report its record. +const runsProxy = (pathname: string) => + config.matcher.some((matcher) => new RegExp(`^${matcher}$`).test(pathname)); + +test("the proxy still runs on app pages, so this check means something", () => { + assert.equal(runsProxy("/dashboard/12"), true); +}); + +test("the proxy never runs for the desktop update feed or usage reports", () => { + for (const pathname of [ + "/api/updates/stable.yml", + "/api/updates/stable-mac.yml", + "/api/updates/stable-linux-arm64.yml", + "/api/updates/latest.yml", + "/api/usage", + ]) { + assert.equal(runsProxy(pathname), false, pathname); + } +}); From 58a0cc9456a3719dd8a26e733b33b4f81bf007c1 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:56:17 -0700 Subject: [PATCH 5/7] fix(web): leave HEAD requests out of the desktop update check count Next answers HEAD with the GET handler, so uptime monitors and scanners were recorded as update checks. The app only GETs the channel file. --- .../update-feed/serve-update-manifest.ts | 22 +++++++++++-------- .../desktop-telemetry/update-feed.test.ts | 10 ++++++++- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts index 80cb079cf7..97c3a5df58 100644 --- a/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts +++ b/surfsense_web/lib/desktop-telemetry/update-feed/serve-update-manifest.ts @@ -26,15 +26,19 @@ export async function serveUpdateManifest( ): Promise { if (!isChannelFile(file)) return new Response("Not found\n", { status: 404, headers: NO_STORE }); - // Counted whatever GitHub answers: the app checked either way. - deps.record({ - event: "desktop_update_check", - properties: { - file, - ...readUpdateCheckDimensions(request.headers), - country: requestCountry(request.headers), - }, - }); + // Counted whatever GitHub answers: the app checked either way. Next answers + // HEAD with this handler too, but the app only GETs, so a HEAD is a monitor + // or a scanner and is not an update check. + if (request.method !== "HEAD") { + deps.record({ + event: "desktop_update_check", + properties: { + file, + ...readUpdateCheckDimensions(request.headers), + country: requestCountry(request.headers), + }, + }); + } try { const manifest = pinManifestToRelease( diff --git a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts index 040a3d1acb..9932f1f202 100644 --- a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts +++ b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts @@ -42,11 +42,13 @@ async function check( { status = 200, headers = CLIENT_HEADERS, - }: { status?: number; headers?: Record } = {} + method = "GET", + }: { status?: number; headers?: Record; method?: string } = {} ) { const events: PersonlessEvent[] = []; const { calls, fetchManifest } = upstream(manifest, status); const request = new Request(`https://updates.surfsense.com/api/updates/${file}?noCache=1`, { + method, headers, }); const response = await serveUpdateManifest(request, file, { @@ -232,6 +234,12 @@ test("each check records one desktop_update_check with exactly the contract's fi ]); }); +test("a HEAD request is answered but not recorded, since the app only GETs", async () => { + const { response, events } = await check("stable.yml", stable, { method: "HEAD" }); + assert.equal(response.status, 200); + assert.equal(events.length, 0); +}); + test("a check is recorded even when GitHub fails, since the app still checked", async () => { const { events } = await check("stable-mac.yml", "nope", { status: 500 }); assert.equal(events.length, 1); From c46c2306cfa38af6c0521917c1c68852fe01bc73 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:56:18 -0700 Subject: [PATCH 6/7] test(web): pin the update manifest size cap with an all-valid manifest The old oversize body ended in a line the parser refuses anyway, so it passed without the cap. Repeated file entries straddling 64 KB now get 200 under the cap and 502 over it. --- .../unit/desktop-telemetry/update-feed.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts index 9932f1f202..7793dcbb31 100644 --- a/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts +++ b/surfsense_web/tests/unit/desktop-telemetry/update-feed.test.ts @@ -213,9 +213,18 @@ test("GitHub failing outright is a 502", async () => { assert.equal(response.status, 502); }); -test("a huge upstream body is a 502", async () => { - const { response } = await check("stable.yml", stable + "#".repeat(70 * 1024)); - assert.equal(response.status, 502); +test("a manifest over 64 KB is a 502, even when every line is valid", async () => { + // Repeats the fixture's own file entry, so only the size cap can refuse it. + const cap = 64 * 1024; + const entry = `${stable.split("\n").slice(2, 5).join("\n")}\n`; + const withEntries = (count: number) => + stable.replace("files:\n", `files:\n${entry.repeat(count)}`); + const under = withEntries(Math.floor((cap - stable.length) / entry.length)); + const over = withEntries(Math.floor((cap - stable.length) / entry.length) + 1); + assert.ok(under.length <= cap && over.length > cap); + + assert.equal((await check("stable.yml", under)).response.status, 200); + assert.equal((await check("stable.yml", over)).response.status, 502); }); test("each check records one desktop_update_check with exactly the contract's fields", async () => { From 8a12f3adff79fb82974eb7fad1aac04b93b62e07 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 21 Oct 2026 16:56:20 -0700 Subject: [PATCH 7/7] docs(web): correct what the feed cache costs and note where the telemetry key must live The 15-minute cache limits our GitHub downloads to about 96 a day per channel file rather than removing them. The PostHog key must belong to the instance NEXT_PUBLIC_POSTHOG_HOST names, and pre-release builds count as invalid. --- surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts | 3 ++- surfsense_web/lib/desktop-telemetry/personless-capture.ts | 6 +++++- .../desktop-telemetry/update-feed/latest-github-manifest.ts | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts index bea3545157..45ec8e2914 100644 --- a/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts +++ b/surfsense_web/lib/desktop-telemetry/desktop-dimensions.ts @@ -16,7 +16,8 @@ export const DESKTOP_PLATFORMS = [ const FIXED_OS_NAMES = new Set(["windows-10", "windows-11", "linux"]); const MACOS_MAJOR = /^macos-[1-9][0-9]$/; -// Plain x.y.z only: a pre-release tag is free text and could carry a name. +// Plain x.y.z only: a pre-release tag is free text and could carry a name, so +// a pre-release build's checks count as "invalid" and its usage reports get 400. export const APP_VERSION = /^(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/; export function isDesktopPlatform(value: unknown): value is (typeof DESKTOP_PLATFORMS)[number] { diff --git a/surfsense_web/lib/desktop-telemetry/personless-capture.ts b/surfsense_web/lib/desktop-telemetry/personless-capture.ts index f6484e01c6..3d8e98270a 100644 --- a/surfsense_web/lib/desktop-telemetry/personless-capture.ts +++ b/surfsense_web/lib/desktop-telemetry/personless-capture.ts @@ -47,7 +47,11 @@ export function createPersonlessCapture({ apiKey, host, fetch }: CaptureConfig): let captureFromEnv: CapturePersonless | undefined; -/** The routes' capture. Does nothing until DESKTOP_TELEMETRY_POSTHOG_KEY is set. */ +/** + * The routes' capture. Does nothing until DESKTOP_TELEMETRY_POSTHOG_KEY is set. + * The key's project must live on the instance NEXT_PUBLIC_POSTHOG_HOST names + * (fixed at build time), or PostHog drops every event without an error. + */ export function capturePersonlessEvent(event: PersonlessEvent): Promise { captureFromEnv ??= createPersonlessCapture({ apiKey: process.env.DESKTOP_TELEMETRY_POSTHOG_KEY, diff --git a/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts index 6c199150c1..148ee3334f 100644 --- a/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts +++ b/surfsense_web/lib/desktop-telemetry/update-feed/latest-github-manifest.ts @@ -9,8 +9,9 @@ export type FetchManifest = ( /** * The channel file of the latest published release. "latest" only moves on - * publish, so a draft merged to main early is never served, and 15 minutes of - * caching keeps our own fetches out of GitHub's download counts. + * publish, so a draft merged to main early is never served. Each revalidation + * is still a download, so 15 minutes of caching adds about 96 a day per file + * (more if Vercel caches per region) to GitHub's stable*.yml counts. */ export async function fetchLatestGithubManifest( file: string,