From 15bc0b9bf23395525dd7c2e257481848c92c1c1b Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 25 Oct 2026 12:29:27 -0400 Subject: [PATCH] YouTube comments: read with an API key instead of the channel token commentThreads.list rejects youtube.readonly ("insufficient authentication scopes"); it needs youtube.force-ssl, which we don't want to request. Public comments are now read with YOUTUBE_API_KEY. Also records the re-submitted Google verification (video v2, new scope justification). Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 ++++ connector/src/platforms/index.js | 5 +++-- connector/src/platforms/youtube.js | 7 ++++--- connector/test/platforms.test.mjs | 10 ++++++++-- docs/google-app.md | 21 +++++++++++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 93e8fe7..eefb4c5 100644 --- a/.env.example +++ b/.env.example @@ -75,3 +75,7 @@ DASHBOARD_NIGHTLY_REFRESH=true # STRIPE_TRIAL_DAYS=14 # In a hosted agency workspace (set by the provisioning script): # SETUP_TOKEN= # only the emailed setup link can create the first admin + +# YouTube comments in the Inbox (public comments, read with an API key, not the channel's sign-in). +# Google Cloud (project Quorla) → APIs & Services → Credentials → Create API key → restrict to YouTube Data API v3. +# YOUTUBE_API_KEY= diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js index e426803..dcf3145 100644 --- a/connector/src/platforms/index.js +++ b/connector/src/platforms/index.js @@ -128,9 +128,10 @@ export async function gbpReply(client, reviewId, text) { export async function youtubeComments(client) { const conn = vault.connectionFor(client, "youtube"); if (!conn) throw err("YouTube isn't connected for this client."); - const { tokens } = await tokensFor(conn.id); + const key = process.env.YOUTUBE_API_KEY; + if (!key) throw err("YouTube comments need YOUTUBE_API_KEY in .env (Google Cloud → Credentials → API key, restricted to YouTube Data API v3)."); quota.ensure("youtube", null, "read"); - const out = await adapter("youtube").comments(tokens, conn.account); + const out = await adapter("youtube").comments(key, conn.account); quota.record("youtube", null, "read"); return { channel: conn.account?.name, comments: out }; } diff --git a/connector/src/platforms/youtube.js b/connector/src/platforms/youtube.js index 62720da..f6a6564 100644 --- a/connector/src/platforms/youtube.js +++ b/connector/src/platforms/youtube.js @@ -32,9 +32,10 @@ export default { }; }, - /** Latest comments across the channel's videos (read-only: replying needs a broader scope, so reply on YouTube). */ - async comments(tokens, account, { max = 30 } = {}) { - const { data } = await request("YouTube", `${API}/commentThreads`, { token: tokens.accessToken, query: { part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } }); + /** Latest public comments across the channel's videos. Uses an API key, not the user's token: commentThreads.list + * needs youtube.force-ssl with OAuth, a scope we don't request. Reply on YouTube. */ + async comments(apiKey, account, { max = 30 } = {}) { + const { data } = await request("YouTube", `${API}/commentThreads`, { query: { key: apiKey, part: "snippet", allThreadsRelatedToChannelId: account.id, maxResults: max, order: "time" } }); return (data.items || []).map((t) => { const c = t.snippet?.topLevelComment?.snippet || {}; return { id: t.id, author: c.authorDisplayName, avatar: c.authorProfileImageUrl || null, text: c.textOriginal || "", at: c.publishedAt, likes: c.likeCount ?? 0, replies: t.snippet?.totalReplyCount ?? 0, videoId: t.snippet?.videoId, url: `https://www.youtube.com/watch?v=${t.snippet?.videoId}&lc=${t.id}` }; diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index 8a7cfb8..9e18882 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -364,10 +364,16 @@ test("Google reviews: list with stars and replies, reply only to this location's test("YouTube comments: latest threads on the channel with a link to reply", async () => { reset(); - route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "yt-new", expires_in: 3600 })); + delete process.env.YOUTUBE_API_KEY; + await assert.rejects(P.youtubeComments("acme"), /YOUTUBE_API_KEY/); + process.env.YOUTUBE_API_KEY = "yt-key"; route("GET", "https://www.googleapis.com/youtube/v3/commentThreads", json({ items: [{ id: "t1", snippet: { videoId: "v1", totalReplyCount: 2, topLevelComment: { snippet: { authorDisplayName: "Ana", textOriginal: "Love this", publishedAt: "2026-10-05T10:00:00Z", likeCount: 3 } } } }] })); const r = await P.youtubeComments("acme"); + delete process.env.YOUTUBE_API_KEY; assert.equal(r.comments[0].author, "Ana"); assert.equal(r.comments[0].url, "https://www.youtube.com/watch?v=v1&lc=t1"); - assert.match(sent(/commentThreads/)[0].url, /allThreadsRelatedToChannelId=/); + const call = sent(/commentThreads/)[0]; + assert.match(call.url, /allThreadsRelatedToChannelId=/); + assert.match(call.url, /key=yt-key/); + assert.ok(!call.headers?.Authorization, "comments are read with the API key, not the user's token"); }); diff --git a/docs/google-app.md b/docs/google-app.md index 26f013c..e358949 100644 --- a/docs/google-app.md +++ b/docs/google-app.md @@ -53,6 +53,27 @@ Two Google Cloud projects, both owned by dev@metatroncubesolutions.com: together with the demo video link): > Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: the team prepares a video post in Quorla; an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel at the scheduled time. Nothing is uploaded without that approval. youtube.readonly: show which channel is connected (name, handle, avatar, subscriber count) so the agency posts to the right channel, and link to the uploaded video afterwards. More limited scopes are not sufficient: youtube.upload is the narrowest scope that allows uploading, and youtube.readonly is needed to read the channel identity, which upload alone does not return. Tokens are encrypted at rest and deleted when the channel is disconnected. +Status 24 Oct 2026: Google asked for a **new demo video** (first one didn't show the consent screen with both YouTube +scopes expanded, or every feature that uses them). New "How will the scopes be used?" text (replaces the one above): +> Quorla (quorlasocial.com) is a social media management tool for marketing agencies. An agency admin connects a client's YouTube channel on Quorla's Connections page. youtube.upload: an admin reviews and approves the exact video, title, description, visibility and made-for-kids setting, and Quorla uploads it to the connected channel (videos.insert). Nothing is uploaded without that approval. youtube.readonly is used for two read-only features: (1) Connections page: channels.list (mine=true) shows the connected channel's name, handle, avatar and subscriber count, so the agency posts to the right channel; (2) Published page: videos.list shows the views, likes and comment counts of the videos Quorla uploaded, so the agency can report results to the client. youtube.upload cannot read any of this and there is no narrower read scope. We do not request the broader youtube or youtube.force-ssl scopes. Tokens are encrypted at rest and deleted when the channel is disconnected. + +v2 video recorded 25 Oct 2026: https://youtu.be/CHHreGKIpXE (unlisted). **Submitted 25 Oct 2026** (Data Access updated with +the text below and the new link; replied to Google's email from dev@). Waiting for Google's answer. + +**Re-recorded demo video (v2)**, in this order, address bar visible, English: +1. Before recording: in Quorla disconnect YouTube; at https://myaccount.google.com/connections remove Quorla's access + for the test Google account (so the full consent screen shows). YouTube comments in the Inbox use an API key + (`YOUTUBE_API_KEY`), not these scopes, so they are not part of this video. +2. quorlasocial.com → Privacy policy → app.quorlasocial.com sign in. +3. Connections → client → YouTube → Connect → account chooser. **Pause on the consent screen**: zoom the address bar + (client_id and `scope=` with youtube.upload + youtube.readonly), click each scope's arrow / "Show all services" so + both descriptions are fully readable, tick both, Continue. +4. Back in Quorla: channel name, avatar, subscribers shown → say "youtube.readonly: channels.list". +5. Publishing → new post with a vertical video → tick YouTube → title, visibility, made for kids → save draft → Approve → + published → open on YouTube → say "youtube.upload: videos.insert". +6. Published page → refresh stats → the YouTube video's views / likes / comments → "youtube.readonly: videos.list". +7. Connections → Disconnect YouTube. + Status 22 Oct 2026 (evening): **Quorla project submitted for verification** (youtube.upload, youtube.readonly) with demo video https://youtu.be/lB8FMGDbBOo (unlisted); Data access status "under review". Google replies by email to dev@.