Skip to content

feat: account deletion with 6-month retention purge - #37

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

feat: account deletion with 6-month retention purge#37
Bonobo791 merged 18 commits into
mainfrom
feat-account-deletion

Conversation

@Bonobo791

Copy link
Copy Markdown
Owner

What

Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:

  • Dashboard danger zone: labeled confirmation checkbox + "Delete my account". One transaction sets users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.
  • Sign-in restores: authenticating within the retention window clears deletedAt in the login callback. Channels stay active=0 until the user re-enables them — moderation never resumes silently.
  • Cron purge (I10/I8): each cron invocation purges ONE user whose 6-month retention expired — sessions, channels, and their rules/comments/moderation actions/audit rows are deleted explicitly (no FK cascades on channel-scoped tables). Skipped entirely under DRY_RUN=true.
  • The consent log survives (LGPD Art. 16): the users row is anonymized to a tombstone (deleted:<id>, [deleted]) rather than deleted, keeping consents.userId valid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.

Migration

0008 adds nullable users.deleted_at (I7 expand-migrate-contract). After merge: run npm run db:migrate from the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.

Notes

  • Coordination: the privacy policy's retention wording is owned by the legal-pages work — if it's updated materially, LEGAL_VERSION must be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.
  • Out of scope: Google token revocation on deletion (channel deactivation already stops all API use), admin-initiated deletion.

Verification

  • npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)
  • npm run check: 0 errors, 0 warnings
  • npm run build: green (adapter-netlify)

@cla-bot cla-bot Bot added the cla-signed label Aug 1, 2026
@codeant-ai

codeant-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Incremental review completed 99e29a5 Aug 02, 2026 · 00:50 00:53
✅ Incremental review completed 3b9219e Aug 01, 2026 · 23:39 23:42
✅ Reviewed your PR cc3b41c Aug 01, 2026 · 22:32 22:47

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for moderaty ready!

Name Link
🔨 Latest commit 99e29a5
🔍 Latest deploy log https://app.netlify.com/projects/moderaty/deploys/6a6e94454382400008cd7da2
😎 Deploy Preview https://deploy-preview-37--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: 95
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 1, 2026
@codeant-ai

codeant-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

User description

What

Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:

  • Dashboard danger zone: labeled confirmation checkbox + "Delete my account". One transaction sets users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.
  • Sign-in restores: authenticating within the retention window clears deletedAt in the login callback. Channels stay active=0 until the user re-enables them — moderation never resumes silently.
  • Cron purge (I10/I8): each cron invocation purges ONE user whose 6-month retention expired — sessions, channels, and their rules/comments/moderation actions/audit rows are deleted explicitly (no FK cascades on channel-scoped tables). Skipped entirely under DRY_RUN=true.
  • The consent log survives (LGPD Art. 16): the users row is anonymized to a tombstone (deleted:<id>, [deleted]) rather than deleted, keeping consents.userId valid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.

Migration

0008 adds nullable users.deleted_at (I7 expand-migrate-contract). After merge: run npm run db:migrate from the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.

Notes

  • Coordination: the privacy policy's retention wording is owned by the legal-pages work — if it's updated materially, LEGAL_VERSION must be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.
  • Out of scope: Google token revocation on deletion (channel deactivation already stops all API use), admin-initiated deletion.

Verification

  • npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)
  • npm run check: 0 errors, 0 warnings
  • npm run build: green (adapter-netlify)

CodeAnt-AI Description

Add self-service account deletion with a six-month recovery window and permanent cleanup

What Changed

  • Users can delete their account from the dashboard after confirming, which signs them out everywhere and immediately stops channel moderation.
  • Signing in again within six months cancels the deletion; channels remain inactive until the user re-enables them.
  • Scheduled cleanup permanently removes expired account data while retaining anonymized consent records for compliance.
  • Cleanup processes one expired account per run and makes no changes during dry runs.

Impact

✅ Immediate global sign-out after deletion
✅ No moderation from deleted accounts
✅ Recoverable accounts for six months
✅ Consent records retained for compliance

💡 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.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added an account deletion option with confirmation, immediate sign-out, channel deactivation, and a six-month recovery window.
    • Accounts can be restored by signing in and completing consent during the retention period.
    • Expired deleted accounts are permanently purged, with personal records anonymized and consent history preserved.
  • Bug Fixes
    • Deactivated channels now stop moderation safely without further writes or external enforcement.
    • Scheduled cleanup reports purge errors while allowing other processing to continue.
  • Documentation
    • Documented account deletion, retention, restoration, purging, and migration verification procedures.

Walkthrough

The PR adds soft account deletion with session invalidation, channel deactivation, six-month retention, sign-in restoration, consent preservation, anonymized purging, and cron integration. It also adds schema migrations, pipeline safeguards, tests, and operating documentation.

Changes

Account deletion lifecycle

Layer / File(s) Summary
Retention schema and operating policy
AGENTS.md, EXECUTION_PLAN_YouTube_Comment_Moderator.md, drizzle/*, drizzle/meta/*, src/lib/server/db/schema.ts, src/lib/server/testdb.ts, src/lib/server/db/schema-indexes.test.ts
Adds users.deleted_at, its index, migration metadata, test database support, and documented retention rules.
Bounded retention purge
src/lib/server/retention.ts, src/lib/server/retention.test.ts, src/routes/api/cron/*
Purges one oldest expired account per run, deletes owned records, preserves consent records, anonymizes the user, supports rollback, and skips purge during dry runs.
Self-service account deletion
src/routes/(app)/dashboard/*, src/lib/server/session.*
Adds confirmed dashboard deletion, transactional session destruction and channel deactivation, cookie removal, redirects, and soft-deleted session rejection.
Channel deactivation safeguards
src/lib/server/pipeline.*
Stops processing when a channel becomes inactive before durable writes or YouTube enforcement.
Sign-in restoration
src/routes/api/auth/google/login/*, src/routes/consent/*
Purges expired identities during sign-in and restores retained accounts only after consent and sign-in preparation complete.

Legal consistency test

Layer / File(s) Summary
Refund promise validation
src/lib/landing/legal.test.ts
Updates the test to reject retired unconditional refund promises across configured surfaces.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant Dashboard
  participant Database
  participant GoogleOAuth
  participant Consent
  participant Cron
  User->>Dashboard: Submit confirmed account deletion
  Dashboard->>Database: Mark user deleted, destroy sessions, deactivate channels
  User->>GoogleOAuth: Start sign-in
  GoogleOAuth->>Database: Check deletion timestamp and retention window
  Database-->>GoogleOAuth: Restore retained account or require fresh signup
  GoogleOAuth->>Consent: Redirect for consent when required
  Consent->>Database: Clear deletion timestamp and create session after consent
  Cron->>Database: Select and purge one expired deleted user
  Database-->>Cron: Return purged user ID or purge error
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% 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
Title check ✅ Passed The title clearly summarizes the main change: account deletion with six-month retention and purge processing.
Description check ✅ Passed The description directly explains the account-deletion workflow, retention purge, migration, scope, and verification results.
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.
✨ 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.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Account deletion with 6-month retention and bounded cron purge

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

Grey Divider

AI Description

• Add self-service account deletion: soft-delete user, revoke sessions, deactivate channels.
• Restore soft-deleted accounts on sign-in within the 6-month retention window.
• Add cron-driven, one-user-per-run purge that tombstones users but preserves consent logs.
Diagram

graph TD
  UI["Dashboard page"] --> DEL(["deleteAccount action"]) --> DB[("DB: users/sessions/channels")]
  LOGIN(["Google login callback"]) --> RESTORE(["Clear deletedAt"]) --> DB
  CRON(["/api/cron endpoint"]) --> PURGE(["Purge 1 expired user"]) --> DB
  subgraph Legend
    direction LR
    _ui["UI"] ~~~ _svc(["Route/Action"]) ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. FK cascades + hard-delete users
  • ➕ Less application-level delete code to maintain
  • ➕ Lower risk of missing a dependent table during purge
  • ➖ Current schema explicitly lacks cascades on channel-scoped tables; introducing them is invasive
  • ➖ Hard-deleting users breaks consent evidentiary chain unless consent model changes
2. Async purge queue (worker) instead of bounded cron purge
  • ➕ Purges can run to completion without cron time budgeting concerns
  • ➕ Easier to batch multiple users during low traffic
  • ➖ Requires queue/worker infrastructure and operational complexity
  • ➖ More moving parts vs the current one-user-per-invocation bound
3. Separate PII table + delete PII row on purge
  • ➕ Cleaner compliance story: keep user identity vs PII clearly separated
  • ➕ Avoids tombstoning fields in the primary users table
  • ➖ Schema refactor and wider code churn (joins, auth lookup, uniqueness on google_sub)
  • ➖ More migration complexity than necessary for current goals

Recommendation: The PR’s approach is a good fit: soft-delete + immediate session revocation/channel deactivation minimizes risk, and the bounded cron purge respects runtime limits. Tombstoning (instead of deleting) is a pragmatic way to preserve consents.userId without reworking the consent schema. The main alternatives (FK cascades, worker queue, or PII table split) add significant schema/ops complexity relative to the current requirements.

Files changed (14) +959 / -17

Enhancement (5) +122 / -10
schema.tsAdd users.deletedAt soft-delete marker to schema +3/-0

Add users.deletedAt soft-delete marker to schema

• Extends the users table model with deletedAt (mapped to deleted_at) and documents intended retention/purge semantics.

src/lib/server/db/schema.ts

+page.server.tsAdd deleteAccount action to soft-delete user and revoke access +21/-3

Add deleteAccount action to soft-delete user and revoke access

• Implements a new dashboard action that requires checkbox confirmation, sets users.deletedAt, deletes all sessions for global sign-out, and deactivates all channels in a single transaction. Clears the session cookie and redirects to home.

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

+page.svelteAdd dashboard danger-zone UI for account deletion +39/-1

Add dashboard danger-zone UI for account deletion

• Adds a “Delete account” card with retention explanation, confirmation checkbox, and enhanced form submission. Includes styling and displays server-side validation errors.

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

+server.tsRestore soft-deleted users on sign-in within retention window +7/-0

Restore soft-deleted users on sign-in within retention window

• Adds logic in the login callback to clear users.deletedAt when present, cancelling pending deletion while leaving channels inactive. Logs a restoration event for traceability.

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

+server.tsAdd bounded retention purge and tombstoning to cron endpoint +52/-6

Add bounded retention purge and tombstoning to cron endpoint

• Introduces purgeExpiredUser() that selects the oldest expired soft-deleted user (one per invocation), deletes owned sessions/channels and channel-scoped data explicitly, and anonymizes the users row to a tombstone while retaining consents. Runs purge before channel processing, skips entirely under DRY_RUN, and returns the purged user id in the cron JSON response.

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

Tests (4) +153 / -6
testdb.tsExtend test DB schema with users.deleted_at +1/-0

Extend test DB schema with users.deleted_at

• Updates the in-memory test database schema creation SQL to include deleted_at, keeping tests aligned with migrations.

src/lib/server/testdb.ts

dashboard.test.tsAdd tests for deleteAccount confirmation and side effects +51/-4

Add tests for deleteAccount confirmation and side effects

• Adds coverage ensuring signed-out requests are rejected, confirmation is required, and successful deletion sets deletedAt, removes sessions, deactivates channels, and clears the session cookie.

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

login.test.tsTest sign-in restoration clears deletedAt +19/-0

Test sign-in restoration clears deletedAt

• Adds a test that seeds a soft-deleted user and verifies the login callback restores the account by nulling deletedAt and creating a new session.

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

cron.test.tsAdd retention purge tests (tombstone, bounds, DRY_RUN) +82/-2

Add retention purge tests (tombstone, bounds, DRY_RUN)

• Adds test fixtures and assertions verifying purge removes owned data but keeps consents, ignores users within the retention window, purges only one user per run (oldest first), and does nothing under DRY_RUN.

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

Documentation (2) +39 / -1
AGENTS.mdDocument soft-delete retention model and migration verification step +15/-1

Document soft-delete retention model and migration verification step

• Adds a concise description of the 6-month account deletion/retention/purge behavior and the consent-log rationale. Documents a post-migration verification step due to prior drizzle-kit “exit 0 without applying” behavior when DB is unreachable.

AGENTS.md

EXECUTION_PLAN_YouTube_Comment_Moderator.mdAdd execution plan section for account deletion and retention purge +24/-0

Add execution plan section for account deletion and retention purge

• Documents the user-facing delete flow, sign-in restoration semantics, bounded purge behavior, and tombstone approach for preserving consents. Notes the migration and the need to verify application.

EXECUTION_PLAN_YouTube_Comment_Moderator.md

Other (3) +645 / -0
0008_aromatic_red_wolf.sqlAdd users.deleted_at column migration +19/-0

Add users.deleted_at column migration

• Introduces a Drizzle migration that adds a nullable deleted_at column to users to support soft-deletion and retention tracking.

drizzle/0008_aromatic_red_wolf.sql

0008_snapshot.jsonUpdate Drizzle snapshot for deleted_at column +619/-0

Update Drizzle snapshot for deleted_at column

• Updates the generated Drizzle schema snapshot to include the new users.deleted_at field and associated metadata.

drizzle/meta/0008_snapshot.json

_journal.jsonRegister migration 0008 in Drizzle journal +7/-0

Register migration 0008 in Drizzle journal

• Adds the new migration entry to the Drizzle migration journal to track application order/state.

drizzle/meta/_journal.json

@codacy-production

codacy-production Bot commented Aug 1, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 68 complexity · -10 duplication

Metric Results
Complexity 68 (≤ 100 complexity)
Duplication -10 (≤ 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.

@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.

Summary

This PR implements account deletion with soft-delete and 6-month retention. The implementation is generally sound with comprehensive test coverage (238/238 tests passing). However, I've identified 4 critical defects that must be addressed before merge:

Critical Issues Found

  1. Race condition in account restoration - Users can restore accounts after the 6-month retention period has expired if they sign in after the cutoff but before the cron purge runs
  2. Transaction rollback handling - The deleteAccount transaction could leave inconsistent state if operations fail partway through
  3. Error handling in purge - The cron endpoint may return success even if purge fails, masking data integrity issues
  4. Empty array handling - The purge logic may crash when processing users with no channels, depending on ORM behavior with empty arrays

Security & Compliance

The implementation correctly preserves consent records per LGPD requirements and uses proper soft-delete with tombstone anonymization. The 6-month retention window is appropriate for data protection compliance.

Next Steps

Address the 4 critical findings above, particularly the race condition in restoration logic and empty array handling, before merging. Consider adding test coverage for edge cases like restoring after retention expiry and purging users with no channels.


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 thread src/routes/api/auth/google/login/callback/+server.ts Outdated
Comment on lines +89 to +93
await db.transaction(async (tx) => {
await tx.update(users).set({ deletedAt: new Date().toISOString() }).where(eq(users.id, user.id));
await tx.delete(sessions).where(eq(sessions.userId, user.id));
await tx.update(channels).set({ active: 0 }).where(eq(channels.userId, 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.

🛑 Crash Risk: The transaction marks deletedAt and deactivates channels, but if session deletion fails mid-transaction, the user might retain an active session with a "deleted" account status. This could allow continued API access until session expiry despite account deletion. The transaction will rollback on failure, but error handling should explicitly verify all operations succeeded.

Comment thread src/routes/api/cron/+server.ts Outdated
Comment on lines +103 to +104
purged = await purgeExpiredUser();
}

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.

🛑 Data Integrity Risk: The purge function performs cascading deletes across multiple tables outside a transaction scope. If the database operation fails partway through (e.g., between deleting comments and rules), the user's data will be partially deleted, leaving orphaned records. The inner transaction at line 58 protects the purge logic, but wrapping the purgeExpiredUser() call itself in error handling would prevent returning a success response if purge fails.

Comment thread src/routes/api/cron/+server.ts Outdated
@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Context used
✅ Compliance rules (platform): 62 rules

Grey Divider


Action required

1. Purge reselects same user ⊘ Outdated 🐞 Bug ≡ Correctness
Description
purgeExpiredUser() anonymizes the user but leaves users.deletedAt unchanged, so the same (oldest)
user remains eligible and can be selected every cron invocation, starving other expired users
indefinitely. This breaks the “one expired user per invocation” bounded drain behavior.
Code

src/routes/api/cron/+server.ts[R69-72]

+		await tx
+			.update(users)
+			.set({ googleSub: `deleted:${expired.id}`, email: '[deleted]', displayName: '[deleted]' })
+			.where(eq(users.id, expired.id));
Relevance

●●● Strong

Likely real logic bug: without updating deletedAt, cron can keep selecting the same expired user
repeatedly.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The selection criteria is only based on deletedAt, and the purge transaction never updates that
field, so the row stays eligible and (being the oldest) can be selected repeatedly.

src/routes/api/cron/+server.ts[48-56]
src/routes/api/cron/+server.ts[69-72]

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

## Issue description
`purgeExpiredUser()` selects users purely by `deletedAt` being non-null and older than the cutoff, but the purge transaction does not change `deletedAt`. After the first purge, that same user stays eligible forever and will likely be re-selected every run (because it is still the oldest), preventing any other expired users from being purged.

## Issue Context
The purge currently tombstones by rewriting `googleSub/email/displayName` only. A purged/tombstoned user should not remain in the “expired but pending purge” queue.

## Fix Focus Areas
- src/routes/api/cron/+server.ts[48-74]

## Suggested fix
- In the purge transaction, also transition the row out of eligibility, e.g.:
 - set `deletedAt` to `null` (or introduce a new `purgedAt` / `tombstonedAt` column), OR
 - adjust the selection predicate to exclude already-tombstoned rows (e.g. `google_sub NOT LIKE 'deleted:%'`) and set that marker atomically.
- Consider making the “claim” atomic (update-returning) so concurrent cron invocations can’t select the same user.

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


2. Expired deletions can restore ✓ Resolved 🐞 Bug ≡ Correctness
Description
The Google login callback clears deletedAt for any soft-deleted user without checking whether the
deletion is still within the retention cutoff. If cron hasn’t purged yet, a user can sign in after
the retention window and permanently prevent/undo the intended purge.
Code

src/routes/api/auth/google/login/callback/+server.ts[R88-93]

+	// Signing back in within the 6-month retention window cancels a pending
+	// deletion. Channels stay inactive (active=0 from the deletion) until the
+	// user re-enables them — moderation never resumes silently.
+	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

●●● Strong

Matches PR intent (“within retention window”); missing cutoff check is a concrete correctness bug.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Cron defines a retention cutoff and only purges after it, but the login callback clears deletedAt
whenever it is set, with no cutoff check, allowing post-retention restoration before cron runs.

src/routes/api/auth/google/login/callback/+server.ts[88-94]
src/routes/api/cron/+server.ts[30-56]

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 login callback unconditionally restores any `user.deletedAt` by setting it to null. This violates the documented behavior that restoration is only allowed within the retention window; it also allows “expired but not yet purged” accounts to be brought back.

## Issue Context
Cron’s retention logic is `cutoff = now - RETENTION_MS` and purges when `deletedAt < cutoff`. The login callback needs to enforce the same cutoff semantics to avoid restoring beyond retention.

## Fix Focus Areas
- src/routes/api/auth/google/login/callback/+server.ts[82-104]
- src/routes/api/cron/+server.ts[30-56]

## Suggested fix
- Compute the same cutoff in the login callback and only clear `deletedAt` when `deletedAt` is within the retention window.
- If `deletedAt` is older than the cutoff, do NOT restore; instead tombstone/purge immediately (freeing `googleSub`) and continue the flow as a fresh signup (park pending consent and redirect to `/consent?state=...`), or surface a user-friendly “account permanently deleted” flow that also frees `googleSub`.
- Prefer extracting a shared helper (e.g. `retentionCutoffIso()` / `isWithinRetention(deletedAt)`) to keep cron + login consistent.

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



Remediation recommended

3. No deleted_at purge index ✓ Resolved 🐞 Bug ➹ Performance
Description
The purge query filters/orders by users.deletedAt, but the migration only adds the column without an
index, so the cron purge will tend to degrade to a scan as the users table grows. This can increase
cron latency and reduce reliability under load.
Code

drizzle/0008_aromatic_red_wolf.sql[19]

+ALTER TABLE `users` ADD `deleted_at` text;
Relevance

●● Moderate

Mixed precedent: index on new FK accepted, but extra composite index for query perf was rejected.

PR-#25
PR-#36

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The migration shows only a column add, while cron’s purge query filters and orders on that column;
indexing is needed for scalable selection of the oldest expired deletion.

drizzle/0008_aromatic_red_wolf.sql[19-19]
src/routes/api/cron/+server.ts[48-56]

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

## Issue description
`purgeExpiredUser()` queries `users` by `deletedAt` (non-null and < cutoff) and orders by `deletedAt` with `LIMIT 1`. Without an index, this will not scale well as user rows grow.

## Issue Context
Migration 0008 adds `users.deleted_at` but does not create an index.

## Fix Focus Areas
- drizzle/0008_aromatic_red_wolf.sql[19-19]
- src/routes/api/cron/+server.ts[48-56]
- src/lib/server/db/schema.ts[22-33]

## Suggested fix
- Add a new migration (preferred) that creates an index, e.g. `CREATE INDEX users_deleted_at_idx ON users(deleted_at);`.
- Declare the same index in Drizzle schema (`sqliteTable(..., (t)=>[ index('users_deleted_at_idx').on(t.deletedAt) ])`) so drift is pinned.

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



Informational

4. 180-day retention mismatch ⊘ Outdated 🐞 Bug ≡ Correctness
Description
RETENTION_MS is implemented as a fixed 180 days, which may not match the documented “6-month
retention” policy if “month” is intended to mean calendar months. This can purge a few days
earlier/later than expected depending on month lengths.
Code

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

+const RETENTION_MS = 180 * 24 * 60 * 60 * 1000; // deleted accounts are purged after 6 months
Relevance

●● Moderate

Calendar-month vs fixed-day retention is policy/semantics; likely treated as acceptable
simplification.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Docs describe the behavior as “6-month retention,” but cron uses a fixed 180-day millisecond
constant to compute cutoff.

AGENTS.md[178-186]
src/routes/api/cron/+server.ts[28-31]

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

## Issue description
Retention is described as “6-month retention” in docs, but the implementation uses a fixed 180-day interval. If legal/product intent is calendar months, this can drift.

## Issue Context
This is only an issue if retention must be calculated by calendar months rather than an approximate day count.

## Fix Focus Areas
- src/routes/api/cron/+server.ts[28-31]
- AGENTS.md[178-186]

## Suggested fix
- If calendar semantics are required, compute cutoff via month arithmetic, e.g. `const cutoffDate = new Date(); cutoffDate.setMonth(cutoffDate.getMonth() - 6); const cutoff = cutoffDate.toISOString();`, and use that in both cron purge and sign-in restore gating.

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


5. deletedAt used without migration guard 📘 Rule violation ≡ Correctness
Description
The PR adds the users.deleted_at column and immediately reads/writes it in request handlers
without a runtime guard or a deployment guarantee that migrations ran first. If the app is deployed
before db:migrate is applied, these code paths can fail at runtime with missing-column database
errors.
Code

src/routes/api/auth/google/login/callback/+server.ts[R88-94]

+	// Signing back in within the 6-month retention window cancels a pending
+	// deletion. Channels stay inactive (active=0 from the deletion) until the
+	// user re-enables them — moderation never resumes silently.
+	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

● Weak

Very similar migration-guard suggestion was explicitly rejected for new DB object reads in login
callback.

PR-#36

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires guarding reads of newly added DB columns unless migrations are guaranteed to
run before serving traffic. This PR adds deleted_at via migration and then uses user.deletedAt /
users.deletedAt in handlers, which will break if the migration hasn’t been applied yet.

Rule 2407776: Guard reads of newly added database columns until after migrations are applied
drizzle/0008_aromatic_red_wolf.sql[19-19]
src/routes/api/auth/google/login/callback/+server.ts[82-94]
src/routes/api/cron/+server.ts[48-56]
src/routes/(app)/dashboard/+page.server.ts[79-93]

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

## Issue description
A new DB column (`users.deleted_at`) is introduced and then used in production handlers, but there is no safeguard for deployments where the migration has not yet been applied. This can cause runtime failures (missing column) during login, cron, or account deletion.

## Issue Context
The migration adds `deleted_at` as a nullable column, but Turso/SQLite will still error if queries reference a column that does not exist yet. The compliance requirement expects either a guaranteed “migrate-before-serve” deployment unit or a defensive runtime fallback.

## Fix Focus Areas
- drizzle/0008_aromatic_red_wolf.sql[19-19]
- src/routes/api/auth/google/login/callback/+server.ts[82-94]
- src/routes/api/cron/+server.ts[48-56]
- src/routes/(app)/dashboard/+page.server.ts[79-93]

ⓘ 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 thread src/routes/api/cron/+server.ts Outdated
Comment thread src/routes/api/auth/google/login/callback/+server.ts Outdated
Comment thread drizzle/0009_aromatic_red_wolf.sql
Comment thread src/routes/api/cron/+server.ts Outdated
Bonobo791 and others added 2 commits August 1, 2026 22:36
Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com>
Co-authored-by: amazon-q-developer[bot] <208079219+amazon-q-developer[bot]@users.noreply.github.com>
@codeant-ai

codeant-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: 99e29a53
Scan Time: 2026-08-02 00:57:57 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
Antipatterns ✅ PASSED No antipatterns

View Full Results

@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 `@src/routes/api/auth/google/login/callback/`+server.ts:
- Around line 91-94: Update src/routes/api/auth/google/login/callback/+server.ts
lines 91-94 around the deletedAt restoration logic to restore accounts only when
the deletion timestamp is within the purge’s six-month retention cutoff; reject
expired deletion markers without creating a session. Update
src/routes/api/auth/google/login/login.test.ts lines 248-263 to use a controlled
clock, cover rejection of expired markers, and verify an inactive channel
remains inactive after valid restoration.

In `@src/routes/api/auth/google/login/login.test.ts`:
- Around line 248-263: Make the signing-back-in restoration test deterministic
by freezing the test clock and deriving the soft-deletion timestamp from it,
rather than using a fixed date. Extend coverage around loginCallback with an
expired-marker case that rejects, preserves users.deletedAt, and creates no
sessions. Seed an inactive channel for the in-window case and assert restoration
preserves its inactive state, while keeping the existing account restoration
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: bd472de0-1585-4003-aab9-686eb29667f5

📥 Commits

Reviewing files that changed from the base of the PR and between a2db915 and cc3b41c.

📒 Files selected for processing (14)
  • AGENTS.md
  • EXECUTION_PLAN_YouTube_Comment_Moderator.md
  • drizzle/0008_aromatic_red_wolf.sql
  • drizzle/meta/0008_snapshot.json
  • drizzle/meta/_journal.json
  • src/lib/server/db/schema.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

Comment thread src/routes/api/auth/google/login/callback/+server.ts Outdated
Comment thread src/routes/api/auth/google/login/login.test.ts
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Note

Docstrings generation - SUCCESS
Generated docstrings and committed to branch feat-account-deletion (commit: acde65d4d9420db805534eb84c1d1f9e17c60eb6)

Docstrings generation was requested by @Bonobo791.

The following files were modified:

* `src/lib/server/retention.ts`
* `src/lib/server/testdb.ts`
* `src/routes/(app)/dashboard/+page.server.ts`
* `src/routes/api/auth/google/login/callback/+server.ts`

These files were ignored:
* `src/routes/(app)/dashboard/dashboard.test.ts`
* `src/routes/api/auth/google/login/login.test.ts`
* `src/routes/api/cron/cron.test.ts`

These file types are not supported:
* `AGENTS.md`
* `EXECUTION_PLAN_YouTube_Comment_Moderator.md`
* `drizzle/0008_aromatic_red_wolf.sql`
* `drizzle/meta/0008_snapshot.json`
* `drizzle/meta/_journal.json`
* `src/routes/(app)/dashboard/+page.svelte`
@codeant-ai codeant-ai Bot removed the size:XL This PR changes 500-999 lines, ignoring generated files label Aug 1, 2026
@codeant-ai codeant-ai Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Aug 1, 2026
@codeant-ai

codeant-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

User description

What

Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:

  • Dashboard danger zone: labeled confirmation checkbox + "Delete my account". One transaction sets users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.
  • Sign-in restores: authenticating within the retention window clears deletedAt in the login callback. Channels stay active=0 until the user re-enables them — moderation never resumes silently.
  • Cron purge (I10/I8): each cron invocation purges ONE user whose 6-month retention expired — sessions, channels, and their rules/comments/moderation actions/audit rows are deleted explicitly (no FK cascades on channel-scoped tables). Skipped entirely under DRY_RUN=true.
  • The consent log survives (LGPD Art. 16): the users row is anonymized to a tombstone (deleted:<id>, [deleted]) rather than deleted, keeping consents.userId valid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.

Migration

0008 adds nullable users.deleted_at (I7 expand-migrate-contract). After merge: run npm run db:migrate from the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.

Notes

  • Coordination: the privacy policy's retention wording is owned by the legal-pages work — if it's updated materially, LEGAL_VERSION must be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.
  • Out of scope: Google token revocation on deletion (channel deactivation already stops all API use), admin-initiated deletion.

Verification

  • npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)
  • npm run check: 0 errors, 0 warnings
  • npm run build: green (adapter-netlify)

CodeAnt-AI Description

Add self-service account deletion with a six-month recovery window and permanent cleanup

What Changed

  • Users can delete their account from the dashboard after confirming; they are signed out everywhere and all connected channels stop moderating immediately.
  • Signing in within six months cancels the deletion, while keeping channels inactive until the user manually re-enables them.
  • Sign-ins after six months and scheduled cleanup permanently remove account data, while retaining an anonymized user record and legal consent history.
  • Scheduled cleanup processes the oldest expired account one at a time, skips changes in dry-run mode, and reports which account was purged.
  • Expired-account lookups now use a dedicated index, with tests covering deletion, restoration, cleanup, retention boundaries, and dry runs.

Impact

✅ Immediate global sign-out after account deletion
✅ No moderation resumes without manual re-enablement
✅ Consent records remain available after personal data removal

💡 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.

@codeant-ai

codeant-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Previous suggestions up to commit 3b9219e
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Race condition
A stale purge selection can permanently remove an account restored during the race window

The expired user is selected outside the transaction, but purgeUserById does not
verify that the user is still soft-deleted or still has the selected expired
timestamp. If the login callback restores the account after this selection and
before the purge transaction starts, the purge still deletes the restored account's
channels and sessions and anonymizes the user. Include the deletion marker and
expected timestamp in an atomic conditional purge.

src/lib/server/retention.ts [80-89]

Why it matters? 🤔
  • ❌ Restored accounts can be purged during concurrent cron and login.
  • ❌ Restored sessions and channels are deleted unexpectedly.
  • ⚠️ Users may lose an account after signing back in.

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/retention.ts
**Line:** 80:89
**Comment:**
	*Race Condition: The expired user is selected outside the transaction, but `purgeUserById` does not verify that the user is still soft-deleted or still has the selected expired timestamp. If the login callback restores the account after this selection and before the purge transaction starts, the purge still deletes the restored account's channels and sessions and anonymizes the user. Include the deletion marker and expected timestamp in an atomic conditional purge.

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-01 23:42
A concurrent login can recreate a valid session after account deletion signs the user out

Deleting existing sessions is not atomic with preventing new session creation. A
Google login callback can read the user before this transaction, then create a
session after this transaction deletes all sessions; session resolution does not
check users.deletedAt, so that newly created session gives access to an account that
was already deleted. Serialize deletion with login/session creation or make session
creation conditional on the user still being active.

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

Why it matters? 🤔
  • ❌ Deleted users can regain an authenticated session.
  • ❌ Global sign-out can be bypassed by an in-flight login.
  • ⚠️ Account deletion state may be accessed or altered afterward.

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:** 96:96
**Comment:**
	*Race Condition: Deleting existing sessions is not atomic with preventing new session creation. A Google login callback can read the user before this transaction, then create a session after this transaction deletes all sessions; session resolution does not check `users.deletedAt`, so that newly created session gives access to an account that was already deleted. Serialize deletion with login/session creation or make session creation conditional on the user still being active.

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-01 23:42
Deactivation does not cancel an already-claimed moderation run

The active = 0 update does not stop a cron run that already passed its active check
and claimed this channel. That run can continue calling YouTube and insert comments,
moderation actions, and audit rows after deletion commits, so account deletion does
not guarantee immediate moderation shutdown. Coordinate the claim/run lifecycle with
deletion or re-check ownership and active state before every external action and
write.

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

Why it matters? 🤔
  • ❌ Moderation can continue after account deletion.
  • ❌ Deleted channels may receive new moderation records.
  • ⚠️ Immediate global moderation shutdown is not guaranteed.

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:** 97:97
**Comment:**
	*Race Condition: The `active = 0` update does not stop a cron run that already passed its active check and claimed this channel. That run can continue calling YouTube and insert comments, moderation actions, and audit rows after deletion commits, so account deletion does not guarantee immediate moderation shutdown. Coordinate the claim/run lifecycle with deletion or re-check ownership and `active` state before every external action and write.

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-01 23:42
Logic error
Account deletion is cancelled before the user completes the required consent flow

Clearing deletedAt before checking current consent permanently cancels the deletion
as soon as Google authentication succeeds. If the account has stale or missing
consent, the callback redirects to /consent; abandoning that flow leaves the account
restored and prevents the retention purge. Restore the account only after the
consent flow completes successfully, or otherwise preserve the pending-deletion
state.

src/routes/api/auth/google/login/callback/+server.ts [107-110]

Why it matters? 🤔
  • ❌ Abandoned consent leaves the deleted account retained indefinitely.
  • ⚠️ Retention purge no longer selects that account.

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/auth/google/login/callback/+server.ts
**Line:** 107:110
**Comment:**
	*Logic Error: Clearing `deletedAt` before checking current consent permanently cancels the deletion as soon as Google authentication succeeds. If the account has stale or missing consent, the callback redirects to `/consent`; abandoning that flow leaves the account restored and prevents the retention purge. Restore the account only after the consent flow completes successfully, or otherwise preserve the pending-deletion state.

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-01 23:42
A retention purge failure aborts the entire cron invocation before moderation runs

The retention purge runs outside the channel-processing error boundary, so any
database or transaction failure from purgeExpiredUser() rejects the cron request
before a channel is selected or claimed. This makes a purge outage stop scheduled
moderation for that invocation and returns no structured purge failure. Isolate
purge failure from channel execution, or catch and report it while continuing with
the channel work.

src/routes/api/cron/+server.ts [62-67]

Why it matters? 🤔
  • ❌ Cron invocation skips its selected moderation channel.
  • ⚠️ Retention failure produces no structured purge result.

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:** 62:67
**Comment:**
	*Logic Error: The retention purge runs outside the channel-processing error boundary, so any database or transaction failure from `purgeExpiredUser()` rejects the cron request before a channel is selected or claimed. This makes a purge outage stop scheduled moderation for that invocation and returns no structured purge failure. Isolate purge failure from channel execution, or catch and report it while continuing with the channel work.

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-01 23:42

Previous suggestions up to commit 99e29a5
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Race condition
Separate activity validation and persistence permits durable moderation data to be written after deletion

This check and the following stageDecisions transaction are separate operations.
Account deletion can commit after the check but before the insert transaction,
allowing comments, moderation actions, or audit rows to be written for a channel
that is already deactivated. The active-state check must be coupled with the durable
write or the write must condition on the channel still being active.

src/lib/server/pipeline.ts [538-544]

Why it matters? 🤔
  • ⚠️ Deleted channels can receive new moderation rows.
  • ⚠️ Audit and action history can outlive deletion timing.
  • ⚠️ Concurrent moderation violates immediate account shutdown.

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/pipeline.ts
**Line:** 538:544
**Comment:**
	*Race Condition: This check and the following `stageDecisions` transaction are separate operations. Account deletion can commit after the check but before the insert transaction, allowing comments, moderation actions, or audit rows to be written for a channel that is already deactivated. The active-state check must be coupled with the durable write or the write must condition on the channel still being active.

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 00:52
Deactivation during enforcement still allows later moderation actions and state writes to complete

The second check only validates the channel before entering
processOutstandingActions; that function then claims and processes multiple batches
with YouTube calls and database updates. Deletion can commit immediately after this
check, so later batches can still moderate comments and persistResults can update
channel state after the account has been deleted. Re-check or enforce the active
condition at each batch/write boundary.

src/lib/server/pipeline.ts [556-558]

Why it matters? 🤔
  • ❌ Deleted channels may continue YouTube enforcement.
  • ⚠️ Pending actions can complete after deletion.
  • ⚠️ Channel cursors can update after deactivation.

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/pipeline.ts
**Line:** 556:558
**Comment:**
	*Race Condition: The second check only validates the channel before entering `processOutstandingActions`; that function then claims and processes multiple batches with YouTube calls and database updates. Deletion can commit immediately after this check, so later batches can still moderate comments and `persistResults` can update channel state after the account has been deleted. Re-check or enforce the active condition at each batch/write boundary.

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 00:52
Concurrent account lifecycle changes can cause sign-in to purge a newer deletion unexpectedly

Pass the originally observed deletedAt value to purgeUserById and handle a false
result. The account can be restored and deleted again after the initial lookup;
without an expected-marker check, this call can purge the newer deletion rather than
the expired deletion that was evaluated.

src/routes/api/auth/google/login/callback/+server.ts [93-97]

Why it matters? 🤔
  • ❌ A newly deleted account can purge before six months.
  • ⚠️ Retained channel data can be removed prematurely.
  • ⚠️ The retention guarantee becomes race-dependent.

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/auth/google/login/callback/+server.ts
**Line:** 93:97
**Comment:**
	*Race Condition: Pass the originally observed `deletedAt` value to `purgeUserById` and handle a `false` result. The account can be restored and deleted again after the initial lookup; without an expected-marker check, this call can purge the newer deletion rather than the expired deletion that was evaluated.

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 00:52
An unconditional restore can overwrite a concurrent account deletion and recreate access after sign-out

The restore update is not conditional on the deletedAt value that was read earlier.
If account deletion commits after the callback reads the user but before this
update, this statement clears the newly-created deletion marker and the callback
then creates a valid session, defeating deletion's immediate global sign-out and
allowing the deleted account back in.

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

Why it matters? 🤔
  • ❌ Account deletion can be undone by an in-flight login.
  • ⚠️ Global sign-out can be bypassed by session recreation.
  • ⚠️ Deleted accounts can regain dashboard access.

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/auth/google/login/callback/+server.ts
**Line:** 124:126
**Comment:**
	*Race Condition: The restore update is not conditional on the `deletedAt` value that was read earlier. If account deletion commits after the callback reads the user but before this update, this statement clears the newly-created deletion marker and the callback then creates a valid session, defeating deletion's immediate global sign-out and allowing the deleted account back in.

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 00:52
Non-atomic session validation can authenticate a request after account deletion commits

The deleted-account check is based on a separate, non-transactional read. If account
deletion commits after this select but before the function returns, the stale row is
still returned as an authenticated user, allowing a request to proceed after
deletion and immediate sign-out should have taken effect. Make the session
validation and deletion-state check atomic, or revalidate the session/user state
before returning.

src/lib/server/session.ts [75-85]

Why it matters? 🤔
  • ⚠️ Concurrent requests can survive account deletion.
  • ⚠️ Immediate global sign-out has a narrow race.
  • ⚠️ Deleted users may finish an in-flight action.

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:** 75:85
**Comment:**
	*Race Condition: The deleted-account check is based on a separate, non-transactional read. If account deletion commits after this select but before the function returns, the stale row is still returned as an authenticated user, allowing a request to proceed after deletion and immediate sign-out should have taken effect. Make the session validation and deletion-state check atomic, or revalidate the session/user state 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 00:52
Performance
An unbounded purge can consume the entire cron budget and prevent the scheduled channel run

The purge runs synchronously before channel selection but is not given the handler
deadline. Although only one user is selected, deleting all of that user's channels
and every channel-scoped child row can take longer than the 20-second budget; the
subsequent runChannel then starts with an expired deadline and scheduled moderation
is skipped or fails. Bound the purge work or reserve/enforce time for channel
processing.

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

Why it matters? 🤔
  • ⚠️ Large retained accounts delay scheduled moderation.
  • ⚠️ Cron invocations can finish without processing channels.
  • ⚠️ Netlify’s scheduled request can hit its abort limit.

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:** 70:75
**Comment:**
	*Performance: The purge runs synchronously before channel selection but is not given the handler deadline. Although only one user is selected, deleting all of that user's channels and every channel-scoped child row can take longer than the 20-second budget; the subsequent `runChannel` then starts with an expired deadline and scheduled moderation is skipped or fails. Bound the purge work or reserve/enforce time 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 00:52

Latest suggestions up to commit 99e29a5
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Race condition
A stale pre-transaction user read can create a session for an anonymized tombstone

The user is read before the transaction, so a concurrent purge can anonymize this
row after the read but before the transaction starts. The transaction then inserts a
consent and creates a session for the tombstone; because the stale deletedAt value
is used, the conditional update is skipped and the anonymized account becomes
authenticated. Re-read and validate the account inside the transaction, or use a
conditional update that verifies the expected non-tombstone state before creating
the session.

src/routes/consent/+page.server.ts [115-130]

Why it matters? 🤔
  • ❌ Tombstones can receive authenticated sessions.
  • ❌ Deleted identities may access the dashboard.
  • ⚠️ Consent records can attach to anonymized accounts.

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:** 115:130
**Comment:**
	*Race Condition: The user is read before the transaction, so a concurrent purge can anonymize this row after the read but before the transaction starts. The transaction then inserts a consent and creates a session for the tombstone; because the stale `deletedAt` value is used, the conditional update is skipped and the anonymized account becomes authenticated. Re-read and validate the account inside the transaction, or use a conditional update that verifies the expected non-tombstone state before creating the session.

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 00:55

# Conflicts:
#	EXECUTION_PLAN_YouTube_Comment_Moderator.md
#	drizzle/meta/0008_snapshot.json
#	drizzle/meta/_journal.json
@codeant-ai codeant-ai Bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels Aug 2, 2026
@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

User description

What

Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:

  • Dashboard danger zone: labeled confirmation checkbox + "Delete my account". One transaction sets users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.
  • Sign-in restores: authenticating within the retention window clears deletedAt in the login callback. Channels stay active=0 until the user re-enables them — moderation never resumes silently.
  • Cron purge (I10/I8): each cron invocation purges ONE user whose 6-month retention expired — sessions, channels, and their rules/comments/moderation actions/audit rows are deleted explicitly (no FK cascades on channel-scoped tables). Skipped entirely under DRY_RUN=true.
  • The consent log survives (LGPD Art. 16): the users row is anonymized to a tombstone (deleted:<id>, [deleted]) rather than deleted, keeping consents.userId valid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.

Migration

0008 adds nullable users.deleted_at (I7 expand-migrate-contract). After merge: run npm run db:migrate from the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.

Notes

  • Coordination: the privacy policy's retention wording is owned by the legal-pages work — if it's updated materially, LEGAL_VERSION must be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.
  • Out of scope: Google token revocation on deletion (channel deactivation already stops all API use), admin-initiated deletion.

Verification

  • npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)
  • npm run check: 0 errors, 0 warnings
  • npm run build: green (adapter-netlify)

CodeAnt-AI Description

Add self-service account deletion with safe six-month retention and purge handling

What Changed

  • Users can confirm account deletion from the dashboard; they are signed out everywhere and all channels stop moderating immediately.
  • Deleted accounts can be restored by signing in within six months, while channels remain inactive until manually re-enabled.
  • Accounts past the retention window are purged automatically or during sign-in; owned sessions, channels, moderation data, and audit records are removed while consent records remain in an anonymized account record.
  • Scheduled cleanup processes one expired account at a time, skips cleanup in dry-run mode, reports cleanup failures, and continues channel moderation.
  • In-progress moderation stops before saving new work or applying YouTube actions when account deletion deactivates a channel.
  • Sessions belonging to deleted accounts are rejected and removed, and retention lookups use an index for scheduled cleanup.

Impact

✅ Immediate sign-out and moderation shutdown after deletion
✅ Account recovery within the six-month retention window
✅ Consent records preserved after personal data purge

💡 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.

@sonarqubecloud

sonarqubecloud Bot commented Aug 2, 2026

Copy link
Copy Markdown

@Bonobo791

Copy link
Copy Markdown
Owner Author

@CodeAnt-AI review

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels Aug 2, 2026
@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

User description

What

Self-service account deletion with a 6-month soft-delete retention window and an automatic bounded purge:

  • Dashboard danger zone: labeled confirmation checkbox + "Delete my account". One transaction sets users.deletedAt, destroys every session (immediate global sign-out), and deactivates the user's channels — moderation stops at once.
  • Sign-in restores: authenticating within the retention window clears deletedAt in the login callback. Channels stay active=0 until the user re-enables them — moderation never resumes silently.
  • Cron purge (I10/I8): each cron invocation purges ONE user whose 6-month retention expired — sessions, channels, and their rules/comments/moderation actions/audit rows are deleted explicitly (no FK cascades on channel-scoped tables). Skipped entirely under DRY_RUN=true.
  • The consent log survives (LGPD Art. 16): the users row is anonymized to a tombstone (deleted:<id>, [deleted]) rather than deleted, keeping consents.userId valid and the evidentiary chain intact (doc version, exact checkbox text, IP, user agent). The tombstone also frees the real Google sub for a future fresh signup.

Migration

0008 adds nullable users.deleted_at (I7 expand-migrate-contract). After merge: run npm run db:migrate from the main checkout AND verify the column exists — per the 0007 incident, drizzle-kit can exit 0 without applying when Turso is unreachable; AGENTS.md now documents this verification step.

Notes

  • Coordination: the privacy policy's retention wording is owned by the legal-pages work — if it's updated materially, LEGAL_VERSION must be bumped (the re-consent flow from feat: post-OAuth consent interstitial with evidentiary consent log #36 already handles routing). Not changed here.
  • Out of scope: Google token revocation on deletion (channel deactivation already stops all API use), admin-initiated deletion.

Verification

  • npm run test: 238/238 (incl. 3 new deleteAccount tests, sign-in restore test, 4 purge tests covering consent-log retention, retention-window exclusion, one-per-invocation bound, and DRY_RUN)
  • npm run check: 0 errors, 0 warnings
  • npm run build: green (adapter-netlify)

CodeAnt-AI Description

Add self-service account deletion with safe six-month retention handling

What Changed

  • Users can delete their account from the dashboard after confirming; they are signed out everywhere and channel moderation stops immediately.
  • Deleted accounts can be restored by completing sign-in and any required consent within six months, while channels remain inactive until manually re-enabled.
  • Accounts past the retention period are anonymized, their sessions and channel data are removed, and consent records remain available for compliance.
  • Scheduled cleanup removes at most one expired account per run, skips cleanup in dry-run mode, reports cleanup failures, and continues channel moderation.
  • Moderation stops before writing data or contacting YouTube if account deletion deactivates a channel during an in-progress run.
  • Sessions belonging to deleted accounts no longer grant access and are removed when encountered.

Impact

✅ Immediate global sign-out after account deletion
✅ No moderation after channel deactivation
✅ Six-month account recovery window
✅ Consent records retained after personal data purge

💡 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.

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Sequence Diagram

This diagram shows the self-service deletion transaction and the bounded cron purge added by the PR. Deletion immediately signs the user out and stops moderation, while the later purge removes owned data and preserves an anonymized consent tombstone.

sequenceDiagram
    participant User
    participant Dashboard
    participant Backend
    participant Database
    participant Cron

    User->>Dashboard: Confirm account deletion
    Dashboard->>Backend: Submit deletion request
    Backend->>Database: Soft delete user and stop sessions and channels
    Database-->>Backend: Transaction committed
    Backend-->>User: Sign out and redirect

    Cron->>Backend: Run retention purge
    Backend->>Database: Select oldest expired user
    Database-->>Backend: Expired user
    Backend->>Database: Delete owned data and keep consent tombstone
    Backend-->>Cron: Purge result
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented Aug 2, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

@Bonobo791
Bonobo791 merged commit bfab18a into main Aug 2, 2026
14 of 18 checks passed
@Bonobo791
Bonobo791 deleted the feat-account-deletion branch August 2, 2026 00:57

@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 (2)
src/routes/consent/consent.test.ts (1)

232-271: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add a test for restoring past the retention cutoff.

Only the in-window case (deletedAt 30 days old) is tested. Add a case where deletedAt is already past the retention cutoff when consent completes, asserting the account is not restored. This pairs with the missing isRetentionExpired check in src/routes/consent/+page.server.ts.

🤖 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/consent.test.ts` around lines 232 - 271, Add a
consent-flow test alongside “a soft-deleted existing user is restored only when
the consent flow completes” using a deletedAt timestamp beyond the retention
cutoff, then assert consent completion does not clear deletedAt while preserving
the expected account/consent/session outcomes. Reuse seedExistingAndConsent and
the existing database assertions, and target the isRetentionExpired behavior in
the consent action.

Source: Coding guidelines

drizzle/meta/_journal.json (1)

61-81: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Migration reachability is broken and unguarded. Journal entries 9 and 10 carry when values below entry 8, so the Drizzle migrator skips both migrations after 0008 is applied. users.deleted_at and users_deleted_at_idx then never exist, while db:migrate still exits successfully. The new index guard cannot catch this because it inspects .sql file text only.

  • drizzle/meta/_journal.json#L61-L81: raise the when values of entries 9 and 10 above entry 8's 1785627758830, keeping them strictly increasing with idx.
  • src/lib/server/db/schema-indexes.test.ts#L26-L35: assert that the migration file containing CREATE INDEX users_deleted_at_idx is listed in drizzle/meta/_journal.json, and assert that the journal when values increase strictly with idx.
🤖 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 `@drizzle/meta/_journal.json` around lines 61 - 81, Fix migration reachability
in drizzle/meta/_journal.json by raising entries 9 and 10 `when` values above
entry 8’s value while keeping them strictly increasing by `idx`; update
src/lib/server/db/schema-indexes.test.ts around the migration checks to assert
the `CREATE INDEX users_deleted_at_idx` migration is journaled and that all
journal `when` values strictly increase with `idx`.
🤖 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 240-241: Update the legal-text validation around RETIRED_PROMISES
to be policy-aware rather than relying only on literal substring checks. For
every surface, assert both the seven-day refund scope and denial of refunds for
unused credits after the window, while recognizing negated compliant wording.
Preserve the existing full-refund coverage and make the assertions fail for
unconditional refund promises.

In `@src/lib/server/db/schema-indexes.test.ts`:
- Around line 26-35: The schema index test should validate Drizzle metadata and
only journaled migrations instead of scanning raw schema text and every SQL
file. Update the test to use getTableConfig(users) when asserting
users_deleted_at_idx targets deleted_at, and load migration files according to
entries in drizzle/meta/_journal.json before checking the CREATE INDEX
statement.

In `@src/lib/server/retention.test.ts`:
- Line 114: Update the retentionCutoffIso assertion to capture Date.now() once
in a local reference-time variable, then use that same value for both the
function input and expected Date calculation. Keep the existing 180-day cutoff
expectation unchanged.
- Around line 30-102: Add test coverage in retention.test.ts by extending
seedUser or the relevant test setup to create rows in comments, rules,
audit_log, moderation_actions, and consents, and add assertions that
purgeUserById removes only the purged user’s child rows while preserving other
users’ channels and sessions. Assert that the purged user’s consent row remains
intact, and add consents to the setupTestDb truncation list so tests remain
isolated.

In `@src/lib/server/retention.ts`:
- Around line 62-83: Guard purgeUserById with DRY_RUN from $env/dynamic/private
so it performs no deletes or anonymizing updates during dry runs. Update the
Google login callback’s purge path to recognize a skipped purge and stop instead
of treating the tombstoned account as a fresh signup while its Google identity
remains claimed.
- Line 27: Replace the fixed-day RETENTION_MS cutoff with a true
six-calendar-month calculation for account retention, and update the associated
account-deletion behavior or copy to use that cutoff. Keep statutory-log
retention references unchanged, since they describe separate data.

In `@src/routes/api/auth/google/login/callback/`+server.ts:
- Around line 28-30: Wrap the sign-in-time purge call in the login callback,
specifically the flow around purgeUserById(user.id), in try/catch handling. On
failure, return the same explicit error(502, '...') pattern and wording style
used by the other failure branches in this function, rather than allowing the
exception to escape; preserve the existing successful login flow.

In `@src/routes/consent/`+page.server.ts:
- Around line 115-129: Move the users lookup into the db.transaction callback,
re-read the current row there, and use that row for both consent insertion and
restoration. Before restoring a soft-deleted account, call isRetentionExpired
with the transaction-fresh deletion timestamp and reject expired accounts using
the callback’s existing retention handling. Ensure concurrent purges cannot lead
to consent insertion or session creation for a tombstoned user.

---

Outside diff comments:
In `@drizzle/meta/_journal.json`:
- Around line 61-81: Fix migration reachability in drizzle/meta/_journal.json by
raising entries 9 and 10 `when` values above entry 8’s value while keeping them
strictly increasing by `idx`; update src/lib/server/db/schema-indexes.test.ts
around the migration checks to assert the `CREATE INDEX users_deleted_at_idx`
migration is journaled and that all journal `when` values strictly increase with
`idx`.

In `@src/routes/consent/consent.test.ts`:
- Around line 232-271: Add a consent-flow test alongside “a soft-deleted
existing user is restored only when the consent flow completes” using a
deletedAt timestamp beyond the retention cutoff, then assert consent completion
does not clear deletedAt while preserving the expected account/consent/session
outcomes. Reuse seedExistingAndConsent and the existing database assertions, and
target the isRetentionExpired behavior in the consent action.
🪄 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: b14d62b7-6258-4834-89f2-390225dc5aa4

📥 Commits

Reviewing files that changed from the base of the PR and between cc3b41c and 99e29a5.

📒 Files selected for processing (25)
  • AGENTS.md
  • EXECUTION_PLAN_YouTube_Comment_Moderator.md
  • drizzle/0009_aromatic_red_wolf.sql
  • drizzle/0010_users_deleted_at_idx.sql
  • drizzle/meta/0009_snapshot.json
  • drizzle/meta/0010_snapshot.json
  • drizzle/meta/_journal.json
  • src/lib/landing/legal.test.ts
  • src/lib/server/db/schema-indexes.test.ts
  • src/lib/server/db/schema.ts
  • src/lib/server/pipeline.test.ts
  • src/lib/server/pipeline.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/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

Comment on lines +240 to +241
for (const pattern of RETIRED_PROMISES) {
expect(text, `${name} still carries a retired promise: ${pattern}`).not.toMatch(pattern);

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 | 🟠 Major | ⚡ Quick win

Make the refund guard policy-aware.

The loop checks only literal substrings. It can allow an unconditional promise such as Full refunds are available for unused credits. because none of RETIRED_PROMISES matches, while the separate /full refund/i assertion still passes. It can also reject compliant text such as No refund upon cancellation of your account. because the matcher ignores negation. Assert the seven-day refund scope and the post-window unused-credit denial for each surface, or use polarity-aware matching. The canonical policy is defined in src/lib/server/legal.ts:63-64.

As per coding guidelines, tests must fail when the real logic is wrong.

🤖 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` around lines 240 - 241, Update the legal-text
validation around RETIRED_PROMISES to be policy-aware rather than relying only
on literal substring checks. For every surface, assert both the seven-day refund
scope and denial of refunds for unused credits after the window, while
recognizing negated compliant wording. Preserve the existing full-refund
coverage and make the assertions fail for unconditional refund promises.

Source: Coding guidelines

Comment on lines +26 to +35
const schemaSource = readFileSync(new URL('./schema.ts', import.meta.url), 'utf8');
const migrations = readdirSync(new URL('../../../../drizzle', import.meta.url))
.filter((file) => file.endsWith('.sql'))
.map((file) => readFileSync(new URL(`../../../../drizzle/${file}`, import.meta.url), 'utf8'))
.join('\n');

test('users.deleted_at is indexed in the schema and in a migration', () => {
expect(schemaSource).toContain("index('users_deleted_at_idx').on(table.deletedAt)");
expect(migrations).toMatch(/CREATE INDEX `?users_deleted_at_idx`? ON `?users`? \(`?deleted_at`?\)/);
});

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.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect getTableConfig index typings in the installed drizzle-orm.
set -euo pipefail

jq -r '.dependencies["drizzle-orm"] // .devDependencies["drizzle-orm"]' package.json

fd -t f 'utils.d.ts' node_modules/drizzle-orm/sqlite-core 2>/dev/null | while IFS= read -r f; do
  echo "== $f"; cat -n "$f"
done

fd -t f 'indexes.d.ts' node_modules/drizzle-orm/sqlite-core 2>/dev/null | while IFS= read -r f; do
  echo "== $f"; cat -n "$f"
done

Repository: Bonobo791/Moderaty

Length of output: 3402


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== target test =='
cat -n src/lib/server/db/schema-indexes.test.ts

echo '== schema index declarations =='
rg -n -C 3 "users_deleted_at_idx|deletedAt|deleted_at" src/lib/server/db/schema.ts

echo '== migration journal =='
cat -n drizzle/meta/_journal.json

echo '== migration files containing the index =='
rg -l -n "users_deleted_at_idx|CREATE INDEX" drizzle --glob '*.sql' | while IFS= read -r file; do
  echo "== $file"
  rg -n -C 2 "users_deleted_at_idx|CREATE INDEX" "$file"
done

echo '== migration naming and journal references =='
find drizzle -maxdepth 2 -type f -print | sort

Repository: Bonobo791/Moderaty

Length of output: 6859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node --input-type=module <<'JS'
import { getTableConfig, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';

const users = sqliteTable('users', {
  deletedAt: text('deleted_at')
}, (table) => [
  index('users_deleted_at_idx').on(table.deletedAt)
]);

const { indexes } = getTableConfig(users);
const match = indexes.find((entry) => entry.config.name === 'users_deleted_at_idx');

console.log(JSON.stringify({
  indexCount: indexes.length,
  configName: match?.config.name,
  columns: match?.config.columns.map((column) => ({
    constructor: column.constructor.name,
    name: column.name,
    columnName: column.columnName
  }))
}, null, 2));
JS

python3 - <<'PY'
import json
from pathlib import Path

journal = json.loads(Path("drizzle/meta/_journal.json").read_text())
entries = journal["entries"]
tags = [entry["tag"] for entry in entries]
migration = Path("drizzle/0010_users_deleted_at_idx.sql")
print(json.dumps({
    "journal_has_index_migration": "0010_users_deleted_at_idx" in tags,
    "journal_last_tag": tags[-1],
    "migration_exists": migration.exists(),
    "migration_sql": migration.read_text(),
}, indent=2))
PY

Repository: Bonobo791/Moderaty

Length of output: 1405


Use Drizzle metadata and the migration journal in src/lib/server/db/schema-indexes.test.ts.

  • Use getTableConfig(users) to assert users_deleted_at_idx targets deleted_at.
  • Read only migrations listed in drizzle/meta/_journal.json; concatenating every .sql file allows an unjournaled migration to pass.
🤖 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/db/schema-indexes.test.ts` around lines 26 - 35, The schema
index test should validate Drizzle metadata and only journaled migrations
instead of scanning raw schema text and every SQL file. Update the test to use
getTableConfig(users) when asserting users_deleted_at_idx targets deleted_at,
and load migration files according to entries in drizzle/meta/_journal.json
before checking the CREATE INDEX statement.

Source: Coding guidelines

Comment on lines +30 to +102
async function seedUser(id: string, deletedAt: string | null) {
await testDb().db.insert(users).values({ id, googleSub: `sub-${id}`, email: `${id}@example.com`, displayName: id, deletedAt });
await testDb().db.insert(channels).values({ id: `UC-${id}`, userId: id, title: `channel ${id}`, refreshTokenEnc: 'enc', active: 0 });
await testDb().db.insert(sessions).values({ id: `token-${id}`, userId: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() });
return id;
}

async function userRow(id: string) {
return await testDb().db.select().from(users).where(eq(users.id, id)).get();
}

test('purges an expired soft-deleted user: owned rows removed, tombstone anonymized', async () => {
const expiredAt = new Date(Date.now() - 181 * DAY_MS).toISOString();
const userId = await seedUser('purge-me', expiredAt);

const purged = await purgeUserById(userId, expiredAt);

expect(purged).toBe(true);
expect(await testDb().db.select().from(channels).all()).toEqual([]);
expect(await testDb().db.select().from(sessions).all()).toEqual([]);
expect(await userRow(userId)).toMatchObject({
googleSub: `deleted:${userId}`,
email: '[deleted]',
displayName: '[deleted]',
deletedAt: null
});
});

test('race: skips the purge when the account was restored after selection', async () => {
// purgeExpiredUser selected this user at `selectedAt`; before the purge
// transaction starts, the login callback restores the account.
const selectedAt = new Date(Date.now() - 181 * DAY_MS).toISOString();
const userId = await seedUser('restored', selectedAt);
await testDb().db.update(users).set({ deletedAt: null }).where(eq(users.id, userId));

const purged = await purgeUserById(userId, selectedAt);

expect(purged).toBe(false);
expect(await userRow(userId)).toMatchObject({
googleSub: 'sub-restored',
email: 'restored@example.com',
deletedAt: null
});
expect(await testDb().db.select().from(channels).all()).toHaveLength(1);
expect(await testDb().db.select().from(sessions).all()).toHaveLength(1);
});

test('race: skips the purge when the deletion marker changed after selection', async () => {
// The account was restored and deleted again — the new deleted_at is NOT
// the expired timestamp the selection saw, so purging on it is wrong.
const selectedAt = new Date(Date.now() - 181 * DAY_MS).toISOString();
const userId = await seedUser('re-deleted', new Date().toISOString());

const purged = await purgeUserById(userId, selectedAt);

expect(purged).toBe(false);
expect(await userRow(userId)).toMatchObject({ googleSub: 'sub-re-deleted', email: 're-deleted@example.com' });
expect(await testDb().db.select().from(channels).all()).toHaveLength(1);
});

test('purgeExpiredUser purges only the oldest expired user and leaves the rest alone', async () => {
const older = await seedUser('older', new Date(Date.now() - 200 * DAY_MS).toISOString());
const newer = await seedUser('newer', new Date(Date.now() - 190 * DAY_MS).toISOString());
const withinWindow = await seedUser('within', new Date(Date.now() - 10 * DAY_MS).toISOString());
await seedUser('active', null);

expect(await purgeExpiredUser()).toBe(older);
expect(await userRow(older)).toMatchObject({ googleSub: `deleted:${older}` });
// The next-oldest expired user drains on a later run (bounded runs, I10).
expect(await userRow(newer)).toMatchObject({ googleSub: 'sub-newer' });
expect(await userRow(withinWindow)).toMatchObject({ googleSub: 'sub-within' });
expect(await userRow('active')).toMatchObject({ googleSub: 'sub-active' });
});

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 | 🟠 Major | ⚡ Quick win

Add coverage for the child-table deletes, cross-user isolation, and consent preservation.

seedUser creates only a user, a channel, and a session. No test creates rows in comments, rules, audit_log, moderation_actions, or consents.

Three defects in purgeUserById pass this suite today:

  1. Lines 70-73 of src/lib/server/retention.ts delete moderationActions, comments, auditLog, and rules. Remove any of those four statements and every test still passes.
  2. Change eq(channels.userId, userId) on line 75 to an unscoped delete and the suite still passes. Test 4 seeds four users but asserts only users rows, never the other users' channels and sessions.
  3. Add a tx.delete(consents) to the purge and the suite still passes. Consent preservation is the stated legal reason for the tombstone design, and it has no test.

Add consents to the setupTestDb truncation list on line 26 before adding the consent assertion.

💚 Proposed coverage additions
-setupTestDb(['moderation_actions', 'comments', 'audit_log', 'rules', 'channels', 'sessions', 'users']);
+setupTestDb([
+	'moderation_actions',
+	'comments',
+	'audit_log',
+	'rules',
+	'consents',
+	'channels',
+	'sessions',
+	'users'
+]);
 async function seedUser(id: string, deletedAt: string | null) {
 	await testDb().db.insert(users).values({ id, googleSub: `sub-${id}`, email: `${id}`@example.com``, displayName: id, deletedAt });
 	await testDb().db.insert(channels).values({ id: `UC-${id}`, userId: id, title: `channel ${id}`, refreshTokenEnc: 'enc', active: 0 });
 	await testDb().db.insert(sessions).values({ id: `token-${id}`, userId: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() });
+	await testDb().db.insert(rules).values({ channelId: `UC-${id}`, type: 'keyword', pattern: id, action: 'reject' });
+	await testDb().db.insert(comments).values({
+		id: `c-${id}`, channelId: `UC-${id}`, text: 'hi', publishedAt: new Date().toISOString(),
+		status: 'approved', decidedBy: 'rule'
+	});
+	await testDb().db.insert(auditLog).values({
+		channelId: `UC-${id}`, commentId: `c-${id}`, action: 'approve', reason: 'r', actor: 'cron'
+	});
+	await testDb().db.insert(moderationActions).values({
+		commentId: `c-${id}`, channelId: `UC-${id}`, action: 'reject', reason: 'r', state: 'done'
+	});
+	await testDb().db.insert(consents).values({
+		userId: id, docVersion: 'v1', checkboxText: 'text', ip: '127.0.0.1', userAgent: 'test'
+	});
 	return id;
 }
+test('purge removes only the target user rows and keeps the consent log', async () => {
+	const expiredAt = new Date(Date.now() - 181 * DAY_MS).toISOString();
+	const target = await seedUser('purge-me', expiredAt);
+	const other = await seedUser('keep-me', null);
+
+	expect(await purgeUserById(target, expiredAt)).toBe(true);
+
+	for (const table of [comments, rules, auditLog, moderationActions]) {
+		const rows = await testDb().db.select().from(table).all();
+		expect(rows.map((row) => row.channelId)).toEqual([`UC-${other}`]);
+	}
+	expect((await testDb().db.select().from(channels).all()).map((ch) => ch.id)).toEqual([`UC-${other}`]);
+	expect((await testDb().db.select().from(sessions).all()).map((s) => s.userId)).toEqual([other]);
+	// The evidentiary consent log survives the purge for BOTH users.
+	expect((await testDb().db.select().from(consents).all()).map((c) => c.userId).sort()).toEqual([target, other].sort());
+});
🤖 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/retention.test.ts` around lines 30 - 102, Add test coverage in
retention.test.ts by extending seedUser or the relevant test setup to create
rows in comments, rules, audit_log, moderation_actions, and consents, and add
assertions that purgeUserById removes only the purged user’s child rows while
preserving other users’ channels and sessions. Assert that the purged user’s
consent row remains intact, and add consents to the setupTestDb truncation list
so tests remain isolated.

Source: Coding guidelines

test('retention helpers agree on the boundary', async () => {
expect(isRetentionExpired(new Date(Date.now() - 181 * DAY_MS).toISOString())).toBe(true);
expect(isRetentionExpired(new Date(Date.now() - 10 * DAY_MS).toISOString())).toBe(false);
expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString());

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

Capture the reference time once; this assertion can flake.

Line 114 calls Date.now() twice. If the clock advances by one millisecond between the two calls, the two ISO strings differ and the test fails.

💚 Proposed fix
-	expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString());
+	const now = Date.now();
+	expect(retentionCutoffIso(now)).toBe(new Date(now - 180 * DAY_MS).toISOString());
📝 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
expect(retentionCutoffIso(Date.now())).toBe(new Date(Date.now() - 180 * DAY_MS).toISOString());
const now = Date.now();
expect(retentionCutoffIso(now)).toBe(new Date(now - 180 * DAY_MS).toISOString());
🤖 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/retention.test.ts` at line 114, Update the retentionCutoffIso
assertion to capture Date.now() once in a local reference-time variable, then
use that same value for both the function input and expected Date calculation.
Keep the existing 180-day cutoff expectation unchanged.

import { db } from '$lib/server/db';
import { auditLog, channels, comments, moderationActions, rules, sessions, users } from '$lib/server/db/schema';

export const RETENTION_MS = 180 * 24 * 60 * 60 * 1000; // 180 days ≈ 6 months

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate public copy that states the account-deletion retention window.
set -euo pipefail

rg -n -C3 -i '6[- ]month|six month|180 day' src --iglob '*.{ts,svelte,md}'

Repository: Bonobo791/Moderaty

Length of output: 9232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- retention implementation ---'
cat -n src/lib/server/retention.ts | sed -n '1,180p'

printf '%s\n' '--- retention references and tests ---'
rg -n -C4 'RETENTION_MS|retentionCutoffIso|isRetentionExpired|deletedAt|6-month|6 months|180 days' src AGENTS.md --glob '!**/.svelte-kit/**' --glob '!**/build/**'

printf '%s\n' '--- relevant test context ---'
cat -n src/routes/api/cron/cron.test.ts | sed -n '130,180p'

printf '%s\n' '--- AGENTS retention context ---'
cat -n AGENTS.md | sed -n '165,190p'

Repository: Bonobo791/Moderaty

Length of output: 49591


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from datetime import datetime, timedelta, timezone

for start in (datetime(2025, 1, 1, tzinfo=timezone.utc),
              datetime(2025, 3, 1, tzinfo=timezone.utc),
              datetime(2025, 7, 1, tzinfo=timezone.utc)):
    six_month_boundary = start.replace(
        year=start.year + (start.month + 5) // 12,
        month=(start.month + 5) % 12 + 1,
    )
    print(
        start.date(),
        "calendar-six-month-days=",
        (six_month_boundary - start).days,
        "180-day-boundary=",
        (start + timedelta(days=180)).date(),
        "calendar-boundary=",
        six_month_boundary.date(),
    )
PY

printf '%s\n' '--- account-retention wording outside the initial source search ---'
rg -n -C3 -i 'account.{0,30}(retention|kept|purge|erase)|retention.{0,30}account|deleted.{0,30}(month|day)|month.{0,30}(delete|account|record)' . \
  --glob '!**/.svelte-kit/**' --glob '!**/build/**' --glob '!node_modules/**'

Repository: Bonobo791/Moderaty

Length of output: 13546


Use a true six-calendar-month account-retention cutoff. RETENTION_MS purges accounts after 180 days, but the dashboard and project documentation promise six months. Six calendar months can last 181–184 days. Calculate the cutoff by calendar months, or change the account-deletion copy to “180 days.” Do not change statutory-log references, which describe different data.

🤖 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/retention.ts` at line 27, Replace the fixed-day RETENTION_MS
cutoff with a true six-calendar-month calculation for account retention, and
update the associated account-deletion behavior or copy to use that cutoff. Keep
statutory-log retention references unchanged, since they describe separate data.

Source: Coding guidelines

Comment on lines +62 to +83
export async function purgeUserById(userId: string, expectedDeletedAt?: string): Promise<boolean> {
return await db.transaction(async (tx) => {
const user = await tx.select({ deletedAt: users.deletedAt }).from(users).where(eq(users.id, userId)).get();
if (!user?.deletedAt) return false;
if (expectedDeletedAt !== undefined && user.deletedAt !== expectedDeletedAt) return false;
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]', deletedAt: null })
.where(eq(users.id, userId));
return true;
});
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Trace DRY_RUN enforcement around the retention purge call sites.
set -euo pipefail

rg -n -C6 'purgeUserById|purgeExpiredUser' src

echo "== DRY_RUN reads =="
rg -n -C4 'DRY_RUN' src

Repository: Bonobo791/Moderaty

Length of output: 31805


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== retention imports and purge helper =="
sed -n '1,115p' src/lib/server/retention.ts

echo "== Google callback around retention handling =="
sed -n '70,125p' 'src/routes/api/auth/google/login/callback/+server.ts'

echo "== callback environment imports and writes =="
sed -n '1,35p' 'src/routes/api/auth/google/login/callback/+server.ts'
rg -n -C4 'insert\\(users\\)|update\\(users\\)|insert\\(consents\\)|purgeUserById|DRY_RUN|env' 'src/routes/api/auth/google/login/callback/+server.ts'

Repository: Bonobo791/Moderaty

Length of output: 10711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pending consent handling and user creation =="
rg -n -C8 'kind: .new.|pending.*new|insert\\(users\\)|googleSub|parkPendingConsent' src/routes src/lib/server \
  -g '*.ts' -g '*.svelte'

Repository: Bonobo791/Moderaty

Length of output: 50374


Guard all retention purge paths during dry runs.

purgeUserById performs durable deletes and an anonymizing update without checking DRY_RUN. The Google login callback calls it directly, so an expired account can be purged during a dry run even though the cron route skips purgeExpiredUser.

Read DRY_RUN through $env/dynamic/private. If the purge is skipped, do not continue the callback as a fresh signup while the existing tombstone still owns the Google identity.

🤖 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/retention.ts` around lines 62 - 83, Guard purgeUserById with
DRY_RUN from $env/dynamic/private so it performs no deletes or anonymizing
updates during dry runs. Update the Google login callback’s purge path to
recognize a skipped purge and stop instead of treating the tombstoned account as
a fresh signup while its Google identity remains claimed.

Source: Coding guidelines

Comment on lines +28 to 30
import { isRetentionExpired, purgeUserById } from '$lib/server/retention';
import { createSession, SESSION_COOKIE } from '$lib/server/session';

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 | ⚡ Quick win

Wrap the sign-in-time purge in error handling.

Line 95 calls purgeUserById(user.id) without a try/catch. The cron endpoint catches the equivalent purge failure, logs it, and continues (src/routes/api/cron/+server.ts lines 70-75). This file has no such handling: if the purge write fails, the exception propagates uncaught out of the login callback, and the user gets no clear message — unlike every other failure branch in this function, which returns error(502, '...') with a specific reason.

Wrap the purge call and fail with the same explicit error pattern already used in this file.

🐛 Proposed fix
 	if (user?.deletedAt && isRetentionExpired(user.deletedAt)) {
 		console.info(`account ${user.id} past the retention window; purging at sign-in`);
-		await purgeUserById(user.id);
+		try {
+			await purgeUserById(user.id);
+		} catch (cause) {
+			console.error(`retention purge at sign-in failed for ${user.id}:`, cause);
+			throw error(502, 'Sign-in failed — please retry');
+		}
 		user = undefined;
 	}

Also applies to: 89-97, 119-128

🤖 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/auth/google/login/callback/`+server.ts around lines 28 - 30,
Wrap the sign-in-time purge call in the login callback, specifically the flow
around purgeUserById(user.id), in try/catch handling. On failure, return the
same explicit error(502, '...') pattern and wording style used by the other
failure branches in this function, rather than allowing the exception to escape;
preserve the existing successful login flow.

Comment on lines +115 to +129
const user = await db
.select({ id: users.id, deletedAt: users.deletedAt })
.from(users)
.where(eq(users.id, pending.userId))
.get();
if (!user) return fail(400, { error: 'Your sign-in session expired — please sign in again.' });
session = await db.transaction(async (tx) => {
await tx.insert(consents).values(consentRecord(user.id));
// The login callback leaves a soft-deleted account pending while
// it is parked here; completing re-acceptance is what cancels
// the deletion — atomically with the consent record and session.
if (user.deletedAt) {
await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`);
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Re-check the retention cutoff inside the transaction before restoring a soft-deleted account.

This branch restores a soft-deleted account (clears deletedAt) without calling isRetentionExpired, unlike src/routes/api/auth/google/login/callback/+server.ts (lines 93-97), which enforces the same cutoff before allowing a restore. If the account crosses the retention boundary between the login callback's earlier check and this action running, it gets restored here instead of purged — defeating the documented 6-month retention guarantee.

The user row is also read once, before the transaction starts, and its deletedAt value is used unchanged inside the transaction. If a concurrent cron purge anonymizes this same user in between (setting google_sub = 'deleted:<id>', wiping email/display name, clearing deletedAt), this code still inserts a consent row and creates a live session bound to the now-tombstoned identity, because it never re-reads the row inside the transaction.

Move the lookup inside the transaction and reject with isRetentionExpired, matching the callback's pattern.

🐛 Proposed fix
+import { isRetentionExpired } from '$lib/server/retention';
...
 		} else {
-			const user = await db
-				.select({ id: users.id, deletedAt: users.deletedAt })
-				.from(users)
-				.where(eq(users.id, pending.userId))
-				.get();
-			if (!user) return fail(400, { error: 'Your sign-in session expired — please sign in again.' });
-			session = await db.transaction(async (tx) => {
-				await tx.insert(consents).values(consentRecord(user.id));
-				// The login callback leaves a soft-deleted account pending while
-				// it is parked here; completing re-acceptance is what cancels
-				// the deletion — atomically with the consent record and session.
-				if (user.deletedAt) {
-					await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
-					console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`);
-				}
-				return createSession(user.id, tx);
-			});
-			userId = user.id;
+			const outcome = await db.transaction(async (tx) => {
+				// Re-read inside the transaction so a concurrent retention purge
+				// cannot be missed between the check and the write (TOCTOU).
+				const user = await tx
+					.select({ id: users.id, deletedAt: users.deletedAt })
+					.from(users)
+					.where(eq(users.id, pending.userId))
+					.get();
+				if (!user || (user.deletedAt && isRetentionExpired(user.deletedAt))) return null;
+				await tx.insert(consents).values(consentRecord(user.id));
+				if (user.deletedAt) {
+					await tx.update(users).set({ deletedAt: null }).where(eq(users.id, user.id));
+					console.info(`account ${user.id} restored by re-consent; pending deletion cancelled`);
+				}
+				return { id: user.id, session: await createSession(user.id, tx) };
+			});
+			if (!outcome) return fail(400, { error: 'Your sign-in session expired — please sign in again.' });
+			userId = outcome.id;
+			session = outcome.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 115 - 129, Move the users
lookup into the db.transaction callback, re-read the current row there, and use
that row for both consent insertion and restoration. Before restoring a
soft-deleted account, call isRetentionExpired with the transaction-fresh
deletion timestamp and reject expired accounts using the callback’s existing
retention handling. Ensure concurrent purges cannot lead to consent insertion or
session creation for a tombstoned user.

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

Labels

cla-signed size:XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant