From 908152f771fd981e0bfafce588fbde07043a1c29 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:58:28 +0700 Subject: [PATCH 1/3] feat(extension): onboarding consent screen for the opt-in On a first install the extension opens a short consent page (vi, en, cs): what the lookup sends, what the opt-in would add (the same field list as the side panel), that nothing more is sent until the user says yes, and that the side panel can change it. "No" has the focus and the primary style; closing the page keeps the default, off. The choice goes through RATE_UPDATE_SETTINGS to the same setting as the side panel toggle, which now follows changes made elsewhere. The sender guard accepts a tab sender only when it is this extension's consent page URL, exactly; content scripts stay refused. Updates and reloads do not open the page. Refs #1603, #1175 Co-Authored-By: Claude Opus 5.5 --- apps/extension/scripts/build.mjs | 19 ++- apps/extension/src/_locales/cs/messages.json | 30 ++++ apps/extension/src/_locales/en/messages.json | 30 ++++ apps/extension/src/_locales/vi/messages.json | 30 ++++ .../src/background/service-worker.ts | 14 +- apps/extension/src/onboarding/onboarding.html | 138 ++++++++++++++++++ apps/extension/src/onboarding/onboarding.ts | 42 ++++++ apps/extension/src/security-guards.ts | 15 +- apps/extension/src/sidepanel/sidepanel.ts | 7 + apps/extension/src/test-extension-pipeline.ts | 33 +++++ 10 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 apps/extension/src/onboarding/onboarding.html create mode 100644 apps/extension/src/onboarding/onboarding.ts diff --git a/apps/extension/scripts/build.mjs b/apps/extension/scripts/build.mjs index f8b0892fd..7486dd105 100644 --- a/apps/extension/scripts/build.mjs +++ b/apps/extension/scripts/build.mjs @@ -16,6 +16,7 @@ async function main() { fs.rmSync(distDir, { recursive: true, force: true }); ensureDir(path.join(distDir, 'icons')); ensureDir(path.join(distDir, 'sidepanel')); + ensureDir(path.join(distDir, 'onboarding')); // 1. Copy manifest.json fs.copyFileSync( @@ -53,8 +54,13 @@ async function main() { ...common, format: 'esm', entryPoints: { - 'background/service-worker': path.join(srcDir, 'background', 'service-worker.ts'), + 'background/service-worker': path.join( + srcDir, + 'background', + 'service-worker.ts' + ), 'sidepanel/sidepanel': path.join(srcDir, 'sidepanel', 'sidepanel.ts'), + 'onboarding/onboarding': path.join(srcDir, 'onboarding', 'onboarding.ts'), }, outdir: distDir, }); @@ -62,7 +68,11 @@ async function main() { ...common, format: 'iife', entryPoints: { - 'content/content-script': path.join(srcDir, 'content', 'content-script.ts'), + 'content/content-script': path.join( + srcDir, + 'content', + 'content-script.ts' + ), }, outdir: distDir, }); @@ -70,6 +80,11 @@ async function main() { path.join(srcDir, 'sidepanel', 'sidepanel.html'), path.join(distDir, 'sidepanel', 'sidepanel.html') ); + // The install consent page (DOS/Rate#1603). + fs.copyFileSync( + path.join(srcDir, 'onboarding', 'onboarding.html'), + path.join(distDir, 'onboarding', 'onboarding.html') + ); console.log( '✅ Built Rate Browser Extension (Manifest V3) into apps/extension/dist' diff --git a/apps/extension/src/_locales/cs/messages.json b/apps/extension/src/_locales/cs/messages.json index 6d2564ce0..d46102c01 100644 --- a/apps/extension/src/_locales/cs/messages.json +++ b/apps/extension/src/_locales/cs/messages.json @@ -652,5 +652,35 @@ "content": "$2" } } + }, + "onboardingTitle": { + "message": "Rate.vn je připraveno" + }, + "onboardingLookup": { + "message": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn název, cenu a ID produktu, aby vyhledalo historii cen. Účet není potřeba." + }, + "onboardingQuestion": { + "message": "Chcete přispívat nabídkami, které si prohlížíte?" + }, + "onboardingNothingYet": { + "message": "Rozšíření nic dalšího neposílá, dokud nezvolíte \"Ano\". Volbu můžete kdykoli změnit v bočním panelu." + }, + "onboardingNo": { + "message": "Ne, děkuji" + }, + "onboardingYes": { + "message": "Ano, chci přispívat" + }, + "onboardingSavedNo": { + "message": "Uloženo: nepřispívat. Tuto stránku můžete zavřít." + }, + "onboardingSavedYes": { + "message": "Uloženo: přispívat. Vypnout to můžete v bočním panelu." + }, + "onboardingSaveFailed": { + "message": "Neuloženo. Zkuste to znovu nebo zvolte v bočním panelu." + }, + "onboardingPrivacyLink": { + "message": "Zásady ochrany soukromí" } } diff --git a/apps/extension/src/_locales/en/messages.json b/apps/extension/src/_locales/en/messages.json index f69af4fcd..9635cf8e2 100644 --- a/apps/extension/src/_locales/en/messages.json +++ b/apps/extension/src/_locales/en/messages.json @@ -652,5 +652,35 @@ "content": "$2" } } + }, + "onboardingTitle": { + "message": "Rate.vn is ready" + }, + "onboardingLookup": { + "message": "On product pages of the 10 supported stores, the extension sends the product name, price and ids to Rate.vn to look up price history. No account needed." + }, + "onboardingQuestion": { + "message": "Would you like to contribute the listings you view?" + }, + "onboardingNothingYet": { + "message": "The extension sends nothing more until you choose \"Yes\". You can change this any time in the side panel." + }, + "onboardingNo": { + "message": "No, thanks" + }, + "onboardingYes": { + "message": "Yes, I want to contribute" + }, + "onboardingSavedNo": { + "message": "Saved: not contributing. You can close this page." + }, + "onboardingSavedYes": { + "message": "Saved: contributing. You can turn it off in the side panel." + }, + "onboardingSaveFailed": { + "message": "Not saved. Try again or choose in the side panel." + }, + "onboardingPrivacyLink": { + "message": "Privacy policy" } } diff --git a/apps/extension/src/_locales/vi/messages.json b/apps/extension/src/_locales/vi/messages.json index 188394535..23251cc4f 100644 --- a/apps/extension/src/_locales/vi/messages.json +++ b/apps/extension/src/_locales/vi/messages.json @@ -652,5 +652,35 @@ "content": "$2" } } + }, + "onboardingTitle": { + "message": "Rate.vn đã sẵn sàng" + }, + "onboardingLookup": { + "message": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi tên, giá và mã sản phẩm cho Rate.vn để tra lịch sử giá. Không cần tài khoản." + }, + "onboardingQuestion": { + "message": "Bạn có muốn đóng góp các tin bán bạn xem không?" + }, + "onboardingNothingYet": { + "message": "Tiện ích không gửi thêm gì cho đến khi bạn chọn \"Có\". Bạn có thể đổi lựa chọn bất cứ lúc nào trong bảng bên cạnh." + }, + "onboardingNo": { + "message": "Không, cảm ơn" + }, + "onboardingYes": { + "message": "Có, tôi muốn đóng góp" + }, + "onboardingSavedNo": { + "message": "Đã lưu: không đóng góp. Bạn có thể đóng trang này." + }, + "onboardingSavedYes": { + "message": "Đã lưu: có đóng góp. Bạn có thể tắt trong bảng bên cạnh." + }, + "onboardingSaveFailed": { + "message": "Chưa lưu được. Hãy thử lại hoặc chọn trong bảng bên cạnh." + }, + "onboardingPrivacyLink": { + "message": "Chính sách bảo mật" } } diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 0ba4c584a..c8b016a3c 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -14,6 +14,7 @@ import { DEFAULT_API_BASE_URL, isPrivilegedExtensionSender, normalizeAllowedApiBaseUrl, + ONBOARDING_PAGE_PATH, sanitizeExtensionProductSignal, selectFreshestSessionSignal, } from '../security-guards.js'; @@ -209,7 +210,18 @@ async function updateActionBadge( } } -chrome.runtime.onInstalled.addListener(async () => { +chrome.runtime.onInstalled.addListener(async (details) => { + // On a first install only, ask about the opt-in (DOS/Rate#1603). The + // default stays off, so closing the page sends nothing extra. + if (details?.reason === 'install') { + try { + await chrome.tabs.create({ + url: chrome.runtime.getURL(ONBOARDING_PAGE_PATH), + }); + } catch { + // The side panel toggle still offers the choice. + } + } try { await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); const existing = await chrome.storage.local.get([ diff --git a/apps/extension/src/onboarding/onboarding.html b/apps/extension/src/onboarding/onboarding.html new file mode 100644 index 000000000..64bb693c5 --- /dev/null +++ b/apps/extension/src/onboarding/onboarding.html @@ -0,0 +1,138 @@ + + + + + + + + Rate.vn + + + +
+
+

+

+

+

+

+
+ + +
+

+

+ +

+
+
+ + + diff --git a/apps/extension/src/onboarding/onboarding.ts b/apps/extension/src/onboarding/onboarding.ts new file mode 100644 index 000000000..a3ecd1716 --- /dev/null +++ b/apps/extension/src/onboarding/onboarding.ts @@ -0,0 +1,42 @@ +/** + * The consent screen shown once at install (DOS/Rate#1603, JOY 2026-11-04: + * ask at install and keep the side panel toggle, default off). The choice + * goes to the same setting as the side panel toggle; closing the page leaves + * the default, off, so nothing extra is sent. + */ +import { applyDocumentMessages, t } from '../i18n.js'; + +async function choose(optIn: boolean): Promise { + const status = document.getElementById('status'); + const buttons = document.querySelectorAll('button'); + buttons.forEach((b) => (b.disabled = true)); + try { + const res = (await chrome.runtime.sendMessage({ + type: 'RATE_UPDATE_SETTINGS', + crowdContributeOptIn: optIn, + })) as { ok?: boolean; settings?: { crowdContributeOptIn?: boolean } }; + if (!res?.ok || res.settings?.crowdContributeOptIn !== optIn) { + throw new Error('not saved'); + } + if (status) { + status.textContent = t( + optIn ? 'onboardingSavedYes' : 'onboardingSavedNo' + ); + } + } catch { + if (status) status.textContent = t('onboardingSaveFailed'); + } finally { + buttons.forEach((b) => (b.disabled = false)); + } +} + +document.addEventListener('DOMContentLoaded', () => { + applyDocumentMessages(document); + document.title = t('onboardingTitle'); + document + .getElementById('btn-no') + ?.addEventListener('click', () => void choose(false)); + document + .getElementById('btn-yes') + ?.addEventListener('click', () => void choose(true)); +}); diff --git a/apps/extension/src/security-guards.ts b/apps/extension/src/security-guards.ts index 869032254..de3f8e562 100644 --- a/apps/extension/src/security-guards.ts +++ b/apps/extension/src/security-guards.ts @@ -65,6 +65,9 @@ export function normalizeAllowedApiBaseUrl( } } +/** The consent page opened once at install (DOS/Rate#1603). */ +export const ONBOARDING_PAGE_PATH = 'onboarding/onboarding.html'; + export function isPrivilegedExtensionSender( sender: { id?: string; @@ -76,9 +79,6 @@ export function isPrivilegedExtensionSender( if (!sender || typeof sender !== 'object' || Array.isArray(sender)) { return false; } - if (sender.tab != null) { - return false; - } const runtimeId = expectedRuntimeId ?? (typeof globalThis !== 'undefined' && @@ -90,6 +90,15 @@ export function isPrivilegedExtensionSender( .runtime!.id : undefined); + // A sender in a tab is a content script, except the install consent page, + // an extension page opened in a tab (DOS/Rate#1603). + if (sender.tab != null) { + return ( + Boolean(runtimeId) && + sender.url === `chrome-extension://${runtimeId}/${ONBOARDING_PAGE_PATH}` + ); + } + if ( runtimeId && typeof sender.id === 'string' && diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index 314aa2424..bb016e453 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -820,6 +820,13 @@ document.addEventListener('DOMContentLoaded', () => { } }); + chrome.storage.onChanged?.addListener((changes, areaName) => { + const change = changes.crowdContributeOptIn; + if (areaName === 'local' && change && chkOptIn) { + chkOptIn.checked = change.newValue === true; + } + }); + chkOptIn?.addEventListener('change', async () => { await chrome.runtime.sendMessage({ type: 'RATE_UPDATE_SETTINGS', diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index c641cfe40..228c0a708 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -467,6 +467,39 @@ async function runTests() { }), true ); + // In a tab, only this extension's install consent page may change + // settings (DOS/Rate#1603). + assert.equal( + isPrivilegedExtensionSender( + { + tab: { id: 7 }, + url: 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html', + }, + 'abcdefghijklmnop' + ), + true + ); + for (const url of [ + 'https://shopee.vn/onboarding/onboarding.html', + 'chrome-extension://otherextensionid/onboarding/onboarding.html', + 'chrome-extension://abcdefghijklmnop/sidepanel/sidepanel.html', + 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html?x=1', + undefined, + ]) { + assert.equal( + isPrivilegedExtensionSender({ tab: { id: 7 }, url }, 'abcdefghijklmnop'), + false, + String(url) + ); + } + assert.equal( + isPrivilegedExtensionSender({ + tab: { id: 7 }, + url: 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html', + }), + false, + 'without a known runtime id a tab sender is never privileged' + ); assert.equal( escapeSafeExternalHref('javascript:alert(1)'), 'https://rate.vn', From e6253deea62c9787e58c67dda75d15bea2d99392 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:07:15 +0700 Subject: [PATCH 2/3] fix(extension): consent page sets only the opt-in, defaults first - isOnboardingSender accepts the exact consent page URL and lets it set the opt-in alone; isPrivilegedExtensionSender refuses tab senders again. - onInstalled writes each missing default before opening the page, so a quick choice is never overwritten; a stored choice is kept. - The page status follows the stored choice. Refs #1603 Co-Authored-By: Claude Opus 5.5 --- .../src/background/service-worker.ts | 42 +++++++++------ apps/extension/src/onboarding/onboarding.ts | 11 ++++ apps/extension/src/security-guards.ts | 29 ++++++++--- apps/extension/src/test-extension-pipeline.ts | 52 ++++++++++++------- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index c8b016a3c..268352c08 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -12,6 +12,7 @@ import { import { buildCrowdIngestPayload, DEFAULT_API_BASE_URL, + isOnboardingSender, isPrivilegedExtensionSender, normalizeAllowedApiBaseUrl, ONBOARDING_PAGE_PATH, @@ -211,32 +212,38 @@ async function updateActionBadge( } chrome.runtime.onInstalled.addListener(async (details) => { - // On a first install only, ask about the opt-in (DOS/Rate#1603). The - // default stays off, so closing the page sends nothing extra. - if (details?.reason === 'install') { - try { - await chrome.tabs.create({ - url: chrome.runtime.getURL(ONBOARDING_PAGE_PATH), - }); - } catch { - // The side panel toggle still offers the choice. - } - } try { await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); + // Each missing setting gets its default; a stored choice is kept. const existing = await chrome.storage.local.get([ 'apiBaseUrl', 'crowdContributeOptIn', ]); + const defaults: Partial = {}; if (typeof existing.apiBaseUrl !== 'string') { - await chrome.storage.local.set({ - apiBaseUrl: DEFAULT_SETTINGS.apiBaseUrl, - crowdContributeOptIn: DEFAULT_SETTINGS.crowdContributeOptIn, - }); + defaults.apiBaseUrl = DEFAULT_SETTINGS.apiBaseUrl; + } + if (typeof existing.crowdContributeOptIn !== 'boolean') { + defaults.crowdContributeOptIn = DEFAULT_SETTINGS.crowdContributeOptIn; + } + if (Object.keys(defaults).length > 0) { + await chrome.storage.local.set(defaults); } } catch { // Ignore sidePanel or storage init errors during extension reload } + // On a first install only, after the defaults, ask about the opt-in + // (DOS/Rate#1603). The default stays off, so closing the page sends + // nothing extra. + if (details?.reason === 'install') { + try { + await chrome.tabs.create({ + url: chrome.runtime.getURL(ONBOARDING_PAGE_PATH), + }); + } catch { + // The side panel toggle still offers the choice. + } + } }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { @@ -578,7 +585,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (msgType === 'RATE_UPDATE_SETTINGS') { - if (!isPrivilegedExtensionSender(sender)) { + // The install consent page may set the opt-in and nothing else. + const fromOnboarding = isOnboardingSender(sender); + if (!fromOnboarding && !isPrivilegedExtensionSender(sender)) { sendResponse({ ok: false, error: 'Settings can only be updated from the Rate extension sidepanel', @@ -589,6 +598,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { try { const nextSettings: Partial = {}; if ( + !fromOnboarding && typeof message.apiBaseUrl === 'string' && message.apiBaseUrl.trim() ) { diff --git a/apps/extension/src/onboarding/onboarding.ts b/apps/extension/src/onboarding/onboarding.ts index a3ecd1716..9317f72b4 100644 --- a/apps/extension/src/onboarding/onboarding.ts +++ b/apps/extension/src/onboarding/onboarding.ts @@ -32,6 +32,17 @@ async function choose(optIn: boolean): Promise { document.addEventListener('DOMContentLoaded', () => { applyDocumentMessages(document); + // The status always shows the stored choice, also one changed in the side + // panel after this page saved it. + chrome.storage.onChanged?.addListener((changes, areaName) => { + const change = changes.crowdContributeOptIn; + const status = document.getElementById('status'); + if (areaName === 'local' && change && status) { + status.textContent = t( + change.newValue === true ? 'onboardingSavedYes' : 'onboardingSavedNo' + ); + } + }); document.title = t('onboardingTitle'); document .getElementById('btn-no') diff --git a/apps/extension/src/security-guards.ts b/apps/extension/src/security-guards.ts index de3f8e562..d25026002 100644 --- a/apps/extension/src/security-guards.ts +++ b/apps/extension/src/security-guards.ts @@ -68,6 +68,28 @@ export function normalizeAllowedApiBaseUrl( /** The consent page opened once at install (DOS/Rate#1603). */ export const ONBOARDING_PAGE_PATH = 'onboarding/onboarding.html'; +/** + * The install consent page, an extension page in a tab: exactly its URL in + * this extension, never a content script (whose url is the web page). It may + * only set the opt-in (DOS/Rate#1603). + */ +export function isOnboardingSender( + sender: { id?: string; tab?: unknown; url?: string } | null | undefined, + expectedRuntimeId?: string +): boolean { + if (!sender || typeof sender !== 'object' || Array.isArray(sender)) { + return false; + } + const runtimeId = + expectedRuntimeId ?? + (typeof chrome !== 'undefined' ? chrome.runtime?.id : undefined); + if (!runtimeId) return false; + if (typeof sender.id === 'string' && sender.id !== runtimeId) return false; + return ( + sender.url === `chrome-extension://${runtimeId}/${ONBOARDING_PAGE_PATH}` + ); +} + export function isPrivilegedExtensionSender( sender: { id?: string; @@ -90,13 +112,8 @@ export function isPrivilegedExtensionSender( .runtime!.id : undefined); - // A sender in a tab is a content script, except the install consent page, - // an extension page opened in a tab (DOS/Rate#1603). if (sender.tab != null) { - return ( - Boolean(runtimeId) && - sender.url === `chrome-extension://${runtimeId}/${ONBOARDING_PAGE_PATH}` - ); + return false; } if ( diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index 228c0a708..e8bd38032 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -19,6 +19,7 @@ import { DEFAULT_API_BASE_URL, MAX_SPARKLINE_POINTS, escapeSafeExternalHref, + isOnboardingSender, isPrivilegedExtensionSender, normalizeAllowedApiBaseUrl, normalizeFiveStarRating, @@ -467,38 +468,51 @@ async function runTests() { }), true ); - // In a tab, only this extension's install consent page may change - // settings (DOS/Rate#1603). + // The install consent page is an extension page in a tab: it is never a + // privileged sender, and only isOnboardingSender, which lets it set the + // opt-in alone, accepts it by its exact URL (DOS/Rate#1603). + const onboardingUrl = + 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html'; assert.equal( isPrivilegedExtensionSender( - { - tab: { id: 7 }, - url: 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html', - }, + { tab: { id: 7 }, url: onboardingUrl }, + 'abcdefghijklmnop' + ), + false + ); + assert.equal( + isOnboardingSender( + { tab: { id: 7 }, url: onboardingUrl }, 'abcdefghijklmnop' ), true ); - for (const url of [ - 'https://shopee.vn/onboarding/onboarding.html', - 'chrome-extension://otherextensionid/onboarding/onboarding.html', - 'chrome-extension://abcdefghijklmnop/sidepanel/sidepanel.html', - 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html?x=1', - undefined, + for (const sender of [ + { tab: { id: 7 }, url: 'https://shopee.vn/onboarding/onboarding.html' }, + { + tab: { id: 7 }, + url: 'chrome-extension://otherextensionid/onboarding/onboarding.html', + }, + { + tab: undefined, + url: 'chrome-extension://abcdefghijklmnop/sidepanel/sidepanel.html', + }, + { tab: { id: 7 }, url: `${onboardingUrl}?x=1` }, + { tab: { id: 7 }, url: `${onboardingUrl}#x` }, + { tab: { id: 7 }, url: undefined }, + { id: 'otherextensionid', tab: { id: 7 }, url: onboardingUrl }, + null, ]) { assert.equal( - isPrivilegedExtensionSender({ tab: { id: 7 }, url }, 'abcdefghijklmnop'), + isOnboardingSender(sender as never, 'abcdefghijklmnop'), false, - String(url) + JSON.stringify(sender) ); } assert.equal( - isPrivilegedExtensionSender({ - tab: { id: 7 }, - url: 'chrome-extension://abcdefghijklmnop/onboarding/onboarding.html', - }), + isOnboardingSender({ tab: { id: 7 }, url: onboardingUrl }), false, - 'without a known runtime id a tab sender is never privileged' + 'without a known runtime id nothing is the consent page' ); assert.equal( escapeSafeExternalHref('javascript:alert(1)'), From 765c92d55e06cec08a571f7e9476d7a1d42a82de Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:18:01 +0700 Subject: [PATCH 3/3] fix(extension): consent copy, choice state and docs after review - The consent page has its own opt-in text (onboardingOptInBody): the same fields as the side panel, tested, plus the server re-check and "no account, name, email or cookies". The lookup line lists every field lookup sends. - "Off by default" and how to reach the side panel toggle replace the vague line; vi, en and cs say the same. - Keyboard focus stays on the clicked button, the status is announced again on a repeat, and aria-pressed marks the stored choice. - The privacy policy and CHROMEWEBSTORE.md name the consent page; RELEASE.md has a test step and changelog line for it. Refs #1603 Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 2 +- apps/extension/RELEASE.md | 5 ++- apps/extension/src/_locales/cs/messages.json | 7 +++- apps/extension/src/_locales/en/messages.json | 7 +++- apps/extension/src/_locales/vi/messages.json | 9 +++-- apps/extension/src/onboarding/onboarding.html | 4 +- apps/extension/src/onboarding/onboarding.ts | 40 ++++++++++++++----- apps/extension/src/test-extension-pipeline.ts | 16 ++++++++ apps/web/locales/cs.json | 2 +- apps/web/locales/en.json | 2 +- apps/web/locales/vi.json | 2 +- 11 files changed, 71 insertions(+), 25 deletions(-) diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index 5f79ca19b..56bc201b9 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -45,7 +45,7 @@ This table lists exactly what `manifest.json` requests; `test-extension-pipeline 1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, TikTok Shop product pages on `www.tiktok.com` and `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page. The extension itself never opens or fetches pages the user did not open, never works around a store's bot checks and never solves captchas. 2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account, name, email or cookies. -3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" (English: "Help find new products (only when you turn it on)") in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue, stored with the time the server received them; Rate.vn's servers may re-check the listing on the store. They never change public prices directly. +3. **Opt-in crowd signal (off by default):** only when the user chooses "Có" / "Yes" on the consent page opened once at first install, or turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" (English: "Help find new products (only when you turn it on)") in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue, stored with the time the server received them; Rate.vn's servers may re-check the listing on the store. They never change public prices directly. Closing the consent page keeps the opt-in off, and updates or reloads do not open it again (DOS/Rate#1603). 4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search, their lookup results and a lookup wait time after a rate limit (`chrome.storage.session`). 5. **IP address:** like any web request, the Rate.vn server receives the user's IP address. The lookup and opt-in routes use it only for a per-minute request limit held in memory, and Rate.vn's code does not store it; the hosting provider keeps standard access logs for a short time. 6. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API. diff --git a/apps/extension/RELEASE.md b/apps/extension/RELEASE.md index 3d41739bd..2c44ff297 100644 --- a/apps/extension/RELEASE.md +++ b/apps/extension/RELEASE.md @@ -26,6 +26,7 @@ Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026- - **Product pages:** on one product page each of Shopee, Tiki and CellphoneS, the pill appears and the side panel opens. - **Language:** set Chrome to English, then Czech. The panel and the verdict follow the browser language, and en/cs never show the Vietnamese review summary (#1385). - **Opt-in:** the crowd opt-in is off by default, and its label names the fields it sends (#1230, #1241). + - **Consent page:** a fresh install opens the consent page once, with "No" focused. Closing it leaves the opt-in off; "Yes" turns on the side panel toggle; reloading or updating the extension does not open it again (#1603). 5. Update the store listing: - Description and screenshots match the panel as it is now (the price list and the states from #1319). - The changelog below. @@ -44,7 +45,7 @@ Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026- - Biểu tượng trên thanh công cụ cho biết nhanh giá đang xem có tốt không. Tin bán đã hết hàng không còn được tính vào giá so sánh. - Nhận diện sản phẩm chính xác hơn: tra "iPhone 16" không còn ra iPhone 16e. Trang danh mục và trang thương hiệu không bị nhầm là trang sản phẩm. - Gian hàng chưa có đánh giá được phân tích sẽ ghi "Chưa có dữ liệu", không hiện con số rủi ro. -- Lựa chọn "Đóng góp phát hiện sản phẩm mới" ghi rõ những gì được gửi đi và vẫn tắt mặc định. TikTok chỉ còn chạy trên trang sản phẩm. +- Lựa chọn "Đóng góp phát hiện sản phẩm mới" ghi rõ những gì được gửi đi và vẫn tắt mặc định. Lần cài đầu tiên, tiện ích hỏi bạn một lần. TikTok chỉ còn chạy trên trang sản phẩm. ### English @@ -55,4 +56,4 @@ Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026- - The toolbar badge shows at a glance whether the price you see is a good one. Sold-out listings no longer count towards the price comparison. - More accurate product matching: "iPhone 16" no longer matches the iPhone 16e. Category and brand pages are no longer mistaken for product pages. - Sellers with no analysed reviews show "No data yet" instead of a risk number. -- The "Help find new products" option names exactly what it sends and is still off by default. On TikTok, the extension runs only on product pages. +- The "Help find new products" option names exactly what it sends and is still off by default. On first install, the extension asks you once. On TikTok, the extension runs only on product pages. diff --git a/apps/extension/src/_locales/cs/messages.json b/apps/extension/src/_locales/cs/messages.json index d46102c01..f61b6bbf7 100644 --- a/apps/extension/src/_locales/cs/messages.json +++ b/apps/extension/src/_locales/cs/messages.json @@ -657,13 +657,13 @@ "message": "Rate.vn je připraveno" }, "onboardingLookup": { - "message": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn název, cenu a ID produktu, aby vyhledalo historii cen. Účet není potřeba." + "message": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn obchod, URL bez parametrů, název, ID produktu a obchodu, variantu, cenu a původní cenu, aby vyhledalo historii cen. Účet není potřeba." }, "onboardingQuestion": { "message": "Chcete přispívat nabídkami, které si prohlížíte?" }, "onboardingNothingYet": { - "message": "Rozšíření nic dalšího neposílá, dokud nezvolíte \"Ano\". Volbu můžete kdykoli změnit v bočním panelu." + "message": "Ve výchozím stavu vypnuto. Změnit to můžete kdykoli: klikněte na ikonu Rate.vn, otevře se boční panel, volba „Pomoc s hledáním nových produktů“." }, "onboardingNo": { "message": "Ne, děkuji" @@ -682,5 +682,8 @@ }, "onboardingPrivacyLink": { "message": "Zásady ochrany soukromí" + }, + "onboardingOptInBody": { + "message": "Když zvolíte „Ano“, každá nabídka, kterou otevřete, se také odešle do fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, název produktu, zvolená varianta, cena, původní cena, počet hvězdiček a počet recenzí. Servery Rate.vn mohou nabídku v obchodě znovu ověřit. Bez účtu, jména, e-mailu a cookies." } } diff --git a/apps/extension/src/_locales/en/messages.json b/apps/extension/src/_locales/en/messages.json index 9635cf8e2..2843358d2 100644 --- a/apps/extension/src/_locales/en/messages.json +++ b/apps/extension/src/_locales/en/messages.json @@ -657,13 +657,13 @@ "message": "Rate.vn is ready" }, "onboardingLookup": { - "message": "On product pages of the 10 supported stores, the extension sends the product name, price and ids to Rate.vn to look up price history. No account needed." + "message": "On product pages of the 10 supported stores, the extension sends Rate.vn the store, the URL without parameters, the title, the item and shop ids, the variant, the price and the original price to look up price history. No account needed." }, "onboardingQuestion": { "message": "Would you like to contribute the listings you view?" }, "onboardingNothingYet": { - "message": "The extension sends nothing more until you choose \"Yes\". You can change this any time in the side panel." + "message": "Off by default. Change it any time: click the Rate.vn icon to open the side panel, \"Help find new products\"." }, "onboardingNo": { "message": "No, thanks" @@ -682,5 +682,8 @@ }, "onboardingPrivacyLink": { "message": "Privacy policy" + }, + "onboardingOptInBody": { + "message": "If you choose \"Yes\", every listing you open is also sent to the Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, product name, selected variant, price, original price, star rating and number of reviews. Rate.vn's servers may re-check that listing on the store. No account, name, email or cookies are included." } } diff --git a/apps/extension/src/_locales/vi/messages.json b/apps/extension/src/_locales/vi/messages.json index 23251cc4f..355d9682a 100644 --- a/apps/extension/src/_locales/vi/messages.json +++ b/apps/extension/src/_locales/vi/messages.json @@ -657,13 +657,13 @@ "message": "Rate.vn đã sẵn sàng" }, "onboardingLookup": { - "message": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi tên, giá và mã sản phẩm cho Rate.vn để tra lịch sử giá. Không cần tài khoản." + "message": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi cho Rate.vn sàn, URL đã bỏ tham số, tên, mã sản phẩm và mã gian hàng, phiên bản, giá bán và giá gốc để tra lịch sử giá. Không cần tài khoản." }, "onboardingQuestion": { "message": "Bạn có muốn đóng góp các tin bán bạn xem không?" }, "onboardingNothingYet": { - "message": "Tiện ích không gửi thêm gì cho đến khi bạn chọn \"Có\". Bạn có thể đổi lựa chọn bất cứ lúc nào trong bảng bên cạnh." + "message": "Mặc định tắt. Bạn đổi được bất cứ lúc nào: bấm biểu tượng Rate.vn để mở bảng bên cạnh, mục \"Đóng góp phát hiện sản phẩm mới\"." }, "onboardingNo": { "message": "Không, cảm ơn" @@ -675,12 +675,15 @@ "message": "Đã lưu: không đóng góp. Bạn có thể đóng trang này." }, "onboardingSavedYes": { - "message": "Đã lưu: có đóng góp. Bạn có thể tắt trong bảng bên cạnh." + "message": "Đã lưu: bật đóng góp. Bạn tắt được trong bảng bên cạnh." }, "onboardingSaveFailed": { "message": "Chưa lưu được. Hãy thử lại hoặc chọn trong bảng bên cạnh." }, "onboardingPrivacyLink": { "message": "Chính sách bảo mật" + }, + "onboardingOptInBody": { + "message": "Nếu bạn chọn \"Có\", mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt của Rate.vn với: sàn, mã sản phẩm, mã gian hàng, URL đã cắt tham số theo dõi, tên sản phẩm, phiên bản đang chọn, giá bán, giá gốc, số sao và số lượt đánh giá. Máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn. Không kèm tài khoản, tên, email hay cookie." } } diff --git a/apps/extension/src/onboarding/onboarding.html b/apps/extension/src/onboarding/onboarding.html index 64bb693c5..0f393d878 100644 --- a/apps/extension/src/onboarding/onboarding.html +++ b/apps/extension/src/onboarding/onboarding.html @@ -110,7 +110,7 @@

-

+

-

+

{ + // A flag, not disabled buttons, so keyboard focus stays where it was. + if (busy) return; + busy = true; const status = document.getElementById('status'); - const buttons = document.querySelectorAll('button'); - buttons.forEach((b) => (b.disabled = true)); + // Cleared first, so the same choice made twice is announced again. + if (status) status.textContent = ''; try { const res = (await chrome.runtime.sendMessage({ type: 'RATE_UPDATE_SETTINGS', @@ -18,6 +35,7 @@ async function choose(optIn: boolean): Promise { if (!res?.ok || res.settings?.crowdContributeOptIn !== optIn) { throw new Error('not saved'); } + markChoice(optIn); if (status) { status.textContent = t( optIn ? 'onboardingSavedYes' : 'onboardingSavedNo' @@ -26,28 +44,30 @@ async function choose(optIn: boolean): Promise { } catch { if (status) status.textContent = t('onboardingSaveFailed'); } finally { - buttons.forEach((b) => (b.disabled = false)); + busy = false; } } document.addEventListener('DOMContentLoaded', () => { applyDocumentMessages(document); + document.title = t('onboardingTitle'); + document + .getElementById('btn-no') + ?.addEventListener('click', () => void choose(false)); + document + .getElementById('btn-yes') + ?.addEventListener('click', () => void choose(true)); // The status always shows the stored choice, also one changed in the side // panel after this page saved it. + if (typeof chrome === 'undefined') return; chrome.storage.onChanged?.addListener((changes, areaName) => { const change = changes.crowdContributeOptIn; const status = document.getElementById('status'); if (areaName === 'local' && change && status) { + markChoice(change.newValue === true); status.textContent = t( change.newValue === true ? 'onboardingSavedYes' : 'onboardingSavedNo' ); } }); - document.title = t('onboardingTitle'); - document - .getElementById('btn-no') - ?.addEventListener('click', () => void choose(false)); - document - .getElementById('btn-yes') - ?.addEventListener('click', () => void choose(true)); }); diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index e8bd38032..a7ffce1d3 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -652,7 +652,23 @@ async function runTests() { ); } assert.ok(!/Chỉ gửi URL/.test(optInText)); + // The install consent page names the same fields (DOS/Rate#1603). + const consentText = catalogue.onboardingOptInBody?.message ?? ''; + for (const key of Object.values(CROWD_INGEST_FIELD_LABELS)) { + assert.ok( + consentText.includes(catalogue[key]?.message ?? '\u0000'), + `${locale} consent text lacks ${key}` + ); + } } + assert.ok( + fs + .readFileSync( + path.resolve(__dirname, 'onboarding/onboarding.html'), + 'utf8' + ) + .includes('data-i18n="onboardingOptInBody"') + ); // The store listing names the extension as the vi catalogue does. const viName = readLocaleCatalogues().vi.extName?.message; assert.equal( diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index bd829dc46..b9c405417 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1534,7 +1534,7 @@ "privacyS4Title": "4. Soukromé údaje prodejců", "privacyS4Desc": "Soukromé údaje prodejce (telefonní číslo, bankovní účet) jsou v polích, která veřejnost nemůže číst. Web i API mohou číst jen veřejná pole.", "privacyS5Title": "5. Rozšíření prohlížeče Rate.vn", - "privacyS5Desc": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn obchod, URL produktu bez parametrů, název produktu, ID položky a obchodu, zvolenou variantu, cenu a původní cenu, aby vyhledalo historii cen; toto vyhledávání je zapnuté ve výchozím stavu, jen čte a nepotřebuje účet. Text, který napíšete do vyhledávacího pole rozšíření, se také odešle k vyhledání. Jen když zapnete \"Pomoc s hledáním nových produktů (jen když ji zapnete)\" (ve výchozím stavu vypnuto), posílá také danou nabídku s hodnocením a počtem hodnocení do fronty ke kontrole, uloženou s časem, kdy ji server přijal; servery Rate.vn mohou nabídku v obchodě znovu ověřit a tato data sama veřejné ceny nemění. Rozšíření neposílá účet, jméno, e-mail ani cookies, neotevírá stránky, které jste neotevřeli, a ve vašem prohlížeči ukládá jen adresu serveru, volbu opt-in a mezipaměť platnou po dobu relace s produkty, které jste si prohlédli, vaším posledním hledáním, výsledky vyhledávání a dobou čekání na další vyhledání. Jako u každého webového požadavku server obdrží vaši IP adresu: kód Rate.vn ji používá jen v paměti pro limit požadavků za minutu a náš poskytovatel hostingu uchovává běžné přístupové záznamy po krátkou dobu.", + "privacyS5Desc": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn obchod, URL produktu bez parametrů, název produktu, ID položky a obchodu, zvolenou variantu, cenu a původní cenu, aby vyhledalo historii cen; toto vyhledávání je zapnuté ve výchozím stavu, jen čte a nepotřebuje účet. Text, který napíšete do vyhledávacího pole rozšíření, se také odešle k vyhledání. Jen když zvolíte \"Ano\" na stránce zobrazené při instalaci nebo zapnete \"Pomoc s hledáním nových produktů (jen když ji zapnete)\" v bočním panelu (ve výchozím stavu vypnuto), posílá také danou nabídku s hodnocením a počtem hodnocení do fronty ke kontrole, uloženou s časem, kdy ji server přijal; servery Rate.vn mohou nabídku v obchodě znovu ověřit a tato data sama veřejné ceny nemění. Rozšíření neposílá účet, jméno, e-mail ani cookies, neotevírá stránky, které jste neotevřeli, a ve vašem prohlížeči ukládá jen adresu serveru, volbu opt-in a mezipaměť platnou po dobu relace s produkty, které jste si prohlédli, vaším posledním hledáním, výsledky vyhledávání a dobou čekání na další vyhledání. Jako u každého webového požadavku server obdrží vaši IP adresu: kód Rate.vn ji používá jen v paměti pro limit požadavků za minutu a náš poskytovatel hostingu uchovává běžné přístupové záznamy po krátkou dobu.", "privacySide1Title": "Ověřte si otevřený kód", "privacySide1Desc": "Pravidla přístupu k datům a způsob ukládání hlasů jsou veřejné v repozitáři na GitHubu.", "privacySide1Btn": "Technická a API dokumentace", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index b7d1b41df..fc5960b1f 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1534,7 +1534,7 @@ "privacyS4Title": "4. Sellers' private details", "privacyS4Desc": "A seller's private details (phone number, bank account) are kept in fields the public cannot read. The website and the API can only read the public fields.", "privacyS5Title": "5. The Rate.vn browser extension", - "privacyS5Desc": "On product pages of the 10 supported stores, the extension sends Rate.vn the store, the product URL without its parameters, the product title, the item and shop ids, the selected variant, the price and the original price to look up price history; this lookup is on by default, read-only and needs no account. Text you type into the extension's search box is also sent to look it up. Only if you turn on \"Help find new products (only when you turn it on)\" (off by default) does it also send that listing, with its rating and rating count, to a moderation queue, stored with the time the server received it; Rate.vn's servers may re-check that listing on the store, and this data does not change public prices by itself. The extension sends no account, name, email or cookies, opens no page you did not open, and keeps only the server address, the opt-in choice and a session-only cache of the products you viewed, your latest search, their lookup results and a lookup wait time in your browser. Like any web request, the server receives your IP address: Rate.vn's code uses it only in memory for a per-minute request limit, and our hosting provider keeps standard access logs for a short time.", + "privacyS5Desc": "On product pages of the 10 supported stores, the extension sends Rate.vn the store, the product URL without its parameters, the product title, the item and shop ids, the selected variant, the price and the original price to look up price history; this lookup is on by default, read-only and needs no account. Text you type into the extension's search box is also sent to look it up. Only if you choose \"Yes\" on the page shown at install or turn on \"Help find new products (only when you turn it on)\" in the side panel (off by default) does it also send that listing, with its rating and rating count, to a moderation queue, stored with the time the server received it; Rate.vn's servers may re-check that listing on the store, and this data does not change public prices by itself. The extension sends no account, name, email or cookies, opens no page you did not open, and keeps only the server address, the opt-in choice and a session-only cache of the products you viewed, your latest search, their lookup results and a lookup wait time in your browser. Like any web request, the server receives your IP address: Rate.vn's code uses it only in memory for a per-minute request limit, and our hosting provider keeps standard access logs for a short time.", "privacySide1Title": "Check the open code", "privacySide1Desc": "The data access rules and how votes are stored are public in the GitHub repository.", "privacySide1Btn": "Technical and API docs", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index a7009f971..c4564139d 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1534,7 +1534,7 @@ "privacyS4Title": "4. Thông tin riêng của gian hàng", "privacyS4Desc": "Thông tin riêng của gian hàng (số điện thoại, tài khoản ngân hàng) nằm ở các trường mà công chúng không đọc được. Trang web và API chỉ đọc được các trường công khai.", "privacyS5Title": "5. Tiện ích trình duyệt Rate.vn", - "privacyS5Desc": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi cho Rate.vn sàn, URL sản phẩm đã bỏ tham số, tên sản phẩm, mã sản phẩm và mã gian hàng, phiên bản đang chọn, giá bán và giá gốc để tra lịch sử giá; việc tra cứu này bật sẵn, chỉ đọc và không cần tài khoản. Nội dung bạn gõ vào ô tìm kiếm của tiện ích cũng được gửi để tra cứu. Chỉ khi bạn bật \"Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)\" (mặc định tắt), tiện ích còn gửi tin bán đó kèm số sao và số lượt đánh giá vào hàng đợi kiểm duyệt, lưu kèm thời điểm máy chủ nhận; máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn, và dữ liệu này không tự đổi giá công khai. Tiện ích không gửi tài khoản, tên, email hay cookie, không mở trang bạn không mở, và chỉ lưu trên trình duyệt của bạn địa chỉ máy chủ, lựa chọn có đóng góp hay không, cùng bộ nhớ tạm theo phiên gồm các sản phẩm bạn vừa xem, lần tìm kiếm gần nhất, kết quả tra cứu và thời gian chờ khi bị giới hạn tra cứu. Như mọi yêu cầu web, máy chủ nhận địa chỉ IP của bạn: mã của Rate.vn chỉ dùng nó trong bộ nhớ để giới hạn số yêu cầu mỗi phút, còn nhà cung cấp hosting giữ nhật ký truy cập thông thường trong thời gian ngắn.", + "privacyS5Desc": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi cho Rate.vn sàn, URL sản phẩm đã bỏ tham số, tên sản phẩm, mã sản phẩm và mã gian hàng, phiên bản đang chọn, giá bán và giá gốc để tra lịch sử giá; việc tra cứu này bật sẵn, chỉ đọc và không cần tài khoản. Nội dung bạn gõ vào ô tìm kiếm của tiện ích cũng được gửi để tra cứu. Chỉ khi bạn chọn \"Có\" ở trang hiện ra lúc cài đặt hoặc bật \"Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)\" trong bảng bên cạnh (mặc định tắt), tiện ích còn gửi tin bán đó kèm số sao và số lượt đánh giá vào hàng đợi kiểm duyệt, lưu kèm thời điểm máy chủ nhận; máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn, và dữ liệu này không tự đổi giá công khai. Tiện ích không gửi tài khoản, tên, email hay cookie, không mở trang bạn không mở, và chỉ lưu trên trình duyệt của bạn địa chỉ máy chủ, lựa chọn có đóng góp hay không, cùng bộ nhớ tạm theo phiên gồm các sản phẩm bạn vừa xem, lần tìm kiếm gần nhất, kết quả tra cứu và thời gian chờ khi bị giới hạn tra cứu. Như mọi yêu cầu web, máy chủ nhận địa chỉ IP của bạn: mã của Rate.vn chỉ dùng nó trong bộ nhớ để giới hạn số yêu cầu mỗi phút, còn nhà cung cấp hosting giữ nhật ký truy cập thông thường trong thời gian ngắn.", "privacySide1Title": "Kiểm tra mã nguồn công khai", "privacySide1Desc": "Quy tắc phân quyền dữ liệu và cách lưu bình chọn đều công khai trong repository trên GitHub.", "privacySide1Btn": "Xem tài liệu kỹ thuật và API",