From 1625f166fe25b149614b154d85682448d2ac72aa Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:30:21 +0700 Subject: [PATCH 1/2] feat(extension): opted-in installs send observation v1, with disclosures The opt-in now sends ExtensionObservation v1 to the observation route with a random submitter id from the submitter route, instead of the legacy crowd ingest (no title, no ratings). Turning the opt-in off deletes the id and every observation sent with it. The web routes stay off (RATE_EXTENSION_OBSERVATIONS), so nothing is stored until JOY turns them on. The opt-in text, the consent page, the privacy policy (vi, en, cs), CHROMEWEBSTORE.md and the changelog name the v1 fields, the submitter id and the salted network-range hash with its retention; a test ties the texts to the v1 keys. Refs #1606 Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 6 +- apps/extension/RELEASE.md | 4 +- apps/extension/src/_locales/cs/messages.json | 22 ++++- apps/extension/src/_locales/en/messages.json | 22 ++++- apps/extension/src/_locales/vi/messages.json | 22 ++++- .../src/background/service-worker.ts | 90 ++++++++++++++++++- apps/extension/src/security-guards.ts | 38 +++++++- apps/extension/src/test-extension-pipeline.ts | 76 +++++++++++++--- apps/extension/src/types.ts | 1 + apps/web/locales/cs.json | 2 +- apps/web/locales/en.json | 2 +- apps/web/locales/vi.json | 2 +- packages/shared-data/src/constants/routes.ts | 2 + 13 files changed, 253 insertions(+), 36 deletions(-) diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index 56bc201b9..cd8d0246d 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -45,9 +45,9 @@ 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 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. +3. **Opt-in observation (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 sends an ExtensionObservation v1 to `POST /api/v1/rate/extension/observation`: the store, the cleaned URL (no query), the item and shop ids, the variant id (SKU), the selected variant, the price, the original price, the stock status, the time the user saw the price and the extension version (DOS/Rate#1606). It carries a random submitter id for the install, obtained once from `POST /api/v1/rate/extension/submitter`. No title, no ratings, no account data. The toggle's label and the consent page list these same fields; a test ties them to the payload. Observations go to a quarantine, stored with the time received and a salted hash of the network range (never the IP) to tell independent senders apart; the hash is deleted after 28 days and observations after 180 days. Rate.vn's servers may re-check the listing on the store. They never change public prices directly. Turning the opt-in off calls `DELETE /api/v1/rate/extension/submitter`, which deletes the id and every observation sent with it. 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, the opt-in choice and, while the user contributes, the submitter id (`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 observation routes use it in memory for a per-minute request limit; the observation routes also derive the salted network-range hash above from it. Rate.vn's code never stores the IP itself; 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. 7. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension. diff --git a/apps/extension/RELEASE.md b/apps/extension/RELEASE.md index 2c44ff297..46ae68bf2 100644 --- a/apps/extension/RELEASE.md +++ b/apps/extension/RELEASE.md @@ -45,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. 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. +- 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. Khi bật, tiện ích gửi một quan sát gọn (giá, tồn kho, SKU) thay cho số sao và lượt đánh giá; tắt là xóa mọi thứ đã gửi. TikTok chỉ còn chạy trên trang sản phẩm. ### English @@ -56,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 first install, the extension asks you once. 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. When on, it sends a minimal observation (price, stock, SKU) instead of ratings; turning it off deletes everything it sent. 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 f61b6bbf7..67d3eebc3 100644 --- a/apps/extension/src/_locales/cs/messages.json +++ b/apps/extension/src/_locales/cs/messages.json @@ -53,7 +53,7 @@ "message": "Pomoc s hledáním nových produktů (jen když ji zapnete):" }, "optInBody": { - "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé 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í. Bez účtu, jména, e-mailu a cookies." + "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav skladu, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID této instalace a otisk rozsahu vaší sítě (ne IP adresa). Servery Rate.vn mohou nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." }, "crowdFieldPlatform": { "message": "obchod" @@ -684,6 +684,24 @@ "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." + "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ů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav skladu, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID této instalace a otisk rozsahu vaší sítě (ne IP adresa). Servery Rate.vn mohou nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." + }, + "observationFieldSku": { + "message": "ID varianty (SKU)" + }, + "observationFieldStock": { + "message": "stav skladu" + }, + "observationFieldObservedAt": { + "message": "čas, kdy jste cenu viděli" + }, + "observationFieldExtractor": { + "message": "verze rozšíření" + }, + "observationFieldSubmitter": { + "message": "náhodné ID této instalace" + }, + "observationFieldNetwork": { + "message": "otisk rozsahu vaší sítě (ne IP adresa)" } } diff --git a/apps/extension/src/_locales/en/messages.json b/apps/extension/src/_locales/en/messages.json index 2843358d2..e8b711c50 100644 --- a/apps/extension/src/_locales/en/messages.json +++ b/apps/extension/src/_locales/en/messages.json @@ -53,7 +53,7 @@ "message": "Help find new products (only when you turn it on):" }, "optInBody": { - "message": "When on, every listing you open is also sent to the independent 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. No account, name, email or cookies are included." + "message": "When on, every listing you open is also sent to the independent Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, variant ID (SKU), selected variant, price, original price, stock status, the time you saw the price, the extension version, plus a random ID for this install and a hash of your network range (not your IP). Rate.vn's servers may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." }, "crowdFieldPlatform": { "message": "store" @@ -684,6 +684,24 @@ "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." + "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, variant ID (SKU), selected variant, price, original price, stock status, the time you saw the price, the extension version, plus a random ID for this install and a hash of your network range (not your IP). Rate.vn's servers may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." + }, + "observationFieldSku": { + "message": "variant ID (SKU)" + }, + "observationFieldStock": { + "message": "stock status" + }, + "observationFieldObservedAt": { + "message": "the time you saw the price" + }, + "observationFieldExtractor": { + "message": "the extension version" + }, + "observationFieldSubmitter": { + "message": "a random ID for this install" + }, + "observationFieldNetwork": { + "message": "a hash of your network range (not your IP)" } } diff --git a/apps/extension/src/_locales/vi/messages.json b/apps/extension/src/_locales/vi/messages.json index 355d9682a..17121143e 100644 --- a/apps/extension/src/_locales/vi/messages.json +++ b/apps/extension/src/_locales/vi/messages.json @@ -53,7 +53,7 @@ "message": "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật):" }, "optInBody": { - "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập 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á. Không kèm tài khoản, tên, email hay cookie." + "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập 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, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này và mã băm dải mạng của bạn (không phải IP). Máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." }, "crowdFieldPlatform": { "message": "sàn" @@ -684,6 +684,24 @@ "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." + "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, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này và mã băm dải mạng của bạn (không phải IP). Máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." + }, + "observationFieldSku": { + "message": "mã phiên bản (SKU)" + }, + "observationFieldStock": { + "message": "tình trạng còn hàng" + }, + "observationFieldObservedAt": { + "message": "thời điểm bạn thấy giá" + }, + "observationFieldExtractor": { + "message": "phiên bản tiện ích" + }, + "observationFieldSubmitter": { + "message": "một mã ngẫu nhiên của lượt cài này" + }, + "observationFieldNetwork": { + "message": "mã băm dải mạng của bạn (không phải IP)" } } diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 50f560419..af44b45cf 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -10,7 +10,7 @@ import { sanitizeProductUrl, } from '../extractors/ecommerce-detector.js'; import { - buildCrowdIngestPayload, + buildExtensionObservationV1, buildLookupParams, DEFAULT_API_BASE_URL, isOnboardingSender, @@ -159,9 +159,19 @@ async function maybeSendOptInCrowdSignal( // A 429 may have come back while the settings were read. if ((await lookupCooldown.secondsLeft()) > 0) return; + // ExtensionObservation v1 into the quarantine (DOS/Rate#1606): only the + // fields v1 names, with this install's random submitter id. + const observation = buildExtensionObservationV1( + signal, + new Date(), + EXTENSION_VERSION + ); + if (!observation) return; try { - await fetch( - `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_INGEST}`, + const submitterId = await observationSubmitterId(settings.apiBaseUrl); + if (!submitterId) return; + const res = await fetch( + `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_OBSERVATION}`, { method: 'POST', headers: { @@ -169,14 +179,82 @@ async function maybeSendOptInCrowdSignal( 'X-Rate-Extension-Version': EXTENSION_VERSION, }, signal: AbortSignal.timeout(8_000), - body: JSON.stringify(buildCrowdIngestPayload(signal)), + body: JSON.stringify({ submitterId, observation }), } ); + // The server no longer knows this id (deleted, purged): get a new one + // next time. + if (res.status === 404) { + const body = (await res.json().catch(() => null)) as { + code?: string; + } | null; + if (body?.code === 'UNKNOWN_SUBMITTER') { + await chrome.storage.local.remove?.(SUBMITTER_ID_KEY); + } + } } catch { // Non-blocking background quarantine submission } } +const SUBMITTER_ID_KEY = 'observationSubmitterId'; +const SUBMITTER_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** + * This install's pseudonymous submitter id, asked from the server once and + * kept in chrome.storage.local; null while the server does not take + * observations (the routes answer 404 until they are turned on). + */ +async function observationSubmitterId( + apiBaseUrl: string +): Promise { + const stored = await chrome.storage.local.get(SUBMITTER_ID_KEY); + const known = stored[SUBMITTER_ID_KEY]; + if (typeof known === 'string' && SUBMITTER_ID_RE.test(known)) return known; + const res = await fetch( + `${apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_SUBMITTER}`, + { + method: 'POST', + headers: { 'X-Rate-Extension-Version': EXTENSION_VERSION }, + signal: AbortSignal.timeout(8_000), + } + ); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { + submitterId?: unknown; + } | null; + const id = body?.submitterId; + if (typeof id !== 'string' || !SUBMITTER_ID_RE.test(id)) return null; + await chrome.storage.local.set({ [SUBMITTER_ID_KEY]: id }); + return id; +} + +/** + * Turning the opt-in off erases this install's contributions: the server + * deletes the submitter id and every observation sent with it, and the id + * is forgotten here (DOS/Rate#1606). + */ +async function forgetObservationSubmitter(apiBaseUrl: string): Promise { + const stored = await chrome.storage.local.get(SUBMITTER_ID_KEY); + const id = stored[SUBMITTER_ID_KEY]; + await chrome.storage.local.remove?.(SUBMITTER_ID_KEY); + if (typeof id !== 'string' || !SUBMITTER_ID_RE.test(id)) return; + try { + await fetch(`${apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_SUBMITTER}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-Rate-Extension-Version': EXTENSION_VERSION, + }, + signal: AbortSignal.timeout(8_000), + body: JSON.stringify({ submitterId: id }), + }); + } catch { + // Best effort; the server's purge removes ids that stop sending. + } +} + async function updateActionBadge( tabId: number | undefined, lookup: ExtensionLookupData | null @@ -598,7 +676,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (typeof message.crowdContributeOptIn === 'boolean') { nextSettings.crowdContributeOptIn = message.crowdContributeOptIn; } + const before = await getSettings(); await chrome.storage.local.set(nextSettings); + if (nextSettings.crowdContributeOptIn === false) { + void forgetObservationSubmitter(before.apiBaseUrl); + } const updated = await getSettings(); sendResponse({ ok: true, settings: updated }); } catch (err) { diff --git a/apps/extension/src/security-guards.ts b/apps/extension/src/security-guards.ts index 9c248025a..cff80b0e8 100644 --- a/apps/extension/src/security-guards.ts +++ b/apps/extension/src/security-guards.ts @@ -375,10 +375,9 @@ export function buildLookupParams( } /** - * The ExtensionObservation v1 an opted-in install would send for `signal` + * The ExtensionObservation v1 an opted-in install sends for `signal` * (DOS/Rate#1606), or null when the page lacks what v1 needs (an item id, a - * price). Not sent yet: the opt-in signal still uses buildCrowdIngestPayload - * until the quarantine table exists and the disclosures name v1. + * price). */ export function buildExtensionObservationV1( rawSignal: ExtractedPageProductSignal, @@ -627,7 +626,38 @@ export function shouldRenderTrustPill( } /** - * What the opt-in crowd signal sends, with the message key of the words the + * What an opted-in install sends (ExtensionObservation v1, DOS/Rate#1606), + * with the message key of the words the opt-in texts use for each field; + * every locale's opt-in and consent texts name each one, plus the submitter + * id and the network hash the server adds. + */ +export const OBSERVATION_FIELD_LABELS = { + marketplace: 'crowdFieldPlatform', + listing_url: 'crowdFieldUrl', + external_item_id: 'crowdFieldExternalItemId', + external_shop_id: 'crowdFieldExternalShopId', + sku_id: 'observationFieldSku', + variant_label: 'crowdFieldSelectedVariant', + price_vnd: 'crowdFieldPrice', + list_price_vnd: 'crowdFieldOriginalPrice', + stock_state: 'observationFieldStock', + stock_qty: 'observationFieldStock', + observed_at: 'observationFieldObservedAt', + extractor: 'observationFieldExtractor', +} as const satisfies Record< + Exclude, + MessageKey +>; + +/** What else an observation carries: the submitter id and the network hash. */ +export const OBSERVATION_EXTRA_LABELS = [ + 'observationFieldSubmitter', + 'observationFieldNetwork', +] as const satisfies readonly MessageKey[]; + +/** + * The legacy opt-in crowd signal (still accepted by the ingest route for the + * contact form); the extension now sends v1. What it sends, with the message key of the words the * side panel uses for each field. The opt-in text in every locale names * each one, so a field added here is disclosed too (DOS/Rate#1230, #1304). */ diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index 33318d466..a8e47830c 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -618,18 +618,34 @@ async function runTests() { hosts.filter((h) => !/rate\.vn/.test(h)).sort() ); - // The opt-in label names every field the crowd signal sends - // (DOS/Rate#1230). - const { buildCrowdIngestPayload, CROWD_INGEST_FIELD_LABELS } = - await import('./security-guards.js'); - const payload = buildCrowdIngestPayload({ - platform: 'shopee', - canonicalUrl: 'https://shopee.vn/x-i.1.2', - title: 'iPhone 16', - } as never); + // The opt-in label names every field an opted-in install sends: the v1 + // observation's keys, plus the submitter id and the network hash + // (DOS/Rate#1230, #1606). + const { + buildExtensionObservationV1, + OBSERVATION_FIELD_LABELS, + OBSERVATION_EXTRA_LABELS, + } = await import('./security-guards.js'); + const sample = buildExtensionObservationV1( + { + platform: 'shopee', + canonicalUrl: 'https://shopee.vn/x-i.1.2', + externalItemId: '2', + externalShopId: '1', + title: 'iPhone 16', + selectedVariant: null, + price: 19_990_000, + originalPrice: null, + ratingStar: 5, + ratingCount: 3, + }, + new Date(), + '2.1.0' + ); + assert.ok(sample); assert.deepEqual( - Object.keys(payload).sort(), - [...Object.keys(CROWD_INGEST_FIELD_LABELS), 'userConsentOptIn'].sort() + Object.keys(sample).sort(), + [...Object.keys(OBSERVATION_FIELD_LABELS), 'schema_version'].sort() ); const panelHtml = fs.readFileSync( path.resolve(__dirname, 'sidepanel/sidepanel.html'), @@ -640,10 +656,16 @@ async function runTests() { panelHtml.indexOf('', panelHtml.indexOf('id="chk-crowd-optin"')) ); assert.ok(optInLabel.includes('data-i18n="optInBody"')); + const disclosedKeys = [ + ...new Set([ + ...Object.values(OBSERVATION_FIELD_LABELS), + ...OBSERVATION_EXTRA_LABELS, + ]), + ]; // Every locale's opt-in text names every field (DOS/Rate#1304). for (const [locale, catalogue] of Object.entries(readLocaleCatalogues())) { const optInText = catalogue.optInBody?.message ?? ''; - for (const key of Object.values(CROWD_INGEST_FIELD_LABELS)) { + for (const key of disclosedKeys) { const label = catalogue[key]?.message ?? ''; assert.ok(label, `${locale} lacks ${key}`); assert.ok( @@ -651,10 +673,21 @@ async function runTests() { `${locale} opt-in text lacks "${label}"` ); } + // Ratings are no longer sent (JOY approved dropping them). + for (const gone of [ + 'crowdFieldRatingStar', + 'crowdFieldRatingCount', + 'crowdFieldTitle', + ]) { + assert.ok( + !optInText.includes(catalogue[gone]?.message ?? '\u0000'), + `${locale} opt-in text still names ${gone}` + ); + } 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)) { + for (const key of disclosedKeys) { assert.ok( consentText.includes(catalogue[key]?.message ?? '\u0000'), `${locale} consent text lacks ${key}` @@ -4296,7 +4329,9 @@ async function runTests() { workerSource.match(/'X-Rate-Extension-Version': EXTENSION_VERSION/g) ?? [] ).length, - 2 + // Every request the worker makes carries it (DOS/Rate#1606 added the + // submitter and observation calls). + (workerSource.match(/\bfetch\(/g) ?? []).length ); assert.ok(workerSource.includes('chrome.runtime.getManifest().version')); // The cooldown is checked before any fetch, and a 429 sets it from Retry-After. @@ -5265,6 +5300,19 @@ async function runTests() { ); assert.ok(!/stockState|skuId|stockQty|extractorSource/.test(workerSource)); assert.ok(workerSource.includes('buildLookupParams(rawSignal, mode)')); + // The opt-in sends v1 to the observation route, never the legacy crowd + // ingest, and turning it off erases the install's id (DOS/Rate#1606). + assert.ok( + workerSource.includes('RATE_API_V1_ENDPOINTS.EXTENSION_OBSERVATION') + ); + assert.ok(!workerSource.includes('EXTENSION_INGEST')); + assert.ok(!workerSource.includes('buildCrowdIngestPayload')); + assert.ok( + /crowdContributeOptIn === false\)\s*\{\s*void forgetObservationSubmitter\(/.test( + workerSource + ) + ); + assert.ok(workerSource.includes("method: 'DELETE'")); // A long variant label is cut to 120 characters, not a lost observation. const longObs = guards.buildExtensionObservationV1( diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index e40ab530a..24da790ca 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -274,6 +274,7 @@ declare global { keys?: string | string[] | Record ) => Promise>; set: (items: Record) => Promise; + remove?: (keys: string | string[]) => Promise; }; session: { get: ( diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index b9c405417..1dc5bf03e 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ž 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.", + "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é do fronty ke kontrole záznam o dané nabídce: obchod, URL bez parametrů, ID položky a obchodu, ID varianty (SKU), zvolenou variantu, cenu, původní cenu, stav skladu, čas, kdy jste cenu viděli, a verzi rozšíření, spolu s náhodným ID této instalace. Server jej ukládá s časem přijetí a s osoleným otiskem rozsahu vaší sítě (ne IP adresou), aby rozlišil nezávislé odesílatele; otisk se maže po 28 dnech a záznam po 180 dnech. Servery Rate.vn mohou nabídku v obchodě znovu ověřit a tato data sama veřejné ceny nemění. Vypnutím přispívání se ID instalace i všechny odeslané záznamy smažou. 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, ID přispěvatele této instalace, dokud přispíváte, 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 fc5960b1f..f73e1fd68 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 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.", + "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 an observation of that listing to a moderation queue: the store, the URL without its parameters, the item and shop ids, the variant ID (SKU), the selected variant, the price, the original price, the stock status, the time you saw the price and the extension version, with a random ID for this install. The server stores it with the time it was received and a salted hash of your network range (not your IP) to tell independent senders apart; the hash is deleted after 28 days and the observation after 180 days. Rate.vn's servers may re-check that listing on the store, and this data does not change public prices by itself. Turning contributions off deletes the install's ID and every observation sent with it. 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, the install's contributor ID while you contribute, 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 c4564139d..2534faff7 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 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.", + "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 vào hàng đợi kiểm duyệt một quan sát về tin bán đó: sàn, URL đã bỏ tham số, mã sản phẩm, mã gian hàng, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá và phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này. Máy chủ lưu kèm thời điểm nhận và một mã băm có muối của dải mạng (không phải IP) để phân biệt người gửi độc lập; mã băm được xóa sau 28 ngày, quan sát sau 180 ngày. 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. Tắt đóng góp là xóa mã người gửi cùng mọi quan sát đã gửi. 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, mã người gửi khi bạn đóng góp, 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", diff --git a/packages/shared-data/src/constants/routes.ts b/packages/shared-data/src/constants/routes.ts index 21270c4c7..2ebd98074 100644 --- a/packages/shared-data/src/constants/routes.ts +++ b/packages/shared-data/src/constants/routes.ts @@ -56,6 +56,8 @@ export const RATE_API_V1_ENDPOINTS = { REVALIDATE: '/api/v1/rate/revalidate', EXTENSION_LOOKUP: '/api/v1/rate/extension/lookup', EXTENSION_INGEST: '/api/v1/rate/extension/ingest', + EXTENSION_SUBMITTER: '/api/v1/rate/extension/submitter', + EXTENSION_OBSERVATION: '/api/v1/rate/extension/observation', REVIEW_VOTE: (reviewId: string) => `/api/reviews/${encodeURIComponent(sanitizePathSegment(reviewId, 128))}/vote`, HEALTH: '/api/health', From 254f6085a947c7af99a8c4645a7f02804a27c38f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:38:54 +0700 Subject: [PATCH 2/2] fix(extension): observation client with kept erasure, holds and consent v2 - observation-client.ts runs register, send and erase on one chain: two product pages register one id, and an opt-out waits for a send in flight; an id issued after the opt-out is erased at once. - Erasures stay queued until the server confirms (200) and are retried at start-up and on the next send; the stored id carries the server that issued it, and an id from another server is erased there. - 429 holds for its Retry-After; routes off (404 NOT_ENABLED) or 503 hold an hour, so opted-in installs stop asking. - An opt-in given to the older crowd signal is not consent to v1: it reads as off, and an update reopens the consent page. - Disclosures: stock quantity, the server-derived network hash, the IP sentence, one name for the contributor ID, the uninstall case, and a release test step for erasure. - Behavioural tests for the client with stubbed storage and fetch. Refs #1606 Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 2 +- apps/extension/RELEASE.md | 1 + apps/extension/src/_locales/cs/messages.json | 8 +- apps/extension/src/_locales/en/messages.json | 8 +- apps/extension/src/_locales/vi/messages.json | 10 +- .../src/background/observation-client.ts | 274 ++++++++++++++++++ .../src/background/service-worker.ts | 152 ++++------ apps/extension/src/test-extension-pipeline.ts | 269 ++++++++++++++++- apps/extension/src/types.ts | 5 +- apps/web/locales/cs.json | 2 +- apps/web/locales/en.json | 2 +- apps/web/locales/vi.json | 2 +- 12 files changed, 621 insertions(+), 114 deletions(-) create mode 100644 apps/extension/src/background/observation-client.ts diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index cd8d0246d..01a030a9c 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 observation (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 sends an ExtensionObservation v1 to `POST /api/v1/rate/extension/observation`: the store, the cleaned URL (no query), the item and shop ids, the variant id (SKU), the selected variant, the price, the original price, the stock status, the time the user saw the price and the extension version (DOS/Rate#1606). It carries a random submitter id for the install, obtained once from `POST /api/v1/rate/extension/submitter`. No title, no ratings, no account data. The toggle's label and the consent page list these same fields; a test ties them to the payload. Observations go to a quarantine, stored with the time received and a salted hash of the network range (never the IP) to tell independent senders apart; the hash is deleted after 28 days and observations after 180 days. Rate.vn's servers may re-check the listing on the store. They never change public prices directly. Turning the opt-in off calls `DELETE /api/v1/rate/extension/submitter`, which deletes the id and every observation sent with it. Closing the consent page keeps the opt-in off, and updates or reloads do not open it again (DOS/Rate#1603). +3. **Opt-in observation (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 sends an ExtensionObservation v1 to `POST /api/v1/rate/extension/observation`: the store, the cleaned URL (no query), the item and shop ids, the variant id (SKU), the selected variant, the price, the original price, the stock status and quantity, the time the user saw the price and the extension version, with how the page was read (DOS/Rate#1606). It carries a random submitter id for the install, obtained once from `POST /api/v1/rate/extension/submitter`. No title, no ratings, no account data. The toggle's label and the consent page list these same fields; a test ties them to the payload. Observations go to a quarantine, stored with the time received and a salted hash of the network range (never the IP) to tell independent senders apart; the hash is deleted after 28 days and observations after 180 days. Rate.vn's servers may re-check the listing on the store. They never change public prices directly. Turning the opt-in off calls `DELETE /api/v1/rate/extension/submitter`, which deletes the id and every observation sent with it; the extension keeps retrying that erasure until the server confirms it. Uninstalling without turning it off leaves the observations to the 180-day purge. An opt-in given to the older crowd signal is not taken as consent to this: after the update it is off and the consent page asks again. 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, the opt-in choice and, while the user contributes, the submitter id (`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 observation routes use it in memory for a per-minute request limit; the observation routes also derive the salted network-range hash above from it. Rate.vn's code never stores the IP itself; 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 46ae68bf2..83a56311d 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). + - **Contribution erasure:** with the observation routes on, opt in, open a product page, then turn the opt-in off: the server holds no observation for the install's id afterwards, and with the network off the erasure is retried at the next start (#1606). - **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). diff --git a/apps/extension/src/_locales/cs/messages.json b/apps/extension/src/_locales/cs/messages.json index 67d3eebc3..0b0a958ea 100644 --- a/apps/extension/src/_locales/cs/messages.json +++ b/apps/extension/src/_locales/cs/messages.json @@ -53,7 +53,7 @@ "message": "Pomoc s hledáním nových produktů (jen když ji zapnete):" }, "optInBody": { - "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav skladu, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID této instalace a otisk rozsahu vaší sítě (ne IP adresa). Servery Rate.vn mohou nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." + "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav a počet kusů skladem, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID přispěvatele této instalace. Server odvodí a 28 dní uchovává otisk rozsahu vaší sítě (ne IP adresa), odeslané záznamy uchovává 180 dní a může nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." }, "crowdFieldPlatform": { "message": "obchod" @@ -684,13 +684,13 @@ "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ů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav skladu, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID této instalace a otisk rozsahu vaší sítě (ne IP adresa). Servery Rate.vn mohou nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." + "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ů, ID varianty (SKU), zvolená varianta, cena, původní cena, stav a počet kusů skladem, čas, kdy jste cenu viděli, verze rozšíření, a navíc náhodné ID přispěvatele této instalace. Server odvodí a 28 dní uchovává otisk rozsahu vaší sítě (ne IP adresa), odeslané záznamy uchovává 180 dní a může nabídku v obchodě znovu ověřit. Vypnutím se toto ID i vše odeslané smaže. Bez účtu, jména, e-mailu a cookies." }, "observationFieldSku": { "message": "ID varianty (SKU)" }, "observationFieldStock": { - "message": "stav skladu" + "message": "stav a počet kusů skladem" }, "observationFieldObservedAt": { "message": "čas, kdy jste cenu viděli" @@ -699,7 +699,7 @@ "message": "verze rozšíření" }, "observationFieldSubmitter": { - "message": "náhodné ID této instalace" + "message": "náhodné ID přispěvatele této instalace" }, "observationFieldNetwork": { "message": "otisk rozsahu vaší sítě (ne IP adresa)" diff --git a/apps/extension/src/_locales/en/messages.json b/apps/extension/src/_locales/en/messages.json index e8b711c50..1328a9807 100644 --- a/apps/extension/src/_locales/en/messages.json +++ b/apps/extension/src/_locales/en/messages.json @@ -53,7 +53,7 @@ "message": "Help find new products (only when you turn it on):" }, "optInBody": { - "message": "When on, every listing you open is also sent to the independent Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, variant ID (SKU), selected variant, price, original price, stock status, the time you saw the price, the extension version, plus a random ID for this install and a hash of your network range (not your IP). Rate.vn's servers may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." + "message": "When on, every listing you open is also sent to the independent Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, variant ID (SKU), selected variant, price, original price, stock status and quantity, the time you saw the price, the extension version, plus a random contributor ID for this install. The server derives and keeps a hash of your network range (not your IP) for 28 days, keeps what you sent for 180 days, and may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." }, "crowdFieldPlatform": { "message": "store" @@ -684,13 +684,13 @@ "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, variant ID (SKU), selected variant, price, original price, stock status, the time you saw the price, the extension version, plus a random ID for this install and a hash of your network range (not your IP). Rate.vn's servers may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." + "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, variant ID (SKU), selected variant, price, original price, stock status and quantity, the time you saw the price, the extension version, plus a random contributor ID for this install. The server derives and keeps a hash of your network range (not your IP) for 28 days, keeps what you sent for 180 days, and may re-check that listing on the store. Turning it off deletes that ID and everything sent with it. No account, name, email or cookies are included." }, "observationFieldSku": { "message": "variant ID (SKU)" }, "observationFieldStock": { - "message": "stock status" + "message": "stock status and quantity" }, "observationFieldObservedAt": { "message": "the time you saw the price" @@ -699,7 +699,7 @@ "message": "the extension version" }, "observationFieldSubmitter": { - "message": "a random ID for this install" + "message": "a random contributor ID for this install" }, "observationFieldNetwork": { "message": "a hash of your network range (not your IP)" diff --git a/apps/extension/src/_locales/vi/messages.json b/apps/extension/src/_locales/vi/messages.json index 17121143e..70db62958 100644 --- a/apps/extension/src/_locales/vi/messages.json +++ b/apps/extension/src/_locales/vi/messages.json @@ -53,7 +53,7 @@ "message": "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật):" }, "optInBody": { - "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập 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, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này và mã băm dải mạng của bạn (không phải IP). Máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." + "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập 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, mã SKU của phân loại, phiên bản đang chọn, giá bán, giá gốc, tình trạng và số lượng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã người đóng góp ngẫu nhiên của lượt cài này. Máy chủ tính và giữ mã băm dải mạng của bạn (không phải IP) trong 28 ngày, giữ tin đã gửi 180 ngày, và có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." }, "crowdFieldPlatform": { "message": "sàn" @@ -684,13 +684,13 @@ "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, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này và mã băm dải mạng của bạn (không phải IP). Máy chủ Rate.vn có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." + "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, mã SKU của phân loại, phiên bản đang chọn, giá bán, giá gốc, tình trạng và số lượng còn hàng, thời điểm bạn thấy giá, phiên bản tiện ích, kèm một mã người đóng góp ngẫu nhiên của lượt cài này. Máy chủ tính và giữ mã băm dải mạng của bạn (không phải IP) trong 28 ngày, giữ tin đã gửi 180 ngày, và có thể kiểm tra lại tin bán đó trên sàn. Tắt đóng góp là xóa mã đó và mọi tin đã gửi. Không kèm tài khoản, tên, email hay cookie." }, "observationFieldSku": { - "message": "mã phiên bản (SKU)" + "message": "mã SKU của phân loại" }, "observationFieldStock": { - "message": "tình trạng còn hàng" + "message": "tình trạng và số lượng còn hàng" }, "observationFieldObservedAt": { "message": "thời điểm bạn thấy giá" @@ -699,7 +699,7 @@ "message": "phiên bản tiện ích" }, "observationFieldSubmitter": { - "message": "một mã ngẫu nhiên của lượt cài này" + "message": "một mã người đóng góp ngẫu nhiên của lượt cài này" }, "observationFieldNetwork": { "message": "mã băm dải mạng của bạn (không phải IP)" diff --git a/apps/extension/src/background/observation-client.ts b/apps/extension/src/background/observation-client.ts new file mode 100644 index 000000000..42ebef87a --- /dev/null +++ b/apps/extension/src/background/observation-client.ts @@ -0,0 +1,274 @@ +/** + * The opt-in observation client (DOS/Rate#1606): this install's + * pseudonymous submitter id, ExtensionObservation v1 sends, and the erasure + * that turning the opt-in off promises. Storage and fetch are injected so + * the behaviour is tested without a browser. + * + * Every operation runs one after another on one chain, so an opt-out waits + * for a send in flight, and two product pages never register two ids. + */ +import type { ExtensionObservationV1 } from '@repo/shared-data/rules'; + +export interface ObservationStorage { + get(key: string): Promise>; + set(items: Record): Promise; + remove(key: string): Promise; +} + +export interface ObservationClientDeps { + storage: ObservationStorage; + fetch: (url: string, init: RequestInit) => Promise; + now: () => number; + extensionVersion: string; + /** Read at send time and again after a registration. */ + optedIn: () => Promise; + submitterPath: string; + observationPath: string; +} + +/** The stored id, with the server that issued it. */ +export const SUBMITTER_KEY = 'observationSubmitter'; +/** Ids whose erasure the server has not confirmed yet. */ +export const PENDING_ERASURE_KEY = 'observationPendingErasure'; +/** No register or observation before this time (ms). */ +export const HOLD_UNTIL_KEY = 'observationHoldUntil'; + +/** How long to wait after a 429 without Retry-After, or while the routes are off. */ +export const OBSERVATION_DEFAULT_HOLD_SECONDS = 60; +export const OBSERVATION_OFF_HOLD_SECONDS = 3600; +const MAX_HOLD_SECONDS = 3600; +const MAX_PENDING_ERASURES = 20; +const TIMEOUT_MS = 8_000; + +const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +interface SubmitterRecord { + id: string; + apiBaseUrl: string; +} + +function asRecord(raw: unknown): SubmitterRecord | null { + const r = raw as Partial | null; + return r && + typeof r === 'object' && + typeof r.id === 'string' && + ID_RE.test(r.id) && + typeof r.apiBaseUrl === 'string' && + r.apiBaseUrl.length > 0 + ? { id: r.id, apiBaseUrl: r.apiBaseUrl } + : null; +} + +export class ObservationClient { + private chain: Promise = Promise.resolve(); + + constructor(private readonly deps: ObservationClientDeps) {} + + private run(task: () => Promise): Promise { + const result = this.chain.then(task, task); + this.chain = result.catch(() => undefined); + return result; + } + + private async read(key: string, parse: (raw: unknown) => T): Promise { + const stored = await this.deps.storage.get(key); + return parse(stored[key]); + } + + private headers(json: boolean): Record { + return json + ? { + 'Content-Type': 'application/json', + 'X-Rate-Extension-Version': this.deps.extensionVersion, + } + : { 'X-Rate-Extension-Version': this.deps.extensionVersion }; + } + + private async hold(seconds: number): Promise { + const ms = Math.min(Math.max(seconds, 1), MAX_HOLD_SECONDS) * 1000; + await this.deps.storage.set({ [HOLD_UNTIL_KEY]: this.deps.now() + ms }); + } + + private async holdFor(res: Response): Promise { + if (res.status === 429) { + const header = res.headers.get('Retry-After'); + const seconds = + header && /^\d{1,6}$/.test(header.trim()) + ? Number(header.trim()) + : OBSERVATION_DEFAULT_HOLD_SECONDS; + await this.hold(seconds); + } else if (res.status === 503) { + await this.hold(OBSERVATION_OFF_HOLD_SECONDS); + } + } + + private async held(): Promise { + const until = await this.read(HOLD_UNTIL_KEY, (v) => + typeof v === 'number' && Number.isFinite(v) ? v : 0 + ); + return until > this.deps.now(); + } + + private async code(res: Response): Promise { + const body = (await res.json().catch(() => null)) as { + code?: unknown; + } | null; + return typeof body?.code === 'string' ? body.code : null; + } + + private async pending(): Promise { + return this.read(PENDING_ERASURE_KEY, (v) => + Array.isArray(v) + ? v.map(asRecord).filter((r): r is SubmitterRecord => r !== null) + : [] + ); + } + + private async queueErasure(record: SubmitterRecord): Promise { + const list = (await this.pending()).filter((r) => r.id !== record.id); + list.push(record); + await this.deps.storage.set({ + [PENDING_ERASURE_KEY]: list.slice(-MAX_PENDING_ERASURES), + }); + } + + /** + * Sends every queued erasure. One the server confirmed (200), or refused + * as malformed (400), leaves the queue; any other answer or a network + * error keeps it for the next try. + */ + private async flushErasures(): Promise { + const list = await this.pending(); + if (list.length === 0) return; + const left: SubmitterRecord[] = []; + for (const record of list) { + try { + const res = await this.deps.fetch( + `${record.apiBaseUrl}${this.deps.submitterPath}`, + { + method: 'DELETE', + headers: this.headers(true), + signal: AbortSignal.timeout(TIMEOUT_MS), + body: JSON.stringify({ submitterId: record.id }), + } + ); + if (res.status !== 200 && res.status !== 400) left.push(record); + } catch { + left.push(record); + } + } + if (left.length > 0) { + await this.deps.storage.set({ [PENDING_ERASURE_KEY]: left }); + } else { + await this.deps.storage.remove(PENDING_ERASURE_KEY); + } + } + + /** The stored id for this server; an id from another server is erased there. */ + private async currentId(apiBaseUrl: string): Promise { + const record = await this.read(SUBMITTER_KEY, asRecord); + if (!record) return null; + if (record.apiBaseUrl === apiBaseUrl) return record.id; + await this.deps.storage.remove(SUBMITTER_KEY); + await this.queueErasure(record); + await this.flushErasures(); + return null; + } + + private async register(apiBaseUrl: string): Promise { + const res = await this.deps.fetch( + `${apiBaseUrl}${this.deps.submitterPath}`, + { + method: 'POST', + headers: this.headers(false), + signal: AbortSignal.timeout(TIMEOUT_MS), + } + ); + if (!res.ok) { + if (res.status === 404) { + // NOT_ENABLED: the routes are off; ask again in an hour. + await this.hold(OBSERVATION_OFF_HOLD_SECONDS); + } else { + await this.holdFor(res); + } + return null; + } + const body = (await res.json().catch(() => null)) as { + submitterId?: unknown; + } | null; + const id = body?.submitterId; + return typeof id === 'string' && ID_RE.test(id) ? id : null; + } + + /** Sends one observation for an opted-in install; never throws. */ + send(apiBaseUrl: string, observation: ExtensionObservationV1): Promise { + return this.run(async () => { + try { + await this.flushErasures(); + if (!(await this.deps.optedIn()) || (await this.held())) return; + let id = await this.currentId(apiBaseUrl); + if (!id) { + id = await this.register(apiBaseUrl); + if (!id) return; + // Turned off while the id was being issued: erase it at once. + if (!(await this.deps.optedIn())) { + await this.queueErasure({ id, apiBaseUrl }); + await this.flushErasures(); + return; + } + await this.deps.storage.set({ [SUBMITTER_KEY]: { id, apiBaseUrl } }); + } + const res = await this.deps.fetch( + `${apiBaseUrl}${this.deps.observationPath}`, + { + method: 'POST', + headers: this.headers(true), + signal: AbortSignal.timeout(TIMEOUT_MS), + body: JSON.stringify({ submitterId: id, observation }), + } + ); + if (res.status === 404) { + const code = await this.code(res); + if (code === 'UNKNOWN_SUBMITTER') { + // Deleted or purged on the server: get a new id next time. + await this.deps.storage.remove(SUBMITTER_KEY); + } else if (code === 'NOT_ENABLED') { + await this.hold(OBSERVATION_OFF_HOLD_SECONDS); + } + } else { + await this.holdFor(res); + } + } catch { + // Non-blocking: a lost observation is fine, a lost erasure is queued. + } + }); + } + + /** + * The opt-in was turned off: forget the id here and erase it on the server + * that issued it, retrying until the server confirms. + */ + forget(): Promise { + return this.run(async () => { + try { + const record = await this.read(SUBMITTER_KEY, asRecord); + await this.deps.storage.remove(SUBMITTER_KEY); + if (record) await this.queueErasure(record); + await this.flushErasures(); + } catch { + // The queued erasure is retried on the next start or send. + } + }); + } + + /** Retries queued erasures (at start-up). */ + retryErasures(): Promise { + return this.run(async () => { + try { + await this.flushErasures(); + } catch { + // Kept for the next try. + } + }); + } +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index af44b45cf..7e2509345 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -21,6 +21,7 @@ import { selectFreshestSessionSignal, } from '../security-guards.js'; import { LookupCooldown } from '../rate-limit.js'; +import { ObservationClient } from './observation-client.js'; import { t } from '../i18n.js'; import { resolveActionBadge } from '../verdict-display.js'; @@ -29,17 +30,27 @@ const DEFAULT_SETTINGS: ExtensionSettings = { crowdContributeOptIn: false, }; +/** + * The consent the opt-in was given under. Version 2 is ExtensionObservation + * v1 with its submitter id (DOS/Rate#1606); an opt-in given to the older + * crowd signal counts as off until the user says yes again. + */ +const OPT_IN_CONSENT_VERSION = 2; + async function getSettings(): Promise { try { const stored = await chrome.storage.local.get([ 'apiBaseUrl', 'crowdContributeOptIn', + 'crowdConsentVersion', ]); return { apiBaseUrl: normalizeAllowedApiBaseUrl( typeof stored.apiBaseUrl === 'string' ? stored.apiBaseUrl : null ), - crowdContributeOptIn: Boolean(stored.crowdContributeOptIn ?? false), + crowdContributeOptIn: + stored.crowdContributeOptIn === true && + stored.crowdConsentVersion === OPT_IN_CONSENT_VERSION, }; } catch { return DEFAULT_SETTINGS; @@ -160,100 +171,29 @@ async function maybeSendOptInCrowdSignal( if ((await lookupCooldown.secondsLeft()) > 0) return; // ExtensionObservation v1 into the quarantine (DOS/Rate#1606): only the - // fields v1 names, with this install's random submitter id. + // fields v1 names, under this install's random submitter id. const observation = buildExtensionObservationV1( signal, new Date(), EXTENSION_VERSION ); if (!observation) return; - try { - const submitterId = await observationSubmitterId(settings.apiBaseUrl); - if (!submitterId) return; - const res = await fetch( - `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_OBSERVATION}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': EXTENSION_VERSION, - }, - signal: AbortSignal.timeout(8_000), - body: JSON.stringify({ submitterId, observation }), - } - ); - // The server no longer knows this id (deleted, purged): get a new one - // next time. - if (res.status === 404) { - const body = (await res.json().catch(() => null)) as { - code?: string; - } | null; - if (body?.code === 'UNKNOWN_SUBMITTER') { - await chrome.storage.local.remove?.(SUBMITTER_ID_KEY); - } - } - } catch { - // Non-blocking background quarantine submission - } + await observationClient.send(settings.apiBaseUrl, observation); } -const SUBMITTER_ID_KEY = 'observationSubmitterId'; -const SUBMITTER_ID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; - -/** - * This install's pseudonymous submitter id, asked from the server once and - * kept in chrome.storage.local; null while the server does not take - * observations (the routes answer 404 until they are turned on). - */ -async function observationSubmitterId( - apiBaseUrl: string -): Promise { - const stored = await chrome.storage.local.get(SUBMITTER_ID_KEY); - const known = stored[SUBMITTER_ID_KEY]; - if (typeof known === 'string' && SUBMITTER_ID_RE.test(known)) return known; - const res = await fetch( - `${apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_SUBMITTER}`, - { - method: 'POST', - headers: { 'X-Rate-Extension-Version': EXTENSION_VERSION }, - signal: AbortSignal.timeout(8_000), - } - ); - if (!res.ok) return null; - const body = (await res.json().catch(() => null)) as { - submitterId?: unknown; - } | null; - const id = body?.submitterId; - if (typeof id !== 'string' || !SUBMITTER_ID_RE.test(id)) return null; - await chrome.storage.local.set({ [SUBMITTER_ID_KEY]: id }); - return id; -} - -/** - * Turning the opt-in off erases this install's contributions: the server - * deletes the submitter id and every observation sent with it, and the id - * is forgotten here (DOS/Rate#1606). - */ -async function forgetObservationSubmitter(apiBaseUrl: string): Promise { - const stored = await chrome.storage.local.get(SUBMITTER_ID_KEY); - const id = stored[SUBMITTER_ID_KEY]; - await chrome.storage.local.remove?.(SUBMITTER_ID_KEY); - if (typeof id !== 'string' || !SUBMITTER_ID_RE.test(id)) return; - try { - await fetch(`${apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_SUBMITTER}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': EXTENSION_VERSION, - }, - signal: AbortSignal.timeout(8_000), - body: JSON.stringify({ submitterId: id }), - }); - } catch { - // Best effort; the server's purge removes ids that stop sending. - } -} +const observationClient = new ObservationClient({ + storage: { + get: (key) => chrome.storage.local.get(key), + set: (items) => chrome.storage.local.set(items), + remove: (key) => chrome.storage.local.remove(key), + }, + fetch: (url, init) => fetch(url, init), + now: () => Date.now(), + extensionVersion: EXTENSION_VERSION, + optedIn: async () => (await getSettings()).crowdContributeOptIn, + submitterPath: RATE_API_V1_ENDPOINTS.EXTENSION_SUBMITTER, + observationPath: RATE_API_V1_ENDPOINTS.EXTENSION_OBSERVATION, +}); async function updateActionBadge( tabId: number | undefined, @@ -274,7 +214,33 @@ async function updateActionBadge( } } +chrome.runtime.onStartup?.addListener(() => { + void observationClient.retryErasures(); +}); + chrome.runtime.onInstalled.addListener(async (details) => { + void observationClient.retryErasures(); + // An opt-in given to the older crowd signal is not consent to v1: it is + // off, and the consent page asks again (DOS/Rate#1606). + if (details?.reason === 'update') { + try { + const stored = await chrome.storage.local.get([ + 'crowdContributeOptIn', + 'crowdConsentVersion', + ]); + if ( + stored.crowdContributeOptIn === true && + stored.crowdConsentVersion !== OPT_IN_CONSENT_VERSION + ) { + await chrome.storage.local.set({ crowdContributeOptIn: false }); + 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. @@ -673,13 +639,19 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { await lookupCooldown.clear(); } } + const consent: Record = {}; if (typeof message.crowdContributeOptIn === 'boolean') { nextSettings.crowdContributeOptIn = message.crowdContributeOptIn; + consent.crowdConsentVersion = message.crowdContributeOptIn + ? OPT_IN_CONSENT_VERSION + : null; } - const before = await getSettings(); - await chrome.storage.local.set(nextSettings); + await chrome.storage.local.set({ ...nextSettings, ...consent }); + // Turning it off erases this install's contributions (DOS/Rate#1606); + // a new server address leaves the old id to be erased where it came + // from on the next send. if (nextSettings.crowdContributeOptIn === false) { - void forgetObservationSubmitter(before.apiBaseUrl); + void observationClient.forget(); } const updated = await getSettings(); sendResponse({ ok: true, settings: updated }); diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index a8e47830c..f988013e6 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -4329,10 +4329,25 @@ async function runTests() { workerSource.match(/'X-Rate-Extension-Version': EXTENSION_VERSION/g) ?? [] ).length, - // Every request the worker makes carries it (DOS/Rate#1606 added the - // submitter and observation calls). - (workerSource.match(/\bfetch\(/g) ?? []).length + 1 ); + // The observation client's requests carry it too (DOS/Rate#1606): each + // of its fetches takes this.headers(...), which sets the version. + { + const clientSource = fs.readFileSync( + path.resolve(__dirname, 'background/observation-client.ts'), + 'utf8' + ); + assert.equal( + (clientSource.match(/this\.deps\.fetch\(/g) ?? []).length, + (clientSource.match(/headers: this\.headers\(/g) ?? []).length + ); + assert.ok( + clientSource.includes( + "'X-Rate-Extension-Version': this.deps.extensionVersion" + ) + ); + } assert.ok(workerSource.includes('chrome.runtime.getManifest().version')); // The cooldown is checked before any fetch, and a 429 sets it from Retry-After. const requestLookupBody = workerSource.slice( @@ -5308,11 +5323,16 @@ async function runTests() { assert.ok(!workerSource.includes('EXTENSION_INGEST')); assert.ok(!workerSource.includes('buildCrowdIngestPayload')); assert.ok( - /crowdContributeOptIn === false\)\s*\{\s*void forgetObservationSubmitter\(/.test( + /crowdContributeOptIn === false\)\s*\{\s*void observationClient\.forget\(\)/.test( workerSource ) ); - assert.ok(workerSource.includes("method: 'DELETE'")); + // An opt-in given before v1 is not consent to it. + assert.ok( + workerSource.includes( + 'stored.crowdConsentVersion === OPT_IN_CONSENT_VERSION' + ) + ); // A long variant label is cut to 120 characters, not a lost observation. const longObs = guards.buildExtensionObservationV1( @@ -5418,8 +5438,245 @@ async function runTests() { assert.equal(single.stockState, 'in_stock'); assert.equal(single.stockQty, 9); assert.equal(single.skuId, 'BASE-1'); + console.log(' ✅ [Stock, SKU and the v1 observation (#1606)] Verified'); + } + // --- DOS/Rate#1606: the observation client (register, send, erase) --- + { + const { + ObservationClient, + SUBMITTER_KEY, + PENDING_ERASURE_KEY, + HOLD_UNTIL_KEY, + } = await import('./background/observation-client.js'); + const ID1 = '11111111-2222-4333-8444-555555555555'; + const ID2 = '66666666-7777-4888-8999-aaaaaaaaaaaa'; + const BASE = 'https://rate.vn'; + const obs = { + schema_version: 1 as const, + marketplace: 'shopee' as const, + listing_url: 'https://shopee.vn/x-i.1.2', + external_item_id: '2', + external_shop_id: '1', + sku_id: null, + variant_label: null, + price_vnd: 19_990_000, + list_price_vnd: null, + stock_state: 'in_stock' as const, + stock_qty: null, + observed_at: '2026-11-04T10:00:00.000Z', + extractor: { source: 'json_ld' as const, extension_version: '2.1.0' }, + }; + type Call = { + method: string; + url: string; + headers: Record; + body: unknown; + }; + const json = ( + status: number, + body: unknown, + headers: Record = {} + ) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + const make = (opts: { + optedIn?: () => boolean; + respond: (call: Call) => Response | Promise; + now?: number; + }) => { + const store = new Map(); + const calls: Call[] = []; + const client = new ObservationClient({ + storage: { + get: async (key) => (store.has(key) ? { [key]: store.get(key) } : {}), + set: async (items) => { + for (const [k, v] of Object.entries(items)) store.set(k, v); + }, + remove: async (key) => { + store.delete(key); + }, + }, + fetch: async (url, init) => { + const call: Call = { + method: String(init.method), + url, + headers: (init.headers ?? {}) as Record, + body: init.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(call); + return opts.respond(call); + }, + now: () => opts.now ?? 1_000_000, + extensionVersion: '2.1.0', + optedIn: async () => (opts.optedIn ? opts.optedIn() : true), + submitterPath: '/api/v1/rate/extension/submitter', + observationPath: '/api/v1/rate/extension/observation', + }); + return { client, store, calls }; + }; + const ok = (call: Call) => + call.method === 'POST' && call.url.endsWith('/submitter') + ? json(200, { ok: true, submitterId: ID1 }) + : json(200, { ok: true, status: 'quarantined' }); + + // (a) Register once (no body, no Content-Type), then send with the id. + { + const { client, store, calls } = make({ respond: ok }); + await client.send(BASE, obs); + assert.deepEqual( + calls.map((c) => `${c.method} ${c.url}`), + [ + `POST ${BASE}/api/v1/rate/extension/submitter`, + `POST ${BASE}/api/v1/rate/extension/observation`, + ] + ); + assert.equal(calls[0]!.body, undefined); + assert.equal(calls[0]!.headers['Content-Type'], undefined); + assert.equal(calls[1]!.headers['Content-Type'], 'application/json'); + assert.deepEqual(calls[1]!.body, { submitterId: ID1, observation: obs }); + assert.deepEqual(store.get(SUBMITTER_KEY), { id: ID1, apiBaseUrl: BASE }); + // (b) The next product reuses the id. + await client.send(BASE, obs); + assert.equal(calls.filter((c) => c.url.endsWith('/submitter')).length, 1); + } + // (c) Two products at once register one id. + { + const { client, calls } = make({ respond: ok }); + await Promise.all([client.send(BASE, obs), client.send(BASE, obs)]); + assert.equal(calls.filter((c) => c.url.endsWith('/submitter')).length, 1); + } + // (d) Routes off: no observation, no id, and a hold, so the next product + // asks nothing. + { + const { client, store, calls } = make({ + respond: () => json(404, { ok: false, code: 'NOT_ENABLED' }), + }); + await client.send(BASE, obs); + await client.send(BASE, obs); + assert.equal(calls.length, 1); + assert.equal(store.has(SUBMITTER_KEY), false); + assert.ok(Number(store.get(HOLD_UNTIL_KEY)) > 1_000_000); + } + // (e) UNKNOWN_SUBMITTER drops the id; (f) a 429 holds for its Retry-After. + { + let observationAnswer: Response = json(404, { + ok: false, + code: 'UNKNOWN_SUBMITTER', + }); + const { client, store, calls } = make({ + respond: (c) => + c.url.endsWith('/submitter') ? ok(c) : observationAnswer, + }); + await client.send(BASE, obs); + assert.equal(store.has(SUBMITTER_KEY), false); + observationAnswer = json(429, { ok: false }, { 'Retry-After': '3600' }); + await client.send(BASE, obs); + assert.equal(store.get(HOLD_UNTIL_KEY), 1_000_000 + 3_600_000); + const before = calls.length; + await client.send(BASE, obs); + assert.equal(calls.length, before, 'held: nothing is sent'); + } + // (g) Opt-out erases on the issuing server; (h) a failed erasure is kept + // and retried until the server confirms. + { + let deleteOk = false; + const { client, store, calls } = make({ + respond: (c) => + c.method === 'DELETE' + ? deleteOk + ? json(200, { ok: true }) + : json(503, { ok: false }) + : ok(c), + }); + await client.send(BASE, obs); + await client.forget(); + const del = calls.filter((c) => c.method === 'DELETE'); + assert.equal(del.length, 1); + assert.equal(del[0]!.headers['Content-Type'], 'application/json'); + assert.deepEqual(del[0]!.body, { submitterId: ID1 }); + assert.equal(store.has(SUBMITTER_KEY), false); + assert.deepEqual(store.get(PENDING_ERASURE_KEY), [ + { id: ID1, apiBaseUrl: BASE }, + ]); + deleteOk = true; + await client.retryErasures(); + assert.equal(store.has(PENDING_ERASURE_KEY), false); + } + // (i) Opt-out while the id is being issued: it is erased, nothing sent. + { + let opted = true; + let release!: () => void; + const gate = new Promise((r) => (release = r)); + let registering!: () => void; + const registerStarted = new Promise((r) => (registering = r)); + const { client, store, calls } = make({ + optedIn: () => opted, + respond: async (c) => { + if (c.method === 'POST' && c.url.endsWith('/submitter')) { + registering(); + await gate; + return json(200, { ok: true, submitterId: ID2 }); + } + return json(200, { ok: true }); + }, + }); + const sending = client.send(BASE, obs); + await registerStarted; + // The user turns it off while the id is being issued. + opted = false; + const forgetting = client.forget(); + release(); + await Promise.all([sending, forgetting]); + assert.equal( + calls.filter((c) => c.url.endsWith('/observation')).length, + 0 + ); + assert.equal(store.has(SUBMITTER_KEY), false); + assert.deepEqual( + calls.filter((c) => c.method === 'DELETE').map((c) => c.body), + [{ submitterId: ID2 }] + ); + } + // (j) Opted out: nothing at all. + { + const { client, calls } = make({ optedIn: () => false, respond: ok }); + await client.send(BASE, obs); + assert.equal(calls.length, 0); + } + // An id from another server is erased there, not sent here. + { + const { client, store, calls } = make({ respond: ok }); + store.set(SUBMITTER_KEY, { + id: ID2, + apiBaseUrl: 'http://localhost:3000', + }); + await client.send(BASE, obs); + const del = calls.find((c) => c.method === 'DELETE'); + assert.ok(del && del.url.startsWith('http://localhost:3000')); + assert.deepEqual(del.body, { submitterId: ID2 }); + assert.deepEqual( + calls.find((c) => c.url.endsWith('/observation'))?.body, + { + submitterId: ID1, + observation: obs, + } + ); + } + // (k) A body that is not JSON keeps the id and throws nothing. + { + const { client, store } = make({ + respond: (c) => + c.url.endsWith('/submitter') + ? ok(c) + : new Response('nope', { status: 404 }), + }); + await client.send(BASE, obs); + assert.deepEqual(store.get(SUBMITTER_KEY), { id: ID1, apiBaseUrl: BASE }); + } console.log( - ' ✅ [Stock, SKU and the v1 observation, not sent (#1606)] Verified' + ' ✅ [Observation client: one id, erasure kept until confirmed, holds (#1606)] Verified' ); } console.log('🎉 All Rate Browser Extension MV3 v2 tests passed!'); diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index 24da790ca..c067d7ee2 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -262,6 +262,9 @@ declare global { ) => boolean | void ) => void; }; + onStartup?: { + addListener: (callback: () => void) => void; + }; onInstalled: { addListener: ( callback: (details: { reason: string }) => void | Promise @@ -274,7 +277,7 @@ declare global { keys?: string | string[] | Record ) => Promise>; set: (items: Record) => Promise; - remove?: (keys: string | string[]) => Promise; + remove: (keys: string | string[]) => Promise; }; session: { get: ( diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 1dc5bf03e..843d29f16 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ž 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é do fronty ke kontrole záznam o dané nabídce: obchod, URL bez parametrů, ID položky a obchodu, ID varianty (SKU), zvolenou variantu, cenu, původní cenu, stav skladu, čas, kdy jste cenu viděli, a verzi rozšíření, spolu s náhodným ID této instalace. Server jej ukládá s časem přijetí a s osoleným otiskem rozsahu vaší sítě (ne IP adresou), aby rozlišil nezávislé odesílatele; otisk se maže po 28 dnech a záznam po 180 dnech. Servery Rate.vn mohou nabídku v obchodě znovu ověřit a tato data sama veřejné ceny nemění. Vypnutím přispívání se ID instalace i všechny odeslané záznamy smažou. 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, ID přispěvatele této instalace, dokud přispíváte, 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é do fronty ke kontrole záznam o dané nabídce: obchod, URL bez parametrů, ID položky a obchodu, ID varianty (SKU), zvolenou variantu, cenu, původní cenu, stav a počet kusů skladem, čas, kdy jste cenu viděli, a verzi rozšíření, spolu s náhodným ID přispěvatele této instalace. Server jej ukládá s časem přijetí a s osoleným otiskem rozsahu vaší sítě (ne IP adresou), aby rozlišil nezávislé odesílatele; otisk se maže po 28 dnech a záznam po 180 dnech. Servery Rate.vn mohou nabídku v obchodě znovu ověřit a tato data sama veřejné ceny nemění. Vypnutím přispívání se ID přispěvatele i všechny odeslané záznamy smažou; pokud rozšíření odinstalujete bez vypnutí, smažou se po 180 dnech. 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, ID přispěvatele této instalace, dokud přispíváte, 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á v paměti pro limit požadavků za minutu a při přispívání k odvození výše uvedeného otisku rozsahu sítě; samotná IP adresa se nikdy neukládá 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 f73e1fd68..91c1ddbb2 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 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 an observation of that listing to a moderation queue: the store, the URL without its parameters, the item and shop ids, the variant ID (SKU), the selected variant, the price, the original price, the stock status, the time you saw the price and the extension version, with a random ID for this install. The server stores it with the time it was received and a salted hash of your network range (not your IP) to tell independent senders apart; the hash is deleted after 28 days and the observation after 180 days. Rate.vn's servers may re-check that listing on the store, and this data does not change public prices by itself. Turning contributions off deletes the install's ID and every observation sent with it. 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, the install's contributor ID while you contribute, 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 an observation of that listing to a moderation queue: the store, the URL without its parameters, the item and shop ids, the variant ID (SKU), the selected variant, the price, the original price, the stock status and quantity, the time you saw the price and the extension version, with a random contributor ID for this install. The server stores it with the time it was received and a salted hash of your network range (not your IP) to tell independent senders apart; the hash is deleted after 28 days and the observation after 180 days. Rate.vn's servers may re-check that listing on the store, and this data does not change public prices by itself. Turning contributions off deletes the contributor ID and every observation sent with it; if you uninstall without turning it off, they are deleted after 180 days. 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, the contributor ID while you contribute, 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 in memory for a per-minute request limit and, when you contribute, to derive the network-range hash above; the IP itself is never stored, 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 2534faff7..f1ce32e47 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 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 vào hàng đợi kiểm duyệt một quan sát về tin bán đó: sàn, URL đã bỏ tham số, mã sản phẩm, mã gian hàng, mã phiên bản (SKU), phiên bản đang chọn, giá bán, giá gốc, tình trạng còn hàng, thời điểm bạn thấy giá và phiên bản tiện ích, kèm một mã ngẫu nhiên của lượt cài này. Máy chủ lưu kèm thời điểm nhận và một mã băm có muối của dải mạng (không phải IP) để phân biệt người gửi độc lập; mã băm được xóa sau 28 ngày, quan sát sau 180 ngày. 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. Tắt đóng góp là xóa mã người gửi cùng mọi quan sát đã gửi. 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, mã người gửi khi bạn đóng góp, 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 vào hàng đợi kiểm duyệt một quan sát về tin bán đó: sàn, URL đã bỏ tham số, mã sản phẩm, mã gian hàng, mã SKU của phân loại, phiên bản đang chọn, giá bán, giá gốc, tình trạng và số lượng còn hàng, thời điểm bạn thấy giá và phiên bản tiện ích, kèm một mã người đóng góp ngẫu nhiên của lượt cài này. Máy chủ lưu kèm thời điểm nhận và một mã băm có muối của dải mạng (không phải IP) để phân biệt người gửi độc lập; mã băm được xóa sau 28 ngày, quan sát sau 180 ngày. 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. Tắt đóng góp là xóa mã người đóng góp cùng mọi quan sát đã gửi; nếu gỡ tiện ích mà chưa tắt, chúng tự xóa sau 180 ngày. 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, mã người đóng góp khi bạn đóng góp, 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 dùng nó trong bộ nhớ để giới hạn số yêu cầu mỗi phút và, khi bạn đóng góp, để tính mã băm dải mạng nói trên; bản thân IP không bao giờ được lưu, 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",