Skip to content

fix: account deletion v2 — immediate erasure with statutory consent retention (replaces 6-month soft delete) - #42

Merged
Bonobo791 merged 8 commits into
mainfrom
feat-account-deletion
Aug 2, 2026
Merged

fix: account deletion v2 — immediate erasure with statutory consent retention (replaces 6-month soft delete)#42
Bonobo791 merged 8 commits into
mainfrom
feat-account-deletion

Conversation

@Bonobo791

@Bonobo791 Bonobo791 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

User description

What

Replaces the 6-month soft-delete shipped in #37 with immediate deletion + statutory retention, per the LGPD deletion policy:

  • Immediate erasure. deleteAccount revokes each owned channel's YouTube grant at Google (revokeGoogleToken, RFC 7009 — also a YouTube API ToS requirement), then deleteUserRecords (src/lib/server/deletion.ts) erases in one transaction: moderation actions, comments, audit rows, rules, channels, sessions. A revocation failure is logged loudly per channel but never blocks deletion — the encrypted token is erased either way.
  • No restore window. The users row is tombstoned (google_sub = deleted:<id>, e-mail/display name [deleted]), freeing the real Google sub; signing back in is a brand-new signup through /consent, never a restore.
  • Statutory exception — consent evidence, e-mail in consents (Option B). Migration 0011_consents_email.sql adds nullable consents.email and backfills from users (NULLIF keeps the [deleted] tombstone sentinel out of the evidence; WHERE email IS NULL makes it idempotent); the consent action records it at every acceptance. The e-mail lives ONLY in the write-once consent log (LGPD Art. 16, III).
  • 10-year clock. A bounded, DRY_RUN-aware cron sweep (nullExpiredConsentEmails, I8/I10) nulls consents.email on rows older than 10 years (CC Art. 205); the anonymized consent row is kept. The sweep's query shape is covered by the partial consents_email_retention_idx (EXPLAIN QUERY PLAN evidence in migration-0011.test.ts).
  • Contract phase included. 0011 also drops users.deleted_at and its index — the soft-delete remnants from feat: account deletion with 6-month retention purge #37 that no v2 code path reads. Migrations 0009/0010 stay in the journal (applied history is immutable).
  • No LEGAL_VERSION bump — transparency clarification, not a consent change. Dashboard notice + danger-zone copy updated; Privacy §2/§7.1 now state immediate deletion and the blocked, access-restricted, up-to-10-years consent record.

Known gap (not a bug)

Accounts deleted before migration 0011 ships have already wiped users.email; their consent rows keep email = NULL after backfill. Unrecoverable by design.

Verification

  • npm run check — 0 errors; npm run build — green; npm run test — 270/270
  • New behavior tests: deletion.test.ts (full erase, tombstone, consent survival with e-mail, sub freeing, 10-year sweep), migration-0011.test.ts (column + NULLIF backfill + sentinel exclusion + partial-index EXPLAIN evidence on a pre-change db), dashboard deleteAccount tests (revocation with decrypted token, revoke-failure-still-deletes, transactional rollback), cron sweep tests (nulling, failure isolation, DRY_RUN), login/consent/session tests rewritten for immediate deletion.

After merge: run npm run db:migrate, then verify against the actual schema per DEPLOY.md §1 (PRAGMA table_info(consents) shows email; PRAGMA table_info(users) shows no deleted_at; __drizzle_migrations count 12) — drizzle-kit has exited 0 without applying twice now.


CodeAnt-AI Description

Replace delayed account deletion with immediate erasure and limited consent retention

What Changed

  • Account deletion now immediately removes channels, rules, comments, moderation records, audit entries, and sessions, then anonymizes the user so the Google identity can sign up again as a new account.
  • Each connected YouTube grant is revoked during deletion; a failed revocation is logged but does not prevent the account data from being erased.
  • Consent records retain the accepted e-mail and agreement details for up to 10 years, after which only the e-mail is removed while the anonymized evidence remains.
  • Removed the former six-month restore window and its related retention purge.
  • Updated deletion notices, privacy details, refund messaging, migration coverage, and regression tests to reflect the new behavior.

Impact

✅ Immediate account erasure
✅ No deleted-account restore
✅ Revoked YouTube access during deletion

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

…tion (Option B)

Replace the 6-month soft-delete with immediate erasure per the LGPD
deletion policy: deleteAccount revokes each channel's YouTube grant at
Google (revokeGoogleToken, loud per-channel failure, never blocking) and
deleteUserRecords erases channels/rules/comments/moderation actions/audit
rows/sessions in one transaction, tombstoning the users row fully
(google_sub deleted:<id>, email/display name [deleted]).

The retained evidence is the consent-acceptance record under Art. 16, III,
and the e-mail lives ONLY in consents (Option B): migration 0009 adds
nullable consents.email with a users backfill, the consent action records
it at every acceptance, and a bounded DRY_RUN-aware cron sweep nulls it
after 10 years (CC Art. 205) while keeping the anonymized row.

No LEGAL_VERSION bump — transparency clarification, not a consent change;
dashboard notice + Privacy §2/§7.1 copy updated (blocked from any other
use, access-restricted, up to 10 years).
@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed 41bd1c0 Aug 02, 2026 · 13:08 13:09
✅ Incremental review completed ce85389 Aug 02, 2026 · 12:36 12:37
✅ Incremental review completed e6f49d8 Aug 02, 2026 · 02:55 02:56
✅ Incremental review completed 09481eb Aug 02, 2026 · 02:30 02:31
✅ Incremental review completed 5dbf495 Aug 02, 2026 · 01:52 01:55

@cla-bot cla-bot Bot added the cla-signed label Aug 2, 2026
@netlify

netlify Bot commented Aug 2, 2026

Copy link
Copy Markdown

Deploy Preview for moderaty ready!

Name Link
🔨 Latest commit 41bd1c0
🔍 Latest deploy log https://app.netlify.com/projects/moderaty/deploys/6a6f41589a713e0009810ede
😎 Deploy Preview https://deploy-preview-42--moderaty.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 89
Accessibility: 97
Best Practices: 100
SEO: 100
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@codeant-ai codeant-ai Bot added the size:XL This PR changes 500-999 lines, ignoring generated files label Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Account deletion is now immediate and permanent, with YouTube access revoked automatically.
    • Deleted accounts cannot be restored by signing in; re-signup begins through consent.
    • Consent records retain email evidence for up to 10 years, after which emails are removed automatically.
  • Bug Fixes

    • Revocation failures no longer prevent account deletion.
    • Sessions associated with deleted accounts are invalidated.
    • Updated refund messaging consistently reflects the seven-day full-refund policy.
  • Documentation

    • Updated privacy policy, dashboard messaging, and deployment guidance.

Walkthrough

The PR replaces soft deletion with immediate account erasure. It revokes YouTube grants, removes user records, creates tombstones, retains consent evidence, and clears retained consent emails after 10 years through a bounded cron sweep. It also updates refund-policy wording.

Changes

Account deletion and consent retention

Layer / File(s) Summary
Schema and consent evidence
src/lib/server/db/schema.ts, src/lib/server/testdb.ts, drizzle/0011_consents_email.sql, drizzle/meta/*, src/routes/consent/*, src/lib/server/db/migration-0011.test.ts, DEPLOY.md
The schema removes users.deletedAt and adds nullable consent emails with a partial retention index. Migration and consent flows backfill and store email evidence.
Deletion transaction and token revocation
src/lib/server/deletion.ts, src/lib/server/google.ts, src/routes/(app)/dashboard/+page.server.ts, src/lib/server/deletion.test.ts, src/routes/(app)/dashboard/dashboard.test.ts
Account deletion revokes decrypted Google tokens, removes owned records and sessions, and anonymizes the user. Revocation failures do not block deletion.
Tombstone authentication handling
src/lib/server/session.ts, src/lib/server/session.test.ts, src/routes/api/auth/google/login/callback/+server.ts, src/routes/api/auth/google/login/login.test.ts
Session checks use deleted: Google-sub tombstones. Google sign-in routes tombstoned identities to consent without restoring the account.
Cron retention sweep
src/lib/server/deletion.ts, src/routes/api/cron/*
Cron clears up to 50 expired consent emails, reports sweep results, continues channel processing after errors, and skips the sweep during dry runs.
Deletion policy and dashboard messaging
src/lib/components/landing/legal/Privacy.svelte, src/routes/(app)/dashboard/+page.svelte, AGENTS.md, EXECUTION_PLAN_YouTube_Comment_Moderator.md, ROADMAP.md
Policy, dashboard text, and project documentation describe immediate deletion, authorization revocation, consent retention, and a planned revocation outbox.
Refund wording
src/lib/components/landing/PlanHosted.svelte, src/lib/landing/pricing-faq.ts, src/lib/server/legal.ts, src/lib/landing/legal.test.ts
Refund surfaces remove post-seven-day final-sale and unused-credit wording. Consistency tests cover the updated text.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Dashboard
  participant Google
  participant Database
  User->>Dashboard: request account deletion
  Dashboard->>Google: revoke channel tokens
  Google-->>Dashboard: success or error
  Dashboard->>Database: delete owned records and sessions
  Dashboard->>Database: anonymize user tombstone
  Dashboard-->>User: clear session and redirect
Loading
sequenceDiagram
  participant Cron
  participant ConsentSweep
  participant Database
  participant ChannelRunner
  Cron->>ConsentSweep: run non-dry-run sweep
  ConsentSweep->>Database: clear expired consent emails
  Database-->>ConsentSweep: return affected count
  ConsentSweep-->>Cron: return count
  Cron->>ChannelRunner: process channels
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: replacing six-month soft deletion with immediate erasure and statutory consent retention.
Description check ✅ Passed The description directly explains the immediate account erasure, consent retention, migration, testing, and related documentation changes.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-account-deletion

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

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Sequence Diagram

This diagram shows the PR's replacement of soft deletion with immediate account erasure, including Google grant revocation, transactional data deletion, and preservation of statutory consent evidence. It also shows the cron sweep that later removes retained consent e-mails while keeping anonymized records.

sequenceDiagram
    participant User
    participant Dashboard
    participant Google
    participant Database
    participant Cron

    User->>Dashboard: Confirm account deletion
    loop Each owned channel
        Dashboard->>Google: Revoke YouTube grant
        Google-->>Dashboard: Revocation result
    end
    Dashboard->>Database: Erase account records in one transaction
    Database-->>Dashboard: Tombstone user and preserve consent evidence
    Dashboard-->>User: Sign out and redirect

    Cron->>Database: Sweep expired consent e-mails
    Database-->>Cron: Null e-mails and keep consent rows
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: 41bd1c06
Scan Time: 2026-08-02 13:12:36 UTC

✅ Overall Status: PASSED

Quality Gate Details

Quality Gate Status Details
Secrets ✅ PASSED 0 secrets found
Duplicate Code ✅ PASSED 0.0% duplicated
SAST ✅ PASSED No security issues
Bugs ✅ PASSED Rating S: No bugs
IAC ✅ PASSED Rating S: No issues

View Full Results

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR implements immediate account deletion with statutory consent retention, replacing the 6-month soft-delete approach. The architecture is well-designed with proper transaction boundaries, token revocation, and time-bounded consent sweeps.

Critical Issue Identified: One security vulnerability requires immediate attention - potential token exposure in error logs during the deletion flow.

Verification: The PR includes comprehensive test coverage (268/268 passing) and the implementation follows LGPD/LGPD compliance requirements appropriately.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

Comment on lines +103 to +109
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛑 Security Vulnerability: Unencrypted token exposure in error logs creates a token leakage risk. If decrypt() throws an exception before the token string is created, the catch block at line 107 logs cause which could contain the encrypted token string from the exception message. Additionally, if revokeGoogleToken fails, its error messages could propagate encrypted tokens through the error chain.

Suggested change
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}
for (const ch of owned) {
try {
const token = decrypt(ch.refreshTokenEnc);
await revokeGoogleToken(token, `account deletion channel ${ch.id}`);
} catch (cause) {
// Never log the exception details - they may contain token material
console.error(`token revocation failed for channel ${ch.id}; deleting anyway`);
}
}

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Immediate account deletion with consent-email statutory retention and 10-year sweep

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Replace 6-month soft-delete with immediate, transactional erasure and user tombstoning.
• Retain consent-evidence e-mail only in consents; add 10-year cron nulling sweep.
• Revoke YouTube OAuth grants on deletion; update UI/legal copy and expand tests.
Diagram

graph TD
  U["User (Dashboard)"] --> A["deleteAccount action"] --> G{{"Google revoke endpoint"}} --> D["deletion.ts (erase)"] --> DB[("SQLite DB")]
  C["/consent accept"] --> DB
  CR["/api/cron sweep"] --> D --> DB
  DB --> L[("Consent log")]

  subgraph Legend
    direction LR
    _ui["UI/Route"] ~~~ _svc["Server logic"] ~~~ _db[("Database")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Async token revocation job with retries
  • ➕ Deletion UX is faster and less dependent on Google availability/latency
  • ➕ Can retry transient failures and report revocation status separately
  • ➖ Requires job queue / persistence for retry state
  • ➖ Harder to guarantee revocation happens before deletion completes
2. Store consent e-mail encrypted (separate key) instead of plaintext
  • ➕ Reduces breach impact while preserving evidentiary value
  • ➕ Can implement crypto-shredding by rotating/dropping a dedicated key
  • ➖ More operational complexity (key management, rotation, access controls)
  • ➖ Still retains personal data; must ensure lawful access patterns
3. Hard-delete users row and denormalize consent identity
  • ➕ Simplifies deletion semantics (true row removal)
  • ➕ Avoids tombstone handling in session logic
  • ➖ Requires changing consent FK strategy (or losing referential integrity)
  • ➖ Increases risk of consent log drifting from account lifecycle assumptions

Recommendation: The PR’s approach is sound for the stated policy: best-effort revocation (non-blocking) plus a single transactional erase and a user tombstone to preserve consent FK integrity. The main alternative worth considering is moving revocation to an async retriable job if deletion latency or Google flakiness becomes a UX/operational issue; otherwise the current synchronous best-effort flow is acceptable and simpler.

Files changed (23) +690 / -352

Enhancement (4) +181 / -28
deletion.tsImplement deleteUserRecords and 10-year consent email nulling sweep +110/-0

Implement deleteUserRecords and 10-year consent email nulling sweep

• Adds a transactional erase function that deletes channel-scoped tables, channels, and sessions, then tombstones the users row. Adds a bounded batch sweep that nulls consents.email for records older than 10 years.

src/lib/server/deletion.ts

google.tsAdd revokeGoogleToken (RFC 7009) for OAuth grant revocation +44/-0

Add revokeGoogleToken (RFC 7009) for OAuth grant revocation

• Implements token revocation via Google's revoke endpoint with timeout and structured error logging. Designed to be called per channel on account deletion, throwing on failure so callers can log and continue.

src/lib/server/google.ts

+server.tsReplace retention purge with consent email retention sweep in cron +17/-16

Replace retention purge with consent email retention sweep in cron

• Removes purgeExpiredUser flow and runs nullExpiredConsentEmails first each invocation (unless DRY_RUN). Returns sweep results in the JSON response while preserving channel-run behavior and failure isolation.

src/routes/api/cron/+server.ts

+page.server.tsPersist consent evidence e-mail on acceptance (new + existing users) +10/-12

Persist consent evidence e-mail on acceptance (new + existing users)

• Extends the consent record builder to include e-mail for both new-account creation and re-acceptance. Removes soft-delete restoration-at-consent logic since deletion is now immediate and permanent.

src/routes/consent/+page.server.ts

Bug fix (3) +28 / -39
session.tsTreat tombstoned users as deleted when resolving sessions +5/-4

Treat tombstoned users as deleted when resolving sessions

• Switches session resolution from users.deletedAt to checking the tombstone marker (googleSub starts with 'deleted:'). Ensures orphaned sessions never grant access and are removed on resolution.

src/lib/server/session.ts

+page.server.tsSwitch deleteAccount to revoke tokens then immediately erase records +22/-10

Switch deleteAccount to revoke tokens then immediately erase records

• Replaces the soft-delete transaction with per-channel token revocation (best-effort) followed by deleteUserRecords. Uses decrypt(refreshTokenEnc) to supply the revocation token and never blocks deletion on revocation failures.

src/routes/(app)/dashboard/+page.server.ts

+server.tsRemove soft-delete restoration/purge logic from login callback +1/-25

Remove soft-delete restoration/purge logic from login callback

• Eliminates retention-window logic that restored or purged soft-deleted users at sign-in. The callback now treats tombstoned accounts as non-existent by design (freed sub implies new signup).

src/routes/api/auth/google/login/callback/+server.ts

Tests (8) +387 / -215
migration-0009.test.tsAdd behavior test for migration 0009 consents.email backfill +95/-0

Add behavior test for migration 0009 consents.email backfill

• Creates a pre-migration in-memory schema and applies the SQL migration statements. Verifies the new column exists, is nullable/expand-only, and is backfilled from users.email.

src/lib/server/db/migration-0009.test.ts

deletion.test.tsAdd tests for transactional erase, tombstone behavior, and consent sweep +171/-0

Add tests for transactional erase, tombstone behavior, and consent sweep

• Validates deleteUserRecords deletes all owned rows and fully tombstones the user while preserving consent rows. Tests sub reuse after tombstoning and the 10-year bounded nullExpiredConsentEmails behavior.

src/lib/server/deletion.test.ts

session.test.tsUpdate session resolution test to use tombstone marker +4/-3

Update session resolution test to use tombstone marker

• Rewrites the orphaned-session regression test to reflect immediate deletion semantics. Validates that sessions are destroyed when the user's googleSub is tombstoned.

src/lib/server/session.test.ts

testdb.tsAdjust test DB schema: drop users.deleted_at and add consents.email +1/-1

Adjust test DB schema: drop users.deleted_at and add consents.email

• Updates the in-memory test database schema to match the new production schema expectations. Adds consents.email and removes users.deleted_at.

src/lib/server/testdb.ts

dashboard.test.tsRewrite dashboard deletion tests for revocation and immediate erase +62/-17

Rewrite dashboard deletion tests for revocation and immediate erase

• Mocks decrypt and fetch to assert the revocation call uses decrypted tokens. Verifies transactional rollback on erase failures and confirms deletion proceeds even when revocation fails (with loud logging).

src/routes/(app)/dashboard/dashboard.test.ts

login.test.tsUpdate login tests to reflect tombstone-based 'fresh signup' semantics +10/-47

Update login tests to reflect tombstone-based 'fresh signup' semantics

• Removes tests for soft-delete restoration and retention-window purging at sign-in. Adds a test asserting that a freed Google sub (due to tombstoning) routes to /consent as a new signup.

src/routes/api/auth/google/login/login.test.ts

cron.test.tsRewrite cron tests to cover 10-year consent email nulling and DRY_RUN +37/-133

Rewrite cron tests to cover 10-year consent email nulling and DRY_RUN

• Replaces retention purge coverage with tests for consent e-mail nulling based on CONSENT_EMAIL_RETENTION_MS. Verifies failure isolation (sweep failure doesn’t block channel runs) and DRY_RUN behavior.

src/routes/api/cron/cron.test.ts

consent.test.tsAssert consent rows include e-mail and remove soft-delete restoration tests +7/-14

Assert consent rows include e-mail and remove soft-delete restoration tests

• Updates expectations so consents rows include the user’s e-mail as evidence. Deletes tests specific to soft-deleted account restoration via consent completion.

src/routes/consent/consent.test.ts

Documentation (4) +68 / -44
AGENTS.mdUpdate account deletion policy documentation to immediate erasure model +19/-11

Update account deletion policy documentation to immediate erasure model

• Replaces the documented 6-month soft-delete flow with immediate deletion semantics. Documents tombstoning, consent-email retention in consents only, and the 10-year nulling sweep behavior.

AGENTS.md

EXECUTION_PLAN_YouTube_Comment_Moderator.mdRevise execution plan section to immediate deletion + consent retention +37/-26

Revise execution plan section to immediate deletion + consent retention

• Updates the design/spec section from soft-delete retention to immediate deletion with best-effort token revocation. Describes the consents.email decision (Option B) and the 10-year retention sweep contract.

EXECUTION_PLAN_YouTube_Comment_Moderator.md

Privacy.svelteClarify immediate deletion and consent-evidence retention in Privacy Policy +3/-3

Clarify immediate deletion and consent-evidence retention in Privacy Policy

• Updates retention language to reflect immediate account and service-record erasure. Adds explicit disclosure of the consent-acceptance record retention, access restriction, and 10-year e-mail erasure.

src/lib/components/landing/legal/Privacy.svelte

+page.svelteUpdate dashboard copy for immediate deletion and retained consent evidence +9/-4

Update dashboard copy for immediate deletion and retained consent evidence

• Adds a small notice pointing to the clarified Privacy Policy. Updates danger-zone text to reflect immediate deletion, revocation, and limited consent-evidence retention up to 10 years.

src/routes/(app)/dashboard/+page.svelte

Other (4) +26 / -26
0009_consents_email.sqlAdd consents.email column and backfill from users +9/-1

Add consents.email column and backfill from users

• Introduces nullable consents.email to hold statutory consent evidence. Backfills existing consents rows from the owning users.email to support wiping users.email on deletion.

drizzle/0009_consents_email.sql

0009_snapshot.jsonUpdate Drizzle snapshot for consents.email and remove users.deleted_at +8/-8

Update Drizzle snapshot for consents.email and remove users.deleted_at

• Pins the schema snapshot to include the new consents.email column. Removes users.deleted_at from the snapshot, matching the switch away from soft-delete.

drizzle/meta/0009_snapshot.json

_journal.jsonRenumber migration journal to 0009_consents_email +2/-9

Renumber migration journal to 0009_consents_email

• Replaces the previously planned 0009/0010 soft-delete entries with the new 0009_consents_email migration entry. Aligns the migration history with the new deletion strategy.

drizzle/meta/_journal.json

schema.tsMove retention identity to consents.email; remove users.deletedAt +7/-8

Move retention identity to consents.email; remove users.deletedAt

• Removes users.deletedAt and its associated index definition. Adds consents.email and documents that it is the only retained location for the e-mail post-deletion (with later nulling).

src/lib/server/db/schema.ts

@codacy-production

codacy-production Bot commented Aug 2, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 critical · 6 high

Alerts:
⚠ 7 issues (≤ 0 issues of at least high severity)
⚠ 2 issues (≤ 0 issues of at least high severity)

Results:
7 new issues

Category Results
Compatibility 5 high
Security 1 critical
1 high

View in Codacy

🔴 Metrics 31 complexity · 5 duplication

Metric Results
Complexity 31 (≤ 100 complexity)
Duplication ⚠️ 5 (≤ 1 duplication)

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Previous suggestions up to commit 637d327
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Race condition
Concurrent channel attachment can recreate account data after deletion completes

The transaction does not establish a deletion marker or otherwise prevent the
channel-connect callback from attaching a channel after this channel enumeration and
after the deletion transaction commits. An in-flight authenticated callback can
therefore insert or reattach a channel with its encrypted refresh token after
deletion, leaving account data behind. Mark the user as deleting/tombstoned before
accepting channel writes, and make the callback reject that state, or perform
deletion with an equivalent write guard.

src/lib/server/deletion.ts [65-66]

Why it matters? 🤔
  • ❌ Deleted accounts can retain newly attached channels.
  • ❌ Encrypted YouTube refresh tokens can survive deletion.
  • ⚠️ Subsequent callbacks can recreate deleted-account data.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/server/deletion.ts
**Line:** 65:66
**Comment:**
	*Race Condition: The transaction does not establish a deletion marker or otherwise prevent the channel-connect callback from attaching a channel after this channel enumeration and after the deletion transaction commits. An in-flight authenticated callback can therefore insert or reattach a channel with its encrypted refresh token after deletion, leaving account data behind. Mark the user as deleting/tombstoned before accepting channel writes, and make the callback reject that state, or perform deletion with an equivalent write guard.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Critical2026-08-02 01:38
Concurrent moderation work can recreate erased records after deletion commits

Calling the erase transaction without coordinating with in-flight moderation runs
does not guarantee immediate erasure. A pipeline run can pass its channel-active
check, this deletion can commit and remove the channel, and the run can then insert
comments, moderation actions, or audit rows because those tables do not enforce a
foreign key to the deleted channel. This can recreate deleted account data after the
action redirects; deletion must synchronize with writers or make the durable writes
atomically conditional on the channel still existing and belonging to a non-deleted
account.

src/routes/(app)/dashboard/+page.server.ts [110-111]

Why it matters? 🤔
  • ❌ Cron moderation can recreate deleted comments.
  • ❌ Deleted moderation and audit records may reappear.
  • ⚠️ Account erasure is not guaranteed under concurrent runs.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 110:111
**Comment:**
	*Race Condition: Calling the erase transaction without coordinating with in-flight moderation runs does not guarantee immediate erasure. A pipeline run can pass its channel-active check, this deletion can commit and remove the channel, and the run can then insert comments, moderation actions, or audit rows because those tables do not enforce a foreign key to the deleted channel. This can recreate deleted account data after the action redirects; deletion must synchronize with writers or make the durable writes atomically conditional on the channel still existing and belonging to a non-deleted account.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Critical2026-08-02 01:38
Snapshotting channels before deletion allows newly written grants to escape revocation

The channel list and encrypted tokens are read before deleteUserRecords starts, so a
concurrent YouTube OAuth callback can attach or replace a channel after this
snapshot. The deletion transaction then removes that channel without revoking the
token that was actually stored, leaving a live Google grant orphaned. Coordinate
channel writes with deletion or mark the account as deleting before collecting and
revoking tokens.

src/routes/(app)/dashboard/+page.server.ts [98-105]

Why it matters? 🤔
  • ❌ Newly connected grants may remain active at Google.
  • ❌ Account deletion can orphan a YouTube authorization.
  • ⚠️ Callback and deletion share the authenticated user session.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 98:105
**Comment:**
	*Race Condition: The channel list and encrypted tokens are read before `deleteUserRecords` starts, so a concurrent YouTube OAuth callback can attach or replace a channel after this snapshot. The deletion transaction then removes that channel without revoking the token that was actually stored, leaving a live Google grant orphaned. Coordinate channel writes with deletion or mark the account as deleting before collecting and revoking tokens.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:38
Account deletion can race with consent acceptance and leave a consent row and session for a deleted account

The existing-user path reads the user before opening the transaction. If account
deletion commits after this read but before these statements, the transaction
inserts a consent row containing the tombstone email [deleted] and creates a session
for the deleted account. The session will later be rejected, but the action still
reports success and leaves an invalid consent record. Re-read and verify the user is
still live inside the same transaction that inserts the consent and creates the
session, or serialize this flow with deletion.

src/routes/consent/+page.server.ts [126-129]

Why it matters? 🤔
  • ❌ Consent acceptance can create credentials for deleted accounts.
  • ⚠️ Deleted-account consent rows can receive post-deletion events.
  • ⚠️ Users are redirected successfully before session rejection.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/routes/consent/+page.server.ts
**Line:** 126:129
**Comment:**
	*Race Condition: The existing-user path reads the user before opening the transaction. If account deletion commits after this read but before these statements, the transaction inserts a consent row containing the tombstone email `[deleted]` and creates a session for the deleted account. The session will later be rejected, but the action still reports success and leaves an invalid consent record. Re-read and verify the user is still live inside the same transaction that inserts the consent and creates the session, or serialize this flow with deletion.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:38
Performance
An unbounded retention sweep can consume the moderation run budget before channel processing starts

The retention sweep performs an unbounded database select and update before channel
selection, despite the deadline being captured at handler start. A slow remote
database operation can consume the entire 20-second run budget, after which
runChannel receives an already-expired deadline and may skip or fail moderation
while the handler can still return a successful response. Pass the deadline or a
database timeout into the sweep, or reserve a bounded portion of the budget for
channel processing.

src/routes/api/cron/+server.ts [71]

Why it matters? 🤔
  • ⚠️ Slow Turso access leaves channel moderation partial.
  • ⚠️ Cron reports success despite incomplete moderation.
  • ⚠️ One channel run may be delayed until lease rotation.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/routes/api/cron/+server.ts
**Line:** 71:71
**Comment:**
	*Performance: The retention sweep performs an unbounded database select and update before channel selection, despite the deadline being captured at handler start. A slow remote database operation can consume the entire 20-second run budget, after which `runChannel` receives an already-expired deadline and may skip or fail moderation while the handler can still return a successful response. Pass the deadline or a database timeout into the sweep, or reserve a bounded portion of the budget for channel processing.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:38
Comment mismatch
The deletion notice falsely claims that consent records are the only data retained after account closure

The deletion notice incorrectly says that only consent-acceptance records are
retained, but the Privacy Policy also states that invoices and tax records,
connection logs, support correspondence, and security logs may be retained after
account closure. This gives users a materially false deletion guarantee; either
enumerate the other legally retained categories here or change the wording to match
the actual retention policy.

src/routes/(app)/dashboard/+page.svelte [125-127]

Why it matters? 🤔
  • ⚠️ Account deletion notice contradicts Privacy Policy retention categories.
  • ⚠️ Users receive an inaccurate guarantee about post-deletion data retention.
  • ⚠️ Billing, support, connection, and security records are omitted.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/routes/(app)/dashboard/+page.svelte
**Line:** 125:127
**Comment:**
	*Comment Mismatch: The deletion notice incorrectly says that only consent-acceptance records are retained, but the Privacy Policy also states that invoices and tax records, connection logs, support correspondence, and security logs may be retained after account closure. This gives users a materially false deletion guarantee; either enumerate the other legally retained categories here or change the wording to match the actual retention policy.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:38

Latest suggestions up to commit 5dbf495
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Race condition
Concurrent deletion can let a stale session resolution grant access after the account was tombstoned

The tombstone check is not atomic with the later session renewal and return. If
account deletion commits after this query but before the renewal update, this code
can return the pre-deletion user and recreate or renew a session that the deletion
transaction removed. Perform the validation and session update in one transaction or
re-read the user/session state immediately before returning.

src/lib/server/session.ts [91-94]

Why it matters? 🤔
  • ⚠️ In-flight requests can retain deleted-account authorization.
  • ⚠️ Session renewal may target a session deleted by account erasure.
  • ⚠️ Concurrent OAuth callbacks can continue with stale user state.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/server/session.ts
**Line:** 91:94
**Comment:**
	*Race Condition: The tombstone check is not atomic with the later session renewal and return. If account deletion commits after this query but before the renewal update, this code can return the pre-deletion user and recreate or renew a session that the deletion transaction removed. Perform the validation and session update in one transaction or re-read the user/session state immediately before returning.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:55
Comment mismatch
Deletion policy incorrectly presents the consent record as the only retained data despite other retention periods listed in the same policy

Section 7.1 states that the consent record is the one exception to immediate
deletion, but Section 2 separately says connection/application-access logs are
retained for six months, billing and tax records for five years, support
correspondence for two years, and security logs for up to twelve months. This
contradiction gives users an inaccurate account-deletion retention disclosure;
reconcile Section 7.1 with all data categories that remain retained.

src/lib/components/landing/legal/Privacy.svelte [116]

Why it matters? 🤔
  • ⚠️ Privacy policy gives conflicting account-deletion retention disclosures.
  • ⚠️ Users cannot identify which statutory records survive deletion.
  • ⚠️ Compliance review may flag inconsistent retention language.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/components/landing/legal/Privacy.svelte
**Line:** 116:116
**Comment:**
	*Comment Mismatch: Section 7.1 states that the consent record is the one exception to immediate deletion, but Section 2 separately says connection/application-access logs are retained for six months, billing and tax records for five years, support correspondence for two years, and security logs for up to twelve months. This contradiction gives users an inaccurate account-deletion retention disclosure; reconcile Section 7.1 with all data categories that remain retained.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Major2026-08-02 01:55

@qodo-code-review

qodo-code-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (3) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 68 rules

Grey Divider


Action required

1. Non-atomic revoke ordering 🐞 Bug ☼ Reliability
Description
deleteAccount revokes Google tokens before running the transactional DB erase, so a DB
failure/rollback can still leave the user’s YouTube grant revoked while their account/channel rows
remain intact.
This creates a partially-completed deletion state that the app cannot roll back.
Code

src/routes/(app)/dashboard/+page.server.ts[R98-110]

+		const owned = await db
+			.select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc })
+			.from(channels)
+			.where(eq(channels.userId, user.id))
+			.all();
+		for (const ch of owned) {
+			try {
+				await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
+			} catch (cause) {
+				console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
+			}
+		}
+		await deleteUserRecords(user.id);
Relevance

●● Moderate

External revoke vs DB transaction ordering is subjective; no strong repo precedent requiring
atomic/compensating workflow.

PR-#37

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The dashboard action revokes each channel’s token in a loop and only then calls deleteUserRecords.
revokeGoogleToken performs a real HTTP POST to Google, while deleteUserRecords is a separate DB
transaction—so a DB rollback cannot undo a successful remote revocation.

src/routes/(app)/dashboard/+page.server.ts[87-113]
src/lib/server/google.ts[117-159]
src/lib/server/deletion.ts[63-79]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`deleteAccount` performs an irreversible remote side effect (Google token revocation) before executing the transactional database erase. If `deleteUserRecords(...)` fails and rolls back, the user is left with a still-present account but a revoked YouTube grant.

### Issue Context
- The DB erase is transactional (`deleteUserRecords` wraps deletes + tombstone update in `db.transaction(...)`).
- Google revocation is an external HTTP call and cannot be rolled back.

### How to fix
- Decrypt and collect the refresh tokens first (in memory), but do **not** revoke yet.
- Run `await deleteUserRecords(user.id)`.
- Only after the erase succeeds, attempt revocations using the collected plaintext tokens.
 - Use `Promise.allSettled(...)` (or bounded concurrency) so one channel’s failure doesn’t block others.
 - Keep the current “log loudly but never block” behavior for per-channel revocation failures.

### Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[93-112]
- src/lib/server/deletion.ts[63-79]
- src/lib/server/google.ts[117-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Backfill stores '[deleted]' ✓ Resolved 🐞 Bug ≡ Correctness
Description
Migration 0009 backfills consents.email from users.email for all rows; for tombstoned users
users.email is set to '[deleted]', so the migration will persist '[deleted]' as the consent
“email” instead of leaving it NULL/unknown.
That undermines the intended meaning of consents.email as retained evidence.
Code

drizzle/0009_consents_email.sql[R26-27]

+ALTER TABLE `consents` ADD `email` text;--> statement-breakpoint
+UPDATE `consents` SET `email` = (SELECT `email` FROM `users` WHERE `users`.`id` = `consents`.`user_id`);
Relevance

●●● Strong

Likely treated as correctness bug: consent evidence shouldn’t backfill tombstone '[deleted]'
sentinel into retained email field.

PR-#40

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The migration’s UPDATE copies users.email into consents.email without filtering. The new
deletion code tombstones users.email to '[deleted]', so the backfill will store that sentinel
for any already-deleted accounts at migration time.

drizzle/0009_consents_email.sql[19-27]
src/lib/server/deletion.ts[73-79]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The migration backfill unconditionally copies `users.email` into `consents.email`. Since account deletion tombstones `users.email` to the sentinel `'[deleted]'`, any user deleted before this migration runs will get `consents.email='[deleted]'`.

### Issue Context
- Deletion tombstones `users.email` (not NULL) to satisfy `users.email NOT NULL`.
- The migration comment claims pre-migration deletions result in `consents.email = NULL`, but the SQL as written will copy the sentinel.

### How to fix
Adjust the migration backfill to avoid copying the tombstone value, e.g.:
- `UPDATE consents SET email = NULLIF((SELECT email FROM users WHERE users.id = consents.user_id), '[deleted]');`
Optionally scope to only rows where `consents.email IS NULL` to make the statement safer if re-run.

### Fix Focus Areas
- drizzle/0009_consents_email.sql[19-27]
- src/lib/server/deletion.ts[73-79]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. deleteAccount missing deletedAt update 📘 Rule violation ≡ Correctness
Description
The dashboard deleteAccount action no longer sets users.deletedAt and deactivates channels
within a single transaction; it now revokes tokens and hard-erases user-owned records via
deleteUserRecords. This violates the required soft-delete/deactivation transaction semantics and
changes the mandated retention behavior.
Code

src/routes/(app)/dashboard/+page.server.ts[R93-110]

+		// Immediate deletion: everything is erased NOW except the evidentiary
+		// consent log (statutory retention, LGPD Art. 16, III). Each channel's
+		// YouTube grant is revoked at Google first (YouTube API ToS); a
+		// revocation failure is logged loudly but does not block deletion — the
+		// encrypted token is erased either way, orphaning the grant.
+		const owned = await db
+			.select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc })
+			.from(channels)
+			.where(eq(channels.userId, user.id))
+			.all();
+		for (const ch of owned) {
+			try {
+				await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
+			} catch (cause) {
+				console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
+			}
+		}
+		await deleteUserRecords(user.id);
Relevance

●● Moderate

Seems intentional immediate-deletion redesign; no close accepted/rejected precedent for enforcing
deletedAt+deactivate semantics.

PR-#37

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2462242 requires a single transaction that sets users.deletedAt, deletes
sessions, and deactivates channels. The new deleteAccount implementation instead iterates channels
to revoke tokens, then calls deleteUserRecords, which deletes channels/sessions and tombstones the
user without setting deletedAt or deactivating channels.

Rule 2462242: Dashboard deleteAccount must perform all user deactivation steps in a single database transaction
src/routes/(app)/dashboard/+page.server.ts[87-112]
src/lib/server/deletion.ts[63-79]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Compliance requires the dashboard `deleteAccount` flow to soft-delete the user (`users.deletedAt`), destroy sessions, and deactivate channels (`channels.active=0`) inside a single DB transaction.

## Issue Context
The current implementation performs immediate erasure (deleting channels/sessions and tombstoning the user) and does not set `deletedAt` or deactivate channels.

## Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[87-113]
- src/lib/server/db/schema.ts[22-29]
- src/lib/server/deletion.ts[63-79]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. /api/cron no longer purges 📘 Rule violation ≡ Correctness
Description
The cron endpoint no longer selects and purges a single expired user per invocation; it only runs a
consent e-mail nulling sweep. This violates the requirement that the retention purge cron processes
exactly one expired user and anonymizes the user record while preserving consent logs.
Code

src/routes/api/cron/+server.ts[R60-75]

+	// Consent-evidence retention sweep runs first, while the full budget
+	// remains: consent e-mails older than 10 years (CC Art. 205) are erased —
+	// the row stays as anonymized evidence. I8: a dry run changes nothing
+	// durable — the would-be sweep is only logged. A sweep failure must not
+	// stop scheduled moderation: log it loudly, report it, continue.
+	let consentEmailsNulled = 0;
+	let sweepError: string | null = null;
	if (dryRun) {
-		console.info('dry run: retention purge skipped');
+		console.info('dry run: consent e-mail retention sweep skipped');
	} else {
		try {
-			purged = await purgeExpiredUser();
+			consentEmailsNulled = await nullExpiredConsentEmails();
		} catch (cause) {
-			purgeError = cause instanceof Error ? cause.message : String(cause);
-			console.error('retention purge failed:', cause);
+			sweepError = cause instanceof Error ? cause.message : String(cause);
+			console.error('consent e-mail retention sweep failed:', cause);
		}
Relevance

●● Moderate

Cron switched from user purge to consent sweep intentionally; no strong precedent how strictly “one
expired user per run” is enforced.

PR-#37

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2462296 requires the cron job to query and purge one expired user per run and
tombstone the user while leaving consents intact. The current cron implementation runs
nullExpiredConsentEmails() and does not perform any user selection or purge operations.

Rule 2462296: Retention purge cron processes a single expired user and anonymizes the user record while preserving consent logs
src/routes/api/cron/+server.ts[59-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Compliance requires cron to purge exactly one expired soft-deleted user per invocation (deleting sessions/channels/rules/comments/moderation actions/audit rows) while anonymizing the user row and preserving `consents`.

## Issue Context
The PR replaces the retention purge call with `nullExpiredConsentEmails()`, and the cron response fields are updated accordingly.

## Fix Focus Areas
- src/routes/api/cron/+server.ts[59-85]
- src/lib/server/deletion.ts[63-110]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
5. No migration verification step ✓ Resolved 📘 Rule violation ☼ Reliability
Description
This PR introduces a new Drizzle migration but does not add any explicit verification command/query
after npm run db:migrate in scripts or deployment docs. This violates the requirement to verify
that migrations actually applied on the target database.
Code

drizzle/0009_consents_email.sql[R19-27]

+-- Consent-evidence e-mail (account deletion v2): the e-mail is statutory
+-- retention evidence (LGPD Art. 16, III) and lives ONLY in the consent log,
+-- so account deletion can wipe users.email entirely. Expand-only: nullable
+-- column + backfill from the owning user. Known gap (not a bug): accounts
+-- deleted BEFORE this ships have already wiped users.email, so their consent
+-- rows keep email NULL after backfill — that history is unrecoverable.
+
+ALTER TABLE `consents` ADD `email` text;--> statement-breakpoint
+UPDATE `consents` SET `email` = (SELECT `email` FROM `users` WHERE `users`.`id` = `consents`.`user_id`);
Relevance

●● Moderate

Repo sometimes documents migration steps, but no clear precedent requiring post-migrate verification
command/query in scripts/docs.

PR-#40
PR-#1

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2462319 requires an explicit verification step after running npm run db:migrate.
The PR adds a migration altering the schema, while the repo scripts/docs still only show
db:migrate with no verification command or query.

Rule 2462319: Verify Drizzle migrations actually applied after running npm run db:migrate
drizzle/0009_consents_email.sql[19-27]
package.json[10-19]
DEPLOY.md[29-34]
README.md[69-83]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When a schema migration is added, the project must provide a concrete post-migrate verification step (scripted command or documented query) to ensure Drizzle migrations actually applied on Turso.

## Issue Context
A new migration adds `consents.email`, but `package.json` only includes `db:migrate` and the deployment docs describe running `npm run db:migrate` without a follow-up verification step.

## Fix Focus Areas
- package.json[10-19]
- DEPLOY.md[29-34]
- README.md[69-83]
- drizzle/0009_consents_email.sql[19-27]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Consent sweep missing index ✓ Resolved 🐞 Bug ➹ Performance
Description
nullExpiredConsentEmails filters by consents.created_at (cutoff) and `consents.email IS NOT
NULL, but the schema/migrations only index consents.user_id`, so cron sweeps can devolve into
full-table scans as the consent log grows.
The LIMIT 50 bounds updates but does not guarantee bounded read work without an index.
Code

src/lib/server/deletion.ts[R92-98]

+export async function nullExpiredConsentEmails(): Promise<number> {
+	const expired = await db
+		.select({ id: consents.id })
+		.from(consents)
+		.where(and(isNotNull(consents.email), lt(consents.createdAt, consentEmailCutoffIso())))
+		.limit(CONSENT_SWEEP_BATCH)
+		.all();
Relevance

●● Moderate

Team accepted some index additions, but also rejected others; unclear for this sweep path.

PR-#25
PR-#36

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The sweep query predicates are on consents.email and consents.created_at, but the only consent
index defined is on user_id, and the migration adding email does not add any index for the sweep
path.

src/lib/server/deletion.ts[92-110]
src/lib/server/db/schema.ts[112-133]
drizzle/0009_consents_email.sql[19-27]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The 10-year sweep query selects expired consent rows using `created_at < cutoff` and `email IS NOT NULL`. Without an index on `created_at` (or a covering composite), SQLite may scan the entire `consents` table to find matching rows.

### Issue Context
- `consents` currently has an index on `user_id` only.
- The sweep runs at cron frequency, so scan cost can become a recurring tax.

### How to fix
- Add a schema index supporting the sweep, e.g. `index('consents_created_at_idx').on(table.createdAt)`.
- Add a new Drizzle SQL migration to create the same DB index.
- (Optional) consider `orderBy(consents.createdAt)` to make “draining the oldest” deterministic once indexed.

### Fix Focus Areas
- src/lib/server/deletion.ts[92-110]
- src/lib/server/db/schema.ts[112-133]
- drizzle/0009_consents_email.sql[19-27]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. login callback removed retention handling 📘 Rule violation ≡ Correctness
Description
The Google login callback no longer restores soft-deleted accounts within 6 months nor purges
accounts older than 6 months before proceeding as a fresh signup. This violates the required 6‑month
reactivation/purge logic for deleted accounts.
Code

src/routes/api/auth/google/login/callback/+server.ts[L119-126]

-	// Signing back in WITHIN the 6-month retention window cancels a pending
-	// deletion — only now that the sign-in completes. Channels stay inactive
-	// (active=0 from the deletion) until the user re-enables them — moderation
-	// never resumes silently. (Accounts past the window never reach here —
-	// they are purged inline above.)
-	if (user.deletedAt) {
-		await db.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
-		console.info(`account ${user.id} restored by sign-in; pending deletion cancelled`);
Relevance

●● Moderate

PR explicitly removes restore window; could conflict with prior retention rule, but no precedent
predicting team response.

PR-#37

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2462257 requires time-based handling of deletedAt during sign-in (restore within
6 months, purge beyond). The PR removes the block that cleared deletedAt on successful sign-in,
and the current callback code simply creates a session for any existing users.googleSub match with
no retention checks.

Rule 2462257: Handle account reactivation and purge based on 6‑month retention window
src/routes/api/auth/google/login/callback/+server.ts[83-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Compliance requires the login/session-creation flow to (1) restore accounts within a 6-month retention window by clearing `deletedAt` (without re-enabling channels) and (2) purge accounts older than 6 months and treat the flow as a fresh signup.

## Issue Context
The PR removes the `deletedAt` restoration logic from the login callback and eliminates the `deletedAt` column entirely, making the required behavior impossible.

## Fix Focus Areas
- src/routes/api/auth/google/login/callback/+server.ts[83-118]
- src/lib/server/db/schema.ts[22-29]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +93 to +110
// Immediate deletion: everything is erased NOW except the evidentiary
// consent log (statutory retention, LGPD Art. 16, III). Each channel's
// YouTube grant is revoked at Google first (YouTube API ToS); a
// revocation failure is logged loudly but does not block deletion — the
// encrypted token is erased either way, orphaning the grant.
const owned = await db
.select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc })
.from(channels)
.where(eq(channels.userId, user.id))
.all();
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}
await deleteUserRecords(user.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. deleteaccount missing deletedat update 📘 Rule violation ≡ Correctness

The dashboard deleteAccount action no longer sets users.deletedAt and deactivates channels
within a single transaction; it now revokes tokens and hard-erases user-owned records via
deleteUserRecords. This violates the required soft-delete/deactivation transaction semantics and
changes the mandated retention behavior.
Agent Prompt
## Issue description
Compliance requires the dashboard `deleteAccount` flow to soft-delete the user (`users.deletedAt`), destroy sessions, and deactivate channels (`channels.active=0`) inside a single DB transaction.

## Issue Context
The current implementation performs immediate erasure (deleting channels/sessions and tombstoning the user) and does not set `deletedAt` or deactivate channels.

## Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[87-113]
- src/lib/server/db/schema.ts[22-29]
- src/lib/server/deletion.ts[63-79]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines -119 to -126
// Signing back in WITHIN the 6-month retention window cancels a pending
// deletion — only now that the sign-in completes. Channels stay inactive
// (active=0 from the deletion) until the user re-enables them — moderation
// never resumes silently. (Accounts past the window never reach here —
// they are purged inline above.)
if (user.deletedAt) {
await db.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
console.info(`account ${user.id} restored by sign-in; pending deletion cancelled`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Login callback removed retention handling 📘 Rule violation ≡ Correctness

The Google login callback no longer restores soft-deleted accounts within 6 months nor purges
accounts older than 6 months before proceeding as a fresh signup. This violates the required 6‑month
reactivation/purge logic for deleted accounts.
Agent Prompt
## Issue description
Compliance requires the login/session-creation flow to (1) restore accounts within a 6-month retention window by clearing `deletedAt` (without re-enabling channels) and (2) purge accounts older than 6 months and treat the flow as a fresh signup.

## Issue Context
The PR removes the `deletedAt` restoration logic from the login callback and eliminates the `deletedAt` column entirely, making the required behavior impossible.

## Fix Focus Areas
- src/routes/api/auth/google/login/callback/+server.ts[83-118]
- src/lib/server/db/schema.ts[22-29]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +60 to 75
// Consent-evidence retention sweep runs first, while the full budget
// remains: consent e-mails older than 10 years (CC Art. 205) are erased —
// the row stays as anonymized evidence. I8: a dry run changes nothing
// durable — the would-be sweep is only logged. A sweep failure must not
// stop scheduled moderation: log it loudly, report it, continue.
let consentEmailsNulled = 0;
let sweepError: string | null = null;
if (dryRun) {
console.info('dry run: retention purge skipped');
console.info('dry run: consent e-mail retention sweep skipped');
} else {
try {
purged = await purgeExpiredUser();
consentEmailsNulled = await nullExpiredConsentEmails();
} catch (cause) {
purgeError = cause instanceof Error ? cause.message : String(cause);
console.error('retention purge failed:', cause);
sweepError = cause instanceof Error ? cause.message : String(cause);
console.error('consent e-mail retention sweep failed:', cause);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. /api/cron no longer purges 📘 Rule violation ≡ Correctness

The cron endpoint no longer selects and purges a single expired user per invocation; it only runs a
consent e-mail nulling sweep. This violates the requirement that the retention purge cron processes
exactly one expired user and anonymizes the user record while preserving consent logs.
Agent Prompt
## Issue description
Compliance requires cron to purge exactly one expired soft-deleted user per invocation (deleting sessions/channels/rules/comments/moderation actions/audit rows) while anonymizing the user row and preserving `consents`.

## Issue Context
The PR replaces the retention purge call with `nullExpiredConsentEmails()`, and the cron response fields are updated accordingly.

## Fix Focus Areas
- src/routes/api/cron/+server.ts[59-85]
- src/lib/server/deletion.ts[63-110]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread drizzle/0009_consents_email.sql Outdated
Comment on lines +98 to +110
const owned = await db
.select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc })
.from(channels)
.where(eq(channels.userId, user.id))
.all();
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}
await deleteUserRecords(user.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Non-atomic revoke ordering 🐞 Bug ☼ Reliability

deleteAccount revokes Google tokens before running the transactional DB erase, so a DB
failure/rollback can still leave the user’s YouTube grant revoked while their account/channel rows
remain intact.
This creates a partially-completed deletion state that the app cannot roll back.
Agent Prompt
### Issue description
`deleteAccount` performs an irreversible remote side effect (Google token revocation) before executing the transactional database erase. If `deleteUserRecords(...)` fails and rolls back, the user is left with a still-present account but a revoked YouTube grant.

### Issue Context
- The DB erase is transactional (`deleteUserRecords` wraps deletes + tombstone update in `db.transaction(...)`).
- Google revocation is an external HTTP call and cannot be rolled back.

### How to fix
- Decrypt and collect the refresh tokens first (in memory), but do **not** revoke yet.
- Run `await deleteUserRecords(user.id)`.
- Only after the erase succeeds, attempt revocations using the collected plaintext tokens.
  - Use `Promise.allSettled(...)` (or bounded concurrency) so one channel’s failure doesn’t block others.
  - Keep the current “log loudly but never block” behavior for per-channel revocation failures.

### Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[93-112]
- src/lib/server/deletion.ts[63-79]
- src/lib/server/google.ts[117-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread drizzle/0009_consents_email.sql Outdated
Comment thread src/lib/server/deletion.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

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 (1)
src/routes/consent/+page.server.ts (1)

119-131: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject tombstoned accounts before re-consent.

If deletion occurs after the user lookup, this branch inserts a consent record with email: '[deleted]' and creates a session for the tombstoned account. Select googleSub and reject values starting with deleted: before inserting consent or creating the session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/consent/`+page.server.ts around lines 119 - 131, Update the user
lookup in the re-consent branch to also select users.googleSub, then reject the
request when googleSub starts with "deleted:" before inserting consent or
calling createSession. Preserve the existing expired-session failure for missing
users and ensure tombstoned accounts cannot receive a new session.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@drizzle/0009_consents_email.sql`:
- Around line 19-27: Restore the existing 0009 migration and the 0010 migration
that add users.deleted_at and users_deleted_at_idx, preserving their original
contents and numbering. Move the consents.email addition and backfill into a new
later migration with an unused migration number; do not repurpose 0009 or remove
0010, and add the requested subsequent migration to drop the index and column.

In `@src/lib/server/deletion.ts`:
- Around line 49-80: Update the account-deletion flow around deleteUserRecords
so a failure after successful Google token revocation does not leave the active
channel with a revoked refresh token. On deletion failure, either retry the
deletion transaction or mark the affected channel inactive and persist the
existing reauthorization state, ensuring cron no longer attempts
refreshAccessToken for it.

In `@src/lib/server/google.ts`:
- Around line 116-159: Extract the shared Google form-request behavior from
exchangeGoogleCode and revokeGoogleToken into reusable fetch/error-detail
helpers, including response text collection and best-effort JSON parsing via a
helper such as extractGoogleErrorDetail. Update both callers to use the shared
logic while preserving their existing log context and distinct failure behavior:
exchangeGoogleCode must retain its 502 user-facing error, and revokeGoogleToken
must retain its Error throw.

In `@src/routes/`(app)/dashboard/+page.server.ts:
- Around line 93-110: Best-effort YouTube token revocation is described as
guaranteed despite failures being logged and tokens deleted. In
src/routes/(app)/dashboard/+page.server.ts lines 93-110, update the deletion
flow around revokeGoogleToken and deleteUserRecords to either persist pending
revocations for out-of-band retry or explicitly accept the residual risk; in
src/lib/components/landing/legal/Privacy.svelte line 116 and
src/routes/(app)/dashboard/+page.svelte lines 123-127, soften the
customer-facing claims to state revocation is attempted and may fail, including
Google's security settings as the independent remedy where appropriate.
- Around line 93-110: Update the account deletion flow around revokeGoogleToken
and deleteUserRecords to add a durable reconciliation path for failed YouTube
token revocations, rather than only logging the failure before deleting the
encrypted token. Reuse the existing action_pending-style mechanism and ensure
failed channel revocations are recorded for later retry while preserving
immediate user-record deletion.
- Around line 103-109: Add a test covering the channel-deletion loop around
revokeGoogleToken: configure multiple owned channels so revocation fails for
one, assert processing continues to the subsequent channel, deletion still
completes, and console.error identifies the failed channel.

In `@src/routes/api/cron/`+server.ts:
- Around line 60-76: The consent retention sweep around nullExpiredConsentEmails
must enforce the remaining RUN_BUDGET_MS deadline instead of allowing an
unbounded table scan. Add a suitable index for the query’s createdAt/email
filtering, pass the deadline or remaining budget into nullExpiredConsentEmails,
and stop or abort the sweep once the budget is exhausted while preserving
dry-run behavior and existing error reporting.

In `@src/routes/api/cron/cron.test.ts`:
- Around line 38-48: Move the duplicated DAY_MS constant and seedConsent helper
into a shared test-support module near the existing test database utilities,
then import and reuse both from cron.test.ts and deletion.test.ts. Preserve the
helper’s current user and consent row values and its createdAt parameter
behavior.

---

Outside diff comments:
In `@src/routes/consent/`+page.server.ts:
- Around line 119-131: Update the user lookup in the re-consent branch to also
select users.googleSub, then reject the request when googleSub starts with
"deleted:" before inserting consent or calling createSession. Preserve the
existing expired-session failure for missing users and ensure tombstoned
accounts cannot receive a new session.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: 2ee96503-9b53-43ea-a780-7b83ab0aec18

📥 Commits

Reviewing files that changed from the base of the PR and between bfab18a and 637d327.

📒 Files selected for processing (28)
  • AGENTS.md
  • EXECUTION_PLAN_YouTube_Comment_Moderator.md
  • drizzle/0009_consents_email.sql
  • drizzle/0010_users_deleted_at_idx.sql
  • drizzle/meta/0009_snapshot.json
  • drizzle/meta/0010_snapshot.json
  • drizzle/meta/_journal.json
  • src/lib/components/landing/legal/Privacy.svelte
  • src/lib/server/db/migration-0009.test.ts
  • src/lib/server/db/schema-indexes.test.ts
  • src/lib/server/db/schema.ts
  • src/lib/server/deletion.test.ts
  • src/lib/server/deletion.ts
  • src/lib/server/google.ts
  • src/lib/server/retention.test.ts
  • src/lib/server/retention.ts
  • src/lib/server/session.test.ts
  • src/lib/server/session.ts
  • src/lib/server/testdb.ts
  • src/routes/(app)/dashboard/+page.server.ts
  • src/routes/(app)/dashboard/+page.svelte
  • src/routes/(app)/dashboard/dashboard.test.ts
  • src/routes/api/auth/google/login/callback/+server.ts
  • src/routes/api/auth/google/login/login.test.ts
  • src/routes/api/cron/+server.ts
  • src/routes/api/cron/cron.test.ts
  • src/routes/consent/+page.server.ts
  • src/routes/consent/consent.test.ts
💤 Files with no reviewable changes (5)
  • drizzle/meta/0010_snapshot.json
  • drizzle/0010_users_deleted_at_idx.sql
  • src/lib/server/retention.ts
  • src/lib/server/retention.test.ts
  • src/lib/server/db/schema-indexes.test.ts

Comment thread drizzle/0009_consents_email.sql Outdated
Comment on lines +49 to +80
/**
* Immediately and permanently erases a user's account data, preserving only the anonymized tombstone and the consent log.
*
* One transaction: moderation actions, comments, audit rows, and rules for
* the user's channels; the channels themselves; every session. The users row
* is anonymized to a tombstone (`googleSub: 'deleted:<id>'`, e-mail and
* display name wiped) so the same Google identity can sign up again and the
* `consents` evidentiary log survives with its foreign key intact. The
* e-mail survives ONLY in `consents` (statutory retention, Art. 16, III) —
* never in the live users table. OAuth token revocation happens at the
* caller, BEFORE this erase (the encrypted tokens die here either way).
*
* @param userId - The ID of the user to erase
*/
export async function deleteUserRecords(userId: string): Promise<void> {
await db.transaction(async (tx) => {
const chs = await tx.select({ id: channels.id }).from(channels).where(eq(channels.userId, userId)).all();
const channelIds = chs.map((ch) => ch.id);
if (channelIds.length) {
await tx.delete(moderationActions).where(inArray(moderationActions.channelId, channelIds));
await tx.delete(comments).where(inArray(comments.channelId, channelIds));
await tx.delete(auditLog).where(inArray(auditLog.channelId, channelIds));
await tx.delete(rules).where(inArray(rules.channelId, channelIds));
}
await tx.delete(channels).where(eq(channels.userId, userId));
await tx.delete(sessions).where(eq(sessions.userId, userId));
await tx
.update(users)
.set({ googleSub: `deleted:${userId}`, email: '[deleted]', displayName: '[deleted]' })
.where(eq(users.id, userId));
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether cron or dashboard code handles a revoked-but-undeleted channel (invalid_grant).
fd '\+page\.server\.ts$' 'src/routes/(app)/dashboard' --exec cat -n {}
rg -n 'invalid_grant|revoke' src/routes/api/cron src/lib/server/google.ts 2>/dev/null

Repository: Bonobo791/Moderaty

Length of output: 5908


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- cron and token-related files ---'
fd -t f . src | rg 'cron|google|channel|worker|moderation'
printf '%s\n' '--- revoked-token handling across server code ---'
rg -n -i 'invalid.?grant|invalid_grant|invalid token|unauthori[sz]ed|401|revokeGoogleToken|refreshTokenEnc|refresh token' src
printf '%s\n' '--- cron route contents ---'
for f in $(fd -t f '\+server\.ts$' src/routes | rg 'cron'); do
  echo "### $f"
  cat -n "$f"
done

Repository: Bonobo791/Moderaty

Length of output: 17965


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- token refresh and channel failure handling ---'
cat -n src/lib/server/youtube.ts | sed -n '145,215p'
cat -n src/lib/server/pipeline.ts | sed -n '490,555p'
printf '%s\n' '--- active-channel updates and error handling ---'
rg -n -C 4 'active|refreshAccessToken|runChannel|catch' src/lib/server/pipeline.ts src/lib/server/youtube.ts src/routes/\(app\)/channels/\[id\]/queue/+page.server.ts
printf '%s\n' '--- cron failure tests ---'
cat -n src/routes/api/cron/cron.test.ts | sed -n '95,175p'

Repository: Bonobo791/Moderaty

Length of output: 22753


🏁 Script executed:

#!/bin/bash
set -eu
cat -n src/lib/server/pipeline.ts | sed -n '556,585p'
printf '%s\n' '--- channel active writes ---'
rg -n -C 3 'set\(\{[^}]*active|active:' src/lib/server src/routes | head -120

Repository: Bonobo791/Moderaty

Length of output: 8152


Handle revoked tokens after failed account deletion.

When Google revocation succeeds but deleteUserRecords fails, the active channel retains a revoked refresh token. Cron selects it again, and refreshAccessToken fails with invalid_grant. Mark the channel inactive and expose a reauthorization state, or retry the deletion transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/server/deletion.ts` around lines 49 - 80, Update the account-deletion
flow around deleteUserRecords so a failure after successful Google token
revocation does not leave the active channel with a revoked refresh token. On
deletion failure, either retry the deletion transaction or mark the affected
channel inactive and persist the existing reauthorization state, ensuring cron
no longer attempts refreshAccessToken for it.

Comment thread src/lib/server/google.ts
Comment on lines +93 to +110
// Immediate deletion: everything is erased NOW except the evidentiary
// consent log (statutory retention, LGPD Art. 16, III). Each channel's
// YouTube grant is revoked at Google first (YouTube API ToS); a
// revocation failure is logged loudly but does not block deletion — the
// encrypted token is erased either way, orphaning the grant.
const owned = await db
.select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc })
.from(channels)
.where(eq(channels.userId, user.id))
.all();
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}
await deleteUserRecords(user.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Best-effort YouTube revocation is presented as guaranteed in two customer-facing surfaces. The deletion flow logs a revocation failure server-side and proceeds; the encrypted token is erased in the same transaction, so a failed revocation can never be retried. Two user-facing texts state revocation as an unconditional fact, which does not match this behavior.

  • src/routes/(app)/dashboard/+page.server.ts#L93-L110: this is the root cause — a per-channel revocation failure is only logged (console.error), never surfaced to the user, and the token is discarded with no action_pending-style reconciliation. Either add a reconciliation path (record the pending revocation before erasing the token and retry it out-of-band) or accept the residual risk explicitly.
  • src/lib/components/landing/legal/Privacy.svelte#L116-L116: soften "your YouTube authorization is revoked with Google" to reflect that revocation is attempted, not guaranteed, or state the caveat for the rare failure case.
  • src/routes/(app)/dashboard/+page.svelte#L123-L127: soften "revokes Moderaty's access to your YouTube channels" the same way, or note that a revocation failure is possible and independently reversible via Google's own security settings page (already described in Privacy.svelte §4.3).
📍 Affects 3 files
  • src/routes/(app)/dashboard/+page.server.ts#L93-L110 (this comment)
  • src/lib/components/landing/legal/Privacy.svelte#L116-L116
  • src/routes/(app)/dashboard/+page.svelte#L123-L127
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(app)/dashboard/+page.server.ts around lines 93 - 110,
Best-effort YouTube token revocation is described as guaranteed despite failures
being logged and tokens deleted. In src/routes/(app)/dashboard/+page.server.ts
lines 93-110, update the deletion flow around revokeGoogleToken and
deleteUserRecords to either persist pending revocations for out-of-band retry or
explicitly accept the residual risk; in
src/lib/components/landing/legal/Privacy.svelte line 116 and
src/routes/(app)/dashboard/+page.svelte lines 123-127, soften the
customer-facing claims to state revocation is attempted and may fail, including
Google's security settings as the independent remedy where appropriate.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Best-effort revocation with no retry path — see consolidated comment.

Once a channel's revocation fails, the failure is logged and the encrypted token is erased in the same deletion transaction. There is no action_pending-style reconciliation to retry it later, unlike the pattern used elsewhere for YouTube writes (I3). This is a deliberate tradeoff per the code comments, but it means the user-facing claim that "authorization is revoked with Google" is not always true. See the consolidated comment covering this file, src/lib/components/landing/legal/Privacy.svelte, and src/routes/(app)/dashboard/+page.svelte.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/`(app)/dashboard/+page.server.ts around lines 93 - 110, Update the
account deletion flow around revokeGoogleToken and deleteUserRecords to add a
durable reconciliation path for failed YouTube token revocations, rather than
only logging the failure before deleting the encrypted token. Reuse the existing
action_pending-style mechanism and ensure failed channel revocations are
recorded for later retry while preserving immediate user-record deletion.

Comment thread src/routes/(app)/dashboard/+page.server.ts
Comment on lines +60 to 76
// Consent-evidence retention sweep runs first, while the full budget
// remains: consent e-mails older than 10 years (CC Art. 205) are erased —
// the row stays as anonymized evidence. I8: a dry run changes nothing
// durable — the would-be sweep is only logged. A sweep failure must not
// stop scheduled moderation: log it loudly, report it, continue.
let consentEmailsNulled = 0;
let sweepError: string | null = null;
if (dryRun) {
console.info('dry run: retention purge skipped');
console.info('dry run: consent e-mail retention sweep skipped');
} else {
try {
purged = await purgeExpiredUser();
consentEmailsNulled = await nullExpiredConsentEmails();
} catch (cause) {
purgeError = cause instanceof Error ? cause.message : String(cause);
console.error('retention purge failed:', cause);
sweepError = cause instanceof Error ? cause.message : String(cause);
console.error('consent e-mail retention sweep failed:', cause);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect nullExpiredConsentEmails for batch bounding (LIMIT) and any deadline awareness.
fd -t f 'deletion.ts$' src/lib/server | xargs -I{} sh -c 'echo "== {} =="; rg -n -A15 "function nullExpiredConsentEmails|export.*nullExpiredConsentEmails" {}'

Repository: Bonobo791/Moderaty

Length of output: 681


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '== cron handler =='
cat -n src/routes/api/cron/+server.ts | sed -n '1,150p'
printf '%s\n' '== deletion implementation =='
cat -n src/lib/server/deletion.ts | sed -n '1,150p'
printf '%s\n' '== sweep references and budget definitions =='
rg -n -C 4 'nullExpiredConsentEmails|CONSENT_SWEEP_BATCH|RUN_BUDGET_MS|deadline|runChannel' src

Repository: Bonobo791/Moderaty

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '== consent schema and indexes =='
rg -n -C 8 'export const consents|createdAt|email|index\(' src/lib/server/db/schema.ts
printf '%s\n' '== database driver and query configuration =='
rg -n -C 6 'better-sqlite|sqlite|drizzle|busy_timeout|timeout' src/lib/server
printf '%s\n' '== deadline enforcement helper =='
rg -n -C 8 'function assertBeforeDeadline|class DeadlineExceededError|assertBeforeDeadline' src/lib/server

Repository: Bonobo791/Moderaty

Length of output: 41155


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

deletion = Path("src/lib/server/deletion.ts").read_text()
schema = Path("src/lib/server/db/schema.ts").read_text()

fn = deletion[deletion.index("export async function nullExpiredConsentEmails"):].split("\n}", 1)[0]
print("sweep_has_limit:", bool(re.search(r"\.limit\(\s*CONSENT_SWEEP_BATCH\s*\)", fn)))
print("sweep_batch:", re.search(r"const CONSENT_SWEEP_BATCH = (\d+)", deletion).group(1))
consents = schema[schema.index("export const consents"):].split("});", 1)[0]
print("consent_indexes:", re.findall(r"index\('([^']+)'\\)", consents))
print("has_created_at_or_email_index:", bool(re.search(r"index\([^)]*(createdAt|email)", consents)))
print("sweep_accepts_deadline:", bool(re.search(r"nullExpiredConsentEmails\s*\([^)]*deadline", deletion)))
PY

Repository: Bonobo791/Moderaty

Length of output: 742


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

deletion = Path("src/lib/server/deletion.ts").read_text()
schema = Path("src/lib/server/db/schema.ts").read_text()

fn = deletion[deletion.index("export async function nullExpiredConsentEmails"):].split("\n}", 1)[0]
consents = schema[schema.index("export const consents"):].split("});", 1)[0]

print("sweep_has_limit:", bool(re.search(r"\.limit\(\s*CONSENT_SWEEP_BATCH\s*\)", fn)))
print("sweep_batch:", re.search(r"const CONSENT_SWEEP_BATCH = (\d+)", deletion).group(1))
print("consent_indexes:", re.findall(r"index\('([^']+)'\)", consents))
print("has_created_at_or_email_index:", bool(re.search(r"index\([^)]*(createdAt|email)", consents)))
print("sweep_accepts_deadline:", bool(re.search(r"nullExpiredConsentEmails\s*\([^)]*deadline", deletion)))
PY

Repository: Bonobo791/Moderaty

Length of output: 303


Make the consent sweep deadline-aware. nullExpiredConsentEmails() limits the batch to 50 rows, but it does not receive or enforce deadline. The consents table has no index for createdAt or email, so the query can scan the full table before returning 50 rows. Add a suitable index and stop the sweep when the remaining RUN_BUDGET_MS is exhausted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/cron/`+server.ts around lines 60 - 76, The consent retention
sweep around nullExpiredConsentEmails must enforce the remaining RUN_BUDGET_MS
deadline instead of allowing an unbounded table scan. Add a suitable index for
the query’s createdAt/email filtering, pass the deadline or remaining budget
into nullExpiredConsentEmails, and stop or abort the sweep once the budget is
exhausted while preserving dry-run behavior and existing error reporting.

Source: Coding guidelines

Comment thread src/routes/api/cron/cron.test.ts Outdated
…ackfill

The backfill copied users.email verbatim, so a user deleted before the
migration (tombstone sentinel '[deleted]') would get consents.email =
'[deleted]' instead of NULL. NULLIF the sentinel away and scope the UPDATE
to email IS NULL so the statement is idempotent. Reproducing test failed
before the fix, passes after.
@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Aug 2, 2026
@coderabbitai coderabbitai Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Aug 2, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@drizzle/0009_consents_email.sql`:
- Line 29: Format the SQL statement by moving the WHERE predicate beginning with
consents.email onto a new line after the SET expression, preserving the query
logic and values unchanged so SQLFluff LT14 passes.

In `@src/lib/server/db/migration-0009.test.ts`:
- Around line 86-110: Extract the repeated users/consents schema and seed setup
from the existing migration fixture into a reusable helper near the setup at
lines 39–68, accepting the seed data needed by each test. Replace the inline
CREATE TABLE and INSERT block in the tombstone case with a call to that helper,
preserving its existing “gone” seed values and ensuring both tests use the same
schema.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: 814101ca-0450-4736-8db8-465abca1f0d3

📥 Commits

Reviewing files that changed from the base of the PR and between 637d327 and 5dbf495.

📒 Files selected for processing (2)
  • drizzle/0009_consents_email.sql
  • src/lib/server/db/migration-0009.test.ts

Comment thread drizzle/0009_consents_email.sql Outdated
Comment thread src/lib/server/db/migration-0009.test.ts Outdated
@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files size:XL This PR changes 500-999 lines, ignoring generated files labels Aug 2, 2026
… the sweep, contract-drop deleted_at

Audit against the sqlite-engineering skill:

- Journal append-only: 0009/0010 are applied to prod since the hotfix, so
  the renumber violated immutable history. Restored both migrations, their
  snapshots, and their journal entries; the consent-email work is now a
  NEW migration 0011.
- Unindexed sweep (field failure #3): the 10-year sweep's WHERE email IS
  NOT NULL AND created_at < cutoff scanned the whole table per cron tick.
  0011 creates partial index consents_email_retention_idx; an EXPLAIN
  QUERY PLAN test proves SEARCH, not SCAN.
- 0011 is also the CONTRACT phase for the abandoned soft delete: drops
  users.deleted_at and its index (no code path reads them in v2).
- DEPLOY.md gains the post-migrate verification step (field failure #4):
  check the actual schema, never the drizzle-kit exit code.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@EXECUTION_PLAN_YouTube_Comment_Moderator.md`:
- Around line 1967-1973: The data-retention statement in the 0011
consent/deletion paragraph contradicts the documented consents.email retention
behavior. Update it to state that no operational table other than consents
retains a deleted user’s e-mail, while preserving the existing explanation of
the users.email tombstone wipe.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: ac77ef70-a4bd-4914-8f12-986d92c1cc50

📥 Commits

Reviewing files that changed from the base of the PR and between 5dbf495 and cba3f25.

📒 Files selected for processing (9)
  • AGENTS.md
  • DEPLOY.md
  • EXECUTION_PLAN_YouTube_Comment_Moderator.md
  • drizzle/0011_consents_email.sql
  • drizzle/meta/0011_snapshot.json
  • drizzle/meta/_journal.json
  • src/lib/server/db/migration-0011.test.ts
  • src/lib/server/db/schema.ts
  • src/lib/server/testdb.ts

Comment thread EXECUTION_PLAN_YouTube_Comment_Moderator.md Outdated
@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Aug 2, 2026
@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Question: review @codex review

Answer:

Findings

[P1] Dashboard deletion tests do not create all tables used by deleteUserRecords

deleteAccount now calls deleteUserRecords, which unconditionally deletes from moderation_actions, comments, audit_log, and rules whenever the user owns a channel:

await tx.delete(moderationActions) ...
await tx.delete(comments) ...
await tx.delete(auditLog) ...
await tx.delete(rules) ...

However, src/routes/(app)/dashboard/dashboard.test.ts still initializes only:

setupTestDb(['comments', 'channels', 'users', 'sessions']);

It does not create moderation_actions, audit_log, or rules. The successful deletion tests seed a channel and therefore execute those deletes against nonexistent tables, causing the action to fail instead of redirecting. The revocation-failure and multi-channel tests will hit the same problem.

Either include all tables required by deleteUserRecords in the dashboard test setup:

setupTestDb([
  'moderation_actions',
  'comments',
  'audit_log',
  'rules',
  'channels',
  'sessions',
  'users',
  'consents'
]);

or make the fixture/schema setup represent the production database as a whole. The tests should also include a retained consent row if the deletion behavior is being exercised end-to-end.

[P2] Revocation and deletion are not atomic with respect to newly added channels

The action first reads the owned channels outside the deletion transaction:

const owned = await db.select(...).from(channels).where(eq(channels.userId, user.id)).all();

for (const ch of owned) {
  await revokeGoogleToken(...);
}

await deleteUserRecords(user.id);

A channel created or attached to the user after owned is selected but before deleteUserRecords runs will be deleted by the transaction without having its Google token revoked. This leaves exactly the orphaned authorization the new flow is intended to avoid.

Consider selecting the channel/token set inside the same transaction used for erasure, or otherwise preventing channel ownership changes during deletion. If Google calls cannot occur inside the DB transaction, record a revocation task/outbox transactionally and drain it asynchronously, as already noted in ROADMAP.md.

[P2] Deletion can tombstone a different account after a concurrent identity change

deleteUserRecords(user.id) updates by only the internal user ID:

.update(users)
.set({ googleSub: `deleted:${userId}`, ... })
.where(eq(users.id, userId));

The action obtains user from the session before revocation and deletion. If that session/user relationship becomes invalid or the account is concurrently changed/reused, the operation still tombstones and erases by ID. More importantly, the channel list used for revocation and the rows erased are not based on one consistent snapshot.

The deletion transaction should verify that the user is still the expected authenticated account (for example, include the expected googleSub/session relationship in the operation), and ownership changes should be serialized with deletion. At minimum, the transaction should re-read and validate the user before applying the tombstone.

@Bonobo791

Copy link
Copy Markdown
Owner Author

@CodeAnt-AI review

- Plan doc: 'no live table' was wrong — consents IS a live table holding
  the retained e-mail (CodeRabbit #3697464718)
- deleteUserRecords re-validates the user inside the transaction and
  throws loudly on a missing or already-tombstoned row instead of
  silently re-tombstoning (CodeAnt P2); failing tests first, then guard
- ROADMAP outbox item now also covers the snapshot race: a channel
  connected between the revocation snapshot and the erase must not die
  unrevoked (CodeAnt P2 — durable fix stays roadmap-deferred)
- dashboard.test.ts wipe list covers every table deleteUserRecords
  touches; sweep of all setupTestDb callers found no other gaps
  (CodeAnt P1 — claim of nonexistent tables was wrong: createTestDb
  creates all tables, the array is only the wipe list)
@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Aug 2, 2026

@codacy-production codacy-production Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull Request Overview

The PR successfully implements the transition from a soft-delete to an immediate erasure policy while maintaining statutory 10-year consent retention. However, the current Codacy analysis indicates the code is 'not up to standards' due to several issues.

A critical security risk was identified in the Google API helper regarding Server-Side Request Forgery (SSRF). Additionally, the account deletion process currently performs network revocations sequentially; if a user has multiple channels or if the Google API is slow, the entire database transaction may time out, leaving the account in an inconsistent state. Finally, the SQL migration script uses non-standard identifiers which should be corrected to ensure portability and compliance.

About this PR

  • The account deletion process relies on multiple sequential external API calls (YouTube token revocation). While failures are handled gracefully, the cumulative latency poses a risk of execution timeouts, particularly in environments with strict limits like SvelteKit actions or Netlify functions.

Test suggestions

  • Verify immediate erasure of all user-linked data across multiple tables (channels, sessions, rules, etc.) in a single transaction.
  • Verify YouTube token revocation is attempted for all owned channels before data erasure.
  • Ensure account deletion proceeds and logs a loud error if a specific YouTube token revocation fails.
  • Verify that a tombstoned user's Google sub is freed, allowing a brand-new signup from the same identity.
  • Test migration 0011 backfill: correctly copies email to consents and excludes existing tombstone sentinels.
  • Verify the 10-year cron sweep correctly nulls emails on expired consent records while keeping the anonymized evidence.
  • Confirm the retention sweep query utilizes the partial index (consents_email_retention_idx) rather than a table scan.
  • Verify that active sessions for a deleted account are destroyed immediately upon detection during resolution.

TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback

Comment thread src/lib/server/google.ts
let res: Response;
let text: string;
try {
res = await fetch(url, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 HIGH RISK

The variable 'url' is passed directly to fetch, creating a potential SSRF risk. Restrict the 'url' parameter to specific literal types (e.g., 'https://oauth2.googleapis.com/token' | 'https://oauth2.googleapis.com/revoke') or add a validation check to ensure it starts with the expected Google OAuth base URL.

See Issue in Codacy

Comment on lines +37 to +46
DROP INDEX `users_deleted_at_idx`;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `deleted_at`;--> statement-breakpoint
ALTER TABLE `consents` ADD `email` text;--> statement-breakpoint
UPDATE `consents`
SET `email` = NULLIF(
(SELECT `email` FROM `users` WHERE `users`.`id` = `consents`.`user_id`),
'[deleted]'
)
WHERE `consents`.`email` IS NULL;--> statement-breakpoint
CREATE INDEX `consents_email_retention_idx` ON `consents` (`created_at`) WHERE "consents"."email" is not null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 HIGH RISK

Standardize SQL identifiers by replacing backticks with double quotes (") to ensure ANSI compliance and satisfy static analysis.

Comment on lines +103 to +109
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 MEDIUM RISK

Suggestion: The token revocations are performed sequentially, which may cause a timeout for users with multiple channels. Additionally, using template literals in error logs can lead to log injection. Revoke tokens in parallel and use separate arguments for logging:

Suggested change
for (const ch of owned) {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause);
}
}
await Promise.allSettled(
owned.map(async (ch) => {
try {
await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`);
} catch (cause) {
console.error('token revocation failed for channel %s; deleting anyway:', ch.id, cause);
}
})
);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (2)
src/routes/api/cron/cron.test.ts (2)

120-135: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Add coverage for the 50-row sweep limit.

This test creates only one expired row. It will pass if the CONSENT_SWEEP_BATCH limit is removed. Seed at least 51 expired rows, then assert that 50 e-mails are nulled and one remains.

As per coding guidelines, “Tests must fail when the real logic is wrong; rewrite tests that pass when a function returns garbage.”

Suggested regression test
+	for (let i = 0; i < 51; i++) await seedConsent(`old-${i}`, oldDate);
+	const body = await res.json();
+	expect(body).toMatchObject({ ok: true, consentEmailsNulled: 50 });
+	const rows = await testDb().db.select().from(consents).all();
+	expect(rows.filter((row) => row.email === null)).toHaveLength(50);
+	expect(rows.filter((row) => row.email !== null)).toHaveLength(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/cron/cron.test.ts` around lines 120 - 135, Update the
consent-retention test around call and seedConsent to create at least 51 expired
consent rows, while retaining a recent row if needed for existing coverage.
Assert that the response reports exactly 50 nulled emails and verify that one
expired row still has its email, proving the CONSENT_SWEEP_BATCH limit is
enforced.

Source: Coding guidelines


137-161: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Execute the retry invocation.

The test drops fail_consent_update in finally and then only reads the unchanged row. It never calls GET again. Add a second invocation after cleanup and assert consentEmailsNulled: 1 and a null e-mail.

As per coding guidelines, “Tests must fail when the real logic is wrong; rewrite tests that pass when a function returns garbage.”

Suggested assertion
 	expect((await testDb().db.select().from(consents).all())[0].email).toBe('old@example.com');
+	const retry = await call({ bearer: 'test-secret' });
+	expect(await retry.json()).toMatchObject({ ok: true, consentEmailsNulled: 1, sweepError: null });
+	expect((await testDb().db.select().from(consents).all())[0].email).toBeNull();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/cron/cron.test.ts` around lines 137 - 161, Extend the test
around call() so that after the finally block removes fail_consent_update, it
invokes call({ bearer: 'test-secret' }) again and verifies the retry succeeds
with consentEmailsNulled equal to 1. Assert that the consent row’s email is null
after the second invocation, while preserving the existing first-invocation
failure and channel-run assertions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/landing/legal.test.ts`:
- Around line 249-263: Expand the FINALITY patterns in the test “states
post-window finality only in the Terms, never on consumer surfaces” to reject
equivalent post-window refund claims, including no refunds after seven days,
refunds unavailable after the window, and unused credits excluded from refunds.
Keep the existing consumer-surface assertions and Terms-of-Service expectations
unchanged.

In `@src/lib/landing/pricing-faq.ts`:
- Line 46: Update the refund wording in the pricing FAQ entry to use the
canonical complete phrase “no questions asked,” matching the wording in the
legal refund notice while preserving the rest of the copy.

---

Outside diff comments:
In `@src/routes/api/cron/cron.test.ts`:
- Around line 120-135: Update the consent-retention test around call and
seedConsent to create at least 51 expired consent rows, while retaining a recent
row if needed for existing coverage. Assert that the response reports exactly 50
nulled emails and verify that one expired row still has its email, proving the
CONSENT_SWEEP_BATCH limit is enforced.
- Around line 137-161: Extend the test around call() so that after the finally
block removes fail_consent_update, it invokes call({ bearer: 'test-secret' })
again and verifies the retry succeeds with consentEmailsNulled equal to 1.
Assert that the consent row’s email is null after the second invocation, while
preserving the existing first-invocation failure and channel-run assertions.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: a5d6709d-0046-483c-b608-042f727f6ac5

📥 Commits

Reviewing files that changed from the base of the PR and between cba3f25 and ce85389.

📒 Files selected for processing (15)
  • EXECUTION_PLAN_YouTube_Comment_Moderator.md
  • ROADMAP.md
  • drizzle/0011_consents_email.sql
  • src/lib/components/landing/PlanHosted.svelte
  • src/lib/components/landing/legal/Privacy.svelte
  • src/lib/landing/legal.test.ts
  • src/lib/landing/pricing-faq.ts
  • src/lib/server/deletion.test.ts
  • src/lib/server/deletion.ts
  • src/lib/server/google.ts
  • src/lib/server/legal.ts
  • src/lib/server/testdb.ts
  • src/routes/(app)/dashboard/+page.svelte
  • src/routes/(app)/dashboard/dashboard.test.ts
  • src/routes/api/cron/cron.test.ts

Comment thread src/lib/landing/legal.test.ts
Comment thread src/lib/landing/pricing-faq.ts Outdated
…l refund phrase

- postGoogleForm's url param is now a literal union of the two Google
  endpoints: no caller-controlled URL can reach fetch (Codacy SSRF)
- Pricing FAQ uses the canonical 'no questions asked' from the legal
  refund notice (CodeRabbit #3699046785)
- Refund-finality guard test rejects equivalent post-window claims
  ('no refunds after…', 'unavailable after the window', 'unused credits
  excluded') per the repo test-strength rule (CodeRabbit #3699046776)
@codeant-ai codeant-ai Bot added size:XL This PR changes 500-999 lines, ignoring generated files and removed size:XL This PR changes 500-999 lines, ignoring generated files labels Aug 2, 2026
@sonarqubecloud

sonarqubecloud Bot commented Aug 2, 2026

Copy link
Copy Markdown

@Bonobo791
Bonobo791 merged commit 9655b4a into main Aug 2, 2026
14 of 19 checks passed
@Bonobo791
Bonobo791 deleted the feat-account-deletion branch August 2, 2026 13:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Warning

CodeRabbit couldn't request changes on this pull request because it doesn't have sufficient GitHub permissions.

Please grant CodeRabbit Pull requests: Read and write permission and re-run the review.

👉 Steps to fix this

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/landing/legal.test.ts`:
- Line 259: Update the refund-policy regex near the existing post-window pattern
to also match wording where “after” or “outside” precedes “refunds,” followed by
unavailable or non-refundable language, including “After 7 days, refunds are
unavailable.” Ensure the negative assertion fails when the consumer surface
exposes this policy.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: bcb51701-98c8-41cb-83b3-900f997c3740

📥 Commits

Reviewing files that changed from the base of the PR and between ce85389 and 41bd1c0.

📒 Files selected for processing (3)
  • src/lib/landing/legal.test.ts
  • src/lib/landing/pricing-faq.ts
  • src/lib/server/google.ts

/not refunded/i,
/not refundable/i,
/no refunds?\b.*\bafter\b/i,
/refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover reversed post-window refund wording.

Line 259 does not match After 7 days, refunds are unavailable. A consumer surface with that policy would pass the negative assertion. Add a pattern for after or outside followed by refunds and an unavailable or non-refundable condition.

Proposed test coverage
+			/(?:after|outside)\b.*\b(?:no refunds?\b|refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b)/i,

As per coding guidelines, tests must fail when the real logic is wrong; rewrite tests that pass when the implementation returns garbage.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i,
/refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i,
/(?:after|outside)\b.*\b(?:no refunds?\b|refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b)/i,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/landing/legal.test.ts` at line 259, Update the refund-policy regex
near the existing post-window pattern to also match wording where “after” or
“outside” precedes “refunds,” followed by unavailable or non-refundable
language, including “After 7 days, refunds are unavailable.” Ensure the negative
assertion fails when the consumer surface exposes this policy.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant