Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,28 @@ Cloudflare Pages owns production and preview deployment. The project is `scientf
Do not deploy production from a feature branch or store Cloudflare credentials in this repository.

See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution workflow. The cross-repository operating model is maintained in [`ScientFactory/Scient`](https://github.com/ScientFactory/Scient).

## First-party event measurement

Cloudflare D1 stores four anonymous event types:

- `page_viewed`
- `download_clicked`
- `download_failed`
- `outbound_link_clicked`

The implementation intentionally stores no IP address, cookie, fingerprint, referrer, or persistent visitor identifier. Counts therefore represent events rather than unique visitors. `download_failed` is limited to a failure in ScientFactory's redirect service; the website cannot observe a transfer failure after GitHub begins serving an installer.

The production binding is `DOWNLOAD_DB`, backed by the `scientfactory-downloads` D1 database. Apply new migrations before deploying code that depends on them:

```sh
bun run db:migrate
```

To view the lifetime event summary plus 30-day download, outbound-link, and failure breakdowns:

```sh
bun run analytics:report
```

Local and Cloudflare preview hosts do not write events, which keeps production counts free of development traffic.
121 changes: 121 additions & 0 deletions functions/_lib/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// FILE: events.ts
// Purpose: Stores a deliberately small, anonymous set of first-party website events.
// Layer: Cloudflare Pages Function utility

export const SITE_EVENT_NAMES = [
"page_viewed",
"download_clicked",
"download_failed",
"outbound_link_clicked",
] as const;

export type SiteEventName = (typeof SITE_EVENT_NAMES)[number];

export interface SiteEvent {
readonly eventName: SiteEventName;
readonly pagePath?: string | null;
readonly assetKey?: string | null;
readonly releaseTag?: string | null;
readonly assetName?: string | null;
readonly destinationHost?: string | null;
readonly destinationPath?: string | null;
readonly failureStage?: string | null;
readonly failureReason?: string | null;
}

export interface SiteEventContext {
readonly request: Request;
readonly env: { readonly DOWNLOAD_DB?: D1Database };
waitUntil(promise: Promise<unknown>): void;
}

const INSERT_EVENT = `
INSERT INTO site_events (
event_name,
page_path,
asset_key,
release_tag,
asset_name,
destination_host,
destination_path,
failure_stage,
failure_reason
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;

const PRODUCTION_HOSTS = new Set(["scientfactory.com", "www.scientfactory.com"]);

function limited(value: string | null | undefined, maximum: number): string | null {
if (!value) return null;
return value.slice(0, maximum);
}

export function cleanPath(value: string | null | undefined): string | null {
if (!value?.startsWith("/")) return null;
const path = value.split(/[?#]/, 1)[0] ?? "/";
return limited(path, 512);
}

export function destinationParts(
value: string,
siteOrigin: string,
): {
destinationHost: string;
destinationPath: string;
} | null {
try {
const destination = new URL(value);
if (destination.protocol !== "https:" && destination.protocol !== "http:") return null;
if (destination.origin === siteOrigin) return null;

return {
destinationHost: destination.hostname.slice(0, 253),
destinationPath: cleanPath(destination.pathname) ?? "/",
};
} catch {
return null;
}
}

export function shouldPersistSiteEvents(request: Request): boolean {
return PRODUCTION_HOSTS.has(new URL(request.url).hostname);
}

export async function insertSiteEvent(db: D1Database, event: SiteEvent): Promise<void> {
await db
.prepare(INSERT_EVENT)
.bind(
event.eventName,
cleanPath(event.pagePath),
limited(event.assetKey, 64),
limited(event.releaseTag, 80),
limited(event.assetName, 255),
limited(event.destinationHost, 253),
cleanPath(event.destinationPath),
limited(event.failureStage, 80),
limited(event.failureReason, 120),
)
.run();
}

export function queueSiteEvent(context: SiteEventContext, event: SiteEvent): void {
if (!shouldPersistSiteEvents(context.request)) return;

const db = context.env.DOWNLOAD_DB;
if (!db) {
console.error(JSON.stringify({ message: "Site event database binding is missing" }));
return;
}

const write = insertSiteEvent(db, event).catch((error: unknown) => {
console.error(
JSON.stringify({
message: "Site event write failed",
eventName: event.eventName,
error: error instanceof Error ? error.message : String(error),
}),
);
});

context.waitUntil(write);
}
131 changes: 131 additions & 0 deletions functions/api/download/[asset].test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { onRequestGet, onRequestHead } from "./[asset]";

const releaseFixture = {
tag_name: "v0.5.7",
name: "Scient v0.5.7",
html_url: "https://github.com/ScientFactory/scient-desktop/releases/tag/v0.5.7",
published_at: "2026-07-20T00:00:00Z",
prerelease: false,
assets: [
{
name: "Scient-0.5.7-arm64.dmg",
browser_download_url:
"https://github.com/ScientFactory/scient-desktop/releases/download/v0.5.7/Scient-0.5.7-arm64.dmg",
content_type: "application/x-apple-diskimage",
size: 125_000_000,
},
],
};

function createDatabase(run = vi.fn().mockResolvedValue({ success: true })) {
const bind = vi.fn(() => ({ run }));
const prepare = vi.fn(() => ({ bind }));
return { database: { prepare } as unknown as D1Database, prepare, bind, run };
}

function createContext(
asset = "macArm64",
database = createDatabase().database,
): Parameters<typeof onRequestGet>[0] & { waitUntil: ReturnType<typeof vi.fn> } {
return {
request: new Request(`https://scientfactory.com/api/download/${asset}`),
env: { DOWNLOAD_DB: database },
params: { asset },
data: undefined,
functionPath: "api/download/[asset]",
next: vi.fn(),
waitUntil: vi.fn(),
passThroughOnException: vi.fn(),
} as unknown as Parameters<typeof onRequestGet>[0] & { waitUntil: ReturnType<typeof vi.fn> };
}

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe("tracked download redirect", () => {
it("records a click and redirects to the official installer", async () => {
const db = createDatabase();
const context = createContext("macArm64", db.database);
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(Response.json(releaseFixture)));

const response = await onRequestGet(context);

expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe(releaseFixture.assets[0]?.browser_download_url);
expect(context.waitUntil).toHaveBeenCalledTimes(1);
await context.waitUntil.mock.calls[0]?.[0];
expect(db.bind).toHaveBeenCalledWith(
"download_clicked",
"/download",
"macArm64",
"v0.5.7",
"Scient-0.5.7-arm64.dmg",
"github.com",
"/ScientFactory/scient-desktop/releases/download/v0.5.7/Scient-0.5.7-arm64.dmg",
null,
null,
);
});

it("does not let a database failure block a valid redirect", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const db = createDatabase(vi.fn().mockRejectedValue(new Error("database unavailable")));
const context = createContext("macArm64", db.database);
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(Response.json(releaseFixture)));

const response = await onRequestGet(context);

expect(response.status).toBe(302);
await context.waitUntil.mock.calls[0]?.[0];
});

it("records a ScientFactory-side failure when resolution fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const db = createDatabase();
const context = createContext("macArm64", db.database);
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 })),
);

const response = await onRequestGet(context);

expect(response.status).toBe(503);
await context.waitUntil.mock.calls[0]?.[0];
expect(db.bind).toHaveBeenCalledWith(
"download_failed",
"/download",
"macArm64",
null,
null,
null,
null,
"release_fetch",
"upstream_unavailable",
);
});

it("rejects unknown asset keys before contacting GitHub", async () => {
const fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetchMock);

const response = await onRequestGet(createContext("unknown"));

expect(response.status).toBe(404);
expect(fetchMock).not.toHaveBeenCalled();
});

it("supports an untracked HEAD verification", async () => {
const context = createContext();
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(Response.json(releaseFixture)));

const response = await onRequestHead(context);

expect(response.status).toBe(302);
expect(context.waitUntil).not.toHaveBeenCalled();
});
});
Loading