Skip to content

fix: GDPR and ISO 27001 security remediation#12

Merged
nathanialhenniges merged 4 commits intomainfrom
fix/gdpr-iso27001-remediation
Apr 2, 2026
Merged

fix: GDPR and ISO 27001 security remediation#12
nathanialhenniges merged 4 commits intomainfrom
fix/gdpr-iso27001-remediation

Conversation

@nathanialhenniges
Copy link
Copy Markdown
Member

@nathanialhenniges nathanialhenniges commented Apr 1, 2026

Summary

Addresses all code-addressable findings from the GDPR and ISO 27001:2022 gap analyses. Scoped appropriately for a self-hosted single-user deployment.

  • Data privacy: Remove email from public page response, add analytics consent banner (localStorage), gate view tracking on consent, truncate userAgent to browser/version only, add consent checkbox to contact form
  • Security headers: CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy added; Next.js edge middleware protecting all /admin/* routes
  • Secret masking: Wallet certs and API keys masked in all API responses and excluded from backup exports; settings.get now masks secrets consistently with getAll
  • Audit logging: New audit_log table + logAudit() utility wired into settings updates, backup import/export, and contact deletions
  • Data retention: purgeExpiredData procedure added to analytics router (90-day cutoff for page views and link clicks)
  • Infrastructure: npm audit step in CI, Docker healthcheck, default Alchemy password replaced with CHANGE-ME-BEFORE-DEPLOY
  • Input validation: Email validated and escaped in reset-password.ts script

New files

  • apps/web/src/middleware.ts — Next.js edge auth middleware
  • apps/web/src/components/public/consent-banner.tsx — consent banner + hasAnalyticsConsent() helper
  • packages/api/src/utils/audit.tslogAudit() utility
  • packages/db/src/schema/audit-log.ts — audit log DB table

Test plan

  • Public page loads without email in profile data
  • Consent banner appears on first visit; analytics fires only after accepting
  • /admin/* routes redirect to /admin/login when unauthenticated
  • Contact form blocks submission without consent checkbox checked
  • settings.get for secret keys returns ••••••
  • Wallet getConfig masks cert/key fields
  • Backup export excludes wallet secrets
  • bun run check-types passes ✅

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added analytics consent banner and integrated consent check on page view tracking
    • Admin area now enforces authentication for protected routes
    • Contact form now requires explicit consent and can show a Privacy Policy link
  • Security

    • Applied Content-Security-Policy and additional security headers
    • Introduced audit logging and an audit log store for compliance
  • Chores

    • CI now runs a dependency audit step (non-blocking)
    • Analytics data purge for records older than 90 days
    • Added service healthcheck for runtime monitoring

Security & privacy fixes across the full stack:

**Data Privacy (GDPR)**
- Remove email from public getPage response (data minimisation)
- Add consent field (z.literal(true)) to contact form submission
- Add ConsentBanner component with localStorage acceptance tracking
- Gate analytics trackView on user consent via hasAnalyticsConsent()
- Truncate userAgent to browser/version only before DB insert
- Add privacy policy URL threading to ConnectBlock consent checkbox
- Improve stripHtml with entity-decode pass to prevent bypass
- Add truncateUserAgent() utility for GDPR-compliant UA storage

**Security Headers**
- Add CSP header to Next.js (replaces TODO comment)
- Add X-Frame-Options: DENY to Hono server
- Add Referrer-Policy: strict-origin-when-cross-origin
- Add Permissions-Policy (camera, mic, geolocation, payment)

**Auth & Access Control**
- Add Next.js edge middleware for /admin/* route protection
- Redirect unauthenticated users to /admin/login with ?from= param

**Secret Masking**
- Mask wallet secrets (signer_cert, signer_key, wwdr_cert) in wallet.getConfig
- Mask secrets in settings.get endpoint (matching getAll behaviour)
- Exclude wallet secrets from backup export

**Audit Logging**
- Add audit_log DB schema with action/resource/details/created_at
- Add logAudit() utility with silent failure so it never breaks ops
- Wire audit logging into settings, backup, and contacts mutations

**Data Retention**
- Add purgeExpiredData procedure to analytics router (90-day cutoff)

**Infrastructure**
- Add npm audit step to CI (continue-on-error: true)
- Add Docker healthcheck to docker-compose.yml
- Change default Alchemy password to CHANGE-ME-BEFORE-DEPLOY

**Input Validation**
- Validate and escape email in reset-password script (prevent SQL injection)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 1, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9c91680-6dee-4608-9484-9763b4e029c7

📥 Commits

Reviewing files that changed from the base of the PR and between e3333b8 and 8cc3a44.

📒 Files selected for processing (13)
  • apps/web/next.config.ts
  • apps/web/src/app/page.tsx
  • apps/web/src/components/public/consent-banner.tsx
  • apps/web/src/middleware.ts
  • docker-compose.yml
  • packages/api/src/routers/backup.ts
  • packages/api/src/routers/contacts.ts
  • packages/api/src/utils/audit.ts
  • packages/api/src/utils/sanitize.ts
  • packages/db/src/migrations/0005_talented_shotgun.sql
  • packages/db/src/migrations/meta/0005_snapshot.json
  • packages/db/src/migrations/meta/_journal.json
  • scripts/reset-password.ts

Walkthrough

Adds consent banner & client consent gating, admin edge middleware, audit logging infrastructure and DB migration, security headers and CSP, settings/wallet secret masking, sanitization and UA truncation, contact form consent, data retention purge mutation, CI audit step, and a docker healthcheck.

Changes

Cohort / File(s) Summary
CI & Infra
/.github/workflows/ci.yml, docker-compose.yml
Add npm audit --audit-level=moderate CI step (continue-on-error) and a healthcheck for the linkden service.
Server Security Headers
apps/server/src/index.ts
Add X-Frame-Options, Referrer-Policy, and Permissions-Policy headers in global middleware.
Web CSP
apps/web/next.config.ts
Enable a Content-Security-Policy for all routes with production vs non-production differences (allow localhost connect in dev).
Consent UI & Tracking
apps/web/src/components/public/consent-banner.tsx, apps/web/src/app/page.tsx
New ConsentBanner component, hasAnalyticsConsent() export, consent persisted to localStorage, and homepage tracking gated on consent.
Contact Form Consent
apps/web/src/components/public/connect-block.tsx, apps/web/src/components/public/public-page-content.tsx, apps/web/src/components/public/public-page.tsx
Add optional ppUrl prop, consent checkbox/state/validation, reset consent on submit, pass branding privacy-policy URL through to ConnectBlock, and make profile email optional.
Public API & Validation
packages/api/src/routers/public.ts
Require consent: true on submitContact, remove profile.email from getPage response, replace regex stripping with stripHtml, and normalize userAgent via truncateUserAgent when tracking.
Analytics Router
packages/api/src/routers/analytics.ts
Add purgeExpiredData protected mutation that deletes page views and link clicks older than 90 days.
Audit Logging
packages/api/src/utils/audit.ts, packages/db/src/schema/audit-log.ts, packages/db/src/schema/index.ts, packages/db/src/migrations/*
Add logAudit utility, DB migration and Drizzle schema for audit_log, and re-export; migration snapshot and journal updated.
Audit Calls
packages/api/src/routers/backup.ts, packages/api/src/routers/contacts.ts, packages/api/src/routers/settings.ts
Emit logAudit calls in backup export/import, contact delete/deleteMultiple, and settings update/updateBulk; mask secrets when returning certain settings.
Settings & Wallet Masking
packages/api/src/routers/wallet.ts, packages/api/src/routers/settings.ts
Introduce WALLET_SECRET_KEYS and mask secret wallet setting values ("••••••") when present; settings.get masks secret keys and returns null when missing.
Sanitization & UA truncation
packages/api/src/utils/sanitize.ts
Improve stripHtml (decode entities then strip tags) and add truncateUserAgent(ua?) to normalize/truncate UA strings.
DB Migration Snapshot
packages/db/src/migrations/meta/0005_snapshot.json, packages/db/src/migrations/_journal.json
Add new migration snapshot and journal entry for the audit_log table and related schema changes.
Reset Password Script
scripts/reset-password.ts
Switch to execFileSync, add email validation/escaping, and safer command argument passing to wrangler d1 execute.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Page as Home Page
    participant Banner as Consent Banner
    participant Storage as localStorage
    participant Analytics as Analytics API

    User->>Page: Visit homepage
    Page->>Banner: Render ConsentBanner
    Banner->>Storage: read CONSENT_KEY
    alt no stored value
        Banner->>User: show Accept/Decline
        User->>Banner: Accept
        Banner->>Storage: write "accepted"
    end
    Page->>Analytics: trackView() only if hasAnalyticsConsent()
    Analytics-->>Page: ack
Loading
sequenceDiagram
    actor Client
    participant Middleware as Next Edge Middleware
    participant Cookies as Request Cookies
    participant Login as /admin/login

    Client->>Middleware: Request /admin/some
    Middleware->>Middleware: allow if /admin/login or /admin/setup
    alt other /admin/*
        Middleware->>Cookies: check better-auth.session_token
        alt missing
            Middleware->>Login: redirect to /admin/login?from=...
        else present
            Middleware-->>Client: NextResponse.next()
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped in code where headers grow tall,
I planted consent by the bannered wall,
I logged each nibble in a neat little row,
Masked secret seeds so no prying crow,
A cheerful rabbit guards the changelog hall.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main focus of the changeset: comprehensive GDPR and ISO 27001 security remediation including privacy controls, security headers, secret masking, and audit logging.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/gdpr-iso27001-remediation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@nathanialhenniges
Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nathanialhenniges
Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/api/src/routers/public.ts (1)

121-131: 🛠️ Refactor suggestion | 🟠 Major

Move submitContact validation to shared validators.

This mutation extends an inline schema instead of using a shared schema from packages/validators, which drifts server/client validation over time.

As per coding guidelines: **/{apps/web,apps/server,packages/api}/**/*.{ts,tsx,js,jsx}: Use shared Zod validation schemas from packages/validators for form and API validation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/public.ts` around lines 121 - 131, Replace the
inline Zod schema used in the submitContact mutation with the shared schema from
packages/validators: import the shared contact/form schema (e.g.,
ContactFormSchema or SubmitContactSchema) and use it in the mutation input
instead of the current z.object({...}) block; ensure the shared schema includes
the same fields (firstName, lastName, email, whereMet, message?, captchaToken?,
blockId?, blockTitle?, consent literal true) and update the import at the top of
the file while removing the inline schema to keep server/client validation
consistent.
scripts/reset-password.ts (1)

34-42: ⚠️ Potential issue | 🔴 Critical

Fix shell injection vulnerability in password reset command execution.

The email regex validation (line 22) does not exclude shell metacharacters like $, backticks, and others. Even with SQL escaping of single quotes, an attacker can inject shell commands through the email parameter. For example, an email like test$(id)@test.com passes validation but triggers command substitution when the SQL is embedded in the double-quoted shell string at line 37, leading to arbitrary code execution.

Use execFileSync with an argument array instead of shell string interpolation to prevent this vulnerability:

Safer execution pattern
-import { execSync } from "node:child_process";
+import { execFileSync } from "node:child_process";
@@
-const remoteFlag = remote ? " --remote" : "";
-const cmd = `npx wrangler d1 execute linkden-db --command "${sql}"${remoteFlag}`;
@@
 try {
-  execSync(cmd, { stdio: "inherit", cwd: "apps/server" });
+  const wranglerArgs = ["wrangler", "d1", "execute", "linkden-db", "--command", sql];
+  if (remote) {
+    wranglerArgs.push("--remote");
+  }
+  execFileSync("npx", wranglerArgs, { stdio: "inherit", cwd: "apps/server" });
   console.log("Password reset successfully.");
 } catch {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/reset-password.ts` around lines 34 - 42, The command construction
using a shell-interpolated string (variables sql, cmd) and execSync allows shell
injection via email (even if escaped for SQL); replace the execSync call with a
safe execFileSync/child_process.spawnSync usage that passes the wrangler binary
and its args as an array (avoid any single combined shell string), build the SQL
string separately (using escapedHash/escapedEmail) but pass it as a single
argument like ["d1","execute","linkden-db","--command", sql, ...(remote ?
["--remote"] : [])] to prevent shell interpretation, and update the import/usage
from execSync to execFileSync/ spawnSync accordingly so no shell interpolation
occurs.
🧹 Nitpick comments (5)
.github/workflows/ci.yml (1)

23-25: Make vulnerability auditing enforceable on protected branches.

Line 25 keeps the audit step non-blocking, so moderate+ findings won’t stop merges. That weakens the remediation objective.

Suggested CI tightening
       - name: Audit dependencies
         run: npm audit --audit-level=moderate
-        continue-on-error: true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml around lines 23 - 25, The "Audit dependencies" CI
step currently runs "npm audit --audit-level=moderate" with "continue-on-error:
true", making vulnerability findings non-blocking; change this so audits fail
the job on protected branches by removing or setting "continue-on-error" to
false for that step (or add a branch conditional that enforces failure when
github.ref matches protected branches) and ensure the step continues to use "npm
audit --audit-level=moderate" (or raise the audit level if desired) so moderate+
findings will block merges; update the step named "Audit dependencies" to
enforce failure accordingly.
apps/web/next.config.ts (1)

23-26: frame-src * is very permissive—verify this is intentional.

Allowing frames from any origin may be necessary for embedding third-party content (e.g., YouTube, Spotify blocks), but it weakens clickjacking protection provided by X-Frame-Options: DENY. Consider restricting to known embed domains if possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/next.config.ts` around lines 23 - 26, The CSP entry with key
"Content-Security-Policy" currently uses the very permissive directive frame-src
*; update this policy in next.config.ts to restrict framing to only required,
trusted embed origins (e.g., list explicit domains like https://www.youtube.com,
https://open.spotify.com) or remove the frame-src directive to fall back to
default-src 'self' if framing isn't needed; modify the value string for the
"Content-Security-Policy" header accordingly and ensure any required third-party
embeds are enumerated rather than using a wildcard.
packages/api/src/routers/backup.ts (1)

163-164: Indentation inconsistency on Line 163.

The await logAudit call appears to have incorrect indentation (single tab instead of matching the surrounding code's double-tab level).

🔧 Proposed fix
-		await logAudit("backup.import", undefined, undefined, { mode });
+			await logAudit("backup.import", undefined, undefined, { mode });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/backup.ts` around lines 163 - 164, Adjust the
indentation of the await logAudit call so it matches the surrounding block
(align with the subsequent return { success: true };), i.e., move the await
logAudit("backup.import", ...) line to the same indentation level as the return
statement within the same function/block to fix the inconsistent single-tab
indentation.
packages/api/src/routers/contacts.ts (1)

76-76: Audit logging after deletion is correctly implemented.

The audit call captures the deleted contact ID for traceability. However, since logAudit is awaited, any database failure during audit logging would cause the mutation to fail after the contact has already been deleted—the user would see an error even though the deletion succeeded.

Consider whether audit logging failures should be silent (fire-and-forget) or if you want the current behavior where audit failures propagate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/contacts.ts` at line 76, The current await
logAudit("contact.delete", "contact", input.id) causes the mutation to fail if
audit logging errors after the contact is already deleted; change this to
fire-and-forget by removing the await and ensuring failures are swallowed or
logged asynchronously (e.g., call logAudit(...).catch(err =>
processLogger.error(...))) so the delete operation returns success even when
audit logging fails; update the call site in the delete contact resolver where
logAudit is invoked to use the non-blocking pattern and add a concise error log
on catch.
apps/web/src/app/page.tsx (1)

20-27: Missing trackView in useEffect dependency array.

The useEffect uses trackView.mutate but trackView is not in the dependency array. While this may work due to React Query's stable mutation references, the exhaustive-deps lint rule would flag this. However, adding it could cause re-triggers if the mutation object identity changes.

Consider using trackView.mutate directly from the mutation result or wrapping in a ref to satisfy the lint rule without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/page.tsx` around lines 20 - 27, The effect calls
trackView.mutate but doesn't include it in the dependency array; to satisfy
exhaustive-deps without changing behavior, pull the mutate function out of the
mutation object and reference that stable value in the effect (e.g., const {
mutate } = trackView or store mutate in a ref) and then use mutate inside
useEffect with mutate included in the dependency array alongside
pageData.data?.profile and hasAnalyticsConsent(); update references to
trackView.mutate accordingly so the lint rule is satisfied and the effect
behavior remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/next.config.ts`:
- Around line 23-26: The Content-Security-Policy header defined in the object
with key "Content-Security-Policy" currently sets connect-src to "'self' https:"
which will block local HTTP API calls (e.g.,
NEXT_PUBLIC_SERVER_URL=http://localhost:3000) used by the tRPC client and the
auth client's baseURL during development; update the CSP application to allow
HTTP localhost during development by adding an entry like "http://localhost" (or
the exact origin such as "http://localhost:3000") to connect-src when
process.env.NODE_ENV !== 'production', or alternatively apply the CSP header
only in production so that the connect-src directive permits development HTTP
requests used by tRPC and the auth client.

In `@apps/web/src/components/public/connect-block.tsx`:
- Around line 439-440: The consent error id is hard-coded as "consent-error" in
the ConnectBlock component (used where aria-describedby={errors.consent ?
"consent-error" : undefined} and on the visible error element), which will
collide if multiple blocks render; change to a per-instance unique id (e.g.,
derive via React's useId() or a generated uuid) and use that unique id in both
the aria-describedby attribute and the error element's id so each ConnectBlock
instance references its own error node consistently (update the JSX where
aria-describedby and the consent error element are rendered to use the generated
uniqueId).

In `@apps/web/src/components/public/consent-banner.tsx`:
- Around line 7-9: Wrap all direct localStorage access in try/catch and fall
back to safe defaults to prevent SecurityError in restricted contexts: update
hasAnalyticsConsent() to first check typeof window !== "undefined" and then
attempt localStorage.getItem(CONSENT_KEY) inside a try block, returning true
only if it equals "accepted" and returning false on any thrown error; apply the
same pattern to any other functions in this file that call localStorage.setItem
or getItem (e.g., consent setter/clearer) so they catch errors and no-op or use
in-memory fallback state instead of letting exceptions bubble.

In `@apps/web/src/middleware.ts`:
- Around line 14-15: The allowlist check using PUBLIC_ADMIN_ROUTES with
pathname.startsWith(...) is too permissive and allows prefix matches like
"/admin/login-any"; update the middleware logic (the block using
PUBLIC_ADMIN_ROUTES and pathname.startsWith) to only permit exact route matches
or route-prefixes that are followed by a path separator (e.g., check pathname
=== route || pathname.startsWith(route + "/")) before returning
NextResponse.next(), so only the intended admin paths (/admin/login,
/admin/setup and their proper subpaths) are allowed through.

In `@docker-compose.yml`:
- Line 17: The healthcheck uses curl (test: ["CMD", "curl", "-f",
"http://localhost:3000"]) but the oven/bun:latest image doesn't include curl;
either modify the Dockerfile that builds the image to install curl (add the
appropriate package install step for the base image) or change the healthcheck
to use a tool already available in the image (or a small Node.js one-liner) to
probe http://localhost:3000; update the healthcheck entry (the test array)
accordingly so probes succeed.

In `@packages/api/src/routers/public.ts`:
- Line 130: The request schema requires consent via "consent: z.literal(true, {
error: 'Consent is required' })" but the created contactSubmission record does
not persist consent; update the API to save audit fields and DB schema: add
boolean consentGiven and timestamp consentGivenAt to the contactSubmission table
via a migration, then set consentGiven = true and consentGivenAt = new Date()
when inserting the record in the router handler that constructs the
contactSubmission (update the insert/create code path referenced in this file
and the related block around lines 186-194); ensure the TypeScript types/ORM
model for contactSubmission are updated accordingly.

In `@packages/api/src/utils/sanitize.ts`:
- Around line 27-30: The browser UA parsing in sanitize.ts uses ua.match(...)
and currently maps "OPR" to "Opera" but misses modern Edge tokens like "Edg"
(and possibly "EdgA"/"EdgiOS"); update the regex used in ua.match to include the
"Edg" token (e.g., add Edg|EdgA|EdgiOS) and then extend the browser mapping
logic (the browser variable assignment that checks match[1]) to normalize any
"Edg" variants to "Edge" (in addition to the existing "OPR" => "Opera" handling)
so Edge UAs are correctly labeled as "Edge/<major>".

In `@packages/db/src/schema/audit-log.ts`:
- Around line 4-19: You added the new audit_log table schema (auditLog via
sqliteTable with columns like id, action, resourceType, resourceId, details and
createdAt) but did not generate the migration; run the migration generator (bun
run db:generate) to produce a new migration file (0005_*.sql) containing the
CREATE TABLE audit_log statement and commit that generated SQL alongside your
schema change so the deployment includes the new table (ensure the createdAt
default using unixepoch('subsecond') is preserved in the generated SQL).

---

Outside diff comments:
In `@packages/api/src/routers/public.ts`:
- Around line 121-131: Replace the inline Zod schema used in the submitContact
mutation with the shared schema from packages/validators: import the shared
contact/form schema (e.g., ContactFormSchema or SubmitContactSchema) and use it
in the mutation input instead of the current z.object({...}) block; ensure the
shared schema includes the same fields (firstName, lastName, email, whereMet,
message?, captchaToken?, blockId?, blockTitle?, consent literal true) and update
the import at the top of the file while removing the inline schema to keep
server/client validation consistent.

In `@scripts/reset-password.ts`:
- Around line 34-42: The command construction using a shell-interpolated string
(variables sql, cmd) and execSync allows shell injection via email (even if
escaped for SQL); replace the execSync call with a safe
execFileSync/child_process.spawnSync usage that passes the wrangler binary and
its args as an array (avoid any single combined shell string), build the SQL
string separately (using escapedHash/escapedEmail) but pass it as a single
argument like ["d1","execute","linkden-db","--command", sql, ...(remote ?
["--remote"] : [])] to prevent shell interpretation, and update the import/usage
from execSync to execFileSync/ spawnSync accordingly so no shell interpolation
occurs.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 23-25: The "Audit dependencies" CI step currently runs "npm audit
--audit-level=moderate" with "continue-on-error: true", making vulnerability
findings non-blocking; change this so audits fail the job on protected branches
by removing or setting "continue-on-error" to false for that step (or add a
branch conditional that enforces failure when github.ref matches protected
branches) and ensure the step continues to use "npm audit
--audit-level=moderate" (or raise the audit level if desired) so moderate+
findings will block merges; update the step named "Audit dependencies" to
enforce failure accordingly.

In `@apps/web/next.config.ts`:
- Around line 23-26: The CSP entry with key "Content-Security-Policy" currently
uses the very permissive directive frame-src *; update this policy in
next.config.ts to restrict framing to only required, trusted embed origins
(e.g., list explicit domains like https://www.youtube.com,
https://open.spotify.com) or remove the frame-src directive to fall back to
default-src 'self' if framing isn't needed; modify the value string for the
"Content-Security-Policy" header accordingly and ensure any required third-party
embeds are enumerated rather than using a wildcard.

In `@apps/web/src/app/page.tsx`:
- Around line 20-27: The effect calls trackView.mutate but doesn't include it in
the dependency array; to satisfy exhaustive-deps without changing behavior, pull
the mutate function out of the mutation object and reference that stable value
in the effect (e.g., const { mutate } = trackView or store mutate in a ref) and
then use mutate inside useEffect with mutate included in the dependency array
alongside pageData.data?.profile and hasAnalyticsConsent(); update references to
trackView.mutate accordingly so the lint rule is satisfied and the effect
behavior remains unchanged.

In `@packages/api/src/routers/backup.ts`:
- Around line 163-164: Adjust the indentation of the await logAudit call so it
matches the surrounding block (align with the subsequent return { success: true
};), i.e., move the await logAudit("backup.import", ...) line to the same
indentation level as the return statement within the same function/block to fix
the inconsistent single-tab indentation.

In `@packages/api/src/routers/contacts.ts`:
- Line 76: The current await logAudit("contact.delete", "contact", input.id)
causes the mutation to fail if audit logging errors after the contact is already
deleted; change this to fire-and-forget by removing the await and ensuring
failures are swallowed or logged asynchronously (e.g., call
logAudit(...).catch(err => processLogger.error(...))) so the delete operation
returns success even when audit logging fails; update the call site in the
delete contact resolver where logAudit is invoked to use the non-blocking
pattern and add a concise error log on catch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 67a7e019-4f38-4cf1-acd9-a4fee4745db9

📥 Commits

Reviewing files that changed from the base of the PR and between 21f9cd2 and e3333b8.

📒 Files selected for processing (21)
  • .github/workflows/ci.yml
  • apps/server/src/index.ts
  • apps/web/next.config.ts
  • apps/web/src/app/page.tsx
  • apps/web/src/components/public/connect-block.tsx
  • apps/web/src/components/public/consent-banner.tsx
  • apps/web/src/components/public/public-page-content.tsx
  • apps/web/src/components/public/public-page.tsx
  • apps/web/src/middleware.ts
  • docker-compose.yml
  • packages/api/src/routers/analytics.ts
  • packages/api/src/routers/backup.ts
  • packages/api/src/routers/contacts.ts
  • packages/api/src/routers/public.ts
  • packages/api/src/routers/settings.ts
  • packages/api/src/routers/wallet.ts
  • packages/api/src/utils/audit.ts
  • packages/api/src/utils/sanitize.ts
  • packages/db/src/schema/audit-log.ts
  • packages/db/src/schema/index.ts
  • scripts/reset-password.ts

Comment thread apps/web/next.config.ts
Comment on lines +439 to +440
aria-describedby={errors.consent ? "consent-error" : undefined}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use a unique consent error id per block instance.

Line 439/Line 446 use a static "consent-error" id, which can duplicate if multiple connect blocks are rendered.

💡 Suggested fix
-					aria-describedby={errors.consent ? "consent-error" : undefined}
+					aria-describedby={errors.consent ? `consent-error-${blockId}` : undefined}
@@
-			{errors.consent && (<span id="consent-error" className="text-xs text-red-400" role="alert">{errors.consent}</span>)}
+			{errors.consent && (
+				<span id={`consent-error-${blockId}`} className="text-xs text-red-400" role="alert">
+					{errors.consent}
+				</span>
+			)}

Also applies to: 446-447

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/public/connect-block.tsx` around lines 439 - 440, The
consent error id is hard-coded as "consent-error" in the ConnectBlock component
(used where aria-describedby={errors.consent ? "consent-error" : undefined} and
on the visible error element), which will collide if multiple blocks render;
change to a per-instance unique id (e.g., derive via React's useId() or a
generated uuid) and use that unique id in both the aria-describedby attribute
and the error element's id so each ConnectBlock instance references its own
error node consistently (update the JSX where aria-describedby and the consent
error element are rendered to use the generated uniqueId).

Comment thread apps/web/src/components/public/consent-banner.tsx Outdated
Comment thread apps/web/src/middleware.ts Outdated
Comment thread docker-compose.yml Outdated
captchaToken: z.string().max(4096).optional(),
blockId: z.string().max(100).optional(),
blockTitle: z.string().max(200).optional(),
consent: z.literal(true, { error: "Consent is required" }),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consent is enforced but not persisted for auditability.

consent: true is required at input, but the inserted contactSubmission record stores no consent flag/timestamp. That creates a compliance evidence gap.

If you want, I can draft the DB migration + router update (consentGiven/consentGivenAt) in a follow-up patch.

Also applies to: 186-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/public.ts` at line 130, The request schema requires
consent via "consent: z.literal(true, { error: 'Consent is required' })" but the
created contactSubmission record does not persist consent; update the API to
save audit fields and DB schema: add boolean consentGiven and timestamp
consentGivenAt to the contactSubmission table via a migration, then set
consentGiven = true and consentGivenAt = new Date() when inserting the record in
the router handler that constructs the contactSubmission (update the
insert/create code path referenced in this file and the related block around
lines 186-194); ensure the TypeScript types/ORM model for contactSubmission are
updated accordingly.

Comment thread packages/api/src/utils/sanitize.ts Outdated
Comment on lines +4 to +19
export const auditLog = sqliteTable(
"audit_log",
{
id: text("id").primaryKey(),
action: text("action").notNull(),
resourceType: text("resource_type"),
resourceId: text("resource_id"),
details: text("details"), // JSON string
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index("audit_log_created_at_idx").on(table.createdAt),
index("audit_log_action_idx").on(table.action),
],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if audit-log migration exists
echo "=== Looking for audit_log migration files ==="
fd -e sql . packages/db/drizzle --exec grep -l "audit_log" {} \; 2>/dev/null || echo "No migration files found matching audit_log"

# Check all migration files
echo ""
echo "=== All migration files ==="
fd -e sql . packages/db/drizzle 2>/dev/null || fd -e sql . packages/db/migrations 2>/dev/null || echo "No migrations directory found"

Repository: MrDemonWolf/linkden

Length of output: 211


🏁 Script executed:

# Check the packages/db directory structure
echo "=== packages/db directory structure ==="
fd . packages/db --type f --max-depth 3 2>/dev/null | head -30

# Look for Drizzle configuration files
echo ""
echo "=== Looking for Drizzle/database config files ==="
fd -e config -e conf -e setup . packages/db 2>/dev/null || echo "No config files found"
fd "drizzle.config" . 2>/dev/null || echo "No drizzle.config found"
fd "package.json" packages/db 2>/dev/null

# Look for all .sql files anywhere in the repo
echo ""
echo "=== All .sql files in repo ==="
fd -e sql . 2>/dev/null || echo "No .sql files found"

Repository: MrDemonWolf/linkden

Length of output: 1110


🏁 Script executed:

# Check if audit-log is exported in schema/index.ts
echo "=== packages/db/src/schema/index.ts content ==="
cat packages/db/src/schema/index.ts

# Check the latest migration to see if audit_log is already included
echo ""
echo "=== Latest migration (0004_legal_loners.sql) ==="
cat packages/db/src/migrations/0004_legal_loners.sql

# Look for any documentation about SQLite version or D1 requirements
echo ""
echo "=== Looking for SQLite/D1 version documentation ==="
fd -e md . 2>/dev/null | xargs grep -l "SQLite\|D1\|3.42\|unixepoch" 2>/dev/null | head -5

Repository: MrDemonWolf/linkden

Length of output: 649


🏁 Script executed:

# Check CLAUDE.md, GEMINI.md, README.md for SQLite/D1 version info
echo "=== CLAUDE.md (SQLite/D1 info) ==="
grep -A 5 -B 5 "SQLite\|D1\|3.42\|unixepoch" CLAUDE.md | head -40

echo ""
echo "=== GEMINI.md (SQLite/D1 info) ==="
grep -A 5 -B 5 "SQLite\|D1\|3.42\|unixepoch" GEMINI.md | head -40

echo ""
echo "=== README.md (SQLite/D1 info) ==="
grep -A 5 -B 5 "SQLite\|D1\|3.42\|unixepoch" README.md | head -40

Repository: MrDemonWolf/linkden

Length of output: 3897


🏁 Script executed:

# Check drizzle.config.ts for any settings
echo "=== packages/db/drizzle.config.ts ==="
cat packages/db/drizzle.config.ts

# Check package.json for Drizzle and SQLite versions
echo ""
echo "=== packages/db/package.json (relevant sections) ==="
grep -A 10 "drizzle\|sqlite" packages/db/package.json

Repository: MrDemonWolf/linkden

Length of output: 878


🌐 Web query:

Cloudflare D1 SQLite version support unixepoch subsecond

💡 Result:

Cloudflare D1 supports the SQLite unixepoch function, including subsecond precision via floating-point timestamps. D1 is compatible with most of SQLite's SQL conventions and leverages SQLite's query engine, which includes the full date and time functions like unixepoch. Official docs confirm support for SQLite extensions and PRAGMA statements, and examples use date(timestamp, 'unixepoch') in generated columns, confirming unixepoch modifier availability. unixepoch returns seconds since 1970-01-01 00:00:00 UTC as an integer by default, but accepts floating-point inputs for subsecond (fractional seconds) precision, as per SQLite behavior adopted by D1. No sources indicate limitations on subsecond support. To check exact SQLite version: execute PRAGMA sqlite_version; in D1 console (supported). Recent docs (2025-2026) show ongoing compatibility.

Citations:


Run bun run db:generate to create the migration file for the audit_log table.

The schema is correctly defined with Drizzle ORM and properly exported from packages/db/src/schema/index.ts, but the migration file has not been generated. The latest migration is 0004_legal_loners.sql; a 0005_*.sql file containing the CREATE TABLE audit_log statement must be generated before deployment. Per coding guidelines, after schema changes you must run bun run db:generate and commit the resulting migration file.

The unixepoch('subsecond') syntax is supported by Cloudflare D1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/schema/audit-log.ts` around lines 4 - 19, You added the new
audit_log table schema (auditLog via sqliteTable with columns like id, action,
resourceType, resourceId, details and createdAt) but did not generate the
migration; run the migration generator (bun run db:generate) to produce a new
migration file (0005_*.sql) containing the CREATE TABLE audit_log statement and
commit that generated SQL alongside your schema change so the deployment
includes the new table (ensure the createdAt default using
unixepoch('subsecond') is preserved in the generated SQL).

nathanialhenniges and others added 3 commits April 2, 2026 15:38
Addresses CodeRabbit docstring coverage warning (was 66.67%, threshold 80%).
Adds @param/@returns docs to logAudit(), hasAnalyticsConsent(), ConsentBanner(),
and middleware().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scripts/reset-password.ts: replace execSync+shell string with execFileSync
  and arg array to eliminate shell injection via email metacharacters (critical)
- apps/web/src/middleware.ts: tighten PUBLIC_ADMIN_ROUTES check to exact match
  or proper subpath (prevents /admin/login-anything bypass)
- apps/web/src/components/public/consent-banner.tsx: wrap all localStorage
  access in try/catch to handle SecurityError in restricted browser contexts
- packages/api/src/utils/sanitize.ts: add Edg/EdgA/EdgiOS tokens to UA regex
  so modern Edge is correctly labelled as Edge/<version>
- packages/api/src/routers/backup.ts: fix indentation on logAudit call
- packages/api/src/routers/contacts.ts: make logAudit fire-and-forget (void)
  so a logging failure cannot surface after a successful delete
- apps/web/src/app/page.tsx: destructure trackViewMutate from mutation to
  satisfy exhaustive-deps without changing effect behaviour
- apps/web/next.config.ts: allow http://localhost:* in connect-src during
  development so tRPC calls work; keep frame-src * (intentional for embeds)
- docker-compose.yml: replace curl (not in bun image) with wget for healthcheck
- packages/db/src/migrations/0005_talented_shotgun.sql: generated migration
  for audit_log table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
match[1] and match[2] are typed as string | undefined in strict mode
even inside an if(match) block; use optional chaining to satisfy tsc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nathanialhenniges nathanialhenniges merged commit 476101f into main Apr 2, 2026
2 of 3 checks passed
@nathanialhenniges nathanialhenniges deleted the fix/gdpr-iso27001-remediation branch April 2, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant