Skip to content

feat: multi-channel connect — account chooser, paginated channel list, picker flow - #68

Merged
Bonobo791 merged 1 commit into
mainfrom
feat-multi-channel-connect
Aug 4, 2026
Merged

feat: multi-channel connect — account chooser, paginated channel list, picker flow#68
Bonobo791 merged 1 commit into
mainfrom
feat-multi-channel-connect

Conversation

@Bonobo791

@Bonobo791 Bonobo791 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

User description

Closes #65. Backend half of multi-channel connect (picker UI polish is the frontend counterpart, #66 — this ships a minimal functional page so the flow works end-to-end).

Behavior

  • Account chooser: /api/auth/google now sends prompt=consent select_account, so connecting a channel under a different Google account no longer requires the browser to already be signed into it.
  • Paginated channel listing: the OAuth callback replaces items[0] with a full channels.list?mine=true walk — pageToken followed to a 10-page (500-channel) bound. I1/I2 hold: malformed items are skipped and counted loudly; a malformed response throws 502.
  • Picker branch: exactly 1 valid channel → the existing connect path, unchanged. >1 → the refresh token + candidate list are parked in an encrypted, httpOnly, state-keyed cookie (moderaty_channel_pick_pending, 10-minute TTL, bounded at 5 entries — the moderaty_consent_pending pattern) and the user is redirected to /connect-channel?state=.... The refresh token is never persisted until a channel is chosen.
  • /connect-channel: load returns only the {id, title} list (the token never reaches the browser); the action re-validates the session + admin role, rejects any channel id not in the parked list (400), runs the same conditional upsert (cross-team → 409, row untouched), consumes the parked state, and redirects to /dashboard.
  • The conditional upsert now lives in src/lib/server/channelConnect.ts (upsertChannelConnection) — one ownership guard shared by both the callback and the picker, no copy-paste.
  • Ops copy: netlify/functions/cron.mjs and DEPLOY.md §4 now state the per-channel cadence math (N channels ⇒ N minutes per channel at * * * * *).

Tests (written failing-first)

  • Multi-channel account → picker redirect, nothing written, token parked and readable.
  • Pagination: second request carries pageToken; both pages' channels reach the picker.
  • Malformed item skipped + counted loudly; the single valid channel short-circuits.
  • Picker: tampered/forged channel id → 400, nothing written; replayed state → 400; cross-team channel → 409 unchanged; signed-out → 401; member → 403; happy path encrypts the token and consumes the state; load output asserted to never contain the token.

Verification

npm run test 411/411 · npm run check 0 errors · npm run build clean.


CodeAnt-AI Description

Let users choose which YouTube channel to connect from a multi-channel Google account

What Changed

  • Google sign-in now lets users choose a different Google account when starting a channel connection
  • Accounts with multiple YouTube channels show a channel picker instead of connecting the first channel automatically
  • Channel discovery follows all available pages, skips malformed channel entries, and reports invalid YouTube responses clearly
  • The selected channel is connected only after confirmation; unselected channels and refresh tokens are not saved
  • Expired, forged, replayed, or unauthorized selections are rejected, while channels owned by another team remain unchanged
  • Connection scheduling documentation now explains how the number of channels affects scan frequency

Impact

✅ Choose the intended channel from multi-channel Google accounts
✅ Fewer accidental channel connections
✅ Clearer failures for expired or invalid selections

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

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

codeant-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 CodeAnt AI — Review Status

Status Commit Started (UTC) Finished (UTC)
✅ Reviewed your PR 377e8c6 Aug 04, 2026 · 16:07 16:11

@netlify

netlify Bot commented Aug 4, 2026

Copy link
Copy Markdown

Deploy Preview for moderaty ready!

Name Link
🔨 Latest commit 377e8c6
🔍 Latest deploy log https://app.netlify.com/projects/moderaty/deploys/6a720e4df2fd490008d753f1
😎 Deploy Preview https://deploy-preview-68--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: 90
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.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added support for connecting and selecting from multiple YouTube channels.
    • Added a secure channel selection page with validation and conflict handling.
    • Google sign-in now prompts users to choose an account.
  • Documentation
    • Updated cron deployment guidance to explain per-channel scan timing and scheduling recommendations.

Walkthrough

The Google OAuth flow now supports account selection and multiple YouTube channels. It retrieves paginated channels, parks multi-channel choices in encrypted cookies, and adds an authenticated picker that persists the selected channel. Cron documentation now describes per-channel scan cadence.

Changes

Multi-channel YouTube connection

Layer / File(s) Summary
Pending selection storage and channel upsert
src/lib/server/channelConnect.ts
Adds encrypted, state-keyed, short-lived cookie storage for pending channel selections. Adds conditional channel upsert logic that preserves cross-organization conflicts.
OAuth account selection and channel discovery
src/routes/api/auth/google/+server.ts, src/routes/api/auth/google/callback/+server.ts, src/routes/api/auth/google/callback/callback.test.ts, src/routes/api/auth/google/oauth.test.ts
Adds account selection, paginated channel retrieval, malformed-item handling, multi-channel picker redirects, and single-channel persistence.
Channel picker route and interface
src/routes/connect-channel/..., src/routes/connect-channel/connect-channel.test.ts
Adds admin-only loading and submission, state and channel validation, conflict handling, one-time state consumption, and the channel selection form.
Multi-channel cron cadence documentation
DEPLOY.md, netlify/functions/cron.mjs
Documents least-recently-run channel processing and per-channel cadence for multiple connected channels.

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

Possibly related issues

  • #65 — Directly covers the account chooser, paginated channel discovery, encrypted pending selection, picker action, and cadence documentation.
  • #66 — Covers the /connect-channel picker page and its backend integration.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The new picker UI component is frontend work explicitly assigned to separate issue #66, beyond the backend scope of issue #65. Move the picker UI component to issue #66, or explicitly include and link the frontend scope in this pull request.
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the multi-channel connection, account chooser, pagination, and picker flow changes.
Description check ✅ Passed The description directly explains the multi-channel OAuth, picker, validation, persistence, testing, and cadence changes.
Linked Issues check ✅ Passed The changes satisfy the coding objectives in issue #65, including pagination, secure picker state, validation, delayed persistence, and cadence documentation.
✨ 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-multi-channel-connect

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

❤️ Share

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

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

codeant-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Sequence Diagram

This PR adds account selection and paginated channel discovery to Google OAuth. Accounts with multiple channels park the encrypted token and candidates until the user selects one, while single channel accounts connect immediately.

sequenceDiagram
    participant User
    participant Google
    participant Backend
    participant PendingCookie
    participant Database

    User->>Google: Start connect with account chooser
    Google->>Backend: OAuth callback with authorization code
    Backend->>Google: Exchange code and list all channels
    Google-->>Backend: Refresh token and channel candidates

    alt One valid channel
        Backend->>Database: Connect channel and store encrypted token
        Backend-->>User: Redirect to dashboard
    else Multiple valid channels
        Backend->>PendingCookie: Park token and candidates
        Backend-->>User: Redirect to channel picker
        User->>Backend: Submit selected channel
        Backend->>PendingCookie: Read and consume selected state
        Backend->>Database: Connect selected channel and store encrypted token
        Backend-->>User: Redirect to dashboard
    end
Loading

Generated by CodeAnt AI

@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@codeant-ai

codeant-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

🏁 CodeAnt Quality Gate Results

Commit: 377e8c6f
Scan Time: 2026-08-04 16:11:08 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 No IAC issues

View Full Results

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR implements multi-channel connect functionality with proper security controls. The implementation follows good security practices with encrypted httpOnly cookies, state-keyed storage, and bounded entries.

Critical Issue Found

One logic error in the channel picker action handler that must be fixed before merge:

  • Type assertion safety: Line 74 in connect-channel/+page.server.ts uses unsafe type assertion that could prevent proper cleanup of sensitive tokens

Implementation Quality

The core implementation is solid with good error handling, pagination support, and comprehensive test coverage. The refactoring to extract upsertChannelConnection into a shared utility is a good design choice that prevents code duplication.


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.


// Consumed on success only: a transient failure leaves the pick
// retryable while a success cannot be replayed.
clearPendingChannelPick(cookies, state as string);

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.

🛑 Logic Error: The type assertion state as string on line 74 is unsafe since state can be null from line 53. If state is null, this will pass "null" (the string) to clearPendingChannelPick, preventing proper cleanup and potentially leaving sensitive refresh tokens in the cookie.

Suggested change
clearPendingChannelPick(cookies, state as string);
clearPendingChannelPick(cookies, state!);

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🔴 Metrics 78 complexity · 5 duplication

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

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@Bonobo791
Bonobo791 merged commit 832dd3b into main Aug 4, 2026
15 of 20 checks passed
@Bonobo791
Bonobo791 deleted the feat-multi-channel-connect branch August 4, 2026 16:10
@codeant-ai

codeant-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to commit 377e8c6
CategorySuggestion                                                                                                                                    SeverityGenerated at (UTC)
Possible bug
Parking hundreds of channels and a token in one cookie exceeds browser cookie limits

The pending cookie stores the refresh token plus every channel returned by a listing
capped at 500 channels. That payload is far larger than the approximately 4 KB
browser cookie limit, and encryption adds further overhead, so browsers will reject
or truncate the Set-Cookie value for sufficiently large accounts and the picker will
immediately report an expired or missing selection. Store the grant and candidate
list server-side and put only an opaque state key in the cookie, or enforce a
payload size limit before parking it.

src/routes/api/auth/google/callback/+server.ts [136]

Why it matters? 🤔
  • ❌ Large multi-channel accounts cannot complete connection.
  • ⚠️ Picker load reports an expired selection.

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/callback/+server.ts
**Line:** 136:136
**Comment:**
	*Possible Bug: The pending cookie stores the refresh token plus every channel returned by a listing capped at 500 channels. That payload is far larger than the approximately 4 KB browser cookie limit, and encryption adds further overhead, so browsers will reject or truncate the `Set-Cookie` value for sufficiently large accounts and the picker will immediately report an expired or missing selection. Store the grant and candidate list server-side and put only an opaque state key in the cookie, or enforce a payload size limit before parking it.

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-04 16:10
Race condition
Concurrent submissions can consume the same pending grant more than once

Two concurrent POST requests can both read the same still-valid pending entry,
connect different channels, and only then clear the cookie. Because consumption is
not an atomic claim or compare-and-delete operation, one OAuth grant can result in
multiple channels being connected. Claim and consume the pending state atomically
before performing the upsert, or use durable server-side state with one-time
consumption.

src/routes/connect-channel/+page.server.ts [68-74]

Why it matters? 🤔
  • ❌ One picker grant can connect multiple channels.
  • ⚠️ Duplicate submissions violate the one-channel flow.

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/connect-channel/+page.server.ts
**Line:** 68:74
**Comment:**
	*Race Condition: Two concurrent POST requests can both read the same still-valid pending entry, connect different channels, and only then clear the cookie. Because consumption is not an atomic claim or compare-and-delete operation, one OAuth grant can result in multiple channels being connected. Claim and consume the pending state atomically before performing the upsert, or use durable server-side state with one-time consumption.

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-04 16:10
Api mismatch
Large multi-channel selections exceed browser cookie limits and make the picker flow unusable

The entire candidate list is serialized into a single browser cookie. With up to 500
channels, the encrypted and base64-encoded payload will exceed common 4 KB cookie
limits, causing the Set-Cookie to be rejected or truncated and making the picker
fail immediately after OAuth. Store the pending token and candidates server-side, or
limit and validate the serialized payload before writing the cookie.

src/lib/server/channelConnect.ts [93-97]

Why it matters? 🤔
  • ❌ Large Google accounts cannot complete channel selection.
  • ⚠️ /connect-channel receives no usable pending candidate list.
  • ⚠️ OAuth must be restarted after the picker cookie fails.

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/channelConnect.ts
**Line:** 93:97
**Comment:**
	*Api Mismatch: The entire candidate list is serialized into a single browser cookie. With up to 500 channels, the encrypted and base64-encoded payload will exceed common 4 KB cookie limits, causing the `Set-Cookie` to be rejected or truncated and making the picker fail immediately after OAuth. Store the pending token and candidates server-side, or limit and validate the serialized payload before writing the cookie.

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-04 16:10

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Multi-channel connect: account chooser, paginated listing, picker flow

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds select_account to the Google OAuth prompt so users can connect a channel under a different
 Google account without re-authenticating.
• Replaces the single-channel items[0] lookup in the OAuth callback with a full paginated
 channels.list walk (bounded at 10 pages / 500 channels), skipping malformed items loudly and
 throwing 502 on malformed responses.
• Adds a picker flow: accounts with >1 channel park the refresh token + candidates in an encrypted,
 state-keyed, TTL-bound cookie and redirect to a new /connect-channel page, which never exposes the
 token to the browser.
• Extracts the conditional channel upsert (cross-team ownership guard) into a shared
 upsertChannelConnection helper used by both the callback and the picker action.
• Updates cron.mjs comments and DEPLOY.md to document the per-channel scan cadence formula (N
 channels ⇒ N minutes at * * * * *).
• Adds extensive failing-first tests for pagination, malformed-item handling, picker
 tampering/replay/cross-team/auth guards, and token-never-persisted invariants.
Diagram

sequenceDiagram
    actor User
    participant Google as Google OAuth
    participant Callback as "OAuth callback"
    participant Cookie as "Pick cookie"
    participant Picker as "/connect-channel"
    participant DB as Channels DB

    User->>Google: authorize (prompt=consent select_account)
    Google-->>Callback: code + state
    Callback->>Google: exchange code, list channels (paginated)
    alt exactly 1 channel
        Callback->>DB: upsertChannelConnection()
        Callback-->>User: redirect /dashboard
    else multiple channels
        Callback->>Cookie: parkPendingChannelPick(token, channels)
        Callback-->>User: redirect /connect-channel?state
        User->>Picker: load candidates (no token)
        Picker->>Cookie: readPendingChannelPick()
        User->>Picker: submit chosen channel
        Picker->>DB: upsertChannelConnection()
        Picker->>Cookie: clearPendingChannelPick()
        Picker-->>User: redirect /dashboard
    end
Loading
High-Level Assessment

The approach — parking the refresh token in an encrypted, state-keyed, TTL-bound httpOnly cookie rather than a server-side session/DB row — mirrors the existing moderaty_consent_pending pattern already established and reviewed in this codebase, keeping the app stateless without a temporary-storage table. This is consistent with prior architectural decisions and avoids introducing a new persistence mechanism for a short-lived, low-volume flow (bounded at 5 entries, 10-minute TTL). No meaningfully better alternative stands out for this scale.

Files changed (10) +670 / -73

Enhancement (5) +401 / -57
channelConnect.tsNew shared module for picker cookie + conditional channel upsert +157/-0

New shared module for picker cookie + conditional channel upsert

• Adds parkPendingChannelPick/readPendingChannelPick/clearPendingChannelPick to manage an encrypted, state-keyed, TTL-bound, size-bounded cookie holding the pending refresh token and candidate channels, and extracts upsertChannelConnection as the single shared conditional upsert with cross-team ownership guard.

src/lib/server/channelConnect.ts

+server.tsAdd select_account to Google OAuth prompt +4/-1

Add select_account to Google OAuth prompt

• Changes the prompt parameter from 'consent' to 'consent select_account' so users can pick a different Google account without the browser already being signed into it.

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

+server.tsPaginate channel listing and branch to picker for multi-channel accounts +84/-56

Paginate channel listing and branch to picker for multi-channel accounts

• Replaces the single-item channel lookup with a bounded, paginated channels.list walk that skips malformed items and throws 502 on malformed responses; routes accounts with exactly one channel through the existing connect path and accounts with multiple channels to the new picker via a parked cookie.

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

+page.server.tsNew picker page load/action for choosing a parked channel +77/-0

New picker page load/action for choosing a parked channel

• Adds a load function that validates the session, admin role, and state, returning only {id, title} channel candidates without ever exposing the refresh token; adds an action that re-validates the choice against the parked list, runs the shared conditional upsert, and consumes the parked state on success.

src/routes/connect-channel/+page.server.ts

+page.svelteNew minimal channel picker UI +79/-0

New minimal channel picker UI

• Adds a functional radio-button form listing candidate channels with a submit button to complete the multi-channel connect flow end-to-end.

src/routes/connect-channel/+page.svelte

Tests (3) +260 / -10
callback.test.tsAdd tests for pagination, malformed items, and picker redirect +101/-9

Add tests for pagination, malformed items, and picker redirect

• Adds tests covering pagination page-token propagation, malformed item skipping with logged counts, and the multi-channel picker redirect that writes nothing to the DB and parks the refresh token.

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

oauth.test.tsUpdate prompt assertion for select_account +1/-1

Update prompt assertion for select_account

• Updates the expected prompt query parameter value to 'consent select_account' to match the new account-chooser behavior.

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

connect-channel.test.tsAdd tests for the picker load and action +158/-0

Add tests for the picker load and action

• Adds failing-first tests covering missing/expired state, token never leaking to load output, auth/role guards, tampered/forged channel ids, replayed state, cross-team conflicts, and the happy path that encrypts the token and consumes the parked state.

src/routes/connect-channel/connect-channel.test.ts

Documentation (2) +9 / -6
DEPLOY.mdDocument per-channel cron cadence math +4/-2

Document per-channel cron cadence math

• Clarifies that with N connected channels, the per-channel scan cadence is N minutes at the current * * * * * schedule, and advises raising frequency as N grows.

DEPLOY.md

cron.mjsUpdate cron comment to reflect multi-channel cadence +5/-4

Update cron comment to reflect multi-channel cadence

• Rewrites the schedule comment to describe the least-recently-run-first, one-channel-per-invocation behavior and its N-minute cadence implication now that multiple channels can be connected.

netlify/functions/cron.mjs

@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: 6

🤖 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 `@DEPLOY.md`:
- Around line 79-82: Align the schedule documentation at DEPLOY.md lines 79-82
and netlify/functions/cron.mjs lines 60-64: explain that */15 * * * * invokes
the cron every 15 minutes, changing per-channel scan cadence from N to 15 × N
minutes and increasing latency as intentional load shedding, or retain the
one-minute schedule when cadence is the priority. Update both sites
consistently; no code change is required.

In `@src/lib/server/channelConnect.ts`:
- Around line 91-98: Update parkPendingChannelPick to bound each pending pick
before serialization: cap payload.channels to a reasonable maximum candidate
count and truncate each channel title to a fixed maximum length, while
preserving the existing state/timestamp, TTL filtering, and MAX_PENDING_PICKS
limits. Apply the bounds before entries are passed to writeEntries so the
encrypted cookie remains within browser size limits.

In `@src/routes/api/auth/google/callback/`+server.ts:
- Around line 41-95: Extract the per-page YouTube request and response parsing
from fetchOwnedChannels into a dedicated helper, preserving its existing
validation, error responses, and nextPageToken behavior. Keep fetchOwnedChannels
focused on pagination, accumulating valid channels, counting skipped malformed
items, and enforcing MAX_CHANNEL_PAGES; update it to consume the helper’s page
result without changing observable behavior.

In `@src/routes/api/auth/google/callback/callback.test.ts`:
- Around line 191-210: Add a test alongside the existing pagination test that
stubs every channel-list response with a nextPageToken, invokes captureCallback,
and verifies exactly 10 requests occur. Spy on console.error during the test and
assert the truncation message mentions the 10-page bound, restoring the spy
afterward.

In `@src/routes/connect-channel/`+page.svelte:
- Around line 29-51: Update the connect-channel form to use SvelteKit
enhancement with a submitting flag, disabling the “Connect selected channel”
button while the action is running. Add the established EmptyState component
import used by other routes and render it when data.channels.length is zero
instead of the empty fieldset/form content. Preserve the existing form.error
alert and populated channel-selection flow.

In `@src/routes/connect-channel/connect-channel.test.ts`:
- Around line 99-116: Add authorization coverage for the load path using
loadWith: verify a signed-out request rejects with status 401 and a member
request rejects with status 403. Keep the existing parked-channel success test,
and ensure these tests exercise the requireOrgRole gate in the page load
implementation.
🪄 Autofix

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: b06d6b88-3de8-4160-b1c2-9d8b4a81b122

📥 Commits

Reviewing files that changed from the base of the PR and between fb89349 and 377e8c6.

📒 Files selected for processing (10)
  • DEPLOY.md
  • netlify/functions/cron.mjs
  • src/lib/server/channelConnect.ts
  • src/routes/api/auth/google/+server.ts
  • src/routes/api/auth/google/callback/+server.ts
  • src/routes/api/auth/google/callback/callback.test.ts
  • src/routes/api/auth/google/oauth.test.ts
  • src/routes/connect-channel/+page.server.ts
  • src/routes/connect-channel/+page.svelte
  • src/routes/connect-channel/connect-channel.test.ts

Comment thread DEPLOY.md
Comment on lines +79 to +82
one channel (least-recently-run first), so with N connected channels the
per-channel scan cadence is N minutes at `* * * * *` (e.g. 5 channels ⇒ each
scanned every 5 minutes). Raise the schedule frequency if N × interval grows
past an acceptable cadence. A failed run throws and appears as a failed

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Align the cadence guidance across both files.

*/15 * * * * reduces invocations from once per minute to once per 15 minutes. With one channel per invocation, per-channel cadence changes from N minutes to 15 × N minutes. Document the latency trade-off as intentional load shedding, or keep the one-minute schedule when scan cadence is the priority.

  • DEPLOY.md#L79-L82: replace “raise the schedule frequency” with precise interval guidance and state the 15 × N effect.
  • netlify/functions/cron.mjs#L60-L64: clarify that */15 * * * * lowers invocation frequency and increases scan latency.
📍 Affects 2 files
  • DEPLOY.md#L79-L82 (this comment)
  • netlify/functions/cron.mjs#L60-L64
🤖 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 `@DEPLOY.md` around lines 79 - 82, Align the schedule documentation at
DEPLOY.md lines 79-82 and netlify/functions/cron.mjs lines 60-64: explain that
*/15 * * * * invokes the cron every 15 minutes, changing per-channel scan
cadence from N to 15 × N minutes and increasing latency as intentional load
shedding, or retain the one-minute schedule when cadence is the priority. Update
both sites consistently; no code change is required.

Comment on lines +91 to +98
export function parkPendingChannelPick(cookies: Cookies, state: string, payload: PendingChannelPick): void {
const now = Date.now();
const entries = readEntries(cookies).filter(
(e) => e.state !== state && now - e.ts <= PICK_TTL_MS
);
entries.push({ ...payload, state, ts: now });
writeEntries(cookies, entries.slice(-MAX_PENDING_PICKS));
}

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

Bound the parked channel list to keep the cookie under the browser size limit.

payload.channels arrives from fetchOwnedChannels in src/routes/api/auth/google/callback/+server.ts (line 136), which allows up to 10 pages × 50 items = 500 channels with upstream-controlled titles. Up to MAX_PENDING_PICKS = 5 such entries are serialized into one cookie. A single cookie is limited to about 4096 bytes, and encryption plus base64 expands the payload further.

cookies.set does not report failure. The browser silently drops the oversized cookie, so readPendingChannelPick returns null and the picker load throws 400 "this channel selection expired". Large accounts can then never connect a channel.

Cap the parked candidates and the stored title length.

🛠️ Proposed fix to bound the parked payload
 const MAX_PENDING_PICKS = 5;
+// Keeps the encrypted cookie inside the ~4096-byte per-cookie browser limit.
+const MAX_PICK_CHANNELS = 25;
+const MAX_PICK_TITLE_LEN = 60;
 export function parkPendingChannelPick(cookies: Cookies, state: string, payload: PendingChannelPick): void {
 	const now = Date.now();
 	const entries = readEntries(cookies).filter(
 		(e) => e.state !== state && now - e.ts <= PICK_TTL_MS
 	);
-	entries.push({ ...payload, state, ts: now });
+	const channels = payload.channels
+		.slice(0, MAX_PICK_CHANNELS)
+		.map((c) => ({ id: c.id, title: c.title.slice(0, MAX_PICK_TITLE_LEN) }));
+	entries.push({ ...payload, channels, state, ts: now });
 	writeEntries(cookies, entries.slice(-MAX_PENDING_PICKS));
 }
📝 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
export function parkPendingChannelPick(cookies: Cookies, state: string, payload: PendingChannelPick): void {
const now = Date.now();
const entries = readEntries(cookies).filter(
(e) => e.state !== state && now - e.ts <= PICK_TTL_MS
);
entries.push({ ...payload, state, ts: now });
writeEntries(cookies, entries.slice(-MAX_PENDING_PICKS));
}
const MAX_PENDING_PICKS = 5;
// Keeps the encrypted cookie inside the ~4096-byte per-cookie browser limit.
const MAX_PICK_CHANNELS = 25;
const MAX_PICK_TITLE_LEN = 60;
export function parkPendingChannelPick(cookies: Cookies, state: string, payload: PendingChannelPick): void {
const now = Date.now();
const entries = readEntries(cookies).filter(
(e) => e.state !== state && now - e.ts <= PICK_TTL_MS
);
const channels = payload.channels
.slice(0, MAX_PICK_CHANNELS)
.map((c) => ({ id: c.id, title: c.title.slice(0, MAX_PICK_TITLE_LEN) }));
entries.push({ ...payload, channels, state, ts: now });
writeEntries(cookies, entries.slice(-MAX_PENDING_PICKS));
}
🤖 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/channelConnect.ts` around lines 91 - 98, Update
parkPendingChannelPick to bound each pending pick before serialization: cap
payload.channels to a reasonable maximum candidate count and truncate each
channel title to a fixed maximum length, while preserving the existing
state/timestamp, TTL filtering, and MAX_PENDING_PICKS limits. Apply the bounds
before entries are passed to writeEntries so the encrypted cookie remains within
browser size limits.

Comment on lines +41 to +95
async function fetchOwnedChannels(accessToken: string): Promise<ListedChannel[]> {
const found: ListedChannel[] = [];
let skipped = 0;
let pageToken: string | undefined;
for (let page = 0; page < MAX_CHANNEL_PAGES; page++) {
const endpoint = new URL('https://www.googleapis.com/youtube/v3/channels');
endpoint.searchParams.set('part', 'snippet');
endpoint.searchParams.set('mine', 'true');
endpoint.searchParams.set('maxResults', '50');
if (pageToken) endpoint.searchParams.set('pageToken', pageToken);

const chRes = await fetchWithRetry(endpoint.toString(), {
headers: { Authorization: `Bearer ${accessToken}` }
});
const chText = await chRes.text();
if (!chRes.ok) {
console.error(`youtube channels lookup failed: ${chRes.status}`);
throw error(502, 'YouTube channel lookup failed — please retry');
}
let chData: { items?: unknown; nextPageToken?: unknown };
try {
chData = JSON.parse(chText) as typeof chData;
} catch {
console.error(`youtube channels lookup returned invalid JSON: ${chRes.status}`);
throw error(502, 'invalid response from YouTube — please retry');
}
if (typeof chData !== 'object' || chData === null) {
console.error(`youtube channels lookup returned a non-object body: ${chRes.status}`);
throw error(502, 'invalid response from YouTube — please retry');
}

const items = Array.isArray(chData.items) ? chData.items : [];
for (const item of items as Array<{ id?: unknown; snippet?: { title?: unknown } }>) {
if (typeof item?.id === 'string' && item.id) {
found.push({
id: item.id,
title: typeof item.snippet?.title === 'string' ? item.snippet.title : 'Untitled channel'
});
} else {
skipped++;
}
}

pageToken =
typeof chData.nextPageToken === 'string' && chData.nextPageToken ? chData.nextPageToken : undefined;
if (!pageToken) return finish(found, skipped);
}
console.error(`youtube channels lookup hit the ${MAX_CHANNEL_PAGES}-page bound — listing truncated`);
return finish(found, skipped);
}

function finish(found: ListedChannel[], skipped: number): ListedChannel[] {
if (skipped > 0) console.error(`youtube channels lookup skipped ${skipped} malformed item(s)`);
return found;
}

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

Extract the per-page fetch and parse to clear the complexity gate.

SonarCloud fails this function at cognitive complexity 27 against a limit of 15. The validation logic is correct; the page loop just carries transport, parsing, and item validation together. Move one page into its own helper and keep fetchOwnedChannels as the paging loop.

♻️ Proposed extraction
+type ChannelPage = { items: unknown[]; nextPageToken: string | undefined };
+
+async function fetchChannelPage(accessToken: string, pageToken?: string): Promise<ChannelPage> {
+	const endpoint = new URL('https://www.googleapis.com/youtube/v3/channels');
+	endpoint.searchParams.set('part', 'snippet');
+	endpoint.searchParams.set('mine', 'true');
+	endpoint.searchParams.set('maxResults', '50');
+	if (pageToken) endpoint.searchParams.set('pageToken', pageToken);
+
+	const chRes = await fetchWithRetry(endpoint.toString(), {
+		headers: { Authorization: `Bearer ${accessToken}` }
+	});
+	const chText = await chRes.text();
+	if (!chRes.ok) {
+		console.error(`youtube channels lookup failed: ${chRes.status}`);
+		throw error(502, 'YouTube channel lookup failed — please retry');
+	}
+	let chData: unknown;
+	try {
+		chData = JSON.parse(chText);
+	} catch {
+		console.error(`youtube channels lookup returned invalid JSON: ${chRes.status}`);
+		throw error(502, 'invalid response from YouTube — please retry');
+	}
+	if (typeof chData !== 'object' || chData === null) {
+		console.error(`youtube channels lookup returned a non-object body: ${chRes.status}`);
+		throw error(502, 'invalid response from YouTube — please retry');
+	}
+	const body = chData as { items?: unknown; nextPageToken?: unknown };
+	return {
+		items: Array.isArray(body.items) ? body.items : [],
+		nextPageToken:
+			typeof body.nextPageToken === 'string' && body.nextPageToken ? body.nextPageToken : undefined
+	};
+}

fetchOwnedChannels then keeps only the loop, the per-item validation, and the page bound.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[failure] 41-41: Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Bonobo791_Moderaty&issues=AZ_NiGa1IltA0Jkwtyvs&open=AZ_NiGa1IltA0Jkwtyvs&pullRequest=68

🤖 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/callback/`+server.ts around lines 41 - 95, Extract
the per-page YouTube request and response parsing from fetchOwnedChannels into a
dedicated helper, preserving its existing validation, error responses, and
nextPageToken behavior. Keep fetchOwnedChannels focused on pagination,
accumulating valid channels, counting skipped malformed items, and enforcing
MAX_CHANNEL_PAGES; update it to consume the helper’s page result without
changing observable behavior.

Source: Linters/SAST tools

Comment on lines +191 to +210
test('the channel listing paginates and every valid channel reaches the picker', async () => {
const seenPageTokens: (string | null)[] = [];
stubTokenAndChannels((url) => {
seenPageTokens.push(url.searchParams.get('pageToken'));
if (!url.searchParams.get('pageToken')) {
return new Response(
JSON.stringify({ items: [{ id: 'UC1', snippet: { title: 'One' } }], nextPageToken: 'p2' }),
{ status: 200 }
);
}
return new Response(JSON.stringify({ items: [{ id: 'UC2', snippet: { title: 'Two' } }] }), { status: 200 });
});
const cookies = makeCookiesWithState('s');

const { thrown } = await captureCallback(OWNER, cookies);

expect(seenPageTokens).toEqual([null, 'p2']);
expect(thrown).toMatchObject({ status: 302, location: '/connect-channel?state=s' });
expect(readPendingChannelPick(cookies as never, 's')?.channels).toHaveLength(2);
});

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

Add a test for the page bound.

The pagination test covers two pages. The MAX_CHANNEL_PAGES truncation branch in src/routes/api/auth/google/callback/+server.ts (line 88) has no coverage. A stub that always returns a nextPageToken proves the loop stops at 10 requests and logs the truncation, which protects the serverless time budget.

💚 Proposed test
test('the channel listing stops at the page bound and logs the truncation', async () => {
	const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
	let requests = 0;
	stubTokenAndChannels(() => {
		requests++;
		return new Response(
			JSON.stringify({ items: [{ id: `UC${requests}`, snippet: { title: `C${requests}` } }], nextPageToken: 'more' }),
			{ status: 200 }
		);
	});

	await captureCallback();

	expect(requests).toBe(10);
	expect(errSpy.mock.calls.flat().join(' ')).toMatch(/10-page bound/);
	errSpy.mockRestore();
});
🤖 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/callback/callback.test.ts` around lines 191 - 210,
Add a test alongside the existing pagination test that stubs every channel-list
response with a nextPageToken, invokes captureCallback, and verifies exactly 10
requests occur. Spy on console.error during the test and assert the truncation
message mentions the 10-page bound, restoring the spy afterward.

Comment on lines +29 to +51
<main class="pick-main">
<div class="card pick-card">
<h1>Choose a channel</h1>
<p class="muted">This Google account owns several YouTube channels. Pick the one Moderaty should moderate.</p>

{#if form?.error}
<p class="error-box" role="alert">{form.error}</p>
{/if}

<form method="POST">
<fieldset>
<legend>Your channels</legend>
{#each data.channels as channel (channel.id)}
<label class="check">
<input type="radio" name="channel" value={channel.id} required />
<span>{channel.title}</span>
</label>
{/each}
</fieldset>
<button class="btn" type="submit">Connect selected channel</button>
</form>
</div>
</main>

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

Add the loading and empty states required by I12.

This page has the error state (line 35) and the populated state (lines 41-46). It has no submitting state and no empty state. A slow upsertChannelConnection leaves the submit button with no feedback, and a parked list with no candidates renders an empty fieldset with a submit button.

Add use:enhance with a submitting flag, disable the button while the action runs, and render EmptyState when data.channels.length === 0.

🎛️ Proposed states
 <script lang="ts">
+	import { enhance } from '$app/forms';
+	import EmptyState from '$lib/components/EmptyState.svelte';
+
 	let { data, form } = $props();
+	let submitting = $state(false);
 </script>
-		<form method="POST">
+		{`#if` data.channels.length === 0}
+			<EmptyState message="No channels are available to connect. Reconnect the channel from the dashboard." />
+		{:else}
+		<form
+			method="POST"
+			use:enhance={() => {
+				submitting = true;
+				return async ({ update }) => {
+					await update();
+					submitting = false;
+				};
+			}}
+		>
 			<fieldset>
 				<legend>Your channels</legend>
 				{`#each` data.channels as channel (channel.id)}
 					<label class="check">
 						<input type="radio" name="channel" value={channel.id} required />
 						<span>{channel.title}</span>
 					</label>
 				{/each}
 			</fieldset>
-			<button class="btn" type="submit">Connect selected channel</button>
+			<button class="btn" type="submit" disabled={submitting}>
+				{submitting ? 'Connecting channel…' : 'Connect selected channel'}
+			</button>
 		</form>
+		{/if}

Confirm the EmptyState import path used by the other routes.

As per coding guidelines: "Invariant I12: Every page has all four states — loading (skeleton), empty (EmptyState component), error (.error-box), and populated."
🤖 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/connect-channel/`+page.svelte around lines 29 - 51, Update the
connect-channel form to use SvelteKit enhancement with a submitting flag,
disabling the “Connect selected channel” button while the action is running. Add
the established EmptyState component import used by other routes and render it
when data.channels.length is zero instead of the empty fieldset/form content.
Preserve the existing form.error alert and populated channel-selection flow.

Source: Coding guidelines

Comment on lines +99 to +116
test('load returns the parked channels without ever exposing the refresh token', async () => {
const data = (await loadWith(cookiesWithPick())) as { channels: unknown };

expect(data.channels).toEqual(PICK.channels);
expect(JSON.stringify(data)).not.toContain('refresh-token');
});

test('a signed-out picker POST is rejected before any write', async () => {
const res = await captureAction(cookiesWithPick(), 'UC1', 's', null);
expect(res).toMatchObject({ status: 401 });
expect(await testDb().db.select().from(channels).all()).toHaveLength(0);
});

test('a member cannot complete the picker — 403 before any write', async () => {
const res = await captureAction(cookiesWithPick(), 'UC1', 's', { ...OWNER, orgRole: 'member' });
expect(res).toMatchObject({ status: 403 });
expect(await testDb().db.select().from(channels).all()).toHaveLength(0);
});

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

Cover the load authorization gate.

The action gate has tests for 401 and 403 (lines 106-116). The load gate at src/routes/connect-channel/+page.server.ts lines 34-35 has none, so a removed requireOrgRole there would still pass the suite while exposing the parked channel list to a member.

💚 Proposed tests
test('load rejects a signed-out request with 401', async () => {
	await expect(loadWith(cookiesWithPick(), 's', null)).rejects.toMatchObject({ status: 401 });
});

test('load rejects a member with 403', async () => {
	await expect(
		loadWith(cookiesWithPick(), 's', { ...OWNER, orgRole: 'member' })
	).rejects.toMatchObject({ status: 403 });
});
🤖 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/connect-channel/connect-channel.test.ts` around lines 99 - 116,
Add authorization coverage for the load path using loadWith: verify a signed-out
request rejects with status 401 and a member request rejects with status 403.
Keep the existing parked-channel success test, and ensure these tests exercise
the requireOrgRole gate in the page load implementation.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Context used
✅ Compliance rules (platform): 78 rules

Grey Divider


Action required

1. Oversized pending-pick cookie 🐞 Bug ☼ Reliability
Description
The multi-channel flow serializes the entire owned-channel list into a single encrypted cookie, but
pagination can produce up to 500 channels and easily exceed common cookie/header size limits,
causing the cookie write/send to fail and the picker to read as missing (400). This can make
multi-channel connect impossible for large/managed accounts.
Code

src/lib/server/channelConnect.ts[R76-82]

+	cookies.set(CHANNEL_PICK_COOKIE, encrypt(JSON.stringify(entries)), {
+		path: '/',
+		httpOnly: true,
+		sameSite: 'lax',
+		secure: cookieSecure(),
+		maxAge: PICK_TTL_MS / 1000
+	});
Relevance

●● Moderate

Cookie-based parked state is accepted, but no precedent on cookie-size-limit mitigation for large
lists.

PR-#36

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The callback can collect up to 10 pages of 50 channels each (500 entries) and passes that array into
parkPendingChannelPick, which writes the entire structure into one cookie via
encrypt(JSON.stringify(entries)). That payload size is unbounded by channel count, only by number
of concurrent states, so it can exceed cookie/header limits and break the flow when the cookie
cannot be stored or sent back.

src/routes/api/auth/google/callback/+server.ts[28-51]
src/routes/api/auth/google/callback/+server.ts[123-138]
src/lib/server/channelConnect.ts[47-83]

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

### Issue description
`parkPendingChannelPick` persists `{refreshToken, channels[]}` in a single encrypted cookie. With pagination enabled (`MAX_CHANNEL_PAGES * maxResults`), this payload can become tens of KB after JSON + AES-GCM overhead + base64, exceeding typical per-cookie / header-size limits and breaking the picker flow.

### Issue Context
The consent flow cookie pattern (`legal.ts`) stores a tiny payload; the new picker stores a potentially huge list.

### Fix Focus Areas
- src/lib/server/channelConnect.ts[47-115]
- src/routes/api/auth/google/callback/+server.ts[123-138]
- src/routes/connect-channel/+page.server.ts[31-70]

### Suggested fix approach
- Change the parked cookie payload to store **only** the refresh token (and ts/state), not the full `channels[]` list.
- In `/connect-channel` **load** (server-side), use the parked refresh token to fetch the owned channel list on demand (same API as the callback). Return only `{id,title}` to the browser.
- In the picker **action**, validate the chosen channel by re-fetching (or by checking membership via a targeted API call) using the parked refresh token, then upsert.
- Keep the existing TTL/entry bound; consider adding a maximum channel count returned to the UI if needed for safety.

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



Remediation recommended

2. Picker redirect URL concatenation 📘 Rule violation ≡ Correctness
Description
The new redirect URL is built with a template literal and manual query-string composition instead of
using the URL constructor and searchParams. This violates the project rule and can lead to
subtle encoding/formatting bugs as URL complexity grows.
Code

src/routes/api/auth/google/callback/+server.ts[R136-137]

+		parkPendingChannelPick(cookies, state, { refreshToken: tokens.refreshToken, channels: owned });
+		throw redirect(302, `/connect-channel?state=${encodeURIComponent(state)}`);
Relevance

●●● Strong

Prior reviews explicitly required URL constructor usage over manual string concatenation in OAuth
routes.

PR-#4

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2407445 requires composing URLs via the URL constructor rather than string
concatenation. The new code constructs /connect-channel?state=... via a template literal instead
of using new URL('/connect-channel', base) and url.searchParams.set('state', state).

Rule 2407445: Construct URLs using the URL constructor instead of string concatenation
src/routes/api/auth/google/callback/+server.ts[136-137]

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

## Issue description
`/connect-channel` redirect URL is constructed via template literal and manual query parameter concatenation.

## Issue Context
Compliance requires building URLs via `new URL()` + `searchParams` instead of string concatenation.

## Fix Focus Areas
- src/routes/api/auth/google/callback/+server.ts[132-138]

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


3. No overall list deadline 🐞 Bug ☼ Reliability
Description
fetchOwnedChannels can perform up to 10 sequential fetchWithRetry calls without an overall
deadline, so under throttling/retries the OAuth callback duration can grow unpredictably and exceed
serverless/runtime time budgets. This is new risk introduced by replacing the single-page lookup
with a multi-page walk.
Code

src/routes/api/auth/google/callback/+server.ts[R52-55]

+		const chRes = await fetchWithRetry(endpoint.toString(), {
+			headers: { Authorization: `Bearer ${accessToken}` }
+		});
+		const chText = await chRes.text();
Relevance

●●● Strong

Team has accepted adding timeouts/bounded retries; adding an overall deadline to paginated fetch is
consistent.

PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
fetchOwnedChannels loops up to MAX_CHANNEL_PAGES and calls fetchWithRetry per page, but never
passes the optional deadline. fetchWithRetry itself applies a per-attempt timeout and up to 3
retries, so the total callback time can expand significantly across multiple pages when the upstream
is slow or retryable.

src/routes/api/auth/google/callback/+server.ts[41-55]
src/lib/server/http.ts[19-22]
src/lib/server/http.ts[117-132]

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 new pagination loop does not provide an aggregate deadline to `fetchWithRetry`. Since `fetchWithRetry` has its own per-attempt timeout and retry behavior, the total time across up to 10 pages can become large under slow/failed requests.

### Issue Context
`fetchWithRetry` supports an optional `deadline` parameter, and other server-side API calls (e.g., YouTube helpers) thread deadlines through.

### Fix Focus Areas
- src/routes/api/auth/google/callback/+server.ts[41-90]
- src/lib/server/http.ts[19-22]
- src/lib/server/http.ts[117-132]

### Suggested fix approach
- Compute a request-scoped deadline in the callback (e.g., `const deadline = Date.now() + 18_000`) based on your platform’s execution budget.
- Thread that deadline into `fetchOwnedChannels(accessToken, deadline)` and pass it into each `fetchWithRetry(..., ..., deadline)` call.
- Consider early-aborting with a clear 502/504 message when the deadline is exceeded so users can retry cleanly.

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


Grey Divider

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

Qodo Logo

Comment on lines +136 to +137
parkPendingChannelPick(cookies, state, { refreshToken: tokens.refreshToken, channels: owned });
throw redirect(302, `/connect-channel?state=${encodeURIComponent(state)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Picker redirect url concatenation 📘 Rule violation ≡ Correctness

The new redirect URL is built with a template literal and manual query-string composition instead of
using the URL constructor and searchParams. This violates the project rule and can lead to
subtle encoding/formatting bugs as URL complexity grows.
Agent Prompt
## Issue description
`/connect-channel` redirect URL is constructed via template literal and manual query parameter concatenation.

## Issue Context
Compliance requires building URLs via `new URL()` + `searchParams` instead of string concatenation.

## Fix Focus Areas
- src/routes/api/auth/google/callback/+server.ts[132-138]

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

Comment on lines +76 to +82
cookies.set(CHANNEL_PICK_COOKIE, encrypt(JSON.stringify(entries)), {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: cookieSecure(),
maxAge: PICK_TTL_MS / 1000
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Oversized pending-pick cookie 🐞 Bug ☼ Reliability

The multi-channel flow serializes the entire owned-channel list into a single encrypted cookie, but
pagination can produce up to 500 channels and easily exceed common cookie/header size limits,
causing the cookie write/send to fail and the picker to read as missing (400). This can make
multi-channel connect impossible for large/managed accounts.
Agent Prompt
### Issue description
`parkPendingChannelPick` persists `{refreshToken, channels[]}` in a single encrypted cookie. With pagination enabled (`MAX_CHANNEL_PAGES * maxResults`), this payload can become tens of KB after JSON + AES-GCM overhead + base64, exceeding typical per-cookie / header-size limits and breaking the picker flow.

### Issue Context
The consent flow cookie pattern (`legal.ts`) stores a tiny payload; the new picker stores a potentially huge list.

### Fix Focus Areas
- src/lib/server/channelConnect.ts[47-115]
- src/routes/api/auth/google/callback/+server.ts[123-138]
- src/routes/connect-channel/+page.server.ts[31-70]

### Suggested fix approach
- Change the parked cookie payload to store **only** the refresh token (and ts/state), not the full `channels[]` list.
- In `/connect-channel` **load** (server-side), use the parked refresh token to fetch the owned channel list on demand (same API as the callback). Return only `{id,title}` to the browser.
- In the picker **action**, validate the chosen channel by re-fetching (or by checking membership via a targeted API call) using the parked refresh token, then upsert.
- Keep the existing TTL/entry bound; consider adding a maximum channel count returned to the UI if needed for safety.

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

Comment on lines +52 to +55
const chRes = await fetchWithRetry(endpoint.toString(), {
headers: { Authorization: `Bearer ${accessToken}` }
});
const chText = await chRes.text();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. No overall list deadline 🐞 Bug ☼ Reliability

fetchOwnedChannels can perform up to 10 sequential fetchWithRetry calls without an overall
deadline, so under throttling/retries the OAuth callback duration can grow unpredictably and exceed
serverless/runtime time budgets. This is new risk introduced by replacing the single-page lookup
with a multi-page walk.
Agent Prompt
### Issue description
The new pagination loop does not provide an aggregate deadline to `fetchWithRetry`. Since `fetchWithRetry` has its own per-attempt timeout and retry behavior, the total time across up to 10 pages can become large under slow/failed requests.

### Issue Context
`fetchWithRetry` supports an optional `deadline` parameter, and other server-side API calls (e.g., YouTube helpers) thread deadlines through.

### Fix Focus Areas
- src/routes/api/auth/google/callback/+server.ts[41-90]
- src/lib/server/http.ts[19-22]
- src/lib/server/http.ts[117-132]

### Suggested fix approach
- Compute a request-scoped deadline in the callback (e.g., `const deadline = Date.now() + 18_000`) based on your platform’s execution budget.
- Thread that deadline into `fetchOwnedChannels(accessToken, deadline)` and pass it into each `fetchWithRetry(..., ..., deadline)` call.
- Consider early-aborting with a clear 502/504 message when the deadline is exceeded so users can retry cleanly.

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

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[backend] Multi-channel connect: channel picker + account chooser

1 participant