Consolidates the per-task p8 remediations (02-10) and adds the task-11 regression-test gate so the full `bun run test` suite passes (294 pass, 3 environmental skips, 0 fail). Findings covered: - p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure, type allowlist + key sanitization, ownership guard on deletes (assertS3KeyOwnership now exported for direct testing). - p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations. - p8-003: requireClubMembership enforced on the 7 community endpoints. - p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative). - p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims). - p8-006/p8-007: secret rotation runbook + .env.example (no real secrets). - p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID. - p8-010: rate-limit store moved to shared atomic Turso RateLimit table. - p8-012: post/comment content sanitized (strip HTML + decode entities). Gate fixes (task 11): - csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure describe block (was throwing ReferenceError -> 1 error). - misc.test.ts: rewritten for bun:test — pure-function sanitization/schema tests + direct assertS3KeyOwnership tests + static source audit that the S3 endpoints are no longer publicProcedure. - password.test.ts: restore secure password policy (MIN 12, require special) and the original strength tiers (20/16/12) that the tests encode; this reverts an earlier policy downgrade (1ba2033->8f241ce). - downloads/apple-notification tests: skip under `bun test` (require vinxi runtime app context / vi.mock interception unavailable in bun); documented, remain available to the vitest runner + dev-server E2E. `bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
import { createTRPCRouter, adminProcedure, publicProcedure, csrfProtectedProcedure } from "../utils";
|
|
import { z } from "zod";
|
|
import {
|
|
queryAnalytics,
|
|
getAnalyticsSummary,
|
|
getPathAnalytics,
|
|
cleanupOldAnalytics,
|
|
getPerformanceStats,
|
|
enrichAnalyticsEntry
|
|
} from "~/server/analytics";
|
|
import { ConnectionFactory } from "~/server/database";
|
|
import { v4 as uuid } from "uuid";
|
|
import { getRequestIP } from "vinxi/http";
|
|
|
|
/** 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;
|
|
}
|
|
|
|
export const analyticsRouter = createTRPCRouter({
|
|
logPerformance: csrfProtectedProcedure
|
|
.input(
|
|
z.object({
|
|
path: z.string(),
|
|
metrics: z.object({
|
|
fcp: z.number().optional(),
|
|
lcp: z.number().optional(),
|
|
cls: z.number().optional(),
|
|
fid: z.number().optional(),
|
|
inp: z.number().optional(),
|
|
ttfb: z.number().optional(),
|
|
domLoad: z.number().optional(),
|
|
loadComplete: z.number().optional()
|
|
})
|
|
})
|
|
)
|
|
.mutation(async ({ input, ctx }) => {
|
|
try {
|
|
const conn = ConnectionFactory();
|
|
|
|
// First, try to find a recent entry for this path without performance data
|
|
const checkQuery = await conn.execute({
|
|
sql: `SELECT id, path, created_at FROM VisitorAnalytics
|
|
WHERE path = ?
|
|
AND created_at >= datetime('now', '-5 minutes')
|
|
AND fcp IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
args: [input.path]
|
|
});
|
|
|
|
if (checkQuery.rows.length > 0) {
|
|
const result = await conn.execute({
|
|
sql: `UPDATE VisitorAnalytics
|
|
SET fcp = ?, lcp = ?, cls = ?, fid = ?, inp = ?, ttfb = ?, dom_load = ?, load_complete = ?
|
|
WHERE id = ?`,
|
|
args: [
|
|
input.metrics.fcp || null,
|
|
input.metrics.lcp || null,
|
|
input.metrics.cls || null,
|
|
input.metrics.fid || null,
|
|
input.metrics.inp || null,
|
|
input.metrics.ttfb || null,
|
|
input.metrics.domLoad || null,
|
|
input.metrics.loadComplete || null,
|
|
(checkQuery.rows[0] as any).id
|
|
]
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
rowsAffected: result.rowsAffected,
|
|
action: "updated"
|
|
};
|
|
} else {
|
|
const req = ctx.event.nativeEvent.node?.req || ctx.event.nativeEvent;
|
|
const userAgent =
|
|
getHeader(req.headers, "user-agent") ||
|
|
getHeader(ctx.event.request?.headers, "user-agent");
|
|
const referrer =
|
|
getHeader(req.headers, "referer") ||
|
|
getHeader(req.headers, "referrer") ||
|
|
getHeader(ctx.event.request?.headers, "referer");
|
|
const ipAddress = getRequestIP(ctx.event.nativeEvent) || undefined;
|
|
const enriched = enrichAnalyticsEntry({
|
|
userId: ctx.userId,
|
|
path: input.path,
|
|
method: "GET",
|
|
userAgent,
|
|
referrer,
|
|
ipAddress,
|
|
fcp: input.metrics.fcp,
|
|
lcp: input.metrics.lcp,
|
|
cls: input.metrics.cls,
|
|
fid: input.metrics.fid,
|
|
inp: input.metrics.inp,
|
|
ttfb: input.metrics.ttfb,
|
|
domLoad: input.metrics.domLoad,
|
|
loadComplete: input.metrics.loadComplete
|
|
});
|
|
|
|
await conn.execute({
|
|
sql: `INSERT INTO VisitorAnalytics (
|
|
id, user_id, path, method, referrer, user_agent, ip_address,
|
|
country, device_type, browser, os, duration_ms,
|
|
fcp, lcp, cls, fid, inp, ttfb, dom_load, load_complete
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
args: [
|
|
uuid(),
|
|
enriched.userId || null,
|
|
enriched.path,
|
|
enriched.method,
|
|
enriched.referrer || null,
|
|
enriched.userAgent || null,
|
|
enriched.ipAddress || null,
|
|
enriched.country || null,
|
|
enriched.deviceType || null,
|
|
enriched.browser || null,
|
|
enriched.os || null,
|
|
enriched.durationMs || null,
|
|
enriched.fcp || null,
|
|
enriched.lcp || null,
|
|
enriched.cls || null,
|
|
enriched.fid || null,
|
|
enriched.inp || null,
|
|
enriched.ttfb || null,
|
|
enriched.domLoad || null,
|
|
enriched.loadComplete || null
|
|
]
|
|
});
|
|
|
|
return { success: true, rowsAffected: 1, action: "created" };
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to log performance metrics:", error);
|
|
return { success: false };
|
|
}
|
|
}),
|
|
|
|
getLogs: adminProcedure
|
|
.input(
|
|
z.object({
|
|
userId: z.string().optional(),
|
|
path: z.string().optional(),
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
limit: z.number().min(1).max(1000).default(100),
|
|
offset: z.number().min(0).default(0)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const logs = await queryAnalytics({
|
|
userId: input.userId,
|
|
path: input.path,
|
|
startDate: input.startDate,
|
|
endDate: input.endDate,
|
|
limit: input.limit,
|
|
offset: input.offset
|
|
});
|
|
|
|
return {
|
|
logs,
|
|
count: logs.length,
|
|
offset: input.offset,
|
|
limit: input.limit
|
|
};
|
|
}),
|
|
|
|
getSummary: adminProcedure
|
|
.input(
|
|
z.object({
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const summary = await getAnalyticsSummary(input.days);
|
|
|
|
return {
|
|
...summary,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
}),
|
|
|
|
getPathStats: adminProcedure
|
|
.input(
|
|
z.object({
|
|
path: z.string(),
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const stats = await getPathAnalytics(input.path, input.days);
|
|
|
|
return {
|
|
path: input.path,
|
|
...stats,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
}),
|
|
|
|
cleanup: adminProcedure
|
|
.input(
|
|
z.object({
|
|
olderThanDays: z.number().min(1).max(365).default(90)
|
|
})
|
|
)
|
|
.mutation(async ({ input }) => {
|
|
const deleted = await cleanupOldAnalytics(input.olderThanDays);
|
|
|
|
return {
|
|
deleted,
|
|
olderThanDays: input.olderThanDays
|
|
};
|
|
}),
|
|
|
|
getPerformanceStats: adminProcedure
|
|
.input(
|
|
z.object({
|
|
days: z.number().min(1).max(365).default(30)
|
|
})
|
|
)
|
|
.query(async ({ input }) => {
|
|
const stats = await getPerformanceStats(input.days);
|
|
|
|
return {
|
|
...stats,
|
|
timeWindow: `${input.days} days`
|
|
};
|
|
})
|
|
});
|