Files
freno-dev/src/server/api/utils.ts
Paperclip Agent 39d874a7e2 analytics: make each weekly funnel stage separately measurable (FRE-366)
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.

Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.

Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.

The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
2026-10-29 17:02:39 +00:00

131 lines
3.7 KiB
TypeScript

import { initTRPC, TRPCError } from "@trpc/server";
import type { APIEvent } from "@solidjs/start/server";
import { logVisit, enrichAnalyticsEntry } from "~/server/analytics";
import { resolveClientIp, requestHost } from "~/server/request-client";
import { getAuthPayloadFromEvent } from "~/server/auth";
export type Context = {
event: APIEvent;
userId: string | null;
isAdmin: boolean;
};
/** Safely get a header value from either Fetch API Headers or Node.js IncomingHttpHeaders */
function getHeader(
headers: Record<string, string | string[] | undefined> | Headers | undefined,
name: string
): string | undefined {
if (!headers) return undefined;
// Check if it's a Fetch API Headers object (has .get method)
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name) || undefined;
}
// Otherwise treat as Node.js IncomingHttpHeaders (plain object)
const value = (headers as Record<string, string | string[] | undefined>)[
name.toLowerCase()
];
if (Array.isArray(value)) return value[0];
return value;
}
async function createContextInner(event: APIEvent): Promise<Context> {
const payload = await getAuthPayloadFromEvent(event.nativeEvent);
let userId: string | null = null;
let isAdmin = false;
if (payload) {
userId = payload.sub;
isAdmin = payload.isAdmin;
}
const req = event.nativeEvent.node?.req || event.nativeEvent;
const path = req.url || event.request?.url || "unknown";
const method = req.method || event.request?.method || "GET";
const userAgent =
getHeader(req.headers, "user-agent") ||
getHeader(event.request?.headers, "user-agent");
const referrer =
getHeader(req.headers, "referer") ||
getHeader(req.headers, "referrer") ||
getHeader(event.request?.headers, "referer");
// Behind the production proxy the direct peer is loopback, so the real
// client address comes from the forwarded header the proxy sets — see
// `~/server/request-client` for the trust boundary (FRE-366).
const ipAddress = resolveClientIp(event) || undefined;
const host = requestHost(event) || undefined;
// Don't log the performance logging endpoint itself to avoid circular tracking
if (!path.includes("analytics.logPerformance")) {
logVisit(
enrichAnalyticsEntry({
userId,
path,
method,
userAgent,
referrer,
ipAddress,
host
})
);
}
return {
event,
userId,
isAdmin
};
}
export const createTRPCContext = (event: APIEvent) => {
return createContextInner(event);
};
export const t = initTRPC.context<Context>().create();
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return next({
ctx: {
...ctx,
userId: ctx.userId
}
});
});
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
if (!ctx.isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required"
});
}
return next({
ctx: {
...ctx,
userId: ctx.userId!
}
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const adminProcedure = t.procedure.use(enforceUserIsAdmin);
// CSRF protection middleware - defined here to avoid circular dependency
const csrfProtection = t.middleware(async ({ ctx, next }) => {
// For now, pass through - full CSRF validation in security.ts
// This allows tests to run while maintaining the procedure interface
return next();
});
// CSRF-protected procedure
export const csrfProtectedProcedure = t.procedure.use(csrfProtection);
export { csrfProtection };