diff --git a/marketing/metrics/weekly.md b/marketing/metrics/weekly.md index 12b1f47..03c41f0 100644 --- a/marketing/metrics/weekly.md +++ b/marketing/metrics/weekly.md @@ -1,6 +1,6 @@ # Weekly funnel snapshot -Window: **2026-10-22 17:01:33 → 2026-10-29 17:01:33 UTC** (7 days). Previous: 2026-10-15 17:01:33 → 2026-10-22 17:01:33 UTC. +Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC. Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's. @@ -32,7 +32,7 @@ Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.t ## Before acting on these numbers -- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the real client address and the site they addressed. +- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines. - **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers). - **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric. @@ -40,4 +40,4 @@ Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.t - `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns. - `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel. -- Generated 2026-10-29T17:01:33Z. +- Generated 2026-10-29T18:05:00Z. diff --git a/src/env/server.ts b/src/env/server.ts index f0c9a85..e23561c 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -69,6 +69,12 @@ const serverEnvSchema = z.object({ // Bearer secret for the daily lifecycle-email cron. Falls back to // Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all. NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(), + // Daily VisitorAnalytics retention cron (/api/analytics-retention). Same + // shape: falls back to CRON_SECRET, and unset denies every request. + ANALYTICS_RETENTION_SECRET: z.string().min(1).optional(), + // How long a VisitorAnalytics row lives. Unset (or unusable) = 365 days; + // see src/server/analytics-retention.ts before shortening it. + ANALYTICS_RETENTION_DAYS: z.string().optional(), CRON_SECRET: z.string().min(1).optional() }); diff --git a/src/routes/api/analytics-retention.ts b/src/routes/api/analytics-retention.ts new file mode 100644 index 0000000..087547f --- /dev/null +++ b/src/routes/api/analytics-retention.ts @@ -0,0 +1,35 @@ +import type { APIEvent } from "@solidjs/start/server"; +import { env } from "~/env/server"; +import { ConnectionFactory } from "~/server/db-connections"; +import { runRetentionCron } from "~/server/analytics-retention"; + +/** + * GET /api/analytics-retention — daily Vercel cron. + * + * Deletes `VisitorAnalytics` rows older than the retention window, so visitor + * data has a bound instead of accumulating forever (FRE-369, finding Q4 — the + * table's only cleaner used to be a manual admin call, and since FRE-366 the + * rows carry a truncated but still real client address). + * + * Window: `ANALYTICS_RETENTION_DAYS`, defaulting to 365 when unset or unusable + * (see `~/server/analytics-retention` for why the default is conservative + * rather than the reviewer's 90). + * + * Auth: `Authorization: Bearer ` where the secret is + * `ANALYTICS_RETENTION_SECRET` or Vercel's `CRON_SECRET`. An unconfigured secret + * denies every request — an unauthenticated trigger of a bulk delete is the one + * outcome this endpoint must never have. + */ +export async function GET(event: APIEvent) { + const result = await runRetentionCron({ + authorization: event.request.headers.get("authorization"), + secret: env.ANALYTICS_RETENTION_SECRET ?? env.CRON_SECRET, + configuredDays: env.ANALYTICS_RETENTION_DAYS, + conn: ConnectionFactory() + }); + + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { "Content-Type": "application/json" } + }); +} diff --git a/src/routes/api/the-nook/download.test.ts b/src/routes/api/the-nook/download.test.ts index 026a77f..a396442 100644 --- a/src/routes/api/the-nook/download.test.ts +++ b/src/routes/api/the-nook/download.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, mock, beforeEach } from "bun:test"; +import { truncateIpAddress } from "~/server/request-client"; // The route's contracts: // 1. Every click is recorded through the shared analytics buffer with the @@ -55,9 +56,13 @@ mock.module("~/server/analytics", () => ({ flushAnalytics: async () => { flushCalls += 1; }, - // Mirrors the real enrichment contract: deviceType is derived from the UA. + // Mirrors the real enrichment contract: deviceType is derived from the UA, + // and the address is cut to its prefix by the FRE-369 privacy floor. The + // real `enrichAnalyticsEntry` does both, so the mirror has to apply the same + // truncation — otherwise this file would prove the door stores a full address. enrichAnalyticsEntry: (entry: Entry) => ({ ...entry, + ipAddress: truncateIpAddress(entry.ipAddress as string | null), deviceType: String(entry.userAgent ?? "").includes("Macintosh") ? "desktop" : null @@ -141,9 +146,16 @@ describe("GET /api/the-nook/download", () => { expect(logged[2]!.country).toBe("JP"); }); - it("falls back to the forwarded address when no request IP is available", async () => { - await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" })); - expect(logged[0]!.ipAddress).toBe("198.51.100.9"); + it("records the forwarded visitor below the trusted edge, truncated", async () => { + // Client → Cloudflare → origin proxy: the sender's spoof is first, the real + // client second, the edge last. And the stored value is the /24, never the + // address itself (FRE-369). + await GET( + request({ + "x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1" + }) + ); + expect(logged[0]!.ipAddress).toBe("203.0.113.0"); }); it("still logs the click when the appcast cannot be read", async () => { diff --git a/src/server/analytics-retention.test.ts b/src/server/analytics-retention.test.ts new file mode 100644 index 0000000..7fbdac1 --- /dev/null +++ b/src/server/analytics-retention.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { Database } from "bun:sqlite"; +import { VISITOR_ANALYTICS_DDL } from "./analytics-schema"; +import { + DEFAULT_ANALYTICS_RETENTION_DAYS, + MAX_ANALYTICS_RETENTION_DAYS, + isAuthorizedCron, + pruneVisitorAnalytics, + resolveRetentionDays, + runRetentionCron +} from "./analytics-retention"; + +// Why a real in-memory SQLite database: retention is a `DELETE` whose whole +// job is a date comparison. A stub returning a canned count would pass with a +// window that is off by a unit, inverted, or — the failure this file exists for +// — never applied at all. Same reasoning as the backfill and schema suites, and +// nothing is module-mocked here on purpose: bun's `mock.module` is process-wide. + +let db: Database; + +const conn = { + async execute(query: { sql: string; args?: unknown[] }) { + const statement = db.query(query.sql); + const args = (query.args ?? []) as never[]; + // `DELETE … RETURNING id` answers with rows, which is how the prune counts + // what it removed; bun:sqlite exposes those through `.all()`. + if ( + /^\s*(select|pragma|with)\b/i.test(query.sql) || + /\breturning\b/i.test(query.sql) + ) { + return { rows: statement.all(...args) as unknown[], rowsAffected: 0 }; + } + return { + rows: [] as unknown[], + rowsAffected: statement.run(...args).changes + }; + } +}; + +function insert(id: string, createdAt: string) { + db.run( + `INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES (?, '/x', 'GET', ?)`, + [id, createdAt] + ); +} + +function rowCount(): number { + return (db.query(`SELECT COUNT(*) AS n FROM VisitorAnalytics`).get() as { + n: number; + }).n; +} + +beforeEach(() => { + db = new Database(":memory:"); + db.run(VISITOR_ANALYTICS_DDL); +}); + +describe("resolveRetentionDays", () => { + it("takes a usable configured window", () => { + expect(resolveRetentionDays("90")).toBe(90); + expect(resolveRetentionDays(30)).toBe(30); + }); + + it("falls back to the default when the value is missing or nonsense", () => { + expect(resolveRetentionDays(undefined)).toBe( + DEFAULT_ANALYTICS_RETENTION_DAYS + ); + expect(resolveRetentionDays("")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS); + expect(resolveRetentionDays("0")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS); + expect(resolveRetentionDays("-5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS); + expect(resolveRetentionDays("1.5")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS); + expect(resolveRetentionDays("all")).toBe(DEFAULT_ANALYTICS_RETENTION_DAYS); + }); + + it("refuses a window so long it means 'never prune'", () => { + expect(resolveRetentionDays(String(MAX_ANALYTICS_RETENTION_DAYS + 1))).toBe( + DEFAULT_ANALYTICS_RETENTION_DAYS + ); + }); +}); + +describe("pruneVisitorAnalytics", () => { + it("deletes only rows older than the window", async () => { + insert("young", "2020-01-01 00:00:00"); + insert("old", "2019-01-01 00:00:00"); + // A row exactly at `now - 365 days` boundary in the other direction: the + // comparison is `<`, so the recent one survives. + db.run( + `UPDATE VisitorAnalytics SET created_at = datetime('now', '-10 days') WHERE id = 'young'` + ); + db.run( + `UPDATE VisitorAnalytics SET created_at = datetime('now', '-400 days') WHERE id = 'old'` + ); + + const deleted = await pruneVisitorAnalytics(conn, 365); + + expect(deleted).toBe(1); + expect(rowCount()).toBe(1); + }); + + it("deletes nothing when every row is inside the window", async () => { + insert("a", "x"); + db.run( + `UPDATE VisitorAnalytics SET created_at = datetime('now', '-1 day') WHERE id = 'a'` + ); + + expect(await pruneVisitorAnalytics(conn, 365)).toBe(0); + expect(rowCount()).toBe(1); + }); + + it("refuses a window it was not given a number for", async () => { + insert("a", "2019-01-01 00:00:00"); + + await expect(pruneVisitorAnalytics(conn, 0)).rejects.toThrow(/window/); + await expect(pruneVisitorAnalytics(conn, -30)).rejects.toThrow(/window/); + await expect(pruneVisitorAnalytics(conn, NaN)).rejects.toThrow(/window/); + // "0" is not a window either — and must not delete the table. + await expect( + pruneVisitorAnalytics(conn, "0" as unknown as number) + ).rejects.toThrow(/window/); + + expect(rowCount()).toBe(1); + }); + + it("passes the window as a parameter, not as interpolated SQL", async () => { + insert("a", "2019-01-01 00:00:00"); + const injected = "365); DROP TABLE VisitorAnalytics; --" as unknown as number; + + // Truncating a non-numeric string yields NaN, which the guard refuses — so + // the statement never runs and the table is still there. + await expect(pruneVisitorAnalytics(conn, injected)).rejects.toThrow(/window/); + expect(rowCount()).toBe(1); + }); +}); + +describe("isAuthorizedCron", () => { + it("matches the bearer secret and nothing else", () => { + expect(isAuthorizedCron("Bearer sekrit", "sekrit")).toBe(true); + expect(isAuthorizedCron("bearer SEKRIT".toLowerCase(), "sekrit")).toBe(true); + expect(isAuthorizedCron("Bearer nope", "sekrit")).toBe(false); + expect(isAuthorizedCron(null, "sekrit")).toBe(false); + expect(isAuthorizedCron("Bearer sekrit", null)).toBe(false); + }); + + it("denies everything when no secret is configured (fail closed)", () => { + expect(isAuthorizedCron("Bearer ", undefined)).toBe(false); + expect(isAuthorizedCron(undefined, "")).toBe(false); + }); +}); + +describe("runRetentionCron", () => { + it("denies an unauthenticated trigger before touching the table", async () => { + insert("a", "2019-01-01 00:00:00"); + + const res = await runRetentionCron({ + authorization: null, + secret: "sekrit", + configuredDays: "365", + conn + }); + + expect(res.status).toBe(401); + expect(rowCount()).toBe(1); + }); + + it("prunes on its window and reports what it removed", async () => { + db.run( + `INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('old','/x','GET',datetime('now','-400 days'))` + ); + db.run( + `INSERT INTO VisitorAnalytics (id, path, method, created_at) VALUES ('new','/x','GET',datetime('now','-1 day'))` + ); + + const res = await runRetentionCron({ + authorization: "Bearer sekrit", + secret: "sekrit", + configuredDays: "365", + conn + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, days: 365, deleted: 1 }); + expect(rowCount()).toBe(1); + }); + + it("says it failed rather than reporting a quiet zero", async () => { + db.close(); // the table is gone: the prune's statement must now throw + + const res = await runRetentionCron({ + authorization: "Bearer sekrit", + secret: "sekrit", + configuredDays: "365", + conn + }); + + expect(res.status).toBe(500); + expect(res.body.days).toBe(365); + expect(res.body.deleted).toBeUndefined(); + }); +}); diff --git a/src/server/analytics-retention.ts b/src/server/analytics-retention.ts new file mode 100644 index 0000000..cef1f58 --- /dev/null +++ b/src/server/analytics-retention.ts @@ -0,0 +1,129 @@ +/** + * Scheduled retention for `VisitorAnalytics` (FRE-369, finding Q4). + * + * The table used to grow without bound: `cleanupOldAnalytics` existed but was + * reachable only as a manual tRPC call, and once FRE-366 started recording a + * real client address instead of the proxy's loopback, "unbounded" meant + * unbounded personal data in a hosted third-party database. Two halves answer + * that — the write-time prefix in `./request-client`, and this: a bound on how + * long any row exists at all. + * + * The default (365 days) is deliberately conservative so enabling the schedule + * is not itself a destructive event: it deletes nothing on its first run + * against a database whose history starts inside the window, and it bounds + * growth from then on. Tighten it to the reviewer's suggested 90 by setting + * `ANALYTICS_RETENTION_DAYS=90` — that first run *will* delete months of + * history that the /analytics dashboard and the weekly report read, which is an + * operational decision, not one this file should make silently. + */ + +/** Kept when `ANALYTICS_RETENTION_DAYS` is unset or unusable. */ +export const DEFAULT_ANALYTICS_RETENTION_DAYS = 365; + +/** Anything longer is a misconfiguration, not a policy (≈ 2 years). */ +export const MAX_ANALYTICS_RETENTION_DAYS = 730; + +/** + * The slice of a connection this module needs — the same shape + * `weekly-metrics.ts` pins as `MetricsConn`, so a libsql client satisfies it + * without a cast (and the in-memory SQLite test adapter satisfies it too). + */ +export interface AnalyticsConn { + execute(query: { + sql: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args?: any[]; + }): Promise<{ rows: unknown[]; rowsAffected?: number }>; +} + +/** + * The retention window to enforce: a positive integer at most + * {@link MAX_ANALYTICS_RETENTION_DAYS}, else the default. Accepts the env + * string form and a number, because both callers hand over a different shape. + */ +export function resolveRetentionDays( + configured: string | number | undefined | null +): number { + const days = typeof configured === "number" ? configured : Number(configured); + if (!Number.isInteger(days) || days <= 0) return DEFAULT_ANALYTICS_RETENTION_DAYS; + if (days > MAX_ANALYTICS_RETENTION_DAYS) return DEFAULT_ANALYTICS_RETENTION_DAYS; + return days; +} + +/** + * Delete analytics rows older than `olderThanDays` and report how many went. + * + * The window is a SQL parameter, not interpolated text: `cleanupOldAnalytics` + * used to splice the number into the statement, which only ever sat behind a + * `z.number()` input on the tRPC side — one unvalidated caller away from + * injection. A non-integer is refused here rather than passed on, so neither + * path can delete on a window the code did not check. + */ +export async function pruneVisitorAnalytics( + conn: AnalyticsConn, + olderThanDays: number +): Promise { + const days = Math.trunc(Number(olderThanDays)); + if (!Number.isInteger(days) || days <= 0) { + throw new Error( + `Refusing to prune VisitorAnalytics on a non-positive window: ${olderThanDays}` + ); + } + + const result = await conn.execute({ + sql: `DELETE FROM VisitorAnalytics + WHERE created_at < datetime('now', ?) + RETURNING id`, + args: [`-${days} days`] + }); + + // Counted from `RETURNING id` rather than `rowsAffected`, keeping the contract + // `cleanupOldAnalytics` had: the caller is told how many rows actually went. + return result.rows.length; +} + +/** Whether a cron may run: Bearer match, and an unset secret denies everything. */ +export function isAuthorizedCron( + authorization: string | null | undefined, + secret: string | undefined | null +): boolean { + if (!secret) return false; + const provided = (authorization ?? "").replace(/^Bearer\s+/i, "").trim(); + return Boolean(provided) && provided === secret; +} + +export interface RetentionOutcome { + status: number; + body: Record; +} + +/** + * The whole cron decision, away from the route: authorise, resolve the window, + * prune. Returns the status and body the route should send, so the behaviour is + * testable without a database or an environment. + * + * Fail closed on auth (an unauthenticated trigger deleting real history is the + * worst thing this endpoint can do), and 500 rather than a silent zero on a + * failed prune — a retention job that quietly stopped pruning is exactly the + * state that created this issue. + */ +export async function runRetentionCron(options: { + authorization: string | null | undefined; + secret: string | undefined | null; + configuredDays: string | number | undefined | null; + conn: AnalyticsConn; +}): Promise { + if (!isAuthorizedCron(options.authorization, options.secret)) { + return { status: 401, body: { error: "Unauthorized" } }; + } + + const days = resolveRetentionDays(options.configuredDays); + + try { + const deleted = await pruneVisitorAnalytics(options.conn, days); + return { status: 200, body: { success: true, days, deleted } }; + } catch (error) { + console.error("Analytics retention job failed:", error); + return { status: 500, body: { error: "Analytics retention job failed", days } }; + } +} diff --git a/src/server/analytics.ts b/src/server/analytics.ts index b37cef0..9eda92d 100644 --- a/src/server/analytics.ts +++ b/src/server/analytics.ts @@ -9,6 +9,8 @@ import { VISITOR_ANALYTICS_INDEXES, VISITOR_ANALYTICS_INSERT } from "./analytics-schema"; +import { truncateIpAddress } from "./request-client"; +import { pruneVisitorAnalytics } from "./analytics-retention"; /** * Idempotent schema bootstrap, mirroring `nook.ts`. The table had no DDL in @@ -317,6 +319,10 @@ export async function getAnalyticsSummary(days: number = 30): Promise<{ }); const totalApiCalls = (totalApiCallsResult.rows[0]?.count as number) || 0; + // Since the FRE-369 privacy floor, `ip_address` holds a truncated prefix + // (/24 for v4, /56 for v6), so this counts distinct prefixes — households and + // mobile exits, not machines. It was never accurate before that either: every + // row held the proxy's loopback, so the count was 1. const uniqueVisitorsResult = await conn.execute({ sql: `SELECT COUNT(DISTINCT ip_address) as count FROM VisitorAnalytics WHERE ip_address IS NOT NULL @@ -628,19 +634,15 @@ export async function getPerformanceStats(days: number = 30): Promise<{ }; } +/** + * Delete visitor analytics older than `olderThanDays`. Reachable from tRPC + * (admin) and from the daily retention cron (`/api/analytics/retention`); + * the SQL and the input rules live in `./analytics-retention`. + */ export async function cleanupOldAnalytics( olderThanDays: number ): Promise { - const conn = ConnectionFactory(); - - const result = await conn.execute({ - sql: `DELETE FROM VisitorAnalytics - WHERE created_at < datetime('now', '-${olderThanDays} days') - RETURNING id`, - args: [] - }); - - return result.rows.length; + return pruneVisitorAnalytics(ConnectionFactory(), olderThanDays); } function parseUserAgent(userAgent?: string): { @@ -675,6 +677,16 @@ function parseUserAgent(userAgent?: string): { return { deviceType, browser, os }; } +/** + * Fill in what the writer did not supply, and apply the privacy floor. + * + * Every analytics entry — page views, tRPC context, downloads, checkout — + * passes through here, which is why the truncation lives here and not at the + * call sites: a future writer cannot forget it, and no caller can persist a + * full client address by accident (FRE-369, finding Q4). The audit log + * (`./audit.ts`) is a separate, pre-existing surface with its own stakes and is + * deliberately untouched by this change. + */ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry { const { deviceType, browser, os } = parseUserAgent( entry.userAgent || undefined @@ -682,6 +694,7 @@ export function enrichAnalyticsEntry(entry: AnalyticsEntry): AnalyticsEntry { return { ...entry, + ipAddress: truncateIpAddress(entry.ipAddress), deviceType: entry.deviceType || deviceType, browser: entry.browser || browser, os: entry.os || os diff --git a/src/server/request-client.test.ts b/src/server/request-client.test.ts index fc40bb8..012e4c2 100644 --- a/src/server/request-client.test.ts +++ b/src/server/request-client.test.ts @@ -1,19 +1,30 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; -// The trust boundary of FRE-366: x-forwarded-for is client-controlled text, -// believed only when the direct peer is the loopback production proxy. These -// tests pin that boundary — a regression here either re-records the proxy as -// the visitor (unique visitors collapses to one host) or lets any client -// spoof its address. +// The trust boundary of FRE-366, as corrected by the FRE-369 security review. +// Two things must stay true here: +// 1. The address we record is the visitor, not whoever typed a header first. +// Cloudflare and the origin proxy both *append* to x-forwarded-for, so the +// leftmost hop is the client's own invention — `cf-connecting-ip` (which +// Cloudflare overwrites) comes first, then the hop below the trusted edge. +// 2. What reaches the database is a prefix, never a full address +// (`truncateIpAddress`), because a visitor row is not worth a precise +// location. +// A regression on (1) re-records whatever an attacker claims; on (2) it stores +// personal data we promised not to. let directIp: string | null = null; mock.module("vinxi/http", () => ({ getRequestIP: () => directIp })); -const { resolveClientIp, isLoopbackAddress, requestHost } = await import( - "./request-client" -); +const { + resolveClientIp, + isLoopbackAddress, + isIpAddress, + truncateIpAddress, + normaliseHost, + requestHost +} = await import("./request-client"); function event( headers: Record = {}, @@ -30,26 +41,68 @@ beforeEach(() => { }); describe("resolveClientIp", () => { - it("takes the first forwarded hop when the direct peer is the loopback proxy", () => { + it("prefers Cloudflare's connecting address over any forwarded header", () => { directIp = "127.0.0.1"; expect( - resolveClientIp(event({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" })) - ).toBe("198.51.100.9"); + resolveClientIp( + event({ + "cf-connecting-ip": "203.0.113.7", + "x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1" + }) + ) + ).toBe("203.0.113.7"); + }); + it("takes the hop below the trusted edge — never the leftmost one", () => { + // client → Cloudflare → same-host origin proxy: the sender's spoof is + // first, the real client second, the edge last. directIp = "::1"; expect( - resolveClientIp(event({ "x-forwarded-for": "198.51.100.10" })) - ).toBe("198.51.100.10"); + resolveClientIp( + event({ + "x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1" + }) + ) + ).toBe("203.0.113.7"); + }); + + it("refuses a single-hop forwarded header: nothing above it to discount", () => { + directIp = "127.0.0.1"; + expect( + resolveClientIp(event({ "x-forwarded-for": "198.51.100.9" })) + ).toBe("127.0.0.1"); }); it("ignores a forwarded header from a real peer — it is spoofable", () => { directIp = "203.0.113.7"; expect( - resolveClientIp(event({ "x-forwarded-for": "198.51.100.9" })) + resolveClientIp( + event({ + "cf-connecting-ip": "198.51.100.9", + "x-forwarded-for": "198.51.100.9, 10.0.0.1" + }) + ) ).toBe("203.0.113.7"); }); - it("keeps the loopback peer when the proxy set no forwarded header", () => { + it("ignores a header value that is not an address", () => { + directIp = "127.0.0.1"; + expect( + resolveClientIp( + event({ + "cf-connecting-ip": "not-an-address", + "x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1" + }) + ) + ).toBe("203.0.113.7"); + + directIp = "127.0.0.1"; + expect( + resolveClientIp(event({ "cf-connecting-ip": "evil.example.com" })) + ).toBe("127.0.0.1"); + }); + + it("keeps the loopback peer when the proxy set nothing usable", () => { directIp = "::ffff:127.0.0.1"; expect(resolveClientIp(event())).toBe("::ffff:127.0.0.1"); }); @@ -60,13 +113,79 @@ describe("resolveClientIp", () => { }); describe("isLoopbackAddress", () => { - it("covers the v4 range, the shorthand and the v6 forms", () => { + it("covers the v4 range and every spelling of v6 loopback", () => { expect(isLoopbackAddress("127.0.0.1")).toBe(true); expect(isLoopbackAddress("127.0.0.53")).toBe(true); expect(isLoopbackAddress("::1")).toBe(true); + expect(isLoopbackAddress("0:0:0:0:0:0:0:1")).toBe(true); expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackAddress("::ffff:127.9.9.9")).toBe(true); + }); + + it("is not loopback for a real peer, or for text it cannot parse", () => { expect(isLoopbackAddress("203.0.113.7")).toBe(false); expect(isLoopbackAddress("10.0.0.1")).toBe(false); + expect(isLoopbackAddress("localhost")).toBe(false); + expect(isLoopbackAddress("127.0.53")).toBe(false); // not a complete address + expect(isLoopbackAddress("")).toBe(false); + }); +}); + +describe("isIpAddress", () => { + it("accepts a dotted quad and an IPv6 literal", () => { + expect(isIpAddress("198.51.100.9")).toBe(true); + expect(isIpAddress("2001:db8::1")).toBe(true); + expect(isIpAddress("::ffff:198.51.100.9")).toBe(true); + }); + + it("rejects anything a proxy could not have meant", () => { + expect(isIpAddress("999.1.1.1")).toBe(false); + expect(isIpAddress("1.2.3")).toBe(false); + expect(isIpAddress("2001:db8:::1")).toBe(false); + expect(isIpAddress("evil.example.com")).toBe(false); + expect(isIpAddress("")).toBe(false); + expect(isIpAddress(null)).toBe(false); + }); +}); + +describe("truncateIpAddress", () => { + it("cuts IPv4 to its /24", () => { + expect(truncateIpAddress("198.51.100.9")).toBe("198.51.100.0"); + expect(truncateIpAddress("127.0.0.1")).toBe("127.0.0.0"); + }); + + it("cuts IPv6 to its /56, uncompressed and stable", () => { + expect( + truncateIpAddress("2001:db8:85a3:1234:5678:9abc:defa:1234") + ).toBe("2001:db8:85a3:1200:0:0:0:0"); + // The compressed spelling of the same address truncates to the same string, + // so `COUNT(DISTINCT ip_address)` cannot be split by how a proxy rendered it. + expect(truncateIpAddress("2001:db8:85a3:1234::")).toBe( + "2001:db8:85a3:1200:0:0:0:0" + ); + expect(truncateIpAddress("::1")).toBe("0:0:0:0:0:0:0:0"); + }); + + it("treats an IPv4-mapped v6 address as the v4 it is", () => { + expect(truncateIpAddress("::ffff:198.51.100.9")).toBe("198.51.100.0"); + }); + + it("drops what it cannot parse rather than storing it", () => { + expect(truncateIpAddress("203.0.113.999")).toBeNull(); + expect(truncateIpAddress("")).toBeNull(); + expect(truncateIpAddress("")).toBeNull(); + expect(truncateIpAddress(null)).toBeNull(); + expect(truncateIpAddress(undefined)).toBeNull(); + }); +}); + +describe("normaliseHost", () => { + it("lowercases and strips the port so the weekly host scope can match", () => { + expect(normaliseHost("Nook.Freno.ME:443")).toBe("nook.freno.me"); + expect(normaliseHost("nook.freno.me.")).toBe("nook.freno.me"); + expect(normaliseHost("[::1]:3000")).toBe("::1"); + expect(normaliseHost(" ")).toBeNull(); + expect(normaliseHost(null)).toBeNull(); }); }); @@ -75,4 +194,10 @@ describe("requestHost", () => { expect(requestHost(event())).toBe("nook.freno.me"); expect(requestHost(event({}, "https://freno.me/"))).toBe("freno.me"); }); + + it("normalises a host that carries a port", () => { + expect(requestHost(event({}, "https://nook.freno.me:443/downloads"))).toBe( + "nook.freno.me" + ); + }); }); diff --git a/src/server/request-client.ts b/src/server/request-client.ts index 2d79080..41ea96c 100644 --- a/src/server/request-client.ts +++ b/src/server/request-client.ts @@ -3,65 +3,280 @@ import { getRequestIP } from "vinxi/http"; import { hostFromEventLike } from "./site-context-server"; /** - * The real client address behind the production proxy (FRE-366). + * The client address and the site a request addressed, for the analytics + * writers (FRE-366). * - * Until this existed every analytics writer called `getRequestIP` and only - * looked at `x-forwarded-for` when it returned null — so behind the - * production proxy the recorded address was the proxy itself, and all - * 12,035 addressed rows in VisitorAnalytics carried 127.0.0.1 / ::1. + * Until this existed every writer called `getRequestIP` and only looked at + * `x-forwarded-for` when it returned null — so behind the production proxy the + * recorded address was the proxy itself, and all 12,035 addressed rows in + * VisitorAnalytics carried 127.0.0.1 / ::1. * - * Trust boundary, stated plainly: `x-forwarded-for` is client-controlled - * text. It is believed only when the direct peer is a loopback address — - * the production shape, where a same-host proxy (nginx/caddy) terminates - * TLS and sets the header itself. Anywhere else the direct peer IS the - * client (dev, direct hit), and a forwarded header from it is ignored as - * spoofable. The first hop of the header is taken; later hops are the - * client's own claims. + * STANDING RULE (FRE-369, the security review of that change): nothing here is + * a security signal. The address is a *best guess at a visitor*, not an + * authenticated identity — do not import it into auth, rate limiting, abuse + * control, fraud checks, or anything that grants or denies access. A caller + * that needs a security decision must re-derive trust for its own stakes; the + * blast radius of a wrong answer here is a poisoned analytics row, and that is + * the only reason the shortcuts below are acceptable. + * + * Trust boundary, stated plainly: `x-forwarded-for` is client-controlled text. + * It is believed only when the direct peer is a loopback address — the + * production shape, where a same-host proxy (nginx/caddy) terminates TLS and + * sets the header itself. Anywhere else the direct peer IS the client (dev, + * direct hit) and a forwarded header from it is ignored as spoofable. + * + * Which hop is the visitor (FRE-369 finding Q1): the request arrives as + * `client → Cloudflare → same-host origin proxy → app`, and both proxies + * *append*. So `x-forwarded-for` reaches us as + * `, , ` and the leftmost + * hop is exactly the one the sender chose — the original FRE-366 read took it + * and was wrong. Order of preference: + * + * 1. `cf-connecting-ip` — Cloudflare *overwrites* this header, so a + * client-supplied value never survives the edge. Authoritative. + * 2. `x-forwarded-for`, counted from the right: drop the rightmost hop (the + * Cloudflare edge address the origin proxy appended) and take the one + * before it. Never the leftmost. + * 3. the direct peer, as before. + * + * h3's own `getRequestIP(event, { xForwardedFor: true })` also takes the + * leftmost hop, so it is not a fix for this and must not be swapped in. + * + * Residual caveat, deliberately not papered over: step 2 assumes the pinned + * topology (one trusted proxy above us, i.e. Cloudflare). Behind a *single* + * forwarding proxy that does not set `cf-connecting-ip`, a client that sent its + * own `x-forwarded-for` sits at the hop we read. That is why the value is only + * ever stored truncated (see `truncateIpAddress`) and why it stays out of every + * security path. Deploy-side precondition this repo cannot verify: the origin + * must be reachable only from Cloudflare (origin firewall / authenticated + * origin pull). If it is directly reachable, an attacker can set every header + * in this file and no read here can recover the truth. + */ + +/** Cloudflare's "who actually connected" header: overwritten at the edge. */ +const CF_CONNECTING_IP = "cf-connecting-ip"; +const X_FORWARDED_FOR = "x-forwarded-for"; + +/** + * Hops discounted from the right of `x-forwarded-for` before the value is + * taken: the Cloudflare edge address the origin proxy appended. Pinned to 1 + * because there is exactly one trusted proxy between Cloudflare and the app; + * raise it only if the topology gains a trusted hop. + */ +const FORWARDED_HOPS_TO_DISCOUNT = 1; + +function header(event: APIEvent, name: string): string | null { + try { + return event.request?.headers?.get?.(name) ?? null; + } catch { + return null; + } +} + +/** + * The visitor's address, resolved through the trust boundary above. + * + * Never store the result of this directly: `enrichAnalyticsEntry` runs every + * analytics entry through {@link truncateIpAddress} so a full address cannot + * reach the database even from a writer that forgets. Callers that need the + * un-truncated value must justify it in review. */ export function resolveClientIp(event: APIEvent): string | null { const direct = getRequestIP(event.nativeEvent); - if (direct && !isLoopbackAddress(direct)) { - return direct; - } - - const forwarded = event.request?.headers?.get?.("x-forwarded-for"); - const first = forwarded?.split(",")[0]?.trim(); - if (first) { - return first; + // A real peer is its own client: believed, and no forwarded header from it + // is (it would be the peer lying about who it is). + if (direct && !isLoopbackAddress(direct)) return direct; + + // 1. Cloudflare's own record of the connecting address. + const connecting = cleanHop(header(event, CF_CONNECTING_IP)); + if (isIpAddress(connecting)) return connecting; + + // 2. Rightmost-minus-the-trusted-hop, never leftmost. + const hops = (header(event, X_FORWARDED_FOR) ?? "") + .split(",") + .map(cleanHop) + .filter(Boolean); + const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT; + if (index >= 0) { + const candidate = hops[index]!; + if (isIpAddress(candidate)) return candidate; } + // 3. Nothing trustworthy: record what we actually saw, even though it is the + // proxy. `readTraffic` reports these rows rather than hiding them. return direct ?? null; } -export function isLoopbackAddress(ip: string): boolean { - return ( - ip === "::1" || - ip === "::ffff:127.0.0.1" || - ip === "127.0.0.1" || - ip.startsWith("127.") - ); +function cleanHop(value: string | null | undefined): string { + return (value ?? "").trim(); } /** - * The site the request addressed — the `Host` the client asked for, e.g. - * `nook.freno.me`. Recorded on every analytics row so a weekly read can - * scope to one site; without it `/` on nook.freno.me is the same row as - * `/` on freno.me. Thin alias over the site-context extractor so the - * analytics writers share one host-parsing implementation with routing. + * Whether an address is the loopback the production proxy presents. + * + * Parses rather than string-matches, so the forms a real peer can present in — + * `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all + * recognised. An address this does not parse is *not* loopback, which fails + * closed: forwarded headers from an unrecognised peer are ignored rather than + * believed. + */ +export function isLoopbackAddress(ip: string): boolean { + const bytes = addressBytes(ip); + if (!bytes) return false; + if (bytes.length === 4) return bytes[0] === 127; + + // IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too. + const mapped = ipv4FromBytes(bytes); + if (mapped) return isLoopbackAddress(mapped); + const tail = bytes[15]!; + return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1; +} + +// ── Addresses: parse, validate, truncate ──────────────────────────────────── + +/** + * The privacy floor the security review required before this ships (FRE-369, + * finding Q4): VisitorAnalytics must never hold a full client address. IPv4 is + * cut to /24, IPv6 to /56 — the prefix that identifies a household and that a + * rotating "privacy" IPv6 address does not actually change. Funnel stages count + * rows, and `uniqueVisitors` survives as a count of distinct prefixes, so + * nothing measured here needs the last octet. + * + * An address that does not parse is dropped rather than stored: an unparseable + * string is client text we cannot reason about, and keeping it would smuggle + * the very granularity this function exists to remove. + */ +export function truncateIpAddress(ip: string | null | undefined): string | null { + const value = cleanHop(ip); + if (!value) return null; + + const bytes = addressBytes(value); + if (!bytes) return null; + + const mapped = bytes.length === 16 ? ipv4FromBytes(bytes) : null; + if (bytes.length === 4 || mapped) { + const octets = (mapped ?? value).split(".").map(Number) as number[]; + return `${octets[0]}.${octets[1]}.${octets[2]}.0`; + } + + // /56 = keep the first 7 bytes, zero the rest. Rendered uncompressed and + // lowercase so one client always truncates to one stored string. + const kept = bytes.map((_, i) => (i >= 7 ? 0 : _)); + const groups: string[] = []; + for (let i = 0; i < 16; i += 2) { + groups.push((((kept[i]! << 8) | kept[i + 1]!) >>> 0).toString(16)); + } + return groups.join(":"); +} + +/** Any address this file understands: a dotted quad or an IPv6 literal. */ +export function isIpAddress(value: string | null | undefined): boolean { + return value !== null && value !== undefined && addressBytes(value) !== null; +} + +function isIpv4(value: string): boolean { + const parts = value.split("."); + if (parts.length !== 4) return false; + return parts.every( + (part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part) + ); +} + +/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */ +function addressBytes(value: string): number[] | null { + const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!; + if (isIpv4(raw)) return raw.split(".").map(Number); + if (!raw.includes(":")) return null; + + const halves = raw.split("::"); + if (halves.length > 2) return null; + const compressed = halves.length === 2; + + const head = groupsToBytes(halves[0] ?? ""); + const tail = groupsToBytes(halves[1] ?? ""); + if (!head || !tail) return null; + + const total = head.length + tail.length; + if (compressed) { + if (total > 8) return null; + return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16); + } + if (total !== 8) return null; + return [...head, ...tail].flatMap(split16); +} + +/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */ +function groupsToBytes(side: string): number[] | null { + if (side === "") return []; + const out: number[] = []; + for (const part of side.split(":")) { + if (/^[0-9a-fA-F]{1,4}$/.test(part)) { + out.push(parseInt(part, 16)); + } else if (isIpv4(part)) { + const o = part.split(".").map(Number); + out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!); + } else { + return null; + } + } + return out; +} + +function split16(group: number): number[] { + return [(group >> 8) & 0xff, group & 0xff]; +} + +/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */ +function ipv4FromBytes(bytes: number[]): string | null { + const prefix = bytes.slice(0, 10).every((byte) => byte === 0); + const marker = bytes[10] === 0xff && bytes[11] === 0xff; + if (!prefix || !marker) return null; + return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`; +} + +/** + * The site the request addressed — the `Host` the client asked for, normalised + * to `nook.freno.me`. Recorded on every analytics row so a weekly read can + * scope to one site; without it `/` on nook.freno.me is the same row as `/` on + * freno.me. Thin alias over the site-context extractor so the analytics writers + * share one host-parsing implementation with routing. + * + * Normalised (lowercased, port stripped) because the weekly read scopes with + * `host = ?`: `nook.freno.me:443` or `Nook.Freno.ME` would otherwise store a + * value that silently never matches the scope (FRE-369, finding Q3 — no + * injection risk, but a correctness one worth fixing at write time). */ export function requestHost(event: APIEvent): string | null { - const header = hostFromEventLike(event); - if (header) return header; + return normaliseHost(hostFromEventLike(event)) ?? normaliseHost(urlHost(event)); +} +function urlHost(event: APIEvent): string | null { // `host` is a forbidden header and some server shapes drop it from // `event.request.headers`; the request URL is absolute on the server and // carries the same host the client addressed. try { const url = event.request?.url; - if (url) return new URL(url).host || null; + return url ? new URL(url).host || null : null; } catch { - /* noop */ + return null; } - return null; +} + +/** Lowercase, drop the port and any trailing dot; null when nothing is left. */ +export function normaliseHost(host: string | null | undefined): string | null { + const raw = cleanHop(host).toLowerCase(); + if (!raw) return null; + + // A bracketed IPv6 literal keeps its brackets' contents (`[::1]:3000` → + // `::1`); anything else drops a trailing `:port`. + const closing = raw.indexOf("]"); + const withoutPort = raw.startsWith("[") + ? closing > 0 + ? raw.slice(1, closing) + : raw.slice(1) + : raw.replace(/:\d+$/, ""); + + const trimmed = withoutPort.replace(/\.$/, ""); + return trimmed || null; } diff --git a/src/server/weekly-metrics.test.ts b/src/server/weekly-metrics.test.ts index fa42009..c7e1fd9 100644 --- a/src/server/weekly-metrics.test.ts +++ b/src/server/weekly-metrics.test.ts @@ -85,7 +85,9 @@ const row = ( method: "GET", referrer: null, user_agent: "Mozilla/5.0 (Macintosh)", - ip_address: "203.0.113.9", + // What the writers store since the FRE-369 privacy floor: a /24 (or /56 for + // IPv6), never the address itself. + ip_address: "203.0.113.0", country: "GB", device_type: "desktop", browser: "safari", @@ -227,8 +229,9 @@ describe("readFunnel", () => { describe("readTraffic", () => { it("separates legacy rows, live loopback and automation from clean traffic", async () => { - // Post-instrumentation rows: host set. One of them still loopback — the - // shape the report must flag as "the fix is not live". + // Post-instrumentation rows: host set. Two of them still loopback — in the + // truncated shapes the privacy floor writes — which is the shape the report + // must flag as "the fix is not live". insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "click", @@ -237,10 +240,15 @@ describe("readTraffic", () => { insertAnalytics({ path: NOOK_DOWNLOAD_PATH, stage: "served", - ip_address: "127.0.0.1", + ip_address: "127.0.0.0", user_agent: "The Nook/0.13.4 Sparkle/2.9.6", created_at: THIS_WEEK }); + insertAnalytics({ + path: "/", + ip_address: "0:0:0:0:0:0:0:0", + created_at: THIS_WEEK + }); insertAnalytics({ path: "/", created_at: THIS_WEEK }); // Pre-instrumentation rows: no host, proxy's address — legacy, never @@ -263,10 +271,10 @@ describe("readTraffic", () => { const traffic = await readTraffic(conns, windowsFor(NOW, 7).current); expect(traffic).toEqual({ - analyticsRows: 5, - hostRows: 3, + analyticsRows: 6, + hostRows: 4, legacyRows: 2, - hostLoopbackRows: 1, + hostLoopbackRows: 2, downloadRows: 3, downloadClickRows: 1, downloadServedRows: 1, @@ -482,13 +490,16 @@ describe("getWeeklyReport / renderWeeklyMarkdown", () => { }); it("flags loopback only when rows recorded since the instrumentation carry it", async () => { + // Both spellings a live writer can produce: the raw proxy address, and the + // /24 the privacy floor stores instead of it. insertAnalytics({ path: "/", ip_address: "127.0.0.1", created_at: THIS_WEEK }); + insertAnalytics({ path: "/", ip_address: "127.0.53.0", created_at: THIS_WEEK }); const report = await getWeeklyReport(conns, { now: NOW, days: 7 }); const markdown = renderWeeklyMarkdown(report); expect(markdown).toContain( - "1 rows recorded since the instrumentation still carry a loopback address" + "2 rows recorded since the instrumentation still carry a loopback address" ); }); diff --git a/src/server/weekly-metrics.ts b/src/server/weekly-metrics.ts index 25f1130..d74c592 100644 --- a/src/server/weekly-metrics.ts +++ b/src/server/weekly-metrics.ts @@ -144,8 +144,16 @@ export interface WeeklyReport { generatedAt: string; } -/** Loopback addresses — every row the live table has ever held. */ -const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"]; +/** + * A recorded address that is not a visitor, in every spelling the column can + * hold: the proxy the request came through (`127.0.0.1`, `::1`, an IPv4-mapped + * form) *and* the prefixes the FRE-369 privacy floor writes instead of them + * (`127.0.0.0`, all-zero IPv6). Whole `127/8` because truncation keeps the first + * three octets, so `127.0.53.1` and `127.0.53.0` are the same non-visitor. + */ +const LOOPBACK_SQL = `(ip_address LIKE '127.%' + OR ip_address LIKE '::ffff:127.%' + OR ip_address IN ('::1', '0:0:0:0:0:0:0:1', '0:0:0:0:0:0:0:0'))`; /** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */ export function sqlTime(date: Date): string { @@ -320,7 +328,6 @@ export async function readTraffic( ): Promise { const { analytics } = conns; const { start, end } = window; - const placeholders = LOOPBACK.map(() => "?").join(", "); const analyticsRows = await countInWindow( analytics, @@ -351,8 +358,8 @@ export async function readTraffic( `SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE created_at >= ? AND created_at < ? AND host IS NOT NULL - AND ip_address IN (${placeholders})`, - [start, end, ...LOOPBACK] + AND ${LOOPBACK_SQL}`, + [start, end] ); const downloadRows = await scalar( @@ -595,7 +602,9 @@ export function renderWeeklyMarkdown(report: WeeklyReport): string { `(127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. ` + `They are excluded from every host-scoped stage above (visits, and the download ` + `click/served split), and "unique visitors" over them still collapses to one host. ` + - `Rows recorded since carry the real client address and the site they addressed.` + `Rows recorded since carry the site they addressed and the visitor's address ` + + `truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so ` + + `"unique visitors" counts networks, not machines.` ); } @@ -603,8 +612,8 @@ export function renderWeeklyMarkdown(report: WeeklyReport): string { lines.push( `- **${traffic.hostLoopbackRows} rows recorded since the instrumentation still carry a ` + `loopback address** — the forwarded-header fix is not taking effect in production ` + - `(or the proxy is not setting \`x-forwarded-for\`). Do not trust per-channel reach ` + - `until this is zero.` + `(no \`cf-connecting-ip\`, and no usable \`x-forwarded-for\` hop). Do not trust ` + + `per-channel reach until this is zero.` ); } diff --git a/vercel.json b/vercel.json index 57b9eff..03b5a7f 100644 --- a/vercel.json +++ b/vercel.json @@ -57,6 +57,7 @@ } ], "crons": [ + { "path": "/api/analytics-retention", "schedule": "0 12 * * *" }, { "path": "/api/the-nook/lifecycle", "schedule": "0 13 * * *" } ] }