openvibers/openvibe.extensions Commits
OpenVibe Companion 0.2.0: keep a listing on the Rent board, watch a page (#3)
Changes
@@@ -1,11 +1,12 @@# OpenVibe.Extensions--> OpenVibe's first-party client surfaces: today, the **OpenVibe Coupons Helper** browser extension.++> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.--**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless--Chrome 150 loads the packed build. A development build of it looked up codes and sent a report--against a local OpenVibe.Coupons. It is **not published** to any store, and it hasn't been verified--in Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and++**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium++loads the unpacked 0.2.0 build with no console or CSP errors (2026-10-15). A development build of++the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. It is **not++published** to any store (install it from a release zip, below), and it hasn't been verified in++Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and`https://openvibe.coupons` is public since 2026-10-09, so the release build talks to the liveCoupons API.**Decision record:** [ADR-023](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-023-client-surfaces.md) (first-party client surfaces and their home). Roadmap §4.2 D.@@@ -18,15 +19,24 @@ per-install permissions. ADR-023 gives clients their own repository.| Surface | Directory | State ||---|---|---|--| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |++| OpenVibe Companion (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below (it was the OpenVibe Coupons Helper until 0.2.0) || Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README || Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README || Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |--## The OpenVibe Coupons Helper (`browser/`)++## The OpenVibe Companion (`browser/`)--Open it on a shop's website and it shows the codes--[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:++One button in the browser for the OpenVibe sites that work with the page you are on:++++- **Keep on my Rent board.** On a rental listing (Craigslist, Facebook Marketplace, Zillow,++ Apartments.com, Zumper, Airbnb and the rest) or any page, it opens++ [openvibe.rent/board](https://openvibe.rent/board) with the listing's address and title filled in,++ so every listing from every site sits in one list with your notes and how far you got. On a rental++ site this is the main button.++- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,++ which tells you (or runs your agent) when it changes.++- **Coupon codes.** On a shop's website it shows the codes++ [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:- the code, with a Copy button- its status (validity unknown, reported working, reported not working)@@@ -34,7 +44,8 @@ Open it on a shop's website and it shows the codes- its restrictions, or "No restrictions stated"- how to apply it--If you connected the extension, you can report whether a code worked.++If you connected the extension, you can report whether a code worked. The codes are shown to copy;++the Companion never rewrites links or adds referral tags.### What it can see, and what it sends@@@ -42,8 +53,9 @@ If you connected the extension, you can report whether a code worked.|---|---|| **Permissions** | `activeTab` and `storage`. The host permission is `https://openvibe.coupons/*` only. || **Not requested** | `<all_urls>`, `tabs`, `cookies`, `scripting`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |--| **Read from the page** | The **hostname** of the active tab's URL, when you open the popup (that's what `activeTab` grants). `lib/host.js` drops the path, query, fragment, port and credentials before anything else sees the URL. Browser pages, files, IP addresses and local names are "not a shop", and nothing is sent for them. |--| **Sent** | To `https://openvibe.coupons` only: the hostname (`GET /api/v1/merchants/resolve?host=`), the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |++| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). Never the page's content. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |++| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |++| **Hand-offs (only when you press the button)** | "Keep on my Rent board" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. || **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. || **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |@@@ -75,11 +87,12 @@ browser/manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)popup.html / popup.js DOM wiring onlyoptions.html / .js connect (save a checked token), disconnect, grant the API host permission-- lib/config.js API_ORIGIN — the one origin the extension talks to++ lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)lib/host.js URL → hostname (everything else dropped)++ lib/handoff.js the Rent board and Watch addresses, built from a cleaned URL; which sites are rentalslib/api.js the only code that makes requestslib/controller.js popup logic without the DOM (tested in Node)-- lib/platform.js chrome.* / browser.*: tabs.query, storage.local, permissions++ lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissionslib/format.js wording ("Expiry unknown", "No restrictions stated", …)icons/ generated by scripts/icons.jsscripts/@@@ -99,6 +112,7 @@ npm test| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` || API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` || The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |++| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` || `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |Beyond the automated tests, this was checked by hand on 2026-09-22:@@@ -114,10 +128,11 @@ That check is not in CI. Firefox hasn't been tried.## Packing (never published from here)```bash--npm run pack # dist/openvibe-coupons-helper-<version>.zip (API https://openvibe.coupons)++npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)```++Each version's zip is attached to its [GitHub release](https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest).To load it:- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the@@@ -134,7 +149,7 @@ shows "Allow access to openvibe.coupons".2026-10-09). The release build only talks to `https://openvibe.coupons`.2. **Store listings:** submit the zip to the Chrome Web Store and addons.mozilla.org. The privacydisclosure is the table above: the hostname of the active tab, only on open, sent to-- openvibe.coupons. `browser_specific_settings.gecko.data_collection_permissions` declares++ openvibe.coupons; the address and title only in a hand-off tab the person opens. `browser_specific_settings.gecko.data_collection_permissions` declares`browsingActivity`. Check it against AMO's requirements at submission time.3. **CORS (optional):** set `COUPONS_EXTENSION_ORIGINS` on Coupons to the Chrome Web Store id(`chrome-extension://<id>`) and, if wanted, `moz-extension://*`. The extension doesn't need it,@@@ -145,6 +160,9 @@ shows "Allow access to openvibe.coupons".## Depends on++- [OpenVibe.Rent](https://github.com/OpenVibers/OpenVibe.Rent) `/board?url=&title=` and++ [OpenVibe.Watch](https://github.com/OpenVibers/OpenVibe.Watch) `/watches/new?template=page&url=&name=`++ (pages, not APIs: the person signs in there as usual).- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:- `GET /api/v1/merchants/resolve?host=`- `GET /api/v1/merchants/:id/coupons`@@@ -7,17 +7,29 @@"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)","surfaces": {"browser": {-- "name": "OpenVibe Coupons Helper",-- "version": "0.1.0",++ "name": "OpenVibe Companion",++ "version": "0.2.0","manifest": 3,-- "targets": ["chromium >= 111", "firefox >= 128"],++ "targets": [++ "chromium >= 111",++ "firefox >= 128"++ ],"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",-- "permissions": ["activeTab", "storage"],-- "hostPermissions": ["https://openvibe.coupons/*"],++ "permissions": [++ "activeTab",++ "storage"++ ],++ "hostPermissions": [++ "https://openvibe.coupons/*"++ ],"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session","publishedToStores": false,-- "loadedInChrome": "yes: headless Chrome 150 loads the packed build; a development build looked up and reported against a local OpenVibe.Coupons",-- "loadedInFirefox": "not yet verified"++ "loadedInChrome": "yes: headless Chrome loads the packed 0.2.0 build (popup and options render, no console errors)",++ "loadedInFirefox": "not yet verified",++ "handoffs": [++ "https://openvibe.rent/board (keep a listing)",++ "https://openvibe.watch/watches/new (watch a page)"++ ]}},"pending": [@@@ -1,9 +1,13 @@/*-- * OpenVibe Coupons Helper — configuration.++ * OpenVibe Companion — configuration.*-- * API_ORIGIN is the ONLY origin this extension ever talks to. It must match the manifest's++ * API_ORIGIN is the ONLY origin this extension ever sends a request to. It must match the manifest's* host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.++ *++ * HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons++ * (lib/handoff.js): the Rent board and a new Watch. It sends them nothing itself; the address of the++ * tab it opens carries the page you were on, and you see it.*/(function (root, factory) {const api = factory();@@@ -13,6 +17,9 @@'use strict';return Object.freeze({API_ORIGIN: 'https://openvibe.coupons',++ RENT_ORIGIN: 'https://openvibe.rent',++ WATCH_ORIGIN: 'https://openvibe.watch',++ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch']),CONNECT_PATH: '/connect-extension',TOKEN_KEY: 'installToken',TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,@@@ -1,13 +1,17 @@/** The popup's logic, without the DOM (so Node tests can drive it):*-- * const c = createController({ api, platform, host });-- * const vm = await c.open(); // what to show for the active tab++ * const c = createController({ api, platform, host, handoff });++ * const vm = await c.open(); // the codes for the active tab's shop++ * c.page() // { handoff, rental }: which buttons to offer++ * await c.keepOnBoard() // open the Rent board with this page (a new tab)++ * await c.watchPage() // open a new Watch for this page (a new tab)* await c.report(couponId, 'worked') // → { ok, message }*-- * open() reads the active tab's URL once, keeps only its hostname (host.hostnameOf), and asks the-- * API which shop that is and which codes it has. That hostname is the only thing about the page-- * that ever leaves the browser.++ * open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent++ * anywhere: to the Coupons API, to say which shop that is. The URL and title stay in this closure++ * for the two hand-offs, which open a tab on openvibe.rent or openvibe.watch only when the person++ * presses their button (lib/handoff.js cleans the URL first).** View models:* { state: 'not_a_site' } browser pages, files, IPs, local names@@@ -40,10 +44,20 @@const api = deps.api;const platform = deps.platform;const host = deps.host;++ const handoff = deps.handoff || null;++ let tab = { url: null, title: '' };++++ async function handOff(build) {++ const target = handoff && build(tab.url, tab.title);++ if (!target) return { ok: false, message: 'This page cannot be sent there.' };++ await platform.tabs.open(target);++ return { ok: true };++ }return {async open() {-- const url = await platform.tabs.activeUrl();++ tab = await platform.tabs.active();++ const url = tab.url;const hostname = host.hostnameOf(url);if (!hostname) return { state: 'not_a_site' };const connected = Boolean(await platform.storage.getToken());@@@ -57,6 +71,15 @@}},++ /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board. */++ page() {++ const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));++ return { handoff: ok, rental: ok && deps.isRental ? deps.isRental(tab.url) : false };++ },++++ keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },++ watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },++async report(couponId, outcome, reason) {try {const out = await api.report(couponId, outcome, reason);nnew file mode 100644@@@ -0,0 +1,93 @@++/*++ * Hand-offs: the pages the Companion opens on OpenVibe sites with the page you are on, when you++ * press a button for it — never on its own.++ *++ * boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board++ * watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes++ *++ * This is the only code that uses more of the page than its hostname, and what it builds is a page++ * address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the++ * credentials, the fragment and the tracking parameters (utm_*, fbclid, gclid, …) first; anything++ * that is not an http(s) page gives null and nothing opens. platform.tabs.open() refuses any++ * address outside lib/config.js HANDOFF_ORIGINS.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVHandoff = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ const TRACKING = /^(utm_[a-z_]+|fbclid|gclid|gbraid|wbraid|dclid|msclkid|mc_cid|mc_eid|igshid|yclid|_hsenc|_hsmi|ref_src|spm)$/i;++++ /** Sites whose pages are rental listings: there, keeping it on the board is the main action. */++ const RENTAL_HOSTS = ['craigslist.org', 'zillow.com', 'apartments.com', 'zumper.com', 'rent.com', 'rentcafe.com', 'apartmentlist.com',++ 'padmapper.com', 'hotpads.com', 'trulia.com', 'realtor.com', 'roomies.com', 'furnishedfinder.com', 'airbnb.com', 'vrbo.com',++ 'loopnet.com', 'spothero.com', 'sparefoot.com', 'turo.com', 'spareroom.com', 'spareroom.co.uk', 'rightmove.co.uk', 'zoopla.co.uk',++ 'kijiji.ca', 'rentals.ca', 'idealista.com', 'domain.com.au', 'realestate.com.au', 'openvibe.rent'];++++ function cleanUrl(url) {++ if (typeof url !== 'string' || !url || url.length > 4000) return null;++ let u;++ try { u = new URL(url); } catch (e) { return null; }++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;++ u.username = '';++ u.password = '';++ u.hash = '';++ const keep = [];++ u.searchParams.forEach(function (v, k) { if (!TRACKING.test(k)) keep.push([k, v]); });++ u.search = '';++ keep.forEach(function (kv) { u.searchParams.append(kv[0], kv[1]); });++ const out = u.href;++ return out.length <= 2000 ? out : null;++ }++++ function cleanTitle(title, max) {++ return String(title == null ? '' : title).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);++ }++++ function isRental(url) {++ let u;++ try { u = new URL(String(url)); } catch (e) { return false; }++ const h = u.hostname.toLowerCase().replace(/^www\./, '');++ if (h === 'facebook.com' || h.endsWith('.facebook.com')) return /^\/marketplace\b/.test(u.pathname);++ return RENTAL_HOSTS.some(function (r) { return h === r || h.endsWith('.' + r); });++ }++++ /** The hand-offs for one configuration (lib/config.js RENT_ORIGIN, WATCH_ORIGIN, HANDOFF_ORIGINS). */++ function createHandoff(config) {++ const RENT = config.RENT_ORIGIN;++ const WATCH = config.WATCH_ORIGIN;++ const origins = config.HANDOFF_ORIGINS;++ return {++ boardUrl: function (url, title) { return boardUrl(RENT, url, title); },++ watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },++ isHandoff: function (url) { return isHandoff(origins, url); },++ };++ }++++ function boardUrl(RENT, url, title) {++ const page = cleanUrl(url);++ if (!page) return null;++ const q = new URLSearchParams({ url: page });++ const t = cleanTitle(title, 200);++ if (t) q.set('title', t);++ return RENT + '/board?' + q.toString();++ }++++ function watchUrl(WATCH, url, title) {++ const page = cleanUrl(url);++ if (!page) return null;++ const q = new URLSearchParams({ template: 'page', url: page });++ const t = cleanTitle(title, 80);++ if (t) q.set('name', t);++ return WATCH + '/watches/new?' + q.toString();++ }++++ /** True when `url` is a page on one of the hand-off sites (what platform.tabs.open() accepts). */++ function isHandoff(origins, url) {++ return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });++ }++++ return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff };++});@@@ -1,8 +1,11 @@/*-- * The browser APIs the helper uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):++ * The browser APIs the Companion uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):*-- * tabs.activeUrl() the active tab's URL — available because the user opened the popup-- * (activeTab); handed straight to host.hostnameOf and dropped++ * tabs.active() the active tab's URL and title — available because the user opened the++ * popup (activeTab); the controller sends only the hostname anywhere, and++ * keeps the rest for a hand-off the person may press++ * tabs.open(url) open a new tab on one of config.HANDOFF_ORIGINS (and nowhere else) —++ * tabs.create needs no permission* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local* (never storage.sync: it stays on this device)* permissions.hasApi/requestApi the host permission for the API origin (Firefox@@@ -25,9 +28,15 @@};return {tabs: {-- async activeUrl() {++ async active() {const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);-- return tabs && tabs[0] && typeof tabs[0].url === 'string' ? tabs[0].url : null;++ const t = tabs && tabs[0];++ return { url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };++ },++ async open(url) {++ const ok = typeof url === 'string' && config.HANDOFF_ORIGINS.some(function (o) { return url.indexOf(o + '/') === 0; });++ if (!ok) throw new TypeError('not an OpenVibe hand-off page');++ await call(ext.tabs.create, ext.tabs, [{ url: url }]);},},storage: {@@@ -1,10 +1,10 @@{"manifest_version": 3,-- "name": "OpenVibe Coupons Helper",-- "short_name": "OV Coupons",-- "version": "0.1.0",-- "description": "Shows the coupon codes OpenVibe.Coupons knows for the shop you are on, with their status, expiry and restrictions. Reads only the hostname, only when you open it.",-- "homepage_url": "https://openvibe.coupons/connect-extension",++ "name": "OpenVibe Companion",++ "short_name": "OpenVibe",++ "version": "0.2.0",++ "description": "Coupon codes for the shop you are on, and one click to keep a rental listing on your OpenVibe.Rent board or watch a page with OpenVibe.Watch. Reads the tab only when you open it.",++ "homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions","author": "OpenVibers","icons": {"16": "icons/icon-16.png",@@@ -13,7 +13,7 @@"128": "icons/icon-128.png"},"action": {-- "default_title": "Coupon codes for this shop",++ "default_title": "OpenVibe Companion","default_popup": "popup.html","default_icon": {"16": "icons/icon-16.png",@@@ -37,7 +37,7 @@"minimum_chrome_version": "111","browser_specific_settings": {"gecko": {-- "id": "[email protected]",++ "id": "[email protected]","strict_min_version": "128.0","data_collection_permissions": {"required": ["browsingActivity"]@@@ -2,13 +2,13 @@<html lang="en"><head><meta charset="utf-8">--<title>OpenVibe Coupons Helper — settings</title>++<title>OpenVibe Companion — settings</title><link rel="stylesheet" href="popup.css"></head><body class="options"><main>-- <h1>OpenVibe Coupons Helper</h1>-- <p>The helper works without connecting. Connecting lets it report whether a code worked, as you.</p>++ <h1>OpenVibe Companion</h1>++ <p>Coupon codes, the Rent board and Watch all work without connecting. Connecting lets the Companion report whether a code worked, as you.</p><ol><li>On <a id="connect-link" target="_blank" rel="noopener noreferrer" href="#">openvibe.coupons/connect-extension</a>, sign in and create a token for this browser.</li><li>Paste it here. It can only look up codes and report on them; it is not your OpenVibe sign-in.</li>@@@ -20,12 +20,13 @@</form><p id="state" class="muted" aria-live="polite"></p><p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>-- <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the helper never asks for access to other sites.</span></p>++ <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the Companion never asks for access to other sites.</span></p><h2>What this extension can see</h2><ul>-- <li>Only the hostname of the tab you are on, and only when you open the helper.</li>-- <li>No page content, no full addresses, no cookies, no browsing history.</li>-- <li>It talks to openvibe.coupons only.</li>++ <li>The tab you are on, and only when you open the Companion: its address and title.</li>++ <li>It sends openvibe.coupons only the hostname, to find that shop’s codes.</li>++ <li>When you press “Keep on my Rent board” or “Watch this page”, it opens openvibe.rent or openvibe.watch in a new tab with the page’s address and title, without its tracking parameters. You see that tab, and nothing is saved until you save it there.</li>++ <li>No page content, no cookies, no browsing history, nothing in the background.</li></ul></main><script src="lib/config.js"></script>@@@ -1,5 +1,5 @@--:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #b45309; --ok: #15803d; --bad: #b91c1c; }--@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #f59e0b; --ok: #22c55e; --bad: #f87171; } }++:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }++@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }* { box-sizing: border-box; }body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }header, main, footer { padding: 10px 12px; }@@@ -10,7 +10,7 @@ h2 { font-size: 14px; margin: 0 0 6px; }.coupon { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }.coupon h3 { font-size: 13px; margin: 0 0 4px; }.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }--.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--accent); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }++.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--code); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }.status { font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }.status.reported_working { color: var(--ok); border-color: var(--ok); }.status.reported_failed { color: var(--bad); border-color: var(--bad); }@@@ -19,6 +19,12 @@ button { font: inherit; padding: 3px 9px; border-radius: 5px; border: 1px solidbutton:hover, button:focus-visible { border-color: var(--accent); }select { font: inherit; }.note { font-size: 12px; margin-top: 4px; }++.actions { padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--card); }++.actions .lede { margin: 0 0 8px; }++.btn { padding: 6px 12px; border-radius: 7px; font-weight: 600; }++.btn.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--on-accent-strong); }++.btn.primary:hover, .btn.primary:focus-visible { filter: brightness(1.08); border-color: var(--accent-strong); }++button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }footer { border-top: 1px solid var(--line); font-size: 12px; }a { color: var(--accent); }body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }@@@ -2,21 +2,30 @@<html lang="en"><head><meta charset="utf-8">--<title>Coupon codes for this shop</title>++<title>OpenVibe Companion</title><link rel="stylesheet" href="popup.css"></head><body><header>-- <h1>OpenVibe Coupons</h1>++ <h1>OpenVibe Companion</h1><p id="site" class="muted"></p></header>++<section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe">++ <p id="actions-lede" class="lede"></p>++ <div class="row">++ <button type="button" id="keep" class="btn">Keep on my Rent board</button>++ <button type="button" id="watch" class="btn">Watch this page</button>++ </div>++ <p id="actions-note" class="note muted">Opens openvibe.rent or openvibe.watch in a new tab with this page’s address.</p>++</section><main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main><footer><a id="settings" href="options.html" target="_blank" rel="noopener">Settings</a>-- <span class="muted"> · Reads only this tab's hostname.</span>++ <span class="muted"> · Reads this tab only when you open it.</span></footer><script src="lib/config.js"></script><script src="lib/host.js"></script>++<script src="lib/handoff.js"></script><script src="lib/api.js"></script><script src="lib/format.js"></script><script src="lib/platform.js"></script>@@@ -1,5 +1,5 @@/*-- * Popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to++ * OpenVibe Companion popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to* the controller. All logic lives in lib/controller.js.*/(function () {@@@ -8,7 +8,8 @@const config = self.OVConfig;const platform = self.OVPlatform.createPlatform(ext, config);const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });-- const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost });++ const handoff = self.OVHandoff.createHandoff(config);++ const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost, handoff: handoff, isRental: self.OVHandoff.isRental });const fmt = self.OVFormat;const content = document.getElementById('content');const site = document.getElementById('site');@@@ -83,13 +84,38 @@note);}++ /** The two hand-off buttons: the board leads on a rental listing, Watch everywhere else. */++ function renderActions() {++ const p = controller.page();++ const section = document.getElementById('actions');++ if (!p.handoff) return;++ const keep = document.getElementById('keep');++ const watch = document.getElementById('watch');++ const note = document.getElementById('actions-note');++ document.getElementById('actions-lede').textContent = p.rental++ ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'++ : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';++ (p.rental ? keep : watch).classList.add('primary');++ const go = function (fn) {++ return async function () {++ const r = await fn();++ if (r.ok) window.close();++ else note.textContent = r.message;++ };++ };++ keep.addEventListener('click', go(controller.keepOnBoard));++ watch.addEventListener('click', go(controller.watchPage));++ section.hidden = false;++ }++async function render() {const vm = await controller.open();if (vm.host) site.textContent = vm.host;-- if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a shop’s website.' }));-- if (vm.state === 'no_merchant') return show(el('p', { text: 'OpenVibe.Coupons has no codes for ' + vm.host + '.' }), el('p', null, link('Submit a code', '/submit')));++ renderActions();++ if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a rental listing for your board, any page to watch.' }));++ if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));if (vm.state === 'error') return show(el('p', { text: vm.message }));-- const header = el('h2', { text: vm.merchant.name });++ const header = el('h2', { text: 'Coupon codes for ' + vm.merchant.name });if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));return show(header, ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));}@@@ -1,15 +1,22 @@{"name": "openvibe-extensions",-- "version": "0.1.0",++ "version": "0.2.0","private": true,-- "description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Coupons Helper, a Manifest V3 browser extension for Chromium and Firefox built on OpenVibe.Coupons' public API.",++ "description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.","scripts": {"test": "node test/run.js","pack": "node scripts/pack.js","pack:dev": "node scripts/pack.js --api http://localhost:4850","icons": "node scripts/icons.js"},-- "keywords": ["openvibe", "browser-extension", "webextension", "coupons"],++ "keywords": [++ "openvibe",++ "browser-extension",++ "webextension",++ "coupons",++ "rent",++ "watch"++ ],"author": "OpenVibers","license": "MIT","repository": "github:OpenVibers/OpenVibe.Extensions",@@@ -12,6 +12,8 @@* CSP script-src 'self' only, connect-src only the API origin, no unsafe-** pages popup/options HTML load only local scripts, no inline script* config lib/config.js API_ORIGIN equals the host permission's origin++ * hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the++ * tabs the Companion may open when you press a button; never requested)*/const fs = require('fs');const path = require('path');@@@ -63,8 +65,11 @@ function auditDir(dir) {const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];++ const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);const problems = [];if (!origin) problems.push('lib/config.js has no API_ORIGIN');++ if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');++ for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);problems.push(...auditManifest(manifest, { apiOrigin: origin }));for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));return { manifest, origin, problems };@@@ -3,9 +3,9 @@/*** Packs browser/ into a zip for loading or store review. It never uploads anything anywhere.*-- * node scripts/pack.js → dist/openvibe-coupons-helper-<version>.zip++ * node scripts/pack.js → dist/openvibe-companion-<version>.zip* node scripts/pack.js --api http://localhost:4850-- * → dist/openvibe-coupons-helper-<version>-dev.zip,++ * → dist/openvibe-companion-<version>-dev.zip,* a development build whose API origin (lib/config.js), host permission and CSP connect-src* are rewritten together. Only https origins, or http://localhost / http://127.0.0.1.*@@@ -93,7 +93,7 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})if (audit.problems.length) throw new Error(`permission audit failed:\n - ${audit.problems.join('\n - ')}`);const version = audit.manifest.version;fs.mkdirSync(outDir, { recursive: true });-- const out = path.join(outDir, `openvibe-coupons-helper-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);++ const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));return { out, files: [...files.keys()], origin: api };} finally {nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * The hand-offs (browser/lib/handoff.js): the Companion opens the Rent board or a new Watch with the++ * page you are on only when you press the button, only on openvibe.rent or openvibe.watch, and with++ * the page's credentials, fragment and tracking parameters gone. Opening the popup sends the Coupons++ * API the hostname and nothing more, as before.++ */++const assert = require('assert');++const host = require('../browser/lib/host');++const handoffLib = require('../browser/lib/handoff');++const { createApiClient } = require('../browser/lib/api');++const { createPlatform } = require('../browser/lib/platform');++const { createController } = require('../browser/lib/controller');++const config = require('../browser/lib/config');++const { check, done, startStub, fakeExt } = require('./helpers/harness');++++const LISTING = 'https://user:[email protected]/apa/d/sunny-2br/7790000001.html?lang=en&utm_source=share&fbclid=abc#photos';++const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.html?lang=en';++++(async () => {++ const stub = await startStub();++ const handoff = handoffLib.createHandoff(config);++ const popup = ({ url, title = '' }) => {++ const ext = fakeExt({ url, title });++ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });++ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });++ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };++ };++++ await check('cleanUrl drops credentials, the fragment and tracking parameters, and keeps what identifies the page', () => {++ assert.strictEqual(handoffLib.cleanUrl(LISTING), CLEAN);++ assert.strictEqual(handoffLib.cleanUrl('https://www.facebook.com/marketplace/item/100000000000001/?ref=search&gclid=x'), 'https://www.facebook.com/marketplace/item/100000000000001/?ref=search');++ ['chrome://settings', 'file:///home/a.html', 'javascript:alert(1)', 'about:blank', '', null, `https://a.com/${'x'.repeat(2100)}`]++ .forEach((u) => assert.strictEqual(handoffLib.cleanUrl(u), null, String(u).slice(0, 40)));++ });++++ await check('the board and Watch addresses carry the cleaned page and a tidy title', () => {++ const b = new URL(handoff.boardUrl(LISTING, ' Sunny 2BR\n near the park '));++ assert.strictEqual(b.origin + b.pathname, 'https://openvibe.rent/board');++ assert.deepStrictEqual(Object.fromEntries(b.searchParams), { url: CLEAN, title: 'Sunny 2BR near the park' });++ const w = new URL(handoff.watchUrl(LISTING, 'T'.repeat(300)));++ assert.strictEqual(w.origin + w.pathname, 'https://openvibe.watch/watches/new');++ assert.deepStrictEqual([w.searchParams.get('template'), w.searchParams.get('url'), w.searchParams.get('name').length], ['page', CLEAN, 80]);++ assert.strictEqual(handoff.boardUrl('chrome://extensions', 'x'), null);++ });++++ await check('rental sites lead with the board: the listing sites and Facebook Marketplace, not the rest of Facebook', () => {++ ['https://saltlakecity.craigslist.org/apa/1.html', 'https://www.zillow.com/homedetails/x', 'https://www.apartments.com/x/', 'https://www.facebook.com/marketplace/item/1/', 'https://www.airbnb.com/rooms/1']++ .forEach((u) => assert.ok(handoffLib.isRental(u), u));++ ['https://www.facebook.com/someone', 'https://www.amazon.com/dp/1', 'https://notzillow.com/x']++ .forEach((u) => assert.ok(!handoffLib.isRental(u), u));++ });++++ await check('opening the popup opens nothing and sends the API the hostname only; pressing a button opens one tab', async () => {++ stub.requests.length = 0;++ const p = popup({ url: LISTING, title: 'Sunny 2BR - apts/housing for rent - craigslist' });++ await p.controller.open();++ assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');++ assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true });++++ assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });++ assert.strictEqual(p.ext.opened.length, 1);++ assert.ok(p.ext.opened[0].startsWith('https://openvibe.rent/board?url='));++ assert.ok(!/hunter2|utm_|fbclid|#photos/.test(p.ext.opened[0]), 'nothing private or tracking goes along');++ await p.controller.watchPage();++ assert.ok(p.ext.opened[1].startsWith('https://openvibe.watch/watches/new?template=page&url='));++ assert.strictEqual(stub.requests.length, 1, 'a hand-off is a tab you see, never a request the extension makes');++ });++++ await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {++ const p = popup({ url: 'chrome://extensions' });++ await p.controller.open();++ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false });++ assert.strictEqual((await p.controller.keepOnBoard()).ok, false);++ assert.deepStrictEqual(p.ext.opened, []);++ const platform = createPlatform(fakeExt({}), config);++ await Promise.all(['https://evil.example/board', 'https://openvibe.rent.evil.example/x', 'javascript:alert(1)', 'https://openvibe.rentx/']++ .map((u) => assert.rejects(platform.tabs.open(u), TypeError, u)));++ await platform.tabs.open('https://openvibe.rent/board?url=x');++ });++++ await check('a shop page that is not a rental leads with Watch', async () => {++ const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });++ await p.controller.open();++ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false });++ });++++ await stub.close();++ done();++})();@@@ -70,14 +70,19 @@ function recordingFetch() {return f;}--/** Fakes of chrome.tabs / chrome.storage.local / chrome.permissions. */--function fakeExt({ url = null, token = null, hasPermission = true } = {}) {++/** Fakes of chrome.tabs (query, create) / chrome.storage.local / chrome.permissions; `opened` lists the tabs created. */++function fakeExt({ url = null, title = '', token = null, hasPermission = true } = {}) {const store = token ? { installToken: token } : {};const log = [];++ const opened = [];return {log,store,-- tabs: { async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url }]; } },++ opened,++ tabs: {++ async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url, title }]; },++ async create(o) { log.push(['tabs.create', o]); opened.push(o.url); return { id: 2, url: o.url }; },++ },storage: { local: {async get(k) { log.push(['storage.get', k]); return { [k]: store[k] }; },async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },@@@ -80,8 +80,8 @@ const ORIGIN = 'https://openvibe.coupons';const rel = path.relative(DIR, f);if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);}-- const tabUse = files.filter((f) => /\btabs\.query\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));-- assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs');++ const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));++ assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');});done();@@@ -40,7 +40,7 @@ function unzip(buf) {await check('the release zip holds exactly browser/, byte for byte, and its manifest passes the audit', async () => {const r = build({ outDir: tmp });-- assert.match(path.basename(r.out), /^openvibe-coupons-helper-\d+\.\d+\.\d+\.zip$/);++ assert.match(path.basename(r.out), /^openvibe-companion-\d+\.\d+\.\d+\.zip$/);const files = unzip(fs.readFileSync(r.out));const src = path.join(__dirname, '..', 'browser');for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
@@ -1,11 +1,12 @@
@@ -1,11 +1,12 @@
# OpenVibe.Extensions
# OpenVibe.Extensions
-> OpenVibe's first-party client surfaces: today, the **OpenVibe Coupons Helper** browser extension.
+> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
-**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless
-Chrome 150 loads the packed build. A development build of it looked up codes and sent a report
-against a local OpenVibe.Coupons. It is **not published** to any store, and it hasn't been verified
-in Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
+**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
+loads the unpacked 0.2.0 build with no console or CSP errors (2026-10-15). A development build of
+the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. It is **not
+published** to any store (install it from a release zip, below), and it hasn't been verified in
+Firefox yet. OpenVibe.Coupons runs on the production host with an empty database, and
`https://openvibe.coupons` is public since 2026-10-09, so the release build talks to the live
`https://openvibe.coupons` is public since 2026-10-09, so the release build talks to the live
Coupons API.
Coupons API.
**Decision record:** [ADR-023](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-023-client-surfaces.md) (first-party client surfaces and their home). Roadmap §4.2 D.
**Decision record:** [ADR-023](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-023-client-surfaces.md) (first-party client surfaces and their home). Roadmap §4.2 D.
@@ -18,15 +19,24 @@ per-install permissions. ADR-023 gives clients their own repository.
@@ -18,15 +19,24 @@ per-install permissions. ADR-023 gives clients their own repository.
| Surface | Directory | State |
| Surface | Directory | State |
|---|---|---|
|---|---|---|
-| OpenVibe Coupons Helper (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below |
+| OpenVibe Companion (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below (it was the OpenVibe Coupons Helper until 0.2.0) |
| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
-## The OpenVibe Coupons Helper (`browser/`)
+## The OpenVibe Companion (`browser/`)
-Open it on a shop's website and it shows the codes
-[OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
+One button in the browser for the OpenVibe sites that work with the page you are on:
+
+- **Keep on my Rent board.** On a rental listing (Craigslist, Facebook Marketplace, Zillow,
+ Apartments.com, Zumper, Airbnb and the rest) or any page, it opens
+ [openvibe.rent/board](https://openvibe.rent/board) with the listing's address and title filled in,
+ so every listing from every site sits in one list with your notes and how far you got. On a rental
+ site this is the main button.
+- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,
+ which tells you (or runs your agent) when it changes.
+- **Coupon codes.** On a shop's website it shows the codes
+ [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
- the code, with a Copy button
- the code, with a Copy button
- its status (validity unknown, reported working, reported not working)
- its status (validity unknown, reported working, reported not working)
@@ -34,7 +44,8 @@ Open it on a shop's website and it shows the codes
@@ -34,7 +44,8 @@ Open it on a shop's website and it shows the codes
- its restrictions, or "No restrictions stated"
- its restrictions, or "No restrictions stated"
- how to apply it
- how to apply it
-If you connected the extension, you can report whether a code worked.
+If you connected the extension, you can report whether a code worked. The codes are shown to copy;
+the Companion never rewrites links or adds referral tags.
### What it can see, and what it sends
### What it can see, and what it sends
@@ -42,8 +53,9 @@ If you connected the extension, you can report whether a code worked.
@@ -42,8 +53,9 @@ If you connected the extension, you can report whether a code worked.
|---|---|
|---|---|
| **Permissions** | `activeTab` and `storage`. The host permission is `https://openvibe.coupons/*` only. |
| **Permissions** | `activeTab` and `storage`. The host permission is `https://openvibe.coupons/*` only. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `scripting`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `scripting`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
-| **Read from the page** | The **hostname** of the active tab's URL, when you open the popup (that's what `activeTab` grants). `lib/host.js` drops the path, query, fragment, port and credentials before anything else sees the URL. Browser pages, files, IP addresses and local names are "not a shop", and nothing is sent for them. |
-| **Sent** | To `https://openvibe.coupons` only: the hostname (`GET /api/v1/merchants/resolve?host=`), the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
+| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). Never the page's content. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
+| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
+| **Hand-offs (only when you press the button)** | "Keep on my Rent board" and "Watch this page" open **a new tab you see** on `https://openvibe.rent/board` or `https://openvibe.watch/watches/new`, with the page's address and title in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
@@ -75,11 +87,12 @@ browser/
@@ -75,11 +87,12 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
popup.html / popup.js DOM wiring only
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
options.html / .js connect (save a checked token), disconnect, grant the API host permission
- lib/config.js API_ORIGIN — the one origin the extension talks to
+ lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch)
lib/host.js URL → hostname (everything else dropped)
lib/host.js URL → hostname (everything else dropped)
+ lib/handoff.js the Rent board and Watch addresses, built from a cleaned URL; which sites are rentals
lib/api.js the only code that makes requests
lib/api.js the only code that makes requests
lib/controller.js popup logic without the DOM (tested in Node)
lib/controller.js popup logic without the DOM (tested in Node)
- lib/platform.js chrome.* / browser.*: tabs.query, storage.local, permissions
+ lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
icons/ generated by scripts/icons.js
icons/ generated by scripts/icons.js
scripts/
scripts/
@@ -99,6 +112,7 @@ npm test
@@ -99,6 +112,7 @@ npm test
| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` |
| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` fetches. | `test/manifest.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
+| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
Beyond the automated tests, this was checked by hand on 2026-09-22:
Beyond the automated tests, this was checked by hand on 2026-09-22:
@@ -114,10 +128,11 @@ That check is not in CI. Firefox hasn't been tried.
@@ -114,10 +128,11 @@ That check is not in CI. Firefox hasn't been tried.
## Packing (never published from here)
## Packing (never published from here)
```bash
```bash
-npm run pack # dist/openvibe-coupons-helper-<version>.zip (API https://openvibe.coupons)
+npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)
node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)
node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)
```
```
+Each version's zip is attached to its [GitHub release](https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest).
To load it:
To load it:
- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the
- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the
@@ -134,7 +149,7 @@ shows "Allow access to openvibe.coupons".
@@ -134,7 +149,7 @@ shows "Allow access to openvibe.coupons".
2026-10-09). The release build only talks to `https://openvibe.coupons`.
2026-10-09). The release build only talks to `https://openvibe.coupons`.
2. **Store listings:** submit the zip to the Chrome Web Store and addons.mozilla.org. The privacy
2. **Store listings:** submit the zip to the Chrome Web Store and addons.mozilla.org. The privacy
disclosure is the table above: the hostname of the active tab, only on open, sent to
disclosure is the table above: the hostname of the active tab, only on open, sent to
- openvibe.coupons. `browser_specific_settings.gecko.data_collection_permissions` declares
+ openvibe.coupons; the address and title only in a hand-off tab the person opens. `browser_specific_settings.gecko.data_collection_permissions` declares
`browsingActivity`. Check it against AMO's requirements at submission time.
`browsingActivity`. Check it against AMO's requirements at submission time.
3. **CORS (optional):** set `COUPONS_EXTENSION_ORIGINS` on Coupons to the Chrome Web Store id
3. **CORS (optional):** set `COUPONS_EXTENSION_ORIGINS` on Coupons to the Chrome Web Store id
(`chrome-extension://<id>`) and, if wanted, `moz-extension://*`. The extension doesn't need it,
(`chrome-extension://<id>`) and, if wanted, `moz-extension://*`. The extension doesn't need it,
@@ -145,6 +160,9 @@ shows "Allow access to openvibe.coupons".
@@ -145,6 +160,9 @@ shows "Allow access to openvibe.coupons".
## Depends on
## Depends on
+- [OpenVibe.Rent](https://github.com/OpenVibers/OpenVibe.Rent) `/board?url=&title=` and
+ [OpenVibe.Watch](https://github.com/OpenVibers/OpenVibe.Watch) `/watches/new?template=page&url=&name=`
+ (pages, not APIs: the person signs in there as usual).
- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:
- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:
- `GET /api/v1/merchants/resolve?host=`
- `GET /api/v1/merchants/resolve?host=`
- `GET /api/v1/merchants/:id/coupons`
- `GET /api/v1/merchants/:id/coupons`
@@ -7,17 +7,29 @@
@@ -7,17 +7,29 @@
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
"surfaces": {
"surfaces": {
"browser": {
"browser": {
- "name": "OpenVibe Coupons Helper",
- "version": "0.1.0",
+ "name": "OpenVibe Companion",
+ "version": "0.2.0",
"manifest": 3,
"manifest": 3,
- "targets": ["chromium >= 111", "firefox >= 128"],
+ "targets": [
+ "chromium >= 111",
+ "firefox >= 128"
+ ],
"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
"api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
- "permissions": ["activeTab", "storage"],
- "hostPermissions": ["https://openvibe.coupons/*"],
+ "permissions": [
+ "activeTab",
+ "storage"
+ ],
+ "hostPermissions": [
+ "https://openvibe.coupons/*"
+ ],
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session",
"publishedToStores": false,
"publishedToStores": false,
- "loadedInChrome": "yes: headless Chrome 150 loads the packed build; a development build looked up and reported against a local OpenVibe.Coupons",
- "loadedInFirefox": "not yet verified"
+ "loadedInChrome": "yes: headless Chrome loads the packed 0.2.0 build (popup and options render, no console errors)",
+ "loadedInFirefox": "not yet verified",
+ "handoffs": [
+ "https://openvibe.rent/board (keep a listing)",
+ "https://openvibe.watch/watches/new (watch a page)"
+ ]
}
}
},
},
"pending": [
"pending": [
@@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
/*
/*
- * OpenVibe Coupons Helper — configuration.
+ * OpenVibe Companion — configuration.
*
*
- * API_ORIGIN is the ONLY origin this extension ever talks to. It must match the manifest's
+ * API_ORIGIN is the ONLY origin this extension ever sends a request to. It must match the manifest's
* host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for
* host_permissions and the connect-src of its CSP; scripts/pack.js rewrites all three together for
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
* a development build (--api http://localhost:4850) and test/manifest.test.js checks they agree.
+ *
+ * HANDOFF_ORIGINS are the sites the Companion may open a tab on when you press one of its buttons
+ * (lib/handoff.js): the Rent board and a new Watch. It sends them nothing itself; the address of the
+ * tab it opens carries the page you were on, and you see it.
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
@@ -13,6 +17,9 @@
@@ -13,6 +17,9 @@
'use strict';
'use strict';
return Object.freeze({
return Object.freeze({
API_ORIGIN: 'https://openvibe.coupons',
API_ORIGIN: 'https://openvibe.coupons',
+ RENT_ORIGIN: 'https://openvibe.rent',
+ WATCH_ORIGIN: 'https://openvibe.watch',
+ HANDOFF_ORIGINS: Object.freeze(['https://openvibe.rent', 'https://openvibe.watch']),
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_KEY: 'installToken',
TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
@@ -1,13 +1,17 @@
@@ -1,13 +1,17 @@
/*
/*
* The popup's logic, without the DOM (so Node tests can drive it):
* The popup's logic, without the DOM (so Node tests can drive it):
*
*
- * const c = createController({ api, platform, host });
- * const vm = await c.open(); // what to show for the active tab
+ * const c = createController({ api, platform, host, handoff });
+ * const vm = await c.open(); // the codes for the active tab's shop
+ * c.page() // { handoff, rental }: which buttons to offer
+ * await c.keepOnBoard() // open the Rent board with this page (a new tab)
+ * await c.watchPage() // open a new Watch for this page (a new tab)
* await c.report(couponId, 'worked') // → { ok, message }
* await c.report(couponId, 'worked') // → { ok, message }
*
*
- * open() reads the active tab's URL once, keeps only its hostname (host.hostnameOf), and asks the
- * API which shop that is and which codes it has. That hostname is the only thing about the page
- * that ever leaves the browser.
+ * open() reads the active tab's URL and title once. Only the hostname (host.hostnameOf) is sent
+ * anywhere: to the Coupons API, to say which shop that is. The URL and title stay in this closure
+ * for the two hand-offs, which open a tab on openvibe.rent or openvibe.watch only when the person
+ * presses their button (lib/handoff.js cleans the URL first).
*
*
* View models:
* View models:
* { state: 'not_a_site' } browser pages, files, IPs, local names
* { state: 'not_a_site' } browser pages, files, IPs, local names
@@ -40,10 +44,20 @@
@@ -40,10 +44,20 @@
const api = deps.api;
const api = deps.api;
const platform = deps.platform;
const platform = deps.platform;
const host = deps.host;
const host = deps.host;
+ const handoff = deps.handoff || null;
+ let tab = { url: null, title: '' };
+
+ async function handOff(build) {
+ const target = handoff && build(tab.url, tab.title);
+ if (!target) return { ok: false, message: 'This page cannot be sent there.' };
+ await platform.tabs.open(target);
+ return { ok: true };
+ }
return {
return {
async open() {
async open() {
- const url = await platform.tabs.activeUrl();
+ tab = await platform.tabs.active();
+ const url = tab.url;
const hostname = host.hostnameOf(url);
const hostname = host.hostnameOf(url);
if (!hostname) return { state: 'not_a_site' };
if (!hostname) return { state: 'not_a_site' };
const connected = Boolean(await platform.storage.getToken());
const connected = Boolean(await platform.storage.getToken());
@@ -57,6 +71,15 @@
@@ -57,6 +71,15 @@
}
}
},
},
+ /** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board. */
+ page() {
+ const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));
+ return { handoff: ok, rental: ok && deps.isRental ? deps.isRental(tab.url) : false };
+ },
+
+ keepOnBoard() { return handOff(function (u, t) { return handoff.boardUrl(u, t); }); },
+ watchPage() { return handOff(function (u, t) { return handoff.watchUrl(u, t); }); },
+
async report(couponId, outcome, reason) {
async report(couponId, outcome, reason) {
try {
try {
const out = await api.report(couponId, outcome, reason);
const out = await api.report(couponId, outcome, reason);
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+/*
+ * Hand-offs: the pages the Companion opens on OpenVibe sites with the page you are on, when you
+ * press a button for it — never on its own.
+ *
+ * boardUrl(url, title) https://openvibe.rent/board?url=…&title=… keep a listing on your Rent board
+ * watchUrl(url, title) https://openvibe.watch/watches/new?template=page… watch the page for changes
+ *
+ * This is the only code that uses more of the page than its hostname, and what it builds is a page
+ * address opened in a new tab you see, never a request the extension makes. cleanUrl() drops the
+ * credentials, the fragment and the tracking parameters (utm_*, fbclid, gclid, …) first; anything
+ * that is not an http(s) page gives null and nothing opens. platform.tabs.open() refuses any
+ * address outside lib/config.js HANDOFF_ORIGINS.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVHandoff = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ const TRACKING = /^(utm_[a-z_]+|fbclid|gclid|gbraid|wbraid|dclid|msclkid|mc_cid|mc_eid|igshid|yclid|_hsenc|_hsmi|ref_src|spm)$/i;
+
+ /** Sites whose pages are rental listings: there, keeping it on the board is the main action. */
+ const RENTAL_HOSTS = ['craigslist.org', 'zillow.com', 'apartments.com', 'zumper.com', 'rent.com', 'rentcafe.com', 'apartmentlist.com',
+ 'padmapper.com', 'hotpads.com', 'trulia.com', 'realtor.com', 'roomies.com', 'furnishedfinder.com', 'airbnb.com', 'vrbo.com',
+ 'loopnet.com', 'spothero.com', 'sparefoot.com', 'turo.com', 'spareroom.com', 'spareroom.co.uk', 'rightmove.co.uk', 'zoopla.co.uk',
+ 'kijiji.ca', 'rentals.ca', 'idealista.com', 'domain.com.au', 'realestate.com.au', 'openvibe.rent'];
+
+ function cleanUrl(url) {
+ if (typeof url !== 'string' || !url || url.length > 4000) return null;
+ let u;
+ try { u = new URL(url); } catch (e) { return null; }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
+ u.username = '';
+ u.password = '';
+ u.hash = '';
+ const keep = [];
+ u.searchParams.forEach(function (v, k) { if (!TRACKING.test(k)) keep.push([k, v]); });
+ u.search = '';
+ keep.forEach(function (kv) { u.searchParams.append(kv[0], kv[1]); });
+ const out = u.href;
+ return out.length <= 2000 ? out : null;
+ }
+
+ function cleanTitle(title, max) {
+ return String(title == null ? '' : title).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
+ }
+
+ function isRental(url) {
+ let u;
+ try { u = new URL(String(url)); } catch (e) { return false; }
+ const h = u.hostname.toLowerCase().replace(/^www\./, '');
+ if (h === 'facebook.com' || h.endsWith('.facebook.com')) return /^\/marketplace\b/.test(u.pathname);
+ return RENTAL_HOSTS.some(function (r) { return h === r || h.endsWith('.' + r); });
+ }
+
+ /** The hand-offs for one configuration (lib/config.js RENT_ORIGIN, WATCH_ORIGIN, HANDOFF_ORIGINS). */
+ function createHandoff(config) {
+ const RENT = config.RENT_ORIGIN;
+ const WATCH = config.WATCH_ORIGIN;
+ const origins = config.HANDOFF_ORIGINS;
+ return {
+ boardUrl: function (url, title) { return boardUrl(RENT, url, title); },
+ watchUrl: function (url, title) { return watchUrl(WATCH, url, title); },
+ isHandoff: function (url) { return isHandoff(origins, url); },
+ };
+ }
+
+ function boardUrl(RENT, url, title) {
+ const page = cleanUrl(url);
+ if (!page) return null;
+ const q = new URLSearchParams({ url: page });
+ const t = cleanTitle(title, 200);
+ if (t) q.set('title', t);
+ return RENT + '/board?' + q.toString();
+ }
+
+ function watchUrl(WATCH, url, title) {
+ const page = cleanUrl(url);
+ if (!page) return null;
+ const q = new URLSearchParams({ template: 'page', url: page });
+ const t = cleanTitle(title, 80);
+ if (t) q.set('name', t);
+ return WATCH + '/watches/new?' + q.toString();
+ }
+
+ /** True when `url` is a page on one of the hand-off sites (what platform.tabs.open() accepts). */
+ function isHandoff(origins, url) {
+ return typeof url === 'string' && origins.some(function (o) { return url.indexOf(o + '/') === 0; });
+ }
+
+ return { createHandoff, RENTAL_HOSTS, cleanUrl, isRental, isHandoff };
+});
@@ -1,8 +1,11 @@
@@ -1,8 +1,11 @@
/*
/*
- * The browser APIs the helper uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):
+ * The browser APIs the Companion uses, in one place (Chromium `chrome.*` and Firefox `browser.*`):
*
*
- * tabs.activeUrl() the active tab's URL — available because the user opened the popup
- * (activeTab); handed straight to host.hostnameOf and dropped
+ * tabs.active() the active tab's URL and title — available because the user opened the
+ * popup (activeTab); the controller sends only the hostname anywhere, and
+ * keeps the rest for a hand-off the person may press
+ * tabs.open(url) open a new tab on one of config.HANDOFF_ORIGINS (and nowhere else) —
+ * tabs.create needs no permission
* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local
* storage.getToken/setToken/clearToken the optional cpx_ install token, in storage.local
* (never storage.sync: it stays on this device)
* (never storage.sync: it stays on this device)
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
* permissions.hasApi/requestApi the host permission for the API origin (Firefox
@@ -25,9 +28,15 @@
@@ -25,9 +28,15 @@
};
};
return {
return {
tabs: {
tabs: {
- async activeUrl() {
+ async active() {
const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);
const tabs = await call(ext.tabs.query, ext.tabs, [{ active: true, currentWindow: true }]);
- return tabs && tabs[0] && typeof tabs[0].url === 'string' ? tabs[0].url : null;
+ const t = tabs && tabs[0];
+ return { url: t && typeof t.url === 'string' ? t.url : null, title: t && typeof t.title === 'string' ? t.title : '' };
+ },
+ async open(url) {
+ const ok = typeof url === 'string' && config.HANDOFF_ORIGINS.some(function (o) { return url.indexOf(o + '/') === 0; });
+ if (!ok) throw new TypeError('not an OpenVibe hand-off page');
+ await call(ext.tabs.create, ext.tabs, [{ url: url }]);
},
},
},
},
storage: {
storage: {
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
{
{
"manifest_version": 3,
"manifest_version": 3,
- "name": "OpenVibe Coupons Helper",
- "short_name": "OV Coupons",
- "version": "0.1.0",
- "description": "Shows the coupon codes OpenVibe.Coupons knows for the shop you are on, with their status, expiry and restrictions. Reads only the hostname, only when you open it.",
- "homepage_url": "https://openvibe.coupons/connect-extension",
+ "name": "OpenVibe Companion",
+ "short_name": "OpenVibe",
+ "version": "0.2.0",
+ "description": "Coupon codes for the shop you are on, and one click to keep a rental listing on your OpenVibe.Rent board or watch a page with OpenVibe.Watch. Reads the tab only when you open it.",
+ "homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"author": "OpenVibers",
"icons": {
"icons": {
"16": "icons/icon-16.png",
"16": "icons/icon-16.png",
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"128": "icons/icon-128.png"
"128": "icons/icon-128.png"
},
},
"action": {
"action": {
- "default_title": "Coupon codes for this shop",
+ "default_title": "OpenVibe Companion",
"default_popup": "popup.html",
"default_popup": "popup.html",
"default_icon": {
"default_icon": {
"16": "icons/icon-16.png",
"16": "icons/icon-16.png",
@@ -37,7 +37,7 @@
@@ -37,7 +37,7 @@
"minimum_chrome_version": "111",
"minimum_chrome_version": "111",
"browser_specific_settings": {
"browser_specific_settings": {
"gecko": {
"gecko": {
- "id": "[email protected]",
+ "id": "[email protected]",
"strict_min_version": "128.0",
"strict_min_version": "128.0",
"data_collection_permissions": {
"data_collection_permissions": {
"required": ["browsingActivity"]
"required": ["browsingActivity"]
@@ -2,13 +2,13 @@
@@ -2,13 +2,13 @@
<html lang="en">
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
-<title>OpenVibe Coupons Helper — settings</title>
+<title>OpenVibe Companion — settings</title>
<link rel="stylesheet" href="popup.css">
<link rel="stylesheet" href="popup.css">
</head>
</head>
<body class="options">
<body class="options">
<main>
<main>
- <h1>OpenVibe Coupons Helper</h1>
- <p>The helper works without connecting. Connecting lets it report whether a code worked, as you.</p>
+ <h1>OpenVibe Companion</h1>
+ <p>Coupon codes, the Rent board and Watch all work without connecting. Connecting lets the Companion report whether a code worked, as you.</p>
<ol>
<ol>
<li>On <a id="connect-link" target="_blank" rel="noopener noreferrer" href="#">openvibe.coupons/connect-extension</a>, sign in and create a token for this browser.</li>
<li>On <a id="connect-link" target="_blank" rel="noopener noreferrer" href="#">openvibe.coupons/connect-extension</a>, sign in and create a token for this browser.</li>
<li>Paste it here. It can only look up codes and report on them; it is not your OpenVibe sign-in.</li>
<li>Paste it here. It can only look up codes and report on them; it is not your OpenVibe sign-in.</li>
@@ -20,12 +20,13 @@
@@ -20,12 +20,13 @@
</form>
</form>
<p id="state" class="muted" aria-live="polite"></p>
<p id="state" class="muted" aria-live="polite"></p>
<p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>
<p><button id="disconnect" type="button">Disconnect</button> <span class="muted">Removes the token from this browser. Revoke it on openvibe.coupons as well.</span></p>
- <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the helper never asks for access to other sites.</span></p>
+ <p id="permission-row" hidden><button id="grant" type="button">Allow access to openvibe.coupons</button> <span class="muted">Needed to report; the Companion never asks for access to other sites.</span></p>
<h2>What this extension can see</h2>
<h2>What this extension can see</h2>
<ul>
<ul>
- <li>Only the hostname of the tab you are on, and only when you open the helper.</li>
- <li>No page content, no full addresses, no cookies, no browsing history.</li>
- <li>It talks to openvibe.coupons only.</li>
+ <li>The tab you are on, and only when you open the Companion: its address and title.</li>
+ <li>It sends openvibe.coupons only the hostname, to find that shop’s codes.</li>
+ <li>When you press “Keep on my Rent board” or “Watch this page”, it opens openvibe.rent or openvibe.watch in a new tab with the page’s address and title, without its tracking parameters. You see that tab, and nothing is saved until you save it there.</li>
+ <li>No page content, no cookies, no browsing history, nothing in the background.</li>
</ul>
</ul>
</main>
</main>
<script src="lib/config.js"></script>
<script src="lib/config.js"></script>
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
-:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #b45309; --ok: #15803d; --bad: #b91c1c; }
-@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
+:root { color-scheme: light dark; --fg: #1d2433; --muted: #5d6b82; --bg: #ffffff; --card: #f4f6fa; --line: #d6dce6; --accent: #1d4ed8; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #b45309; --ok: #15803d; --bad: #b91c1c; }
+@media (prefers-color-scheme: dark) { :root { --fg: #e6edf7; --muted: #9aa8bf; --bg: #0f1522; --card: #172033; --line: #2a3650; --accent: #60a5fa; --accent-strong: #2563eb; --on-accent-strong: #ffffff; --code: #f59e0b; --ok: #22c55e; --bad: #f87171; } }
* { box-sizing: border-box; }
* { box-sizing: border-box; }
body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
body { margin: 0; width: 360px; max-height: 560px; overflow-y: auto; background: var(--bg); color: var(--fg); font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
header, main, footer { padding: 10px 12px; }
header, main, footer { padding: 10px 12px; }
@@ -10,7 +10,7 @@ h2 { font-size: 14px; margin: 0 0 6px; }
@@ -10,7 +10,7 @@ h2 { font-size: 14px; margin: 0 0 6px; }
.coupon { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
.coupon { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin: 8px 0; }
.coupon h3 { font-size: 13px; margin: 0 0 4px; }
.coupon h3 { font-size: 13px; margin: 0 0 4px; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
-.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--accent); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }
+.code { font: 600 14px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; border: 1px dashed var(--code); border-radius: 5px; padding: 2px 8px; user-select: all; word-break: break-all; }
.status { font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.status { font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.status.reported_working { color: var(--ok); border-color: var(--ok); }
.status.reported_working { color: var(--ok); border-color: var(--ok); }
.status.reported_failed { color: var(--bad); border-color: var(--bad); }
.status.reported_failed { color: var(--bad); border-color: var(--bad); }
@@ -19,6 +19,12 @@ button { font: inherit; padding: 3px 9px; border-radius: 5px; border: 1px solid
@@ -19,6 +19,12 @@ button { font: inherit; padding: 3px 9px; border-radius: 5px; border: 1px solid
button:hover, button:focus-visible { border-color: var(--accent); }
button:hover, button:focus-visible { border-color: var(--accent); }
select { font: inherit; }
select { font: inherit; }
.note { font-size: 12px; margin-top: 4px; }
.note { font-size: 12px; margin-top: 4px; }
+.actions { padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--card); }
+.actions .lede { margin: 0 0 8px; }
+.btn { padding: 6px 12px; border-radius: 7px; font-weight: 600; }
+.btn.primary { background: var(--accent-strong); border-color: var(--accent-strong); color: var(--on-accent-strong); }
+.btn.primary:hover, .btn.primary:focus-visible { filter: brightness(1.08); border-color: var(--accent-strong); }
+button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
footer { border-top: 1px solid var(--line); font-size: 12px; }
footer { border-top: 1px solid var(--line); font-size: 12px; }
a { color: var(--accent); }
a { color: var(--accent); }
body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }
body.options { width: auto; max-width: 640px; max-height: none; margin: 0 auto; padding: 12px 16px; font-size: 14px; }
@@ -2,21 +2,30 @@
@@ -2,21 +2,30 @@
<html lang="en">
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<meta charset="utf-8">
-<title>Coupon codes for this shop</title>
+<title>OpenVibe Companion</title>
<link rel="stylesheet" href="popup.css">
<link rel="stylesheet" href="popup.css">
</head>
</head>
<body>
<body>
<header>
<header>
- <h1>OpenVibe Coupons</h1>
+ <h1>OpenVibe Companion</h1>
<p id="site" class="muted"></p>
<p id="site" class="muted"></p>
</header>
</header>
+<section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe">
+ <p id="actions-lede" class="lede"></p>
+ <div class="row">
+ <button type="button" id="keep" class="btn">Keep on my Rent board</button>
+ <button type="button" id="watch" class="btn">Watch this page</button>
+ </div>
+ <p id="actions-note" class="note muted">Opens openvibe.rent or openvibe.watch in a new tab with this page’s address.</p>
+</section>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<main id="content" aria-live="polite"><p class="muted">Looking up this shop…</p></main>
<footer>
<footer>
<a id="settings" href="options.html" target="_blank" rel="noopener">Settings</a>
<a id="settings" href="options.html" target="_blank" rel="noopener">Settings</a>
- <span class="muted"> · Reads only this tab's hostname.</span>
+ <span class="muted"> · Reads this tab only when you open it.</span>
</footer>
</footer>
<script src="lib/config.js"></script>
<script src="lib/config.js"></script>
<script src="lib/host.js"></script>
<script src="lib/host.js"></script>
+<script src="lib/handoff.js"></script>
<script src="lib/api.js"></script>
<script src="lib/api.js"></script>
<script src="lib/format.js"></script>
<script src="lib/format.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/platform.js"></script>
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/*
/*
- * Popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to
+ * OpenVibe Companion popup wiring: DOM only (no innerHTML — every value is set as text), clipboard, and the calls to
* the controller. All logic lives in lib/controller.js.
* the controller. All logic lives in lib/controller.js.
*/
*/
(function () {
(function () {
@@ -8,7 +8,8 @@
@@ -8,7 +8,8 @@
const config = self.OVConfig;
const config = self.OVConfig;
const platform = self.OVPlatform.createPlatform(ext, config);
const platform = self.OVPlatform.createPlatform(ext, config);
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
- const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost });
+ const handoff = self.OVHandoff.createHandoff(config);
+ const controller = self.OVController.createController({ api: api, platform: platform, host: self.OVHost, handoff: handoff, isRental: self.OVHandoff.isRental });
const fmt = self.OVFormat;
const fmt = self.OVFormat;
const content = document.getElementById('content');
const content = document.getElementById('content');
const site = document.getElementById('site');
const site = document.getElementById('site');
@@ -83,13 +84,38 @@
@@ -83,13 +84,38 @@
note);
note);
}
}
+ /** The two hand-off buttons: the board leads on a rental listing, Watch everywhere else. */
+ function renderActions() {
+ const p = controller.page();
+ const section = document.getElementById('actions');
+ if (!p.handoff) return;
+ const keep = document.getElementById('keep');
+ const watch = document.getElementById('watch');
+ const note = document.getElementById('actions-note');
+ document.getElementById('actions-lede').textContent = p.rental
+ ? 'A rental listing? Keep it on your board with your notes, next to the ones from every other site.'
+ : 'Keep this page on your Rent board, or have OpenVibe.Watch tell you when it changes.';
+ (p.rental ? keep : watch).classList.add('primary');
+ const go = function (fn) {
+ return async function () {
+ const r = await fn();
+ if (r.ok) window.close();
+ else note.textContent = r.message;
+ };
+ };
+ keep.addEventListener('click', go(controller.keepOnBoard));
+ watch.addEventListener('click', go(controller.watchPage));
+ section.hidden = false;
+ }
+
async function render() {
async function render() {
const vm = await controller.open();
const vm = await controller.open();
if (vm.host) site.textContent = vm.host;
if (vm.host) site.textContent = vm.host;
- if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a shop’s website.' }));
- if (vm.state === 'no_merchant') return show(el('p', { text: 'OpenVibe.Coupons has no codes for ' + vm.host + '.' }), el('p', null, link('Submit a code', '/submit')));
+ renderActions();
+ if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a rental listing for your board, any page to watch.' }));
+ if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
- const header = el('h2', { text: vm.merchant.name });
+ const header = el('h2', { text: 'Coupon codes for ' + vm.merchant.name });
if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));
if (!vm.coupons.length) return show(header, el('p', { text: 'No active codes right now.' }), el('p', null, link('See the shop page', '/m/' + encodeURIComponent(vm.merchant.slug))));
return show(header, ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));
return show(header, ...vm.coupons.map(function (c) { return couponNode(c, vm.connected); }), el('p', { className: 'muted' }, link('All codes and details', '/m/' + encodeURIComponent(vm.merchant.slug))));
}
}
@@ -1,15 +1,22 @@
@@ -1,15 +1,22 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
- "description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Coupons Helper, a Manifest V3 browser extension for Chromium and Firefox built on OpenVibe.Coupons' public API.",
+ "description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
"pack": "node scripts/pack.js",
"pack": "node scripts/pack.js",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"icons": "node scripts/icons.js"
"icons": "node scripts/icons.js"
},
},
- "keywords": ["openvibe", "browser-extension", "webextension", "coupons"],
+ "keywords": [
+ "openvibe",
+ "browser-extension",
+ "webextension",
+ "coupons",
+ "rent",
+ "watch"
+ ],
"author": "OpenVibers",
"author": "OpenVibers",
"license": "MIT",
"license": "MIT",
"repository": "github:OpenVibers/OpenVibe.Extensions",
"repository": "github:OpenVibers/OpenVibe.Extensions",
@@ -12,6 +12,8 @@
@@ -12,6 +12,8 @@
* CSP script-src 'self' only, connect-src only the API origin, no unsafe-*
* CSP script-src 'self' only, connect-src only the API origin, no unsafe-*
* pages popup/options HTML load only local scripts, no inline script
* pages popup/options HTML load only local scripts, no inline script
* config lib/config.js API_ORIGIN equals the host permission's origin
* config lib/config.js API_ORIGIN equals the host permission's origin
+ * hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
+ * tabs the Companion may open when you press a button; never requested)
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -63,8 +65,11 @@ function auditDir(dir) {
@@ -63,8 +65,11 @@ function auditDir(dir) {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');
const configSrc = fs.readFileSync(path.join(dir, 'lib', 'config.js'), 'utf8');
const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];
const origin = (configSrc.match(/API_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const problems = [];
const problems = [];
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
+ if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
+ for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
problems.push(...auditManifest(manifest, { apiOrigin: origin }));
problems.push(...auditManifest(manifest, { apiOrigin: origin }));
for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
for (const page of ['popup.html', 'options.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
return { manifest, origin, problems };
return { manifest, origin, problems };
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
/**
/**
* Packs browser/ into a zip for loading or store review. It never uploads anything anywhere.
* Packs browser/ into a zip for loading or store review. It never uploads anything anywhere.
*
*
- * node scripts/pack.js → dist/openvibe-coupons-helper-<version>.zip
+ * node scripts/pack.js → dist/openvibe-companion-<version>.zip
* node scripts/pack.js --api http://localhost:4850
* node scripts/pack.js --api http://localhost:4850
- * → dist/openvibe-coupons-helper-<version>-dev.zip,
+ * → dist/openvibe-companion-<version>-dev.zip,
* a development build whose API origin (lib/config.js), host permission and CSP connect-src
* a development build whose API origin (lib/config.js), host permission and CSP connect-src
* are rewritten together. Only https origins, or http://localhost / http://127.0.0.1.
* are rewritten together. Only https origins, or http://localhost / http://127.0.0.1.
*
*
@@ -93,7 +93,7 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
@@ -93,7 +93,7 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
if (audit.problems.length) throw new Error(`permission audit failed:\n - ${audit.problems.join('\n - ')}`);
if (audit.problems.length) throw new Error(`permission audit failed:\n - ${audit.problems.join('\n - ')}`);
const version = audit.manifest.version;
const version = audit.manifest.version;
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
- const out = path.join(outDir, `openvibe-coupons-helper-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);
+ const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);
fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));
fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));
return { out, files: [...files.keys()], origin: api };
return { out, files: [...files.keys()], origin: api };
} finally {
} finally {
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * The hand-offs (browser/lib/handoff.js): the Companion opens the Rent board or a new Watch with the
+ * page you are on only when you press the button, only on openvibe.rent or openvibe.watch, and with
+ * the page's credentials, fragment and tracking parameters gone. Opening the popup sends the Coupons
+ * API the hostname and nothing more, as before.
+ */
+const assert = require('assert');
+const host = require('../browser/lib/host');
+const handoffLib = require('../browser/lib/handoff');
+const { createApiClient } = require('../browser/lib/api');
+const { createPlatform } = require('../browser/lib/platform');
+const { createController } = require('../browser/lib/controller');
+const config = require('../browser/lib/config');
+const { check, done, startStub, fakeExt } = require('./helpers/harness');
+
+const LISTING = 'https://user:[email protected]/apa/d/sunny-2br/7790000001.html?lang=en&utm_source=share&fbclid=abc#photos';
+const CLEAN = 'https://saltlakecity.craigslist.org/apa/d/sunny-2br/7790000001.html?lang=en';
+
+(async () => {
+ const stub = await startStub();
+ const handoff = handoffLib.createHandoff(config);
+ const popup = ({ url, title = '' }) => {
+ const ext = fakeExt({ url, title });
+ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });
+ const api = createApiClient({ origin: stub.origin, fetch, getToken: platform.storage.getToken });
+ return { ext, controller: createController({ api, platform, host, handoff, isRental: handoffLib.isRental }) };
+ };
+
+ await check('cleanUrl drops credentials, the fragment and tracking parameters, and keeps what identifies the page', () => {
+ assert.strictEqual(handoffLib.cleanUrl(LISTING), CLEAN);
+ assert.strictEqual(handoffLib.cleanUrl('https://www.facebook.com/marketplace/item/100000000000001/?ref=search&gclid=x'), 'https://www.facebook.com/marketplace/item/100000000000001/?ref=search');
+ ['chrome://settings', 'file:///home/a.html', 'javascript:alert(1)', 'about:blank', '', null, `https://a.com/${'x'.repeat(2100)}`]
+ .forEach((u) => assert.strictEqual(handoffLib.cleanUrl(u), null, String(u).slice(0, 40)));
+ });
+
+ await check('the board and Watch addresses carry the cleaned page and a tidy title', () => {
+ const b = new URL(handoff.boardUrl(LISTING, ' Sunny 2BR\n near the park '));
+ assert.strictEqual(b.origin + b.pathname, 'https://openvibe.rent/board');
+ assert.deepStrictEqual(Object.fromEntries(b.searchParams), { url: CLEAN, title: 'Sunny 2BR near the park' });
+ const w = new URL(handoff.watchUrl(LISTING, 'T'.repeat(300)));
+ assert.strictEqual(w.origin + w.pathname, 'https://openvibe.watch/watches/new');
+ assert.deepStrictEqual([w.searchParams.get('template'), w.searchParams.get('url'), w.searchParams.get('name').length], ['page', CLEAN, 80]);
+ assert.strictEqual(handoff.boardUrl('chrome://extensions', 'x'), null);
+ });
+
+ await check('rental sites lead with the board: the listing sites and Facebook Marketplace, not the rest of Facebook', () => {
+ ['https://saltlakecity.craigslist.org/apa/1.html', 'https://www.zillow.com/homedetails/x', 'https://www.apartments.com/x/', 'https://www.facebook.com/marketplace/item/1/', 'https://www.airbnb.com/rooms/1']
+ .forEach((u) => assert.ok(handoffLib.isRental(u), u));
+ ['https://www.facebook.com/someone', 'https://www.amazon.com/dp/1', 'https://notzillow.com/x']
+ .forEach((u) => assert.ok(!handoffLib.isRental(u), u));
+ });
+
+ await check('opening the popup opens nothing and sends the API the hostname only; pressing a button opens one tab', async () => {
+ stub.requests.length = 0;
+ const p = popup({ url: LISTING, title: 'Sunny 2BR - apts/housing for rent - craigslist' });
+ await p.controller.open();
+ assert.deepStrictEqual(p.ext.opened, [], 'nothing opens on its own');
+ assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=saltlakecity.craigslist.org']);
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: true });
+
+ assert.deepStrictEqual(await p.controller.keepOnBoard(), { ok: true });
+ assert.strictEqual(p.ext.opened.length, 1);
+ assert.ok(p.ext.opened[0].startsWith('https://openvibe.rent/board?url='));
+ assert.ok(!/hunter2|utm_|fbclid|#photos/.test(p.ext.opened[0]), 'nothing private or tracking goes along');
+ await p.controller.watchPage();
+ assert.ok(p.ext.opened[1].startsWith('https://openvibe.watch/watches/new?template=page&url='));
+ assert.strictEqual(stub.requests.length, 1, 'a hand-off is a tab you see, never a request the extension makes');
+ });
+
+ await check('pages that are not websites offer no hand-off, and platform.tabs.open refuses anywhere else', async () => {
+ const p = popup({ url: 'chrome://extensions' });
+ await p.controller.open();
+ assert.deepStrictEqual(p.controller.page(), { handoff: false, rental: false });
+ assert.strictEqual((await p.controller.keepOnBoard()).ok, false);
+ assert.deepStrictEqual(p.ext.opened, []);
+ const platform = createPlatform(fakeExt({}), config);
+ await Promise.all(['https://evil.example/board', 'https://openvibe.rent.evil.example/x', 'javascript:alert(1)', 'https://openvibe.rentx/']
+ .map((u) => assert.rejects(platform.tabs.open(u), TypeError, u)));
+ await platform.tabs.open('https://openvibe.rent/board?url=x');
+ });
+
+ await check('a shop page that is not a rental leads with Watch', async () => {
+ const p = popup({ url: 'https://acme-outdoor.com/tents/trail-2' });
+ await p.controller.open();
+ assert.deepStrictEqual(p.controller.page(), { handoff: true, rental: false });
+ });
+
+ await stub.close();
+ done();
+})();
@@ -70,14 +70,19 @@ function recordingFetch() {
@@ -70,14 +70,19 @@ function recordingFetch() {
return f;
return f;
}
}
-/** Fakes of chrome.tabs / chrome.storage.local / chrome.permissions. */
-function fakeExt({ url = null, token = null, hasPermission = true } = {}) {
+/** Fakes of chrome.tabs (query, create) / chrome.storage.local / chrome.permissions; `opened` lists the tabs created. */
+function fakeExt({ url = null, title = '', token = null, hasPermission = true } = {}) {
const store = token ? { installToken: token } : {};
const store = token ? { installToken: token } : {};
const log = [];
const log = [];
+ const opened = [];
return {
return {
log,
log,
store,
store,
- tabs: { async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url }]; } },
+ opened,
+ tabs: {
+ async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, url, title }]; },
+ async create(o) { log.push(['tabs.create', o]); opened.push(o.url); return { id: 2, url: o.url }; },
+ },
storage: { local: {
storage: { local: {
async get(k) { log.push(['storage.get', k]); return { [k]: store[k] }; },
async get(k) { log.push(['storage.get', k]); return { [k]: store[k] }; },
async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },
async set(o) { log.push(['storage.set', Object.keys(o)]); Object.assign(store, o); },
@@ -80,8 +80,8 @@ const ORIGIN = 'https://openvibe.coupons';
@@ -80,8 +80,8 @@ const ORIGIN = 'https://openvibe.coupons';
const rel = path.relative(DIR, f);
const rel = path.relative(DIR, f);
if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
if (rel !== path.join('lib', 'api.js')) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
}
}
- const tabUse = files.filter((f) => /\btabs\.query\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
- assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs');
+ const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
+ assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
});
});
done();
done();
@@ -40,7 +40,7 @@ function unzip(buf) {
@@ -40,7 +40,7 @@ function unzip(buf) {
await check('the release zip holds exactly browser/, byte for byte, and its manifest passes the audit', async () => {
await check('the release zip holds exactly browser/, byte for byte, and its manifest passes the audit', async () => {
const r = build({ outDir: tmp });
const r = build({ outDir: tmp });
- assert.match(path.basename(r.out), /^openvibe-coupons-helper-\d+\.\d+\.\d+\.zip$/);
+ assert.match(path.basename(r.out), /^openvibe-companion-\d+\.\d+\.\d+\.zip$/);
const files = unzip(fs.readFileSync(r.out));
const files = unzip(fs.readFileSync(r.out));
const src = path.join(__dirname, '..', 'browser');
const src = path.join(__dirname, '..', 'browser');
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);