feat: hash analytics IPs and add data-retention cron #153

Merged
ellamizuki merged 1 commit from feat/ip-privacy-hashing-and-retention-v2 into main 2026-09-05 23:31:27 +00:00
ellamizuki commented 2026-09-05 23:24:21 +00:00 (Migrated from github.com)

Summary

  • IP addresses recorded in analytics_events are now stored as a salted SHA-256 hash (hashIpForAnalytics, salt via ANALYTICS_IP_HASH_SALT) instead of plaintext. Confirmed this is the only write site for that column.
  • New daily cron /api/cron/data-retention (30 3 * * * in vercel.json) prunes old rows from analytics_events (180d), performance_metrics (90d), login_attempts (90d), ai_chat_feedback (365d), and resolved security_events/error_logs (180d). Unresolved security events and error logs are never deleted, regardless of age — checked against the resolved column in the schema.
  • security_events, login_attempts, ip_access_control, error_logs intentionally keep plaintext IPs — they back the real manual IP-block admin feature and need to stay readable.
  • ANALYTICS_IP_HASH_SALT documented in .env.example and declared in turbo.json's env allowlist.

Lint fix included in this PR

While working on this, apps/web's lint-staged pre-commit hook flagged 93 pre-existing errors in monitoring.ts that had nothing to do with my 2-line change. Root cause: inline z.object({...}) schemas passed directly into .input(...) push unicorn/max-nested-calls over its limit, plus several unicorn/no-for-each and unicorn/no-computed-property-existence-check violations.

Fixed properly instead of bypassing the hook:

  • Extracted every inline Zod input schema to a named constant above the router definition.
  • Converted .forEach() loops to for...of.
  • Replaced key in object existence checks with Object.hasOwn(object, key) (the in operator also matches inherited properties, which no-computed-property-existence-check flags).
  • Renamed schema constants that started with get (e.g. getPerformanceMetricsSchema → performanceMetricsQuerySchema) to satisfy unicorn/no-non-function-verb-prefix.

monitoring.ts now lints clean with zero errors — down from 93. No behavior change from these fixes; verified with the full test suite before and after.

Related, separately-scoped finding (not fixed here): apps/web's lint script itself (pnpm -w exec eslint . --max-warnings 0) was resolving paths incorrectly and only linting 13 root config files instead of apps/web/src — meaning pnpm turbo lint (what CI runs) was never actually checking this app's code. That's tracked separately in #152 and affects ~1000 more pre-existing findings across the rest of apps/web, out of scope for this PR.

Testing

  • apps/web/src/tests/lib/ip-hash.test.ts (new) — hash determinism, salt sensitivity, null handling.
  • apps/web/src/tests/api/cron-data-retention-route.test.ts (new) — auth check, lock acquisition/release, per-table deletion counts, resolved-only filtering for security events/error logs.
  • Full suite: 195/195 passing.
  • tsc --noEmit: clean.
  • eslint (bare, not through the broken script) on every changed file: 0 errors.
  • Committed through the normal lint-staged pre-commit hook — no --no-verify.

Vercel cron notes

Project is on the Hobby plan (confirmed via an existing commit 8ba8aa42 that already adjusted a cron for this). Hobby allows at most 1 execution/day per cron and doesn't guarantee the exact minute within the scheduled hour — both fine for this use case since retention doesn't need minute-level precision. Cron only fires against production, never preview deployments, and an in-flight cron run is not interrupted by a new deploy.

Supersedes #151, which had the same functional change but two commits made with --no-verify to bypass the pre-existing lint failures instead of fixing them.

## Summary - IP addresses recorded in `analytics_events` are now stored as a salted SHA-256 hash (`hashIpForAnalytics`, salt via `ANALYTICS_IP_HASH_SALT`) instead of plaintext. Confirmed this is the only write site for that column. - New daily cron `/api/cron/data-retention` (`30 3 * * *` in `vercel.json`) prunes old rows from `analytics_events` (180d), `performance_metrics` (90d), `login_attempts` (90d), `ai_chat_feedback` (365d), and resolved `security_events`/`error_logs` (180d). Unresolved security events and error logs are never deleted, regardless of age — checked against the `resolved` column in the schema. - `security_events`, `login_attempts`, `ip_access_control`, `error_logs` intentionally keep plaintext IPs — they back the real manual IP-block admin feature and need to stay readable. - `ANALYTICS_IP_HASH_SALT` documented in `.env.example` and declared in `turbo.json`'s env allowlist. ## Lint fix included in this PR While working on this, `apps/web`'s `lint-staged` pre-commit hook flagged 93 pre-existing errors in `monitoring.ts` that had nothing to do with my 2-line change. Root cause: inline `z.object({...})` schemas passed directly into `.input(...)` push `unicorn/max-nested-calls` over its limit, plus several `unicorn/no-for-each` and `unicorn/no-computed-property-existence-check` violations. Fixed properly instead of bypassing the hook: - Extracted every inline Zod input schema to a named constant above the router definition. - Converted `.forEach()` loops to `for...of`. - Replaced `key in object` existence checks with `Object.hasOwn(object, key)` (the `in` operator also matches inherited properties, which `no-computed-property-existence-check` flags). - Renamed schema constants that started with `get` (e.g. `getPerformanceMetricsSchema` → `performanceMetricsQuerySchema`) to satisfy `unicorn/no-non-function-verb-prefix`. `monitoring.ts` now lints clean with zero errors — down from 93. No behavior change from these fixes; verified with the full test suite before and after. Related, separately-scoped finding (not fixed here): `apps/web`'s `lint` script itself (`pnpm -w exec eslint . --max-warnings 0`) was resolving paths incorrectly and only linting 13 root config files instead of `apps/web/src` — meaning `pnpm turbo lint` (what CI runs) was never actually checking this app's code. That's tracked separately in #152 and affects ~1000 more pre-existing findings across the rest of `apps/web`, out of scope for this PR. ## Testing - `apps/web/src/tests/lib/ip-hash.test.ts` (new) — hash determinism, salt sensitivity, null handling. - `apps/web/src/tests/api/cron-data-retention-route.test.ts` (new) — auth check, lock acquisition/release, per-table deletion counts, resolved-only filtering for security events/error logs. - Full suite: 195/195 passing. - `tsc --noEmit`: clean. - `eslint` (bare, not through the broken script) on every changed file: 0 errors. - Committed through the normal `lint-staged` pre-commit hook — no `--no-verify`. ## Vercel cron notes Project is on the Hobby plan (confirmed via an existing commit `8ba8aa42` that already adjusted a cron for this). Hobby allows at most 1 execution/day per cron and doesn't guarantee the exact minute within the scheduled hour — both fine for this use case since retention doesn't need minute-level precision. Cron only fires against production, never preview deployments, and an in-flight cron run is not interrupted by a new deploy. Supersedes #151, which had the same functional change but two commits made with `--no-verify` to bypass the pre-existing lint failures instead of fixing them.
isyuricunha (Migrated from github.com) reviewed 2026-09-05 23:24:21 +00:00
vercel[bot] commented 2026-09-05 23:24:28 +00:00 (Migrated from github.com)

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
website-docs Ready Ready Preview Sep 5, 2026 11:29pm UTC
website-web Ready Ready Preview Sep 5, 2026 11:29pm UTC
[vc]: #ETMKZkkvHj80WDfnTOU+oyQLQlCPnxG0LJDnGqWd+8s=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3ZWJzaXRlLXdlYiIsInByb2plY3RJZCI6InByal9iNnJGaEFBUEhJWFI3M2Nlb2RFMHl2bEJ3MzgyIiwicm9vdERpcmVjdG9yeSI6ImFwcHMvd2ViIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3l1cmktY3VuaGFzLXByb2plY3RzL3dlYnNpdGUtd2ViL0JlaVc3aHZUNnJUUjJwWEY4RGZTYjZDOWJKcnQiLCJwcmV2aWV3VXJsIjoid2Vic2l0ZS13ZWItZ2l0LWZlYXQtaXAtcHJpdmFjeS1oYXMtMGViODI2LXl1cmktY3VuaGFzLXByb2plY3RzLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoid2Vic2l0ZS13ZWItZ2l0LWZlYXQtaXAtcHJpdmFjeS1oYXMtMGViODI2LXl1cmktY3VuaGFzLXByb2plY3RzLnZlcmNlbC5hcHAifX0seyJuYW1lIjoid2Vic2l0ZS1kb2NzIiwicHJvamVjdElkIjoicHJqXzJlVTJzVE0waWRZNzByVGJzY0NDdEV0Nk1GT3ciLCJyb290RGlyZWN0b3J5IjoiYXBwcy9kb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3l1cmktY3VuaGFzLXByb2plY3RzL3dlYnNpdGUtZG9jcy81NEhNSG91RXlLS3dLckpoRXFVbW9tMW5RZjJvIiwicHJldmlld1VybCI6IndlYnNpdGUtZG9jcy1naXQtZmVhdC1pcC1wcml2YWN5LWhhLTMxNGZmNy15dXJpLWN1bmhhcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IndlYnNpdGUtZG9jcy1naXQtZmVhdC1pcC1wcml2YWN5LWhhLTMxNGZmNy15dXJpLWN1bmhhcy1wcm9qZWN0cy52ZXJjZWwuYXBwIn19XX0= The latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.link/github-learn-more). | Project | Deployment | Actions | Updated | | :--- | :----- | :------ | :------ | | <a href="https://vercel.com/yuri-cunhas-projects/website-docs"><sup><img src="https://vercel.com/api/www/avatar?projectId=prj_2eU2sTM0idY70rTbscCCtEt6MFOw&teamId=team_g087rqPZJa9s4DWPeZtQhxa9&s=32" width="16" height="16" align="middle" alt="" /></sup></a> [website-docs](https://vercel.com/yuri-cunhas-projects/website-docs) | ![Ready](https://vercel.com/static/status/ready.svg) [Ready](https://vercel.com/yuri-cunhas-projects/website-docs/54HMHouEyKKwKrJhEqUmom1nQf2o) | [Preview](https://website-docs-git-feat-ip-privacy-ha-314ff7-yuri-cunhas-projects.vercel.app) | <relative-time datetime="2026-09-05T23:29:22.413Z">Sep 5, 2026 11:29pm UTC</relative-time> | | <a href="https://vercel.com/yuri-cunhas-projects/website-web"><sup><img src="https://vercel.com/api/www/avatar?projectId=prj_b6rFhAAPHIXR73ceodE0yvlBw382&teamId=team_g087rqPZJa9s4DWPeZtQhxa9&s=32" width="16" height="16" align="middle" alt="" /></sup></a> [website-web](https://vercel.com/yuri-cunhas-projects/website-web) | ![Ready](https://vercel.com/static/status/ready.svg) [Ready](https://vercel.com/yuri-cunhas-projects/website-web/BeiW7hvT6rTR2pXF8DfSb6C9bJrt) | [Preview](https://website-web-git-feat-ip-privacy-has-0eb826-yuri-cunhas-projects.vercel.app) | <relative-time datetime="2026-09-05T23:29:22.414Z">Sep 5, 2026 11:29pm UTC</relative-time> |
Sign in to join this conversation.
No description provided.