fix: account deletion v2 — immediate erasure with statutory consent retention (replaces 6-month soft delete) - #42
Conversation
…tion (Option B) Replace the 6-month soft-delete with immediate erasure per the LGPD deletion policy: deleteAccount revokes each channel's YouTube grant at Google (revokeGoogleToken, loud per-channel failure, never blocking) and deleteUserRecords erases channels/rules/comments/moderation actions/audit rows/sessions in one transaction, tombstoning the users row fully (google_sub deleted:<id>, email/display name [deleted]). The retained evidence is the consent-acceptance record under Art. 16, III, and the e-mail lives ONLY in consents (Option B): migration 0009 adds nullable consents.email with a users backfill, the consent action records it at every acceptance, and a bounded DRY_RUN-aware cron sweep nulls it after 10 years (CC Art. 205) while keeping the anonymized row. No LEGAL_VERSION bump — transparency clarification, not a consent change; dashboard notice + Privacy §2/§7.1 copy updated (blocked from any other use, access-restricted, up to 10 years).
🤖 CodeAnt AI — Review Status
|
✅ Deploy Preview for moderaty ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe PR replaces soft deletion with immediate account erasure. It revokes YouTube grants, removes user records, creates tombstones, retains consent evidence, and clears retained consent emails after 10 years through a bounded cron sweep. It also updates refund-policy wording. ChangesAccount deletion and consent retention
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant Dashboard
participant Google
participant Database
User->>Dashboard: request account deletion
Dashboard->>Google: revoke channel tokens
Google-->>Dashboard: success or error
Dashboard->>Database: delete owned records and sessions
Dashboard->>Database: anonymize user tombstone
Dashboard-->>User: clear session and redirect
sequenceDiagram
participant Cron
participant ConsentSweep
participant Database
participant ChannelRunner
Cron->>ConsentSweep: run non-dry-run sweep
ConsentSweep->>Database: clear expired consent emails
Database-->>ConsentSweep: return affected count
ConsentSweep-->>Cron: return count
Cron->>ChannelRunner: process channels
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
Sequence DiagramThis diagram shows the PR's replacement of soft deletion with immediate account erasure, including Google grant revocation, transactional data deletion, and preservation of statutory consent evidence. It also shows the cron sweep that later removes retained consent e-mails while keeping anonymized records. sequenceDiagram
participant User
participant Dashboard
participant Google
participant Database
participant Cron
User->>Dashboard: Confirm account deletion
loop Each owned channel
Dashboard->>Google: Revoke YouTube grant
Google-->>Dashboard: Revocation result
end
Dashboard->>Database: Erase account records in one transaction
Database-->>Dashboard: Tombstone user and preserve consent evidence
Dashboard-->>User: Sign out and redirect
Cron->>Database: Sweep expired consent e-mails
Database-->>Cron: Null e-mails and keep consent rows
Generated by CodeAnt AI |
🏁 CodeAnt Quality Gate ResultsCommit: ✅ Overall Status: PASSEDQuality Gate Details
|
There was a problem hiding this comment.
This PR implements immediate account deletion with statutory consent retention, replacing the 6-month soft-delete approach. The architecture is well-designed with proper transaction boundaries, token revocation, and time-bounded consent sweeps.
Critical Issue Identified: One security vulnerability requires immediate attention - potential token exposure in error logs during the deletion flow.
Verification: The PR includes comprehensive test coverage (268/268 passing) and the implementation follows LGPD/LGPD compliance requirements appropriately.
You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.
| for (const ch of owned) { | ||
| try { | ||
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | ||
| } catch (cause) { | ||
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛑 Security Vulnerability: Unencrypted token exposure in error logs creates a token leakage risk. If decrypt() throws an exception before the token string is created, the catch block at line 107 logs cause which could contain the encrypted token string from the exception message. Additionally, if revokeGoogleToken fails, its error messages could propagate encrypted tokens through the error chain.
| for (const ch of owned) { | |
| try { | |
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | |
| } catch (cause) { | |
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | |
| } | |
| } | |
| for (const ch of owned) { | |
| try { | |
| const token = decrypt(ch.refreshTokenEnc); | |
| await revokeGoogleToken(token, `account deletion channel ${ch.id}`); | |
| } catch (cause) { | |
| // Never log the exception details - they may contain token material | |
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway`); | |
| } | |
| } |
PR Summary by QodoImmediate account deletion with consent-email statutory retention and 10-year sweep
AI Description
Diagram
High-Level Assessment
Files changed (23)
|
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| Compatibility | 5 high |
| Security | 1 critical 1 high |
🔴 Metrics 31 complexity · 5 duplication
Metric Results Complexity ✅ 31 (≤ 100 complexity) Duplication ⚠️ 5 (≤ 1 duplication)
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
PR Code Suggestions ✨Previous suggestions up to commit
|
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
Concurrent channel attachment can recreate account data after deletion completesThe transaction does not establish a deletion marker or otherwise prevent the src/lib/server/deletion.ts [65-66] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/deletion.ts
**Line:** 65:66
**Comment:**
*Race Condition: The transaction does not establish a deletion marker or otherwise prevent the channel-connect callback from attaching a channel after this channel enumeration and after the deletion transaction commits. An in-flight authenticated callback can therefore insert or reattach a channel with its encrypted refresh token after deletion, leaving account data behind. Mark the user as deleting/tombstoned before accepting channel writes, and make the callback reject that state, or perform deletion with an equivalent write guard.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Critical | 2026-08-02 01:38
|
Concurrent moderation work can recreate erased records after deletion commitsCalling the erase transaction without coordinating with in-flight moderation runs src/routes/(app)/dashboard/+page.server.ts [110-111] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 110:111
**Comment:**
*Race Condition: Calling the erase transaction without coordinating with in-flight moderation runs does not guarantee immediate erasure. A pipeline run can pass its channel-active check, this deletion can commit and remove the channel, and the run can then insert comments, moderation actions, or audit rows because those tables do not enforce a foreign key to the deleted channel. This can recreate deleted account data after the action redirects; deletion must synchronize with writers or make the durable writes atomically conditional on the channel still existing and belonging to a non-deleted account.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Critical | 2026-08-02 01:38
| |
Snapshotting channels before deletion allows newly written grants to escape revocationThe channel list and encrypted tokens are read before src/routes/(app)/dashboard/+page.server.ts [98-105] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/(app)/dashboard/+page.server.ts
**Line:** 98:105
**Comment:**
*Race Condition: The channel list and encrypted tokens are read before `deleteUserRecords` starts, so a concurrent YouTube OAuth callback can attach or replace a channel after this snapshot. The deletion transaction then removes that channel without revoking the token that was actually stored, leaving a live Google grant orphaned. Coordinate channel writes with deletion or mark the account as deleting before collecting and revoking tokens.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:38
| |
Account deletion can race with consent acceptance and leave a consent row and session for a deleted accountThe existing-user path reads the user before opening the transaction. If account src/routes/consent/+page.server.ts [126-129] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/consent/+page.server.ts
**Line:** 126:129
**Comment:**
*Race Condition: The existing-user path reads the user before opening the transaction. If account deletion commits after this read but before these statements, the transaction inserts a consent row containing the tombstone email `[deleted]` and creates a session for the deleted account. The session will later be rejected, but the action still reports success and leaves an invalid consent record. Re-read and verify the user is still live inside the same transaction that inserts the consent and creates the session, or serialize this flow with deletion.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:38
| |
| Performance |
An unbounded retention sweep can consume the moderation run budget before channel processing startsThe retention sweep performs an unbounded database select and update before channel src/routes/api/cron/+server.ts [71] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/api/cron/+server.ts
**Line:** 71:71
**Comment:**
*Performance: The retention sweep performs an unbounded database select and update before channel selection, despite the deadline being captured at handler start. A slow remote database operation can consume the entire 20-second run budget, after which `runChannel` receives an already-expired deadline and may skip or fail moderation while the handler can still return a successful response. Pass the deadline or a database timeout into the sweep, or reserve a bounded portion of the budget for channel processing.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:38
|
| Comment mismatch |
The deletion notice falsely claims that consent records are the only data retained after account closureThe deletion notice incorrectly says that only consent-acceptance records are src/routes/(app)/dashboard/+page.svelte [125-127] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/routes/(app)/dashboard/+page.svelte
**Line:** 125:127
**Comment:**
*Comment Mismatch: The deletion notice incorrectly says that only consent-acceptance records are retained, but the Privacy Policy also states that invoices and tax records, connection logs, support correspondence, and security logs may be retained after account closure. This gives users a materially false deletion guarantee; either enumerate the other legally retained categories here or change the wording to match the actual retention policy.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:38
|
Latest suggestions up to commit 5dbf495
| Category | Suggestion | Severity | Generated at (UTC) |
| Race condition |
Concurrent deletion can let a stale session resolution grant access after the account was tombstonedThe tombstone check is not atomic with the later session renewal and return. If src/lib/server/session.ts [91-94] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/server/session.ts
**Line:** 91:94
**Comment:**
*Race Condition: The tombstone check is not atomic with the later session renewal and return. If account deletion commits after this query but before the renewal update, this code can return the pre-deletion user and recreate or renew a session that the deletion transaction removed. Perform the validation and session update in one transaction or re-read the user/session state immediately before returning.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:55
|
| Comment mismatch |
Deletion policy incorrectly presents the consent record as the only retained data despite other retention periods listed in the same policySection 7.1 states that the consent record is the one exception to immediate src/lib/components/landing/legal/Privacy.svelte [116] Why it matters? 🤔
(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/components/landing/legal/Privacy.svelte
**Line:** 116:116
**Comment:**
*Comment Mismatch: Section 7.1 states that the consent record is the one exception to immediate deletion, but Section 2 separately says connection/application-access logs are retained for six months, billing and tax records for five years, support correspondence for two years, and security logs for up to twelve months. This contradiction gives users an inaccurate account-deletion retention disclosure; reconcile Section 7.1 with all data categories that remain retained.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major | 2026-08-02 01:55
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
68 rules 1. Non-atomic revoke ordering
|
| // Immediate deletion: everything is erased NOW except the evidentiary | ||
| // consent log (statutory retention, LGPD Art. 16, III). Each channel's | ||
| // YouTube grant is revoked at Google first (YouTube API ToS); a | ||
| // revocation failure is logged loudly but does not block deletion — the | ||
| // encrypted token is erased either way, orphaning the grant. | ||
| const owned = await db | ||
| .select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc }) | ||
| .from(channels) | ||
| .where(eq(channels.userId, user.id)) | ||
| .all(); | ||
| for (const ch of owned) { | ||
| try { | ||
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | ||
| } catch (cause) { | ||
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | ||
| } | ||
| } | ||
| await deleteUserRecords(user.id); |
There was a problem hiding this comment.
1. deleteaccount missing deletedat update 📘 Rule violation ≡ Correctness
The dashboard deleteAccount action no longer sets users.deletedAt and deactivates channels within a single transaction; it now revokes tokens and hard-erases user-owned records via deleteUserRecords. This violates the required soft-delete/deactivation transaction semantics and changes the mandated retention behavior.
Agent Prompt
## Issue description
Compliance requires the dashboard `deleteAccount` flow to soft-delete the user (`users.deletedAt`), destroy sessions, and deactivate channels (`channels.active=0`) inside a single DB transaction.
## Issue Context
The current implementation performs immediate erasure (deleting channels/sessions and tombstoning the user) and does not set `deletedAt` or deactivate channels.
## Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[87-113]
- src/lib/server/db/schema.ts[22-29]
- src/lib/server/deletion.ts[63-79]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // Signing back in WITHIN the 6-month retention window cancels a pending | ||
| // deletion — only now that the sign-in completes. Channels stay inactive | ||
| // (active=0 from the deletion) until the user re-enables them — moderation | ||
| // never resumes silently. (Accounts past the window never reach here — | ||
| // they are purged inline above.) | ||
| if (user.deletedAt) { | ||
| await db.update(users).set({ deletedAt: null }).where(eq(users.id, user.id)); | ||
| console.info(`account ${user.id} restored by sign-in; pending deletion cancelled`); |
There was a problem hiding this comment.
2. Login callback removed retention handling 📘 Rule violation ≡ Correctness
The Google login callback no longer restores soft-deleted accounts within 6 months nor purges accounts older than 6 months before proceeding as a fresh signup. This violates the required 6‑month reactivation/purge logic for deleted accounts.
Agent Prompt
## Issue description
Compliance requires the login/session-creation flow to (1) restore accounts within a 6-month retention window by clearing `deletedAt` (without re-enabling channels) and (2) purge accounts older than 6 months and treat the flow as a fresh signup.
## Issue Context
The PR removes the `deletedAt` restoration logic from the login callback and eliminates the `deletedAt` column entirely, making the required behavior impossible.
## Fix Focus Areas
- src/routes/api/auth/google/login/callback/+server.ts[83-118]
- src/lib/server/db/schema.ts[22-29]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // Consent-evidence retention sweep runs first, while the full budget | ||
| // remains: consent e-mails older than 10 years (CC Art. 205) are erased — | ||
| // the row stays as anonymized evidence. I8: a dry run changes nothing | ||
| // durable — the would-be sweep is only logged. A sweep failure must not | ||
| // stop scheduled moderation: log it loudly, report it, continue. | ||
| let consentEmailsNulled = 0; | ||
| let sweepError: string | null = null; | ||
| if (dryRun) { | ||
| console.info('dry run: retention purge skipped'); | ||
| console.info('dry run: consent e-mail retention sweep skipped'); | ||
| } else { | ||
| try { | ||
| purged = await purgeExpiredUser(); | ||
| consentEmailsNulled = await nullExpiredConsentEmails(); | ||
| } catch (cause) { | ||
| purgeError = cause instanceof Error ? cause.message : String(cause); | ||
| console.error('retention purge failed:', cause); | ||
| sweepError = cause instanceof Error ? cause.message : String(cause); | ||
| console.error('consent e-mail retention sweep failed:', cause); | ||
| } |
There was a problem hiding this comment.
3. /api/cron no longer purges 📘 Rule violation ≡ Correctness
The cron endpoint no longer selects and purges a single expired user per invocation; it only runs a consent e-mail nulling sweep. This violates the requirement that the retention purge cron processes exactly one expired user and anonymizes the user record while preserving consent logs.
Agent Prompt
## Issue description
Compliance requires cron to purge exactly one expired soft-deleted user per invocation (deleting sessions/channels/rules/comments/moderation actions/audit rows) while anonymizing the user row and preserving `consents`.
## Issue Context
The PR replaces the retention purge call with `nullExpiredConsentEmails()`, and the cron response fields are updated accordingly.
## Fix Focus Areas
- src/routes/api/cron/+server.ts[59-85]
- src/lib/server/deletion.ts[63-110]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const owned = await db | ||
| .select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc }) | ||
| .from(channels) | ||
| .where(eq(channels.userId, user.id)) | ||
| .all(); | ||
| for (const ch of owned) { | ||
| try { | ||
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | ||
| } catch (cause) { | ||
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | ||
| } | ||
| } | ||
| await deleteUserRecords(user.id); |
There was a problem hiding this comment.
5. Non-atomic revoke ordering 🐞 Bug ☼ Reliability
deleteAccount revokes Google tokens before running the transactional DB erase, so a DB failure/rollback can still leave the user’s YouTube grant revoked while their account/channel rows remain intact. This creates a partially-completed deletion state that the app cannot roll back.
Agent Prompt
### Issue description
`deleteAccount` performs an irreversible remote side effect (Google token revocation) before executing the transactional database erase. If `deleteUserRecords(...)` fails and rolls back, the user is left with a still-present account but a revoked YouTube grant.
### Issue Context
- The DB erase is transactional (`deleteUserRecords` wraps deletes + tombstone update in `db.transaction(...)`).
- Google revocation is an external HTTP call and cannot be rolled back.
### How to fix
- Decrypt and collect the refresh tokens first (in memory), but do **not** revoke yet.
- Run `await deleteUserRecords(user.id)`.
- Only after the erase succeeds, attempt revocations using the collected plaintext tokens.
- Use `Promise.allSettled(...)` (or bounded concurrency) so one channel’s failure doesn’t block others.
- Keep the current “log loudly but never block” behavior for per-channel revocation failures.
### Fix Focus Areas
- src/routes/(app)/dashboard/+page.server.ts[93-112]
- src/lib/server/deletion.ts[63-79]
- src/lib/server/google.ts[117-159]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
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.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/routes/consent/+page.server.ts (1)
119-131: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winReject tombstoned accounts before re-consent.
If deletion occurs after the user lookup, this branch inserts a consent record with
email: '[deleted]'and creates a session for the tombstoned account. SelectgoogleSuband reject values starting withdeleted:before inserting consent or creating the session.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/consent/`+page.server.ts around lines 119 - 131, Update the user lookup in the re-consent branch to also select users.googleSub, then reject the request when googleSub starts with "deleted:" before inserting consent or calling createSession. Preserve the existing expired-session failure for missing users and ensure tombstoned accounts cannot receive a new session.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@drizzle/0009_consents_email.sql`:
- Around line 19-27: Restore the existing 0009 migration and the 0010 migration
that add users.deleted_at and users_deleted_at_idx, preserving their original
contents and numbering. Move the consents.email addition and backfill into a new
later migration with an unused migration number; do not repurpose 0009 or remove
0010, and add the requested subsequent migration to drop the index and column.
In `@src/lib/server/deletion.ts`:
- Around line 49-80: Update the account-deletion flow around deleteUserRecords
so a failure after successful Google token revocation does not leave the active
channel with a revoked refresh token. On deletion failure, either retry the
deletion transaction or mark the affected channel inactive and persist the
existing reauthorization state, ensuring cron no longer attempts
refreshAccessToken for it.
In `@src/lib/server/google.ts`:
- Around line 116-159: Extract the shared Google form-request behavior from
exchangeGoogleCode and revokeGoogleToken into reusable fetch/error-detail
helpers, including response text collection and best-effort JSON parsing via a
helper such as extractGoogleErrorDetail. Update both callers to use the shared
logic while preserving their existing log context and distinct failure behavior:
exchangeGoogleCode must retain its 502 user-facing error, and revokeGoogleToken
must retain its Error throw.
In `@src/routes/`(app)/dashboard/+page.server.ts:
- Around line 93-110: Best-effort YouTube token revocation is described as
guaranteed despite failures being logged and tokens deleted. In
src/routes/(app)/dashboard/+page.server.ts lines 93-110, update the deletion
flow around revokeGoogleToken and deleteUserRecords to either persist pending
revocations for out-of-band retry or explicitly accept the residual risk; in
src/lib/components/landing/legal/Privacy.svelte line 116 and
src/routes/(app)/dashboard/+page.svelte lines 123-127, soften the
customer-facing claims to state revocation is attempted and may fail, including
Google's security settings as the independent remedy where appropriate.
- Around line 93-110: Update the account deletion flow around revokeGoogleToken
and deleteUserRecords to add a durable reconciliation path for failed YouTube
token revocations, rather than only logging the failure before deleting the
encrypted token. Reuse the existing action_pending-style mechanism and ensure
failed channel revocations are recorded for later retry while preserving
immediate user-record deletion.
- Around line 103-109: Add a test covering the channel-deletion loop around
revokeGoogleToken: configure multiple owned channels so revocation fails for
one, assert processing continues to the subsequent channel, deletion still
completes, and console.error identifies the failed channel.
In `@src/routes/api/cron/`+server.ts:
- Around line 60-76: The consent retention sweep around nullExpiredConsentEmails
must enforce the remaining RUN_BUDGET_MS deadline instead of allowing an
unbounded table scan. Add a suitable index for the query’s createdAt/email
filtering, pass the deadline or remaining budget into nullExpiredConsentEmails,
and stop or abort the sweep once the budget is exhausted while preserving
dry-run behavior and existing error reporting.
In `@src/routes/api/cron/cron.test.ts`:
- Around line 38-48: Move the duplicated DAY_MS constant and seedConsent helper
into a shared test-support module near the existing test database utilities,
then import and reuse both from cron.test.ts and deletion.test.ts. Preserve the
helper’s current user and consent row values and its createdAt parameter
behavior.
---
Outside diff comments:
In `@src/routes/consent/`+page.server.ts:
- Around line 119-131: Update the user lookup in the re-consent branch to also
select users.googleSub, then reject the request when googleSub starts with
"deleted:" before inserting consent or calling createSession. Preserve the
existing expired-session failure for missing users and ensure tombstoned
accounts cannot receive a new session.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 2ee96503-9b53-43ea-a780-7b83ab0aec18
📒 Files selected for processing (28)
AGENTS.mdEXECUTION_PLAN_YouTube_Comment_Moderator.mddrizzle/0009_consents_email.sqldrizzle/0010_users_deleted_at_idx.sqldrizzle/meta/0009_snapshot.jsondrizzle/meta/0010_snapshot.jsondrizzle/meta/_journal.jsonsrc/lib/components/landing/legal/Privacy.sveltesrc/lib/server/db/migration-0009.test.tssrc/lib/server/db/schema-indexes.test.tssrc/lib/server/db/schema.tssrc/lib/server/deletion.test.tssrc/lib/server/deletion.tssrc/lib/server/google.tssrc/lib/server/retention.test.tssrc/lib/server/retention.tssrc/lib/server/session.test.tssrc/lib/server/session.tssrc/lib/server/testdb.tssrc/routes/(app)/dashboard/+page.server.tssrc/routes/(app)/dashboard/+page.sveltesrc/routes/(app)/dashboard/dashboard.test.tssrc/routes/api/auth/google/login/callback/+server.tssrc/routes/api/auth/google/login/login.test.tssrc/routes/api/cron/+server.tssrc/routes/api/cron/cron.test.tssrc/routes/consent/+page.server.tssrc/routes/consent/consent.test.ts
💤 Files with no reviewable changes (5)
- drizzle/meta/0010_snapshot.json
- drizzle/0010_users_deleted_at_idx.sql
- src/lib/server/retention.ts
- src/lib/server/retention.test.ts
- src/lib/server/db/schema-indexes.test.ts
| /** | ||
| * Immediately and permanently erases a user's account data, preserving only the anonymized tombstone and the consent log. | ||
| * | ||
| * One transaction: moderation actions, comments, audit rows, and rules for | ||
| * the user's channels; the channels themselves; every session. The users row | ||
| * is anonymized to a tombstone (`googleSub: 'deleted:<id>'`, e-mail and | ||
| * display name wiped) so the same Google identity can sign up again and the | ||
| * `consents` evidentiary log survives with its foreign key intact. The | ||
| * e-mail survives ONLY in `consents` (statutory retention, Art. 16, III) — | ||
| * never in the live users table. OAuth token revocation happens at the | ||
| * caller, BEFORE this erase (the encrypted tokens die here either way). | ||
| * | ||
| * @param userId - The ID of the user to erase | ||
| */ | ||
| export async function deleteUserRecords(userId: string): Promise<void> { | ||
| await db.transaction(async (tx) => { | ||
| const chs = await tx.select({ id: channels.id }).from(channels).where(eq(channels.userId, userId)).all(); | ||
| const channelIds = chs.map((ch) => ch.id); | ||
| if (channelIds.length) { | ||
| await tx.delete(moderationActions).where(inArray(moderationActions.channelId, channelIds)); | ||
| await tx.delete(comments).where(inArray(comments.channelId, channelIds)); | ||
| await tx.delete(auditLog).where(inArray(auditLog.channelId, channelIds)); | ||
| await tx.delete(rules).where(inArray(rules.channelId, channelIds)); | ||
| } | ||
| await tx.delete(channels).where(eq(channels.userId, userId)); | ||
| await tx.delete(sessions).where(eq(sessions.userId, userId)); | ||
| await tx | ||
| .update(users) | ||
| .set({ googleSub: `deleted:${userId}`, email: '[deleted]', displayName: '[deleted]' }) | ||
| .where(eq(users.id, userId)); | ||
| }); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Check whether cron or dashboard code handles a revoked-but-undeleted channel (invalid_grant).
fd '\+page\.server\.ts$' 'src/routes/(app)/dashboard' --exec cat -n {}
rg -n 'invalid_grant|revoke' src/routes/api/cron src/lib/server/google.ts 2>/dev/nullRepository: Bonobo791/Moderaty
Length of output: 5908
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- cron and token-related files ---'
fd -t f . src | rg 'cron|google|channel|worker|moderation'
printf '%s\n' '--- revoked-token handling across server code ---'
rg -n -i 'invalid.?grant|invalid_grant|invalid token|unauthori[sz]ed|401|revokeGoogleToken|refreshTokenEnc|refresh token' src
printf '%s\n' '--- cron route contents ---'
for f in $(fd -t f '\+server\.ts$' src/routes | rg 'cron'); do
echo "### $f"
cat -n "$f"
doneRepository: Bonobo791/Moderaty
Length of output: 17965
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- token refresh and channel failure handling ---'
cat -n src/lib/server/youtube.ts | sed -n '145,215p'
cat -n src/lib/server/pipeline.ts | sed -n '490,555p'
printf '%s\n' '--- active-channel updates and error handling ---'
rg -n -C 4 'active|refreshAccessToken|runChannel|catch' src/lib/server/pipeline.ts src/lib/server/youtube.ts src/routes/\(app\)/channels/\[id\]/queue/+page.server.ts
printf '%s\n' '--- cron failure tests ---'
cat -n src/routes/api/cron/cron.test.ts | sed -n '95,175p'Repository: Bonobo791/Moderaty
Length of output: 22753
🏁 Script executed:
#!/bin/bash
set -eu
cat -n src/lib/server/pipeline.ts | sed -n '556,585p'
printf '%s\n' '--- channel active writes ---'
rg -n -C 3 'set\(\{[^}]*active|active:' src/lib/server src/routes | head -120Repository: Bonobo791/Moderaty
Length of output: 8152
Handle revoked tokens after failed account deletion.
When Google revocation succeeds but deleteUserRecords fails, the active channel retains a revoked refresh token. Cron selects it again, and refreshAccessToken fails with invalid_grant. Mark the channel inactive and expose a reauthorization state, or retry the deletion transaction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/server/deletion.ts` around lines 49 - 80, Update the account-deletion
flow around deleteUserRecords so a failure after successful Google token
revocation does not leave the active channel with a revoked refresh token. On
deletion failure, either retry the deletion transaction or mark the affected
channel inactive and persist the existing reauthorization state, ensuring cron
no longer attempts refreshAccessToken for it.
| // Immediate deletion: everything is erased NOW except the evidentiary | ||
| // consent log (statutory retention, LGPD Art. 16, III). Each channel's | ||
| // YouTube grant is revoked at Google first (YouTube API ToS); a | ||
| // revocation failure is logged loudly but does not block deletion — the | ||
| // encrypted token is erased either way, orphaning the grant. | ||
| const owned = await db | ||
| .select({ id: channels.id, refreshTokenEnc: channels.refreshTokenEnc }) | ||
| .from(channels) | ||
| .where(eq(channels.userId, user.id)) | ||
| .all(); | ||
| for (const ch of owned) { | ||
| try { | ||
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | ||
| } catch (cause) { | ||
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | ||
| } | ||
| } | ||
| await deleteUserRecords(user.id); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Best-effort YouTube revocation is presented as guaranteed in two customer-facing surfaces. The deletion flow logs a revocation failure server-side and proceeds; the encrypted token is erased in the same transaction, so a failed revocation can never be retried. Two user-facing texts state revocation as an unconditional fact, which does not match this behavior.
src/routes/(app)/dashboard/+page.server.ts#L93-L110: this is the root cause — a per-channel revocation failure is only logged (console.error), never surfaced to the user, and the token is discarded with noaction_pending-style reconciliation. Either add a reconciliation path (record the pending revocation before erasing the token and retry it out-of-band) or accept the residual risk explicitly.src/lib/components/landing/legal/Privacy.svelte#L116-L116: soften "your YouTube authorization is revoked with Google" to reflect that revocation is attempted, not guaranteed, or state the caveat for the rare failure case.src/routes/(app)/dashboard/+page.svelte#L123-L127: soften "revokes Moderaty's access to your YouTube channels" the same way, or note that a revocation failure is possible and independently reversible via Google's own security settings page (already described in Privacy.svelte §4.3).
📍 Affects 3 files
src/routes/(app)/dashboard/+page.server.ts#L93-L110(this comment)src/lib/components/landing/legal/Privacy.svelte#L116-L116src/routes/(app)/dashboard/+page.svelte#L123-L127
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/`(app)/dashboard/+page.server.ts around lines 93 - 110,
Best-effort YouTube token revocation is described as guaranteed despite failures
being logged and tokens deleted. In src/routes/(app)/dashboard/+page.server.ts
lines 93-110, update the deletion flow around revokeGoogleToken and
deleteUserRecords to either persist pending revocations for out-of-band retry or
explicitly accept the residual risk; in
src/lib/components/landing/legal/Privacy.svelte line 116 and
src/routes/(app)/dashboard/+page.svelte lines 123-127, soften the
customer-facing claims to state revocation is attempted and may fail, including
Google's security settings as the independent remedy where appropriate.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Best-effort revocation with no retry path — see consolidated comment.
Once a channel's revocation fails, the failure is logged and the encrypted token is erased in the same deletion transaction. There is no action_pending-style reconciliation to retry it later, unlike the pattern used elsewhere for YouTube writes (I3). This is a deliberate tradeoff per the code comments, but it means the user-facing claim that "authorization is revoked with Google" is not always true. See the consolidated comment covering this file, src/lib/components/landing/legal/Privacy.svelte, and src/routes/(app)/dashboard/+page.svelte.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/`(app)/dashboard/+page.server.ts around lines 93 - 110, Update the
account deletion flow around revokeGoogleToken and deleteUserRecords to add a
durable reconciliation path for failed YouTube token revocations, rather than
only logging the failure before deleting the encrypted token. Reuse the existing
action_pending-style mechanism and ensure failed channel revocations are
recorded for later retry while preserving immediate user-record deletion.
| // Consent-evidence retention sweep runs first, while the full budget | ||
| // remains: consent e-mails older than 10 years (CC Art. 205) are erased — | ||
| // the row stays as anonymized evidence. I8: a dry run changes nothing | ||
| // durable — the would-be sweep is only logged. A sweep failure must not | ||
| // stop scheduled moderation: log it loudly, report it, continue. | ||
| let consentEmailsNulled = 0; | ||
| let sweepError: string | null = null; | ||
| if (dryRun) { | ||
| console.info('dry run: retention purge skipped'); | ||
| console.info('dry run: consent e-mail retention sweep skipped'); | ||
| } else { | ||
| try { | ||
| purged = await purgeExpiredUser(); | ||
| consentEmailsNulled = await nullExpiredConsentEmails(); | ||
| } catch (cause) { | ||
| purgeError = cause instanceof Error ? cause.message : String(cause); | ||
| console.error('retention purge failed:', cause); | ||
| sweepError = cause instanceof Error ? cause.message : String(cause); | ||
| console.error('consent e-mail retention sweep failed:', cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect nullExpiredConsentEmails for batch bounding (LIMIT) and any deadline awareness.
fd -t f 'deletion.ts$' src/lib/server | xargs -I{} sh -c 'echo "== {} =="; rg -n -A15 "function nullExpiredConsentEmails|export.*nullExpiredConsentEmails" {}'Repository: Bonobo791/Moderaty
Length of output: 681
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '== cron handler =='
cat -n src/routes/api/cron/+server.ts | sed -n '1,150p'
printf '%s\n' '== deletion implementation =='
cat -n src/lib/server/deletion.ts | sed -n '1,150p'
printf '%s\n' '== sweep references and budget definitions =='
rg -n -C 4 'nullExpiredConsentEmails|CONSENT_SWEEP_BATCH|RUN_BUDGET_MS|deadline|runChannel' srcRepository: Bonobo791/Moderaty
Length of output: 50374
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '== consent schema and indexes =='
rg -n -C 8 'export const consents|createdAt|email|index\(' src/lib/server/db/schema.ts
printf '%s\n' '== database driver and query configuration =='
rg -n -C 6 'better-sqlite|sqlite|drizzle|busy_timeout|timeout' src/lib/server
printf '%s\n' '== deadline enforcement helper =='
rg -n -C 8 'function assertBeforeDeadline|class DeadlineExceededError|assertBeforeDeadline' src/lib/serverRepository: Bonobo791/Moderaty
Length of output: 41155
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
deletion = Path("src/lib/server/deletion.ts").read_text()
schema = Path("src/lib/server/db/schema.ts").read_text()
fn = deletion[deletion.index("export async function nullExpiredConsentEmails"):].split("\n}", 1)[0]
print("sweep_has_limit:", bool(re.search(r"\.limit\(\s*CONSENT_SWEEP_BATCH\s*\)", fn)))
print("sweep_batch:", re.search(r"const CONSENT_SWEEP_BATCH = (\d+)", deletion).group(1))
consents = schema[schema.index("export const consents"):].split("});", 1)[0]
print("consent_indexes:", re.findall(r"index\('([^']+)'\\)", consents))
print("has_created_at_or_email_index:", bool(re.search(r"index\([^)]*(createdAt|email)", consents)))
print("sweep_accepts_deadline:", bool(re.search(r"nullExpiredConsentEmails\s*\([^)]*deadline", deletion)))
PYRepository: Bonobo791/Moderaty
Length of output: 742
🏁 Script executed:
#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re
deletion = Path("src/lib/server/deletion.ts").read_text()
schema = Path("src/lib/server/db/schema.ts").read_text()
fn = deletion[deletion.index("export async function nullExpiredConsentEmails"):].split("\n}", 1)[0]
consents = schema[schema.index("export const consents"):].split("});", 1)[0]
print("sweep_has_limit:", bool(re.search(r"\.limit\(\s*CONSENT_SWEEP_BATCH\s*\)", fn)))
print("sweep_batch:", re.search(r"const CONSENT_SWEEP_BATCH = (\d+)", deletion).group(1))
print("consent_indexes:", re.findall(r"index\('([^']+)'\)", consents))
print("has_created_at_or_email_index:", bool(re.search(r"index\([^)]*(createdAt|email)", consents)))
print("sweep_accepts_deadline:", bool(re.search(r"nullExpiredConsentEmails\s*\([^)]*deadline", deletion)))
PYRepository: Bonobo791/Moderaty
Length of output: 303
Make the consent sweep deadline-aware. nullExpiredConsentEmails() limits the batch to 50 rows, but it does not receive or enforce deadline. The consents table has no index for createdAt or email, so the query can scan the full table before returning 50 rows. Add a suitable index and stop the sweep when the remaining RUN_BUDGET_MS is exhausted.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/routes/api/cron/`+server.ts around lines 60 - 76, The consent retention
sweep around nullExpiredConsentEmails must enforce the remaining RUN_BUDGET_MS
deadline instead of allowing an unbounded table scan. Add a suitable index for
the query’s createdAt/email filtering, pass the deadline or remaining budget
into nullExpiredConsentEmails, and stop or abort the sweep once the budget is
exhausted while preserving dry-run behavior and existing error reporting.
Source: Coding guidelines
…ackfill The backfill copied users.email verbatim, so a user deleted before the migration (tombstone sentinel '[deleted]') would get consents.email = '[deleted]' instead of NULL. NULLIF the sentinel away and scope the UPDATE to email IS NULL so the statement is idempotent. Reproducing test failed before the fix, passes after.
There was a problem hiding this comment.
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.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@drizzle/0009_consents_email.sql`:
- Line 29: Format the SQL statement by moving the WHERE predicate beginning with
consents.email onto a new line after the SET expression, preserving the query
logic and values unchanged so SQLFluff LT14 passes.
In `@src/lib/server/db/migration-0009.test.ts`:
- Around line 86-110: Extract the repeated users/consents schema and seed setup
from the existing migration fixture into a reusable helper near the setup at
lines 39–68, accepting the seed data needed by each test. Replace the inline
CREATE TABLE and INSERT block in the tombstone case with a call to that helper,
preserving its existing “gone” seed values and ensuring both tests use the same
schema.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 814101ca-0450-4736-8db8-465abca1f0d3
📒 Files selected for processing (2)
drizzle/0009_consents_email.sqlsrc/lib/server/db/migration-0009.test.ts
… the sweep, contract-drop deleted_at Audit against the sqlite-engineering skill: - Journal append-only: 0009/0010 are applied to prod since the hotfix, so the renumber violated immutable history. Restored both migrations, their snapshots, and their journal entries; the consent-email work is now a NEW migration 0011. - Unindexed sweep (field failure #3): the 10-year sweep's WHERE email IS NOT NULL AND created_at < cutoff scanned the whole table per cron tick. 0011 creates partial index consents_email_retention_idx; an EXPLAIN QUERY PLAN test proves SEARCH, not SCAN. - 0011 is also the CONTRACT phase for the abandoned soft delete: drops users.deleted_at and its index (no code path reads them in v2). - DEPLOY.md gains the post-migrate verification step (field failure #4): check the actual schema, never the drizzle-kit exit code.
There was a problem hiding this comment.
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.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@EXECUTION_PLAN_YouTube_Comment_Moderator.md`:
- Around line 1967-1973: The data-retention statement in the 0011
consent/deletion paragraph contradicts the documented consents.email retention
behavior. Update it to state that no operational table other than consents
retains a deleted user’s e-mail, while preserving the existing explanation of
the users.email tombstone wipe.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ac77ef70-a4bd-4914-8f12-986d92c1cc50
📒 Files selected for processing (9)
AGENTS.mdDEPLOY.mdEXECUTION_PLAN_YouTube_Comment_Moderator.mddrizzle/0011_consents_email.sqldrizzle/meta/0011_snapshot.jsondrizzle/meta/_journal.jsonsrc/lib/server/db/migration-0011.test.tssrc/lib/server/db/schema.tssrc/lib/server/testdb.ts
…keep it Terms-only
|
Question: review @codex review Answer: Findings[P1] Dashboard deletion tests do not create all tables used by
|
|
@CodeAnt-AI review |
- Plan doc: 'no live table' was wrong — consents IS a live table holding the retained e-mail (CodeRabbit #3697464718) - deleteUserRecords re-validates the user inside the transaction and throws loudly on a missing or already-tombstoned row instead of silently re-tombstoning (CodeAnt P2); failing tests first, then guard - ROADMAP outbox item now also covers the snapshot race: a channel connected between the revocation snapshot and the erase must not die unrevoked (CodeAnt P2 — durable fix stays roadmap-deferred) - dashboard.test.ts wipe list covers every table deleteUserRecords touches; sweep of all setupTestDb callers found no other gaps (CodeAnt P1 — claim of nonexistent tables was wrong: createTestDb creates all tables, the array is only the wipe list)
There was a problem hiding this comment.
Pull Request Overview
The PR successfully implements the transition from a soft-delete to an immediate erasure policy while maintaining statutory 10-year consent retention. However, the current Codacy analysis indicates the code is 'not up to standards' due to several issues.
A critical security risk was identified in the Google API helper regarding Server-Side Request Forgery (SSRF). Additionally, the account deletion process currently performs network revocations sequentially; if a user has multiple channels or if the Google API is slow, the entire database transaction may time out, leaving the account in an inconsistent state. Finally, the SQL migration script uses non-standard identifiers which should be corrected to ensure portability and compliance.
About this PR
- The account deletion process relies on multiple sequential external API calls (YouTube token revocation). While failures are handled gracefully, the cumulative latency poses a risk of execution timeouts, particularly in environments with strict limits like SvelteKit actions or Netlify functions.
Test suggestions
- Verify immediate erasure of all user-linked data across multiple tables (channels, sessions, rules, etc.) in a single transaction.
- Verify YouTube token revocation is attempted for all owned channels before data erasure.
- Ensure account deletion proceeds and logs a loud error if a specific YouTube token revocation fails.
- Verify that a tombstoned user's Google sub is freed, allowing a brand-new signup from the same identity.
- Test migration 0011 backfill: correctly copies email to consents and excludes existing tombstone sentinels.
- Verify the 10-year cron sweep correctly nulls emails on expired consent records while keeping the anonymized evidence.
- Confirm the retention sweep query utilizes the partial index (consents_email_retention_idx) rather than a table scan.
- Verify that active sessions for a deleted account are destroyed immediately upon detection during resolution.
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| let res: Response; | ||
| let text: string; | ||
| try { | ||
| res = await fetch(url, { |
There was a problem hiding this comment.
🔴 HIGH RISK
The variable 'url' is passed directly to fetch, creating a potential SSRF risk. Restrict the 'url' parameter to specific literal types (e.g., 'https://oauth2.googleapis.com/token' | 'https://oauth2.googleapis.com/revoke') or add a validation check to ensure it starts with the expected Google OAuth base URL.
| DROP INDEX `users_deleted_at_idx`;--> statement-breakpoint | ||
| ALTER TABLE `users` DROP COLUMN `deleted_at`;--> statement-breakpoint | ||
| ALTER TABLE `consents` ADD `email` text;--> statement-breakpoint | ||
| UPDATE `consents` | ||
| SET `email` = NULLIF( | ||
| (SELECT `email` FROM `users` WHERE `users`.`id` = `consents`.`user_id`), | ||
| '[deleted]' | ||
| ) | ||
| WHERE `consents`.`email` IS NULL;--> statement-breakpoint | ||
| CREATE INDEX `consents_email_retention_idx` ON `consents` (`created_at`) WHERE "consents"."email" is not null; |
There was a problem hiding this comment.
🔴 HIGH RISK
Standardize SQL identifiers by replacing backticks with double quotes (") to ensure ANSI compliance and satisfy static analysis.
| for (const ch of owned) { | ||
| try { | ||
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | ||
| } catch (cause) { | ||
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Suggestion: The token revocations are performed sequentially, which may cause a timeout for users with multiple channels. Additionally, using template literals in error logs can lead to log injection. Revoke tokens in parallel and use separate arguments for logging:
| for (const ch of owned) { | |
| try { | |
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | |
| } catch (cause) { | |
| console.error(`token revocation failed for channel ${ch.id}; deleting anyway:`, cause); | |
| } | |
| } | |
| await Promise.allSettled( | |
| owned.map(async (ch) => { | |
| try { | |
| await revokeGoogleToken(decrypt(ch.refreshTokenEnc), `account deletion channel ${ch.id}`); | |
| } catch (cause) { | |
| console.error('token revocation failed for channel %s; deleting anyway:', ch.id, cause); | |
| } | |
| }) | |
| ); |
There was a problem hiding this comment.
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.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/routes/api/cron/cron.test.ts (2)
120-135: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick winAdd coverage for the 50-row sweep limit.
This test creates only one expired row. It will pass if the
CONSENT_SWEEP_BATCHlimit is removed. Seed at least 51 expired rows, then assert that 50 e-mails are nulled and one remains.As per coding guidelines, “Tests must fail when the real logic is wrong; rewrite tests that pass when a function returns garbage.”
Suggested regression test
+ for (let i = 0; i < 51; i++) await seedConsent(`old-${i}`, oldDate); + const body = await res.json(); + expect(body).toMatchObject({ ok: true, consentEmailsNulled: 50 }); + const rows = await testDb().db.select().from(consents).all(); + expect(rows.filter((row) => row.email === null)).toHaveLength(50); + expect(rows.filter((row) => row.email !== null)).toHaveLength(1);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/api/cron/cron.test.ts` around lines 120 - 135, Update the consent-retention test around call and seedConsent to create at least 51 expired consent rows, while retaining a recent row if needed for existing coverage. Assert that the response reports exactly 50 nulled emails and verify that one expired row still has its email, proving the CONSENT_SWEEP_BATCH limit is enforced.Source: Coding guidelines
137-161: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winExecute the retry invocation.
The test drops
fail_consent_updateinfinallyand then only reads the unchanged row. It never callsGETagain. Add a second invocation after cleanup and assertconsentEmailsNulled: 1and a null e-mail.As per coding guidelines, “Tests must fail when the real logic is wrong; rewrite tests that pass when a function returns garbage.”
Suggested assertion
expect((await testDb().db.select().from(consents).all())[0].email).toBe('old@example.com'); + const retry = await call({ bearer: 'test-secret' }); + expect(await retry.json()).toMatchObject({ ok: true, consentEmailsNulled: 1, sweepError: null }); + expect((await testDb().db.select().from(consents).all())[0].email).toBeNull();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/routes/api/cron/cron.test.ts` around lines 137 - 161, Extend the test around call() so that after the finally block removes fail_consent_update, it invokes call({ bearer: 'test-secret' }) again and verifies the retry succeeds with consentEmailsNulled equal to 1. Assert that the consent row’s email is null after the second invocation, while preserving the existing first-invocation failure and channel-run assertions.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/landing/legal.test.ts`:
- Around line 249-263: Expand the FINALITY patterns in the test “states
post-window finality only in the Terms, never on consumer surfaces” to reject
equivalent post-window refund claims, including no refunds after seven days,
refunds unavailable after the window, and unused credits excluded from refunds.
Keep the existing consumer-surface assertions and Terms-of-Service expectations
unchanged.
In `@src/lib/landing/pricing-faq.ts`:
- Line 46: Update the refund wording in the pricing FAQ entry to use the
canonical complete phrase “no questions asked,” matching the wording in the
legal refund notice while preserving the rest of the copy.
---
Outside diff comments:
In `@src/routes/api/cron/cron.test.ts`:
- Around line 120-135: Update the consent-retention test around call and
seedConsent to create at least 51 expired consent rows, while retaining a recent
row if needed for existing coverage. Assert that the response reports exactly 50
nulled emails and verify that one expired row still has its email, proving the
CONSENT_SWEEP_BATCH limit is enforced.
- Around line 137-161: Extend the test around call() so that after the finally
block removes fail_consent_update, it invokes call({ bearer: 'test-secret' })
again and verifies the retry succeeds with consentEmailsNulled equal to 1.
Assert that the consent row’s email is null after the second invocation, while
preserving the existing first-invocation failure and channel-run assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: a5d6709d-0046-483c-b608-042f727f6ac5
📒 Files selected for processing (15)
EXECUTION_PLAN_YouTube_Comment_Moderator.mdROADMAP.mddrizzle/0011_consents_email.sqlsrc/lib/components/landing/PlanHosted.sveltesrc/lib/components/landing/legal/Privacy.sveltesrc/lib/landing/legal.test.tssrc/lib/landing/pricing-faq.tssrc/lib/server/deletion.test.tssrc/lib/server/deletion.tssrc/lib/server/google.tssrc/lib/server/legal.tssrc/lib/server/testdb.tssrc/routes/(app)/dashboard/+page.sveltesrc/routes/(app)/dashboard/dashboard.test.tssrc/routes/api/cron/cron.test.ts
…l refund phrase
- postGoogleForm's url param is now a literal union of the two Google
endpoints: no caller-controlled URL can reach fetch (Codacy SSRF)
- Pricing FAQ uses the canonical 'no questions asked' from the legal
refund notice (CodeRabbit #3699046785)
- Refund-finality guard test rejects equivalent post-window claims
('no refunds after…', 'unavailable after the window', 'unused credits
excluded') per the repo test-strength rule (CodeRabbit #3699046776)
|
There was a problem hiding this comment.
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.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/landing/legal.test.ts`:
- Line 259: Update the refund-policy regex near the existing post-window pattern
to also match wording where “after” or “outside” precedes “refunds,” followed by
unavailable or non-refundable language, including “After 7 days, refunds are
unavailable.” Ensure the negative assertion fails when the consumer surface
exposes this policy.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bcb51701-98c8-41cb-83b3-900f997c3740
📒 Files selected for processing (3)
src/lib/landing/legal.test.tssrc/lib/landing/pricing-faq.tssrc/lib/server/google.ts
| /not refunded/i, | ||
| /not refundable/i, | ||
| /no refunds?\b.*\bafter\b/i, | ||
| /refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Cover reversed post-window refund wording.
Line 259 does not match After 7 days, refunds are unavailable. A consumer surface with that policy would pass the negative assertion. Add a pattern for after or outside followed by refunds and an unavailable or non-refundable condition.
Proposed test coverage
+ /(?:after|outside)\b.*\b(?:no refunds?\b|refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b)/i,As per coding guidelines, tests must fail when the real logic is wrong; rewrite tests that pass when the implementation returns garbage.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i, | |
| /refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b.*\b(?:after|outside)\b/i, | |
| /(?:after|outside)\b.*\b(?:no refunds?\b|refunds?\b.*\b(?:not available|unavailable|not refundable|not refunded)\b)/i, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/landing/legal.test.ts` at line 259, Update the refund-policy regex
near the existing post-window pattern to also match wording where “after” or
“outside” precedes “refunds,” followed by unavailable or non-refundable
language, including “After 7 days, refunds are unavailable.” Ensure the negative
assertion fails when the consumer surface exposes this policy.
Source: Coding guidelines




User description
What
Replaces the 6-month soft-delete shipped in #37 with immediate deletion + statutory retention, per the LGPD deletion policy:
deleteAccountrevokes each owned channel's YouTube grant at Google (revokeGoogleToken, RFC 7009 — also a YouTube API ToS requirement), thendeleteUserRecords(src/lib/server/deletion.ts) erases in one transaction: moderation actions, comments, audit rows, rules, channels, sessions. A revocation failure is logged loudly per channel but never blocks deletion — the encrypted token is erased either way.google_sub = deleted:<id>, e-mail/display name[deleted]), freeing the real Google sub; signing back in is a brand-new signup through/consent, never a restore.consents(Option B). Migration0011_consents_email.sqladds nullableconsents.emailand backfills fromusers(NULLIFkeeps the[deleted]tombstone sentinel out of the evidence;WHERE email IS NULLmakes it idempotent); the consent action records it at every acceptance. The e-mail lives ONLY in the write-once consent log (LGPD Art. 16, III).nullExpiredConsentEmails, I8/I10) nullsconsents.emailon rows older than 10 years (CC Art. 205); the anonymized consent row is kept. The sweep's query shape is covered by the partialconsents_email_retention_idx(EXPLAIN QUERY PLAN evidence inmigration-0011.test.ts).users.deleted_atand its index — the soft-delete remnants from feat: account deletion with 6-month retention purge #37 that no v2 code path reads. Migrations 0009/0010 stay in the journal (applied history is immutable).LEGAL_VERSIONbump — transparency clarification, not a consent change. Dashboard notice + danger-zone copy updated; Privacy §2/§7.1 now state immediate deletion and the blocked, access-restricted, up-to-10-years consent record.Known gap (not a bug)
Accounts deleted before migration 0011 ships have already wiped
users.email; their consent rows keepemail = NULLafter backfill. Unrecoverable by design.Verification
npm run check— 0 errors;npm run build— green;npm run test— 270/270deletion.test.ts(full erase, tombstone, consent survival with e-mail, sub freeing, 10-year sweep),migration-0011.test.ts(column + NULLIF backfill + sentinel exclusion + partial-index EXPLAIN evidence on a pre-change db), dashboard deleteAccount tests (revocation with decrypted token, revoke-failure-still-deletes, transactional rollback), cron sweep tests (nulling, failure isolation, DRY_RUN), login/consent/session tests rewritten for immediate deletion.After merge: run
npm run db:migrate, then verify against the actual schema per DEPLOY.md §1 (PRAGMA table_info(consents)showsemail;PRAGMA table_info(users)shows nodeleted_at;__drizzle_migrationscount 12) — drizzle-kit has exited 0 without applying twice now.CodeAnt-AI Description
Replace delayed account deletion with immediate erasure and limited consent retention
What Changed
Impact
✅ Immediate account erasure✅ No deleted-account restore✅ Revoked YouTube access during deletion💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
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:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
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.