openvibers/openvibe.extensions Commits

OpenVibe Coupons Helper (Wave 18, ADR-023): Manifest V3 browser extension for Chromium and Firefox that shows OpenVibe.Coupons codes for the active tab's hostname only, copies codes and reports worked/failed with an optional revocable cpx_ install token; permissions activeTab + storage + the Coupons API origin only, no content scripts, no cookies, strict CSP; Node tests for the permission audit, the API client against a stub and that nothing beyond the hostname is sent; reproducible zip packer; README, MIT license, STATUS alpha, CI

OP OpenVibers committed 23 Sept 2026 · 9057dc85a6 · 31 files, +1632 −0


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..dc4cc23
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,21 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ # No dependencies: the tests and the packer use Node's standard library only.
++ - run: npm test
++ - run: npm run pack
++ - uses: actions/upload-artifact@v4
++ with:
++ name: openvibe-coupons-helper
++ path: dist/*.zip
++ retention-days: 14
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..6439c6d
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,7 @@
++node_modules/
++dist/
++*.log
++.env
++.env.*
++*.pem
++*.key
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..ac75a34
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,21 @@
++MIT License
++
++Copyright (c) 2026 OpenVibers
++
++Permission is hereby granted, free of charge, to any person obtaining a copy
++of this software and associated documentation files (the "Software"), to deal
++in the Software without restriction, including without limitation the rights
++to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
++copies of the Software, and to permit persons to whom the Software is
++furnished to do so, subject to the following conditions:
++
++The above copyright notice and this permission notice shall be included in all
++copies or substantial portions of the Software.
++
++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
++OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
++SOFTWARE.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..31c849c
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,155 @@
++# OpenVibe.Extensions
++
++> OpenVibe's first-party client surfaces: today, the **OpenVibe Coupons Helper** 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 itself isn't deployed yet, so the release build has nothing to
++talk to until it is.
++**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.
++**License:** MIT.
++
++## Purpose
++
++Client code ships on a different schedule from servers: store review, browser updates and
++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 |
++| Kiosk companion (Firefox) and the hardware/Raspberry Pi companions | not yet here | Planned by ADR-023: move them from OpenVibe.Live's `browser-extension/` and `hardware/`, leaving a redirect README in Live. That move isn't part of this change. |
++| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
++
++## The OpenVibe Coupons Helper (`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:
++
++- the code, with a Copy button
++- its status (validity unknown, reported working, reported not working)
++- its confidence, or "no reports", and its expiry, or "Expiry unknown"
++- its restrictions, or "No restrictions stated"
++- how to apply it
++
++If you connected the extension, you can report whether a code worked.
++
++### What it can see, and what it sends
++
++| | |
++|---|---|
++| **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'`. |
++| **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. |
++
++### The credential: an install token, never a session
++
++The extension works without any credential, with the API's tighter anonymous rate limits. To
++report:
++
++1. On [openvibe.coupons/connect-extension](https://openvibe.coupons/connect-extension), sign in and
++ create a token for this browser. It is shown once.
++2. Paste it into the extension's settings. The extension checks that the API accepts it before
++ saving it.
++
++A `cpx_` token has two scopes, `coupons.lookup` and optionally `coupons.report`. It can't submit
++codes, moderate, or read anything private, and it isn't an OpenVibe Network session. The extension
++refuses to store anything that isn't a `cpx_` token, so a Network JWT can't be pasted in by
++mistake. Revoking the token on the site takes effect on its next request, and the popup then says
++the browser was disconnected. "Disconnect" in the settings removes the token from the browser.
++
++Because the extension exposes nothing to web pages (no content scripts, no
++`externally_connectable`, no web-accessible resources) and holds no first-party credential, a
++malicious shop page can't use it to reach anyone's data. The server side of the same guarantee is
++covered in Coupons' own tests: lookups are identical for every caller, and the API ignores cookies.
++
++### Layout
++
++```
++browser/
++ manifest.json MV3; Chromium (minimum_chrome_version 111) and Firefox (gecko id, strict_min_version 128)
++ popup.html / popup.js DOM wiring only
++ 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/host.js URL → hostname (everything else dropped)
++ lib/api.js the only code that makes requests
++ lib/controller.js popup logic without the DOM (tested in Node)
++ lib/platform.js chrome.* / browser.*: tabs.query, storage.local, permissions
++ lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
++ icons/ generated by scripts/icons.js
++scripts/
++ audit.js the permission audit (manifest, CSP, pages)
++ pack.js zip for loading or store review (runs the audit first)
++ icons.js draws the icons (no dependencies)
++```
++
++## Tests (Node 22, no browser, no dependencies)
++
++```bash
++npm test
++```
++
++| Requirement | 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` |
++| `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:
++
++- Headless Chrome 150, loaded through CDP `Extensions.loadUnpacked`, accepted the manifest and
++ rendered both pages with no console errors or CSP violations.
++- A development build then called a local OpenVibe.Coupons (its test harness) with no CORS
++ configured. It resolved a shop, listed its code, and posted a report with a `cpx_` token. The
++ report was stored with channel `extension`.
++
++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)
++node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)
++```
++
++To load it:
++
++- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the
++ unzipped folder.
++- **Firefox:** `about:debugging`, "This Firefox", "Load Temporary Add-on", and pick
++ `manifest.json`.
++
++In Firefox, host permissions for MV3 extensions can be withheld. If they are, the settings page
++shows "Allow access to openvibe.coupons".
++
++## Before a store release (for the lead)
++
++1. **Deploy OpenVibe.Coupons** (see its README). 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
++ 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
++ `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,
++ because its host permission covers the API. CORS only helps when a browser withholds that
++ permission, and only for lookups.
++4. **Version:** bump `browser/manifest.json` for every store upload. Stores refuse a version
++ they've already seen.
++
++## Depends on
++
++- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:
++ - `GET /api/v1/merchants/resolve?host=`
++ - `GET /api/v1/merchants/:id/coupons`
++ - `POST /api/v1/coupons/:id/report`
++ - `cpx_` install tokens from `/connect-extension`
++- No npm dependencies. Tests and scripts use Node's standard library.
++
++---
++
++Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..a34c964
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,29 @@
++{
++ "repository": "OpenVibers/OpenVibe.Extensions",
++ "stage": "alpha",
++ "code": true,
++ "wave": "W18",
++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
++ "adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
++ "surfaces": {
++ "browser": {
++ "name": "OpenVibe Coupons Helper",
++ "version": "0.1.0",
++ "manifest": 3,
++ "targets": ["chromium >= 111", "firefox >= 128"],
++ "api": "https://openvibe.coupons (OpenVibe.Coupons public API)",
++ "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"
++ }
++ },
++ "pending": [
++ "move Live's browser-extension/openvibe-kiosk-companion and hardware/ here (ADR-023), with a redirect README in Live",
++ "store listings (Chrome Web Store, addons.mozilla.org) and the store extension ids in COUPONS_EXTENSION_ORIGINS"
++ ],
++ "deployed": false,
++ "updated": "2026-09-22"
++}
ddiff --git a/browser/icons/icon-128.png b/browser/icons/icon-128.png
nnew file mode 100644
iindex 0000000..2bbcdb7
BBinary files /dev/null and b/browser/icons/icon-128.png differ
ddiff --git a/browser/icons/icon-16.png b/browser/icons/icon-16.png
nnew file mode 100644
iindex 0000000..56e8571
BBinary files /dev/null and b/browser/icons/icon-16.png differ
ddiff --git a/browser/icons/icon-32.png b/browser/icons/icon-32.png
nnew file mode 100644
iindex 0000000..b4cc948
BBinary files /dev/null and b/browser/icons/icon-32.png differ
ddiff --git a/browser/icons/icon-48.png b/browser/icons/icon-48.png
nnew file mode 100644
iindex 0000000..cb88dbc
BBinary files /dev/null and b/browser/icons/icon-48.png differ
ddiff --git a/browser/lib/api.js b/browser/lib/api.js
nnew file mode 100644
iindex 0000000..1956213
---- /dev/null
++++ b/browser/lib/api.js
@@@ -0,0 +1,117 @@
++/*
++ * The OpenVibe.Coupons public API client — the only code in the extension that makes requests.
++ *
++ * const api = createApiClient({ origin, fetch, getToken });
++ * await api.resolve('shop.example.com') GET /api/v1/merchants/resolve?host=… → merchant | null
++ * await api.coupons('mer_…') GET /api/v1/merchants/:id/coupons → { merchant, coupons }
++ * await api.report('cpn_…', 'worked') POST /api/v1/coupons/:id/report (needs a token)
++ * await api.check(token) is this install token accepted? (a lookup with it)
++ *
++ * What it sends: a hostname, a merchant id, a code id, "worked"/"failed" (+ an optional reason),
++ * and — only if you connected the extension — your cpx_ install token. Never cookies
++ * (credentials: 'omit'), never a Referer (referrerPolicy: 'no-referrer'), never a page URL.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVApi = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++ const MERCHANT_RE = /^mer_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const COUPON_RE = /^cpn_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const HOST_RE = /^[a-z0-9.-]{3,253}$/;
++ const TOKEN_RE = /^cpx_[A-Za-z0-9_-]{43}$/;
++ const OUTCOMES = ['worked', 'failed'];
++ const REASONS = ['invalid', 'expired', 'min_spend_not_met', 'not_eligible', 'other'];
++
++ class ApiError extends Error {
++ constructor(status, code, detail) {
++ super(detail || code || ('HTTP ' + status));
++ this.name = 'ApiError';
++ this.status = status;
++ this.code = code || null;
++ }
++ }
++
++ function createApiClient(opts) {
++ const origin = String(opts.origin || '').replace(/\/+$/, '');
++ if (!/^https:\/\/[a-z0-9.-]+$/.test(origin) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
++ throw new TypeError('origin must be an https origin (http only for localhost development)');
++ }
++ const doFetch = opts.fetch;
++ const getToken = opts.getToken || (async function () { return null; });
++ const timeoutMs = opts.timeoutMs || 8000;
++
++ async function request(method, path, { body = null, token } = {}) {
++ const headers = { Accept: 'application/json' };
++ const t = token === undefined ? await getToken() : token;
++ if (t) {
++ if (!TOKEN_RE.test(t)) throw new ApiError(0, 'token.malformed', 'the saved token is not a cpx_ token');
++ headers.Authorization = 'Bearer ' + t;
++ }
++ if (body) headers['Content-Type'] = 'application/json';
++ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
++ const timer = ctrl ? setTimeout(function () { ctrl.abort(); }, timeoutMs) : null;
++ let res;
++ try {
++ res = await doFetch(origin + path, {
++ method: method,
++ headers: headers,
++ body: body ? JSON.stringify(body) : undefined,
++ credentials: 'omit',
++ referrerPolicy: 'no-referrer',
++ redirect: 'error',
++ signal: ctrl ? ctrl.signal : undefined,
++ });
++ } catch (err) {
++ throw new ApiError(0, err && err.name === 'AbortError' ? 'network.timeout' : 'network.error', 'could not reach OpenVibe.Coupons');
++ } finally {
++ if (timer) clearTimeout(timer);
++ }
++ let data = null;
++ try { data = await res.json(); } catch (e) { data = null; }
++ if (!res.ok) throw new ApiError(res.status, data && data.code, data && (data.detail || data.error));
++ return data;
++ }
++
++ return {
++ async resolve(hostname) {
++ if (!HOST_RE.test(String(hostname || ''))) throw new TypeError('a hostname is required');
++ try {
++ const out = await request('GET', '/api/v1/merchants/resolve?host=' + encodeURIComponent(hostname));
++ return out && out.merchant ? out.merchant : null;
++ } catch (err) {
++ if (err.status === 404) return null;
++ throw err;
++ }
++ },
++ async coupons(merchantId) {
++ if (!MERCHANT_RE.test(String(merchantId || ''))) throw new TypeError('a merchant id is required');
++ return request('GET', '/api/v1/merchants/' + merchantId + '/coupons');
++ },
++ async report(couponId, outcome, reason) {
++ if (!COUPON_RE.test(String(couponId || ''))) throw new TypeError('a code id is required');
++ if (OUTCOMES.indexOf(outcome) === -1) throw new TypeError('outcome must be worked or failed');
++ const body = { outcome: outcome };
++ if (outcome === 'failed' && reason && REASONS.indexOf(reason) !== -1) body.reason = reason;
++ const token = await getToken();
++ if (!token) throw new ApiError(401, 'auth.required', 'connect the extension to report');
++ return request('POST', '/api/v1/coupons/' + couponId + '/report', { body: body, token: token });
++ },
++ /** true when the token is accepted, false when it is refused (401/403); throws on other errors. */
++ async check(token) {
++ if (!TOKEN_RE.test(String(token || ''))) return false;
++ try {
++ await request('GET', '/api/v1/merchants/resolve?host=openvibe.coupons', { token: token });
++ return true;
++ } catch (err) {
++ if (err.status === 404) return true;
++ if (err.status === 401 || err.status === 403) return false;
++ throw err;
++ }
++ },
++ };
++ }
++
++ return { createApiClient, ApiError, TOKEN_RE, OUTCOMES, REASONS };
++});
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
nnew file mode 100644
iindex 0000000..93d2861
---- /dev/null
++++ b/browser/lib/config.js
@@@ -0,0 +1,21 @@
++/*
++ * OpenVibe Coupons Helper — configuration.
++ *
++ * API_ORIGIN is the ONLY origin this extension ever talks 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.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVConfig = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++ return Object.freeze({
++ API_ORIGIN: 'https://openvibe.coupons',
++ CONNECT_PATH: '/connect-extension',
++ TOKEN_KEY: 'installToken',
++ TOKEN_RE: /^cpx_[A-Za-z0-9_-]{43}$/,
++ TIMEOUT_MS: 8000,
++ });
++});
ddiff --git a/browser/lib/controller.js b/browser/lib/controller.js
nnew file mode 100644
iindex 0000000..51db7b4
---- /dev/null
++++ b/browser/lib/controller.js
@@@ -0,0 +1,72 @@
++/*
++ * 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
++ * 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.
++ *
++ * View models:
++ * { state: 'not_a_site' } browser pages, files, IPs, local names
++ * { state: 'no_merchant', host }
++ * { state: 'merchant', host, merchant, coupons, connected }
++ * { state: 'error', host, message, code }
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVController = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ function messageFor(err) {
++ const code = err && err.code;
++ if (code === 'token.revoked') return 'This browser was disconnected on openvibe.coupons. Connect it again in the settings.';
++ if (code === 'token.expired') return 'The saved token expired. Connect the extension again in the settings.';
++ if (code === 'token.invalid' || code === 'token.malformed') return 'The saved token is not valid. Connect the extension again in the settings.';
++ if (code === 'token.scope') return 'This token cannot report. Create one with reporting allowed on openvibe.coupons.';
++ if (code === 'auth.required') return 'Connect the extension in the settings to report.';
++ if (code === 'report.rate_limited' || code === 'lookup.rate_limited' || (err && err.status === 429)) return 'Too many requests right now. Try again in a minute.';
++ if (code === 'coupon.not_active') return 'This code is no longer in active results.';
++ if (code === 'network.timeout' || code === 'network.error') return 'Could not reach OpenVibe.Coupons.';
++ return 'Something went wrong. Try again later.';
++ }
++
++ function createController(deps) {
++ const api = deps.api;
++ const platform = deps.platform;
++ const host = deps.host;
++
++ return {
++ async open() {
++ const url = await platform.tabs.activeUrl();
++ const hostname = host.hostnameOf(url);
++ if (!hostname) return { state: 'not_a_site' };
++ const connected = Boolean(await platform.storage.getToken());
++ try {
++ const merchant = await api.resolve(hostname);
++ if (!merchant) return { state: 'no_merchant', host: hostname };
++ const out = await api.coupons(merchant.id);
++ return { state: 'merchant', host: hostname, merchant: out.merchant, coupons: out.coupons || [], connected: connected };
++ } catch (err) {
++ return { state: 'error', host: hostname, message: messageFor(err), code: (err && err.code) || null };
++ }
++ },
++
++ async report(couponId, outcome, reason) {
++ try {
++ const out = await api.report(couponId, outcome, reason);
++ const msg = out && out.deduplicated ? (out.corrected ? 'Your report for today was updated.' : 'You already reported this today.') : 'Thanks — your report was recorded.';
++ return { ok: true, message: msg, coupon: out && out.coupon };
++ } catch (err) {
++ return { ok: false, message: messageFor(err), code: (err && err.code) || null };
++ }
++ },
++ };
++ }
++
++ return { createController, messageFor };
++});
ddiff --git a/browser/lib/format.js b/browser/lib/format.js
nnew file mode 100644
iindex 0000000..15d49c7
---- /dev/null
++++ b/browser/lib/format.js
@@@ -0,0 +1,44 @@
++/*
++ * Words for what the API returns. Unknown is shown as unknown — never as a friendly default.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVFormat = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++ const STATUS = {
++ unknown: 'Validity unknown',
++ reported_working: 'Reported working',
++ reported_failed: 'Reported not working',
++ expired: 'Expired',
++ disabled: 'Taken down',
++ };
++
++ function status(s) { return STATUS[s] || 'Validity unknown'; }
++
++ function confidence(c) {
++ if (!c || c.confidence == null) return 'No reports in the last ' + ((c && c.reports && c.reports.window_days) || 30) + ' days';
++ const n = (c.reports.worked || 0) + (c.reports.failed || 0);
++ return 'Confidence ' + Math.round(c.confidence * 100) + '% from ' + n + ' recent report' + (n === 1 ? '' : 's');
++ }
++
++ function expiry(e) {
++ if (!e || !e.known || !e.expires_at) return 'Expiry unknown';
++ const d = e.expires_at.slice(0, 10);
++ return e.precision === 'date' ? 'Expires ' + d + ' (end of day, UTC)' : 'Expires ' + d + ' ' + e.expires_at.slice(11, 16) + ' UTC';
++ }
++
++ function restrictions(list) {
++ if (!list || !list.length) return ['No restrictions stated'];
++ return list.map(function (r) {
++ if (r.kind === 'min_spend') return 'Minimum spend ' + r.amount;
++ if (r.kind === 'category') return 'Category: ' + r.value;
++ if (r.kind === 'new_customers_only') return 'New customers only';
++ if (r.kind === 'region') return 'Region: ' + r.value;
++ return String(r.value || '');
++ });
++ }
++
++ return { status, confidence, expiry, restrictions, STATUS };
++});
ddiff --git a/browser/lib/host.js b/browser/lib/host.js
nnew file mode 100644
iindex 0000000..530a3a2
---- /dev/null
++++ b/browser/lib/host.js
@@@ -0,0 +1,31 @@
++/*
++ * The one thing the helper reads from the page you are on: the HOSTNAME of the active tab's URL.
++ *
++ * hostnameOf('https://Shop.Example.com:8443/[email protected]#pay') → 'shop.example.com'
++ *
++ * Path, query, fragment, port and credentials are dropped here, before anything else sees the
++ * URL. Non-web pages (browser settings, files, other extensions), IP addresses and local names are
++ * not shops: null.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVHost = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++ const LOCAL = /(^|\.)(localhost|local|internal|invalid|test|example|arpa|lan|home)$/;
++
++ function hostnameOf(url) {
++ if (typeof url !== 'string' || !url) return null;
++ let u;
++ try { u = new URL(url); } catch (e) { return null; }
++ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
++ const host = u.hostname.toLowerCase().replace(/\.$/, '');
++ if (!host || host.indexOf('.') === -1) return null;
++ if (host.charAt(0) === '[' || /^[0-9.]+$/.test(host)) return null;
++ if (LOCAL.test(host)) return null;
++ return host;
++ }
++
++ return { hostnameOf };
++});
ddiff --git a/browser/lib/platform.js b/browser/lib/platform.js
nnew file mode 100644
iindex 0000000..6aa1b8d
---- /dev/null
++++ b/browser/lib/platform.js
@@@ -0,0 +1,61 @@
++/*
++ * The browser APIs the helper 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
++ * 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
++ * lets people withhold it; reporting needs it)
++ *
++ * Nothing else: no cookies, no scripting, no history, no page access.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ else root.OVPlatform = api;
++})(typeof self !== 'undefined' ? self : this, function () {
++ 'use strict';
++
++ function createPlatform(ext, config) {
++ const origins = [config.API_ORIGIN + '/*'];
++ const call = function (fn, thisArg, args) {
++ // chrome.* in MV3 and browser.* both return promises.
++ return fn.apply(thisArg, args);
++ };
++ return {
++ tabs: {
++ async activeUrl() {
++ 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;
++ },
++ },
++ storage: {
++ async getToken() {
++ const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);
++ const t = out && out[config.TOKEN_KEY];
++ return typeof t === 'string' && config.TOKEN_RE.test(t) ? t : null;
++ },
++ async setToken(token) {
++ if (!config.TOKEN_RE.test(String(token || ''))) throw new TypeError('not a cpx_ token');
++ const o = {};
++ o[config.TOKEN_KEY] = token;
++ await call(ext.storage.local.set, ext.storage.local, [o]);
++ },
++ async clearToken() { await call(ext.storage.local.remove, ext.storage.local, [config.TOKEN_KEY]); },
++ },
++ permissions: {
++ async hasApi() {
++ if (!ext.permissions || !ext.permissions.contains) return true;
++ return call(ext.permissions.contains, ext.permissions, [{ origins: origins }]);
++ },
++ async requestApi() {
++ if (!ext.permissions || !ext.permissions.request) return true;
++ return call(ext.permissions.request, ext.permissions, [{ origins: origins }]);
++ },
++ },
++ };
++ }
++
++ return { createPlatform };
++});
ddiff --git a/browser/manifest.json b/browser/manifest.json
nnew file mode 100644
iindex 0000000..9d06325
---- /dev/null
++++ b/browser/manifest.json
@@@ -0,0 +1,47 @@
++{
++ "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",
++ "author": "OpenVibers",
++ "icons": {
++ "16": "icons/icon-16.png",
++ "32": "icons/icon-32.png",
++ "48": "icons/icon-48.png",
++ "128": "icons/icon-128.png"
++ },
++ "action": {
++ "default_title": "Coupon codes for this shop",
++ "default_popup": "popup.html",
++ "default_icon": {
++ "16": "icons/icon-16.png",
++ "32": "icons/icon-32.png"
++ }
++ },
++ "options_ui": {
++ "page": "options.html",
++ "open_in_tab": false
++ },
++ "permissions": [
++ "activeTab",
++ "storage"
++ ],
++ "host_permissions": [
++ "https://openvibe.coupons/*"
++ ],
++ "content_security_policy": {
++ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src https://openvibe.coupons; object-src 'none'; base-uri 'none'"
++ },
++ "minimum_chrome_version": "111",
++ "browser_specific_settings": {
++ "gecko": {
++ "id": "[email protected]",
++ "strict_min_version": "128.0",
++ "data_collection_permissions": {
++ "required": ["browsingActivity"]
++ }
++ }
++ }
++}
ddiff --git a/browser/options.html b/browser/options.html
nnew file mode 100644
iindex 0000000..4503308
---- /dev/null
++++ b/browser/options.html
@@@ -0,0 +1,36 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<title>OpenVibe Coupons Helper — 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>
++ <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>
++ </ol>
++ <form id="token-form">
++ <label for="token">Token</label>
++ <input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="cpx_…" size="48">
++ <button type="submit">Save</button>
++ </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>
++ <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>
++ </ul>
++</main>
++<script src="lib/config.js"></script>
++<script src="lib/api.js"></script>
++<script src="lib/platform.js"></script>
++<script src="options.js"></script>
++</body>
++</html>
ddiff --git a/browser/options.js b/browser/options.js
nnew file mode 100644
iindex 0000000..47a9be2
---- /dev/null
++++ b/browser/options.js
@@@ -0,0 +1,48 @@
++/*
++ * Settings: connect (save a cpx_ install token after checking it is accepted), disconnect (remove
++ * it), and grant the host permission for the API origin when the browser withheld it.
++ */
++(function () {
++ 'use strict';
++ const ext = typeof browser !== 'undefined' ? browser : chrome;
++ 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 state = document.getElementById('state');
++ const input = document.getElementById('token');
++ const permissionRow = document.getElementById('permission-row');
++ document.getElementById('connect-link').href = config.API_ORIGIN + config.CONNECT_PATH;
++
++ async function refresh() {
++ const token = await platform.storage.getToken();
++ state.textContent = token ? 'Connected (token ' + token.slice(0, 10) + '…).' : 'Not connected: lookups work, reporting does not.';
++ permissionRow.hidden = await platform.permissions.hasApi();
++ }
++
++ document.getElementById('token-form').addEventListener('submit', async function (ev) {
++ ev.preventDefault();
++ const token = input.value.trim();
++ if (!config.TOKEN_RE.test(token)) { state.textContent = 'That is not a token from openvibe.coupons (it starts with cpx_).'; return; }
++ state.textContent = 'Checking…';
++ try {
++ if (!(await api.check(token))) { state.textContent = 'openvibe.coupons refused that token (revoked or expired?).'; return; }
++ await platform.storage.setToken(token);
++ input.value = '';
++ await refresh();
++ } catch (e) {
++ state.textContent = 'Could not reach openvibe.coupons. The token was not saved.';
++ }
++ });
++
++ document.getElementById('disconnect').addEventListener('click', async function () {
++ await platform.storage.clearToken();
++ await refresh();
++ });
++
++ document.getElementById('grant').addEventListener('click', async function () {
++ await platform.permissions.requestApi();
++ await refresh();
++ });
++
++ refresh();
++})();
ddiff --git a/browser/popup.css b/browser/popup.css
nnew file mode 100644
iindex 0000000..04ab1e5
---- /dev/null
++++ b/browser/popup.css
@@@ -0,0 +1,25 @@
++: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; } }
++* { 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; }
++header { border-bottom: 1px solid var(--line); }
++h1 { font-size: 14px; margin: 0; }
++h2 { font-size: 14px; margin: 0 0 6px; }
++.muted { color: var(--muted); margin: 2px 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; }
++.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; }
++.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); }
++ul { margin: 4px 0; padding-left: 16px; }
++button { font: inherit; padding: 3px 9px; border-radius: 5px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
++button:hover, button:focus-visible { border-color: var(--accent); }
++select { font: inherit; }
++.note { font-size: 12px; margin-top: 4px; }
++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; }
++body.options input { font: inherit; padding: 4px 6px; max-width: 100%; }
ddiff --git a/browser/popup.html b/browser/popup.html
nnew file mode 100644
iindex 0000000..23e2fa7
---- /dev/null
++++ b/browser/popup.html
@@@ -0,0 +1,26 @@
++<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<title>Coupon codes for this shop</title>
++<link rel="stylesheet" href="popup.css">
++</head>
++<body>
++<header>
++ <h1>OpenVibe Coupons</h1>
++ <p id="site" class="muted"></p>
++</header>
++<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>
++</footer>
++<script src="lib/config.js"></script>
++<script src="lib/host.js"></script>
++<script src="lib/api.js"></script>
++<script src="lib/format.js"></script>
++<script src="lib/platform.js"></script>
++<script src="lib/controller.js"></script>
++<script src="popup.js"></script>
++</body>
++</html>
ddiff --git a/browser/popup.js b/browser/popup.js
nnew file mode 100644
iindex 0000000..5f04abc
---- /dev/null
++++ b/browser/popup.js
@@@ -0,0 +1,101 @@
++/*
++ * 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 () {
++ 'use strict';
++ const ext = typeof browser !== 'undefined' ? browser : chrome;
++ 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 fmt = self.OVFormat;
++ const content = document.getElementById('content');
++ const site = document.getElementById('site');
++
++ function el(tag, attrs) {
++ const node = document.createElement(tag);
++ const props = attrs || {};
++ Object.keys(props).forEach(function (k) {
++ if (k === 'text') node.textContent = props[k];
++ else if (k === 'className') node.className = props[k];
++ else if (k === 'onclick') node.addEventListener('click', props[k]);
++ else node.setAttribute(k, props[k]);
++ });
++ for (let i = 2; i < arguments.length; i++) {
++ const child = arguments[i];
++ if (child == null || child === false) continue;
++ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
++ }
++ return node;
++ }
++
++ function show() {
++ while (content.firstChild) content.removeChild(content.firstChild);
++ for (let i = 0; i < arguments.length; i++) if (arguments[i]) content.appendChild(arguments[i]);
++ }
++
++ function link(text, path) {
++ return el('a', { href: config.API_ORIGIN + path, target: '_blank', rel: 'noopener noreferrer', text: text });
++ }
++
++ async function copy(code, button) {
++ try {
++ await navigator.clipboard.writeText(code);
++ button.textContent = 'Copied';
++ } catch (e) {
++ button.textContent = 'Select the code to copy';
++ }
++ }
++
++ function couponNode(c, connected) {
++ const note = el('p', { className: 'note muted' });
++ const copyBtn = el('button', { type: 'button', text: 'Copy', onclick: function () { copy(c.code, copyBtn); } });
++ let actions = null;
++ if (connected) {
++ const reason = el('select', { 'aria-label': 'Why it did not work' },
++ el('option', { value: '', text: '(reason)' }),
++ el('option', { value: 'invalid', text: 'Code not accepted' }),
++ el('option', { value: 'expired', text: 'Said it has expired' }),
++ el('option', { value: 'min_spend_not_met', text: 'Minimum spend not met' }),
++ el('option', { value: 'not_eligible', text: 'Not eligible' }),
++ el('option', { value: 'other', text: 'Other' }));
++ const send = async function (outcome) {
++ note.textContent = 'Sending…';
++ const r = await controller.report(c.id, outcome, outcome === 'failed' ? reason.value : null);
++ note.textContent = r.message;
++ };
++ actions = el('div', { className: 'row' },
++ el('button', { type: 'button', text: 'It worked', onclick: function () { send('worked'); } }),
++ el('button', { type: 'button', text: 'It didn’t work', onclick: function () { send('failed'); } }),
++ reason);
++ } else {
++ actions = el('p', { className: 'note muted' }, 'To report whether it worked, ', el('a', { href: 'options.html', target: '_blank', text: 'connect the extension' }), '.');
++ }
++ return el('article', { className: 'coupon' },
++ el('h3', { text: c.title }),
++ el('div', { className: 'row' }, el('span', { className: 'code', text: c.code }), copyBtn, el('span', { className: 'status ' + c.status, text: fmt.status(c.status) })),
++ el('p', { className: 'muted', text: fmt.confidence(c) }),
++ el('p', { className: 'muted', text: fmt.expiry(c.expiry) }),
++ el('ul', null, ...fmt.restrictions(c.restrictions).map(function (t) { return el('li', { text: t }); })),
++ ...(c.hints || []).map(function (h) { return el('p', { className: 'muted', text: 'How to apply: ' + h.text }); }),
++ actions,
++ note);
++ }
++
++ 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')));
++ if (vm.state === 'error') return show(el('p', { text: vm.message }));
++ const header = el('h2', { text: 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))));
++ }
++
++ document.getElementById('settings').addEventListener('click', function (ev) {
++ if (ext.runtime && ext.runtime.openOptionsPage) { ev.preventDefault(); ext.runtime.openOptionsPage(); }
++ });
++ render().catch(function () { show(el('p', { text: 'Something went wrong. Try again later.' })); });
++})();
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..56cf134
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,19 @@
++{
++ "name": "openvibe-extensions",
++ "version": "0.1.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.",
++ "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"],
++ "author": "OpenVibers",
++ "license": "MIT",
++ "repository": "github:OpenVibers/OpenVibe.Extensions",
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/scripts/audit.js b/scripts/audit.js
nnew file mode 100644
iindex 0000000..8a51de3
---- /dev/null
++++ b/scripts/audit.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
++ * scripts/pack.js before it writes a zip. It fails when anything widens:
++ *
++ * permissions exactly activeTab + storage
++ * host_permissions exactly <API origin>/*
++ * forbidden keys content_scripts, background, web_accessible_resources,
++ * externally_connectable, optional_permissions, optional_host_permissions,
++ * declarative_net_request, chrome_url_overrides, devtools_page, sandbox,
++ * side_panel, omnibox, … (anything not on the allow-list of keys)
++ * 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
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
++ 'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings']);
++const PERMISSIONS = ['activeTab', 'storage'];
++
++function auditManifest(m, { apiOrigin }) {
++ const problems = [];
++ if (m.manifest_version !== 3) problems.push('manifest_version must be 3');
++ for (const k of Object.keys(m)) if (!ALLOWED_KEYS.has(k)) problems.push(`manifest key "${k}" is not allowed`);
++ const perms = Array.isArray(m.permissions) ? m.permissions : [];
++ if (perms.length !== PERMISSIONS.length || !PERMISSIONS.every((p) => perms.includes(p))) problems.push(`permissions must be exactly ${PERMISSIONS.join(', ')} (got ${perms.join(', ') || 'none'})`);
++ const hosts = Array.isArray(m.host_permissions) ? m.host_permissions : [];
++ if (hosts.length !== 1 || hosts[0] !== `${apiOrigin}/*`) problems.push(`host_permissions must be exactly ${apiOrigin}/* (got ${hosts.join(', ') || 'none'})`);
++ if (!m.action || m.action.default_popup !== 'popup.html') problems.push('action.default_popup must be popup.html');
++ if (!m.options_ui || m.options_ui.page !== 'options.html') problems.push('options_ui.page must be options.html');
++ const csp = m.content_security_policy && m.content_security_policy.extension_pages;
++ if (!csp) problems.push('content_security_policy.extension_pages is required');
++ else {
++ const dirs = Object.fromEntries(csp.split(';').map((d) => d.trim()).filter(Boolean).map((d) => { const [k, ...v] = d.split(/\s+/); return [k, v]; }));
++ if (JSON.stringify(dirs['script-src']) !== JSON.stringify(["'self'"])) problems.push("CSP script-src must be exactly 'self'");
++ if (JSON.stringify(dirs['connect-src']) !== JSON.stringify([apiOrigin])) problems.push(`CSP connect-src must be exactly ${apiOrigin}`);
++ if (JSON.stringify(dirs['default-src']) !== JSON.stringify(["'none'"])) problems.push("CSP default-src must be 'none'");
++ if (JSON.stringify(dirs['object-src']) !== JSON.stringify(["'none'"])) problems.push("CSP object-src must be 'none'");
++ if (/unsafe-|\*|data:|blob:|http:(?!\/\/(localhost|127\.0\.0\.1))/.test(csp)) problems.push('CSP must not allow unsafe-*, wildcards, data:, blob: or remote http');
++ }
++ const gecko = m.browser_specific_settings && m.browser_specific_settings.gecko;
++ if (!gecko || !gecko.id) problems.push('browser_specific_settings.gecko.id is required (Firefox)');
++ return problems;
++}
++
++/** HTML pages load only local scripts and no inline script or handler. */
++function auditPage(html, name) {
++ const problems = [];
++ for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
++ const src = (m[1].match(/\bsrc="([^"]+)"/) || [])[1];
++ if (!src) problems.push(`${name}: inline <script>`);
++ else if (/^(https?:)?\/\//i.test(src)) problems.push(`${name}: remote script ${src}`);
++ if (m[2].trim()) problems.push(`${name}: script body`);
++ }
++ if (/\son[a-z]+\s*=/i.test(html)) problems.push(`${name}: inline event handler`);
++ if (/<link[^>]+href="(https?:)?\/\//i.test(html)) problems.push(`${name}: remote stylesheet`);
++ return problems;
++}
++
++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 problems = [];
++ if (!origin) problems.push('lib/config.js has no API_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));
++ return { manifest, origin, problems };
++}
++
++module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, ALLOWED_KEYS };
ddiff --git a/scripts/icons.js b/scripts/icons.js
nnew file mode 100644
iindex 0000000..9dbf8a9
---- /dev/null
++++ b/scripts/icons.js
@@@ -0,0 +1,64 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Draws the toolbar icons (a ticket in the network's coupons amber) as PNGs, with no dependency:
++ * 4× supersampled coverage, written with zlib. Run once when the design changes:
++ * node scripts/icons.js → browser/icons/icon-{16,32,48,128}.png
++ */
++const fs = require('fs');
++const path = require('path');
++const zlib = require('zlib');
++
++const CRC_TABLE = new Uint32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
++const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
++function chunk(type, data) {
++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
++ const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
++ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
++ return Buffer.concat([len, td, crc]);
++}
++function png(size, rgba) {
++ const ihdr = Buffer.alloc(13);
++ ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
++ const raw = Buffer.alloc((size * 4 + 1) * size);
++ for (let y = 0; y < size; y++) { raw[y * (size * 4 + 1)] = 0; rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); }
++ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
++}
++
++// Shape in unit coordinates (0..1): a rounded ticket with side notches and a perforation.
++function inside(x, y) {
++ const l = 0.08, r = 0.92, t = 0.24, b = 0.76, rad = 0.08;
++ if (x < l || x > r || y < t || y > b) return 0;
++ const cx = Math.min(Math.max(x, l + rad), r - rad), cy = Math.min(Math.max(y, t + rad), b - rad);
++ if ((x - cx) ** 2 + (y - cy) ** 2 > rad * rad) return 0;
++ const notch = 0.09;
++ if ((x - l) ** 2 + (y - 0.5) ** 2 < notch * notch || (x - r) ** 2 + (y - 0.5) ** 2 < notch * notch) return 0;
++ if (Math.abs(x - 0.36) < 0.025 && Math.floor((y - t) / 0.065) % 2 === 1) return 2; // perforation
++ return 1;
++}
++
++function draw(size) {
++ const px = Buffer.alloc(size * size * 4);
++ const S = 4;
++ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
++ let fill = 0, hole = 0;
++ for (let sy = 0; sy < S; sy++) for (let sx = 0; sx < S; sx++) {
++ const v = inside((x + (sx + 0.5) / S) / size, (y + (sy + 0.5) / S) / size);
++ if (v === 1) fill++; else if (v === 2) hole++;
++ }
++ const n = S * S, a = (fill + hole) / n;
++ const i = (y * size + x) * 4;
++ // amber #f59e0b, the perforation darker
++ const mix = hole / Math.max(1, fill + hole);
++ px[i] = Math.round(0xf5 * (1 - mix) + 0x92 * mix);
++ px[i + 1] = Math.round(0x9e * (1 - mix) + 0x40 * mix);
++ px[i + 2] = Math.round(0x0b * (1 - mix) + 0x0e * mix);
++ px[i + 3] = Math.round(a * 255);
++ }
++ return png(size, px);
++}
++
++const out = path.join(__dirname, '..', 'browser', 'icons');
++fs.mkdirSync(out, { recursive: true });
++for (const s of [16, 32, 48, 128]) fs.writeFileSync(path.join(out, `icon-${s}.png`), draw(s));
++console.log('icons written to', out);
ddiff --git a/scripts/pack.js b/scripts/pack.js
nnew file mode 100644
iindex 0000000..b3a4880
---- /dev/null
++++ b/scripts/pack.js
@@@ -0,0 +1,115 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * 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 --api http://localhost:4850
++ * → dist/openvibe-coupons-helper-<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.
++ *
++ * The permission audit (scripts/audit.js) runs on the files that go into the zip; a failure stops
++ * the build. Entries are sorted and dated 1980-01-01 so the same tree gives the same bytes.
++ */
++const fs = require('fs');
++const path = require('path');
++const zlib = require('zlib');
++const { auditDir } = require('./audit');
++
++const ROOT = path.join(__dirname, '..');
++const SRC = path.join(ROOT, 'browser');
++const DEFAULT_ORIGIN = 'https://openvibe.coupons';
++
++const CRC_TABLE = new Uint32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
++const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
++
++/** entries: [{ name, data }] → zip Buffer (deflate, fixed DOS date 1980-01-01 00:00). */
++function zip(entries) {
++ const locals = [];
++ const centrals = [];
++ let offset = 0;
++ for (const { name, data } of entries) {
++ const nameBuf = Buffer.from(name, 'utf8');
++ const deflated = zlib.deflateRawSync(data, { level: 9 });
++ const useDeflate = deflated.length < data.length;
++ const body = useDeflate ? deflated : data;
++ const crc = crc32(data);
++ const local = Buffer.alloc(30);
++ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6);
++ local.writeUInt16LE(useDeflate ? 8 : 0, 8); local.writeUInt16LE(0, 10); local.writeUInt16LE(0x21, 12);
++ local.writeUInt32LE(crc, 14); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(data.length, 22);
++ local.writeUInt16LE(nameBuf.length, 26); local.writeUInt16LE(0, 28);
++ locals.push(local, nameBuf, body);
++ const central = Buffer.alloc(46);
++ central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8);
++ central.writeUInt16LE(useDeflate ? 8 : 0, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14);
++ central.writeUInt32LE(crc, 16); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(data.length, 24);
++ central.writeUInt16LE(nameBuf.length, 28); central.writeUInt16LE(0, 30); central.writeUInt16LE(0, 32);
++ central.writeUInt16LE(0, 34); central.writeUInt16LE(0, 36); central.writeUInt32LE(0, 38); central.writeUInt32LE(offset, 42);
++ centrals.push(central, nameBuf);
++ offset += local.length + nameBuf.length + body.length;
++ }
++ const cd = Buffer.concat(centrals);
++ const end = Buffer.alloc(22);
++ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
++ end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
++ return Buffer.concat([...locals, cd, end]);
++}
++
++function listFiles(dir, base = dir) {
++ const out = [];
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ if (e.name.startsWith('.')) continue;
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) out.push(...listFiles(p, base)); else out.push(path.relative(base, p).split(path.sep).join('/'));
++ }
++ return out.sort();
++}
++
++/** Copy of browser/ with the API origin rewritten (dev builds), as { name → Buffer }. */
++function sourceFiles(apiOrigin) {
++ const files = new Map(listFiles(SRC).map((f) => [f, fs.readFileSync(path.join(SRC, f))]));
++ if (apiOrigin !== DEFAULT_ORIGIN) {
++ const swap = (name) => files.set(name, Buffer.from(files.get(name).toString('utf8').split(DEFAULT_ORIGIN).join(apiOrigin)));
++ swap('manifest.json');
++ swap('lib/config.js');
++ const m = JSON.parse(files.get('manifest.json'));
++ m.name += ' (development)';
++ files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
++ }
++ return files;
++}
++
++function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {}) {
++ if (!/^https:\/\/[a-z0-9.-]+(:\d+)?$/.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(api)) {
++ throw new Error('--api must be an https origin, or http://localhost / http://127.0.0.1 for development');
++ }
++ const files = sourceFiles(api);
++ const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ov-ext-pack-'));
++ try {
++ for (const [name, data] of files) { fs.mkdirSync(path.dirname(path.join(tmp, name)), { recursive: true }); fs.writeFileSync(path.join(tmp, name), data); }
++ const audit = auditDir(tmp);
++ 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`);
++ fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));
++ return { out, files: [...files.keys()], origin: api };
++ } finally {
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++}
++
++if (require.main === module) {
++ const i = process.argv.indexOf('--api');
++ try {
++ const r = build({ api: i > -1 ? String(process.argv[i + 1] || '').replace(/\/+$/, '') : DEFAULT_ORIGIN });
++ console.log(`wrote ${path.relative(process.cwd(), r.out)} (${r.files.length} files, API ${r.origin})`);
++ } catch (err) {
++ console.error(err.message);
++ process.exit(1);
++ }
++}
++
++module.exports = { build, zip, crc32 };
ddiff --git a/test/api-client.test.js b/test/api-client.test.js
nnew file mode 100644
iindex 0000000..9f2f922
---- /dev/null
++++ b/test/api-client.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * The API client against a stub of the Coupons API: the right routes, the token only when there
++ * is one, no cookies, no referrer, no redirects, errors as codes, timeouts, and input it refuses.
++ */
++const assert = require('assert');
++const { createApiClient, ApiError } = require('../browser/lib/api');
++const { check, done, startStub, recordingFetch, MERCHANT, COUPON, GOOD_TOKEN, REVOKED_TOKEN, NO_REPORT_TOKEN } = require('./helpers/harness');
++
++(async () => {
++ const stub = await startStub();
++ const make = (token = null, extra = {}) => {
++ const f = recordingFetch();
++ return { f, api: createApiClient({ origin: stub.origin, fetch: f, getToken: async () => token, ...extra }) };
++ };
++
++ await check('resolve: a hostname in, the merchant out; unknown sites are null, not an error', async () => {
++ const { api } = make();
++ assert.deepStrictEqual(await api.resolve('www.acme-outdoor.com'), MERCHANT);
++ assert.strictEqual(await api.resolve('other-shop.com'), null);
++ assert.strictEqual(stub.requests[0].url, '/api/v1/merchants/resolve?host=www.acme-outdoor.com');
++ });
++
++ await check('coupons: the merchant\'s active codes', async () => {
++ const { api } = make();
++ const out = await api.coupons(MERCHANT.id);
++ assert.deepStrictEqual(out.coupons, [COUPON]);
++ });
++
++ await check('without a token no Authorization header; with one, Bearer cpx_…; never cookies, never a referrer, no redirects', async () => {
++ stub.requests.length = 0;
++ const anon = make();
++ await anon.api.resolve('acme-outdoor.com');
++ assert.strictEqual(stub.requests[0].headers.authorization, undefined);
++ const signed = make(GOOD_TOKEN);
++ await signed.api.resolve('acme-outdoor.com');
++ assert.strictEqual(stub.requests[1].headers.authorization, `Bearer ${GOOD_TOKEN}`);
++ for (const c of [...anon.f.calls, ...signed.f.calls]) {
++ assert.strictEqual(c.opts.credentials, 'omit');
++ assert.strictEqual(c.opts.referrerPolicy, 'no-referrer');
++ assert.strictEqual(c.opts.redirect, 'error');
++ assert.ok(c.url.startsWith(stub.origin + '/api/v1/'));
++ }
++ for (const r of stub.requests) { assert.strictEqual(r.headers.cookie, undefined); assert.strictEqual(r.headers.referer, undefined); }
++ });
++
++ await check('report: needs a token; sends only the outcome (and a known reason for failures)', async () => {
++ stub.requests.length = 0;
++ await assert.rejects(make().api.report(COUPON.id, 'worked'), (e) => e instanceof ApiError && e.code === 'auth.required');
++ assert.strictEqual(stub.requests.length, 0, 'nothing sent without a token');
++ const { api } = make(GOOD_TOKEN);
++ const out = await api.report(COUPON.id, 'failed', 'min_spend_not_met');
++ assert.strictEqual(out.coupon.status, 'reported_working');
++ assert.deepStrictEqual(JSON.parse(stub.requests[0].body), { outcome: 'failed', reason: 'min_spend_not_met' });
++ await api.report(COUPON.id, 'worked', 'invalid');
++ assert.deepStrictEqual(JSON.parse(stub.requests[1].body), { outcome: 'worked' }, 'no reason on a "worked" report');
++ await api.report(COUPON.id, 'failed', '<script>');
++ assert.deepStrictEqual(JSON.parse(stub.requests[2].body), { outcome: 'failed' }, 'unknown reasons are dropped');
++ });
++
++ await check('errors come back as codes: revoked, missing scope, rate limited, unavailable', async () => {
++ await assert.rejects(make(REVOKED_TOKEN).api.resolve('acme-outdoor.com'), (e) => e.status === 401 && e.code === 'token.revoked');
++ await assert.rejects(make(NO_REPORT_TOKEN).api.report(COUPON.id, 'worked'), (e) => e.status === 403 && e.code === 'token.scope');
++ await assert.rejects(make().api.resolve('busy-shop.com'), (e) => e.status === 429);
++ await assert.rejects(make().api.resolve('down.example-shop.com'), (e) => e.status === 503);
++ });
++
++ await check('check(token): accepted → true, refused → false, malformed → false without a request', async () => {
++ stub.requests.length = 0;
++ const { api } = make();
++ assert.strictEqual(await api.check(GOOD_TOKEN), true);
++ assert.strictEqual(await api.check(REVOKED_TOKEN), false);
++ assert.strictEqual(await api.check('cpx_short'), false);
++ assert.strictEqual(await api.check('eyJhbGciOiJSUzI1NiJ9.a.b'), false, 'a Network JWT is never sent');
++ assert.strictEqual(stub.requests.length, 2);
++ });
++
++ await check('timeouts and unreachable servers are network errors', async () => {
++ const slow = await startStub({ delayMs: 300 });
++ const api = createApiClient({ origin: slow.origin, fetch: recordingFetch(), timeoutMs: 50 });
++ await assert.rejects(api.resolve('acme-outdoor.com'), (e) => e.code === 'network.timeout');
++ await slow.close();
++ const gone = createApiClient({ origin: slow.origin, fetch: recordingFetch(), timeoutMs: 1000 });
++ await assert.rejects(gone.resolve('acme-outdoor.com'), (e) => e.code === 'network.error');
++ });
++
++ await check('refused input: bad origins, ids, hosts, outcomes, and a stored value that is not a cpx_ token', async () => {
++ assert.throws(() => createApiClient({ origin: 'http://openvibe.coupons', fetch }), /https/);
++ assert.throws(() => createApiClient({ origin: 'https://openvibe.coupons/path', fetch }), /https/);
++ const { api } = make();
++ await assert.rejects(api.coupons('../admin'), TypeError);
++ await assert.rejects(api.report('cpn_1/../../x', 'worked'), TypeError);
++ await assert.rejects(api.resolve('https://acme-outdoor.com/cart?x=1'), TypeError);
++ await assert.rejects(api.report(COUPON.id, 'great'), TypeError);
++ await assert.rejects(make('not-a-token').api.resolve('acme-outdoor.com'), (e) => e.code === 'token.malformed');
++ });
++
++ await stub.close();
++ done();
++})();
ddiff --git a/test/helpers/harness.js b/test/helpers/harness.js
nnew file mode 100644
iindex 0000000..69e5267
---- /dev/null
++++ b/test/helpers/harness.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * Test helpers: a tiny check/done runner, a stub OpenVibe.Coupons API (records every request:
++ * method, URL, headers, body), and fakes of the browser APIs the extension uses.
++ */
++const http = require('http');
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++const MERCHANT = { id: 'mer_01K5ZK3M9P2Q4R6S8T0V2W4X6Y', slug: 'acme', name: 'Acme Outdoor', homepage_url: 'https://acme-outdoor.com/', url: 'https://openvibe.coupons/m/acme', domains: [{ host: 'acme-outdoor.com', include_subdomains: true, path_prefix: null }], active_codes: 1, hints: [] };
++const COUPON = {
++ id: 'cpn_01K5ZK3M9P2Q4R6S8T0V2W4X6Z', merchant_id: MERCHANT.id, code: 'TRAIL15', title: '15% off tents', description: null, status: 'unknown', active: true, confidence: null,
++ reports: { worked: 0, failed: 0, window_days: 30, last_report_at: null, last_worked_at: null, last_failed_at: null },
++ expiry: { known: false, expires_at: null, precision: null, basis: null }, restrictions: [], hints: [], evidence: [], origin: 'member',
++ created_at: '2026-09-22T12:00:00.000Z', updated_at: '2026-09-22T12:00:00.000Z', expired_at: null, url: 'https://openvibe.coupons/c/cpn_01K5ZK3M9P2Q4R6S8T0V2W4X6Z',
++};
++const GOOD_TOKEN = `cpx_${'a'.repeat(43)}`;
++const REVOKED_TOKEN = `cpx_${'r'.repeat(43)}`;
++const NO_REPORT_TOKEN = `cpx_${'n'.repeat(43)}`;
++
++/** A stub of the Coupons API with the behaviour the extension relies on. */
++function startStub({ delayMs = 0 } = {}) {
++ const requests = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const body = Buffer.concat(chunks).toString('utf8');
++ requests.push({ method: req.method, url: req.url, headers: req.headers, body });
++ const send = (status, obj) => setTimeout(() => {
++ res.writeHead(status, { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' });
++ res.end(JSON.stringify(obj));
++ }, delayMs);
++ const auth = req.headers.authorization || '';
++ if (auth === `Bearer ${REVOKED_TOKEN}`) return send(401, { code: 'token.revoked', detail: 'disconnected' });
++ if (auth && ![`Bearer ${GOOD_TOKEN}`, `Bearer ${NO_REPORT_TOKEN}`].includes(auth)) return send(401, { code: 'token.invalid', detail: 'unknown' });
++ const u = new URL(req.url, 'http://x');
++ if (req.method === 'GET' && u.pathname === '/api/v1/merchants/resolve') {
++ const host = u.searchParams.get('host');
++ if (host === 'acme-outdoor.com' || host.endsWith('.acme-outdoor.com')) return send(200, { host, registrable_domain: 'acme-outdoor.com', matched_rule: {}, merchant: MERCHANT });
++ if (host === 'down.example-shop.com') return send(503, { code: 'unavailable' });
++ if (host === 'busy-shop.com') return send(429, { code: 'lookup.rate_limited' });
++ return send(404, { code: 'merchant.not_found' });
++ }
++ if (req.method === 'GET' && u.pathname === `/api/v1/merchants/${MERCHANT.id}/coupons`) return send(200, { merchant: MERCHANT, coupons: [COUPON] });
++ if (req.method === 'POST' && u.pathname === `/api/v1/coupons/${COUPON.id}/report`) {
++ if (!auth) return send(401, { code: 'auth.required' });
++ if (auth === `Bearer ${NO_REPORT_TOKEN}`) return send(403, { code: 'token.scope' });
++ return send(201, { accepted: true, deduplicated: false, corrected: false, coupon: { ...COUPON, status: 'reported_working', confidence: 0.67 } });
++ }
++ return send(404, { code: 'route.not_found' });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ origin: `http://127.0.0.1:${server.address().port}`, requests, close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++/** fetch that records the options the client passes (and forwards to the real fetch). */
++function recordingFetch() {
++ const calls = [];
++ const f = (url, opts) => { calls.push({ url, opts }); return fetch(url, opts); };
++ f.calls = calls;
++ return f;
++}
++
++/** Fakes of chrome.tabs / chrome.storage.local / chrome.permissions. */
++function fakeExt({ url = null, token = null, hasPermission = true } = {}) {
++ const store = token ? { installToken: token } : {};
++ const log = [];
++ return {
++ log,
++ store,
++ tabs: { async query(q) { log.push(['tabs.query', q]); return url === undefined ? [] : [{ id: 1, 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); },
++ async remove(k) { log.push(['storage.remove', k]); delete store[k]; },
++ } },
++ permissions: {
++ async contains(p) { log.push(['permissions.contains', p]); return hasPermission; },
++ async request(p) { log.push(['permissions.request', p]); return true; },
++ },
++ };
++}
++
++module.exports = { check, done, startStub, recordingFetch, fakeExt, MERCHANT, COUPON, GOOD_TOKEN, REVOKED_TOKEN, NO_REPORT_TOKEN };
ddiff --git a/test/manifest.test.js b/test/manifest.test.js
nnew file mode 100644
iindex 0000000..760cdcc
---- /dev/null
++++ b/test/manifest.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * The permission audit: the shipped manifest passes, and every way of widening it fails. Plus a
++ * scan of the extension's code for APIs it must never use.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { auditDir, auditManifest, auditPage } = require('../scripts/audit');
++const { check, done } = require('./helpers/harness');
++
++const DIR = path.join(__dirname, '..', 'browser');
++const ORIGIN = 'https://openvibe.coupons';
++
++(async () => {
++ const { manifest, origin, problems } = auditDir(DIR);
++
++ await check('the shipped manifest passes the audit: activeTab + storage, one host permission (the API), MV3', async () => {
++ assert.deepStrictEqual(problems, []);
++ assert.strictEqual(origin, ORIGIN);
++ assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'storage']);
++ assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*']);
++ for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
++ assert.ok(!(k in manifest), `${k} must not be present`);
++ }
++ });
++
++ await check('the audit fails if anything widens', async () => {
++ const widen = [
++ (m) => m.permissions.push('tabs'),
++ (m) => m.permissions.push('cookies'),
++ (m) => m.permissions.push('scripting'),
++ (m) => m.permissions.push('<all_urls>'),
++ (m) => m.permissions.push('webRequest'),
++ (m) => m.permissions.push('history'),
++ (m) => m.permissions.push('clipboardRead'),
++ (m) => m.host_permissions.push('<all_urls>'),
++ (m) => m.host_permissions.push('https://*/*'),
++ (m) => { m.host_permissions = ['https://*.openvibe.coupons/*']; },
++ (m) => { m.content_scripts = [{ matches: ['<all_urls>'], js: ['x.js'] }]; },
++ (m) => { m.background = { service_worker: 'bg.js' }; },
++ (m) => { m.web_accessible_resources = [{ resources: ['popup.html'], matches: ['<all_urls>'] }]; },
++ (m) => { m.externally_connectable = { matches: ['https://*/*'] }; },
++ (m) => { m.optional_host_permissions = ['https://*/*']; },
++ (m) => { m.optional_permissions = ['tabs']; },
++ (m) => { m.content_security_policy.extension_pages = "default-src 'none'; script-src 'self' 'unsafe-eval'; connect-src https://openvibe.coupons; object-src 'none'"; },
++ (m) => { m.content_security_policy.extension_pages = "default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons https://tracker.example.net; object-src 'none'"; },
++ (m) => { m.content_security_policy.extension_pages = "default-src 'none'; script-src 'self' https://cdn.example.net; connect-src https://openvibe.coupons; object-src 'none'"; },
++ (m) => { m.manifest_version = 2; },
++ ];
++ for (const [i, mutate] of widen.entries()) {
++ const m = JSON.parse(JSON.stringify(manifest));
++ mutate(m);
++ assert.ok(auditManifest(m, { apiOrigin: ORIGIN }).length > 0, `widening #${i} was not caught: ${mutate}`);
++ }
++ });
++
++ await check('the API origin agrees across lib/config.js, host_permissions and the CSP connect-src', async () => {
++ const csp = manifest.content_security_policy.extension_pages;
++ assert.match(csp, /connect-src https:\/\/openvibe\.coupons;/);
++ assert.strictEqual(auditManifest(manifest, { apiOrigin: 'https://evil.example.net' }).length > 0, true);
++ });
++
++ await check('pages load only local scripts: no inline script, no inline handlers, no remote code', async () => {
++ for (const p of ['popup.html', 'options.html']) assert.deepStrictEqual(auditPage(fs.readFileSync(path.join(DIR, p), 'utf8'), p), []);
++ assert.ok(auditPage('<script>alert(1)</script>', 'x').length);
++ assert.ok(auditPage('<script src="https://cdn.example.net/x.js"></script>', 'x').length);
++ assert.ok(auditPage('<button onclick="x()">', 'x').length);
++ });
++
++ await check('the code never uses page access, cookies, dynamic code or HTML injection; only api.js fetches', async () => {
++ const files = [];
++ (function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) walk(p); else if (p.endsWith('.js')) files.push(p); } })(DIR);
++ const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
++ /executeScript/, /\.scripting\b/, /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
++ for (const f of files) {
++ // Comments may name what the code must not do; only code is scanned.
++ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
++ for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
++ 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');
++ });
++
++ done();
++})();
ddiff --git a/test/pack.test.js b/test/pack.test.js
nnew file mode 100644
iindex 0000000..fa2ba3e
---- /dev/null
++++ b/test/pack.test.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * scripts/pack.js: the zip holds exactly browser/, passes the audit, is byte-for-byte reproducible,
++ * and a development build rewrites the API origin in all three places together.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const zlib = require('zlib');
++const { build } = require('../scripts/pack');
++const { auditManifest } = require('../scripts/audit');
++const { check, done } = require('./helpers/harness');
++
++/** Minimal zip reader for the test: name → Buffer. */
++function unzip(buf) {
++ const out = new Map();
++ const end = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
++ const count = buf.readUInt16LE(end + 10);
++ let p = buf.readUInt32LE(end + 16);
++ for (let i = 0; i < count; i++) {
++ const method = buf.readUInt16LE(p + 10);
++ const csize = buf.readUInt32LE(p + 20);
++ const nameLen = buf.readUInt16LE(p + 28);
++ const extra = buf.readUInt16LE(p + 30);
++ const comment = buf.readUInt16LE(p + 32);
++ const local = buf.readUInt32LE(p + 42);
++ const name = buf.slice(p + 46, p + 46 + nameLen).toString('utf8');
++ const lname = buf.readUInt16LE(local + 26);
++ const lextra = buf.readUInt16LE(local + 28);
++ const data = buf.slice(local + 30 + lname + lextra, local + 30 + lname + lextra + csize);
++ out.set(name, method === 8 ? zlib.inflateRawSync(data) : data);
++ p += 46 + nameLen + extra + comment;
++ }
++ return out;
++}
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ext-test-'));
++
++ 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$/);
++ 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);
++ assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
++ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons' }), []);
++ });
++
++ await check('the build is reproducible', async () => {
++ const a = fs.readFileSync(build({ outDir: tmp }).out);
++ const b = fs.readFileSync(build({ outDir: tmp }).out);
++ assert.ok(a.equals(b));
++ });
++
++ await check('a development build points config, host permission and CSP at the dev origin together', async () => {
++ const r = build({ api: 'http://localhost:4850', outDir: tmp });
++ assert.match(r.out, /-dev\.zip$/);
++ const files = unzip(fs.readFileSync(r.out));
++ const m = JSON.parse(files.get('manifest.json'));
++ assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*']);
++ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850;/);
++ assert.match(m.name, /development/);
++ assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
++ assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
++ });
++
++ await check('refuses remote http origins and anything that is not an origin', async () => {
++ for (const bad of ['http://openvibe.coupons', 'http://evil.example.net', 'https://openvibe.coupons/path', 'ftp://x', '']) {
++ assert.throws(() => build({ api: bad, outDir: tmp }), /--api/, bad);
++ }
++ });
++
++ fs.rmSync(tmp, { recursive: true, force: true });
++ done();
++})();
ddiff --git a/test/privacy.test.js b/test/privacy.test.js
nnew file mode 100644
iindex 0000000..9febf63
---- /dev/null
++++ b/test/privacy.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * The extension never sends page content or URLs beyond the hostname: drive the popup controller
++ * (the same code popup.js runs) over tabs whose URLs carry paths, queries, fragments, credentials
++ * and personal data, and inspect every request the stub API receives.
++ */
++const assert = require('assert');
++const host = require('../browser/lib/host');
++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, recordingFetch, fakeExt, COUPON, GOOD_TOKEN, REVOKED_TOKEN } = require('./helpers/harness');
++
++const SECRETS = ['checkout', 'confirm', 'alice', 'mail.example', 'order', '4111', 'pay', 'session', 'hunter2', 'utm_'];
++
++(async () => {
++ const stub = await startStub();
++ const popup = ({ url, token = null }) => {
++ const ext = fakeExt({ url, token });
++ const platform = createPlatform(ext, { ...config, API_ORIGIN: stub.origin });
++ const f = recordingFetch();
++ const api = createApiClient({ origin: stub.origin, fetch: f, getToken: platform.storage.getToken });
++ return { ext, f, controller: createController({ api, platform, host }) };
++ };
++ const everything = () => stub.requests.map((r) => [r.method, r.url, JSON.stringify(r.headers), r.body].join(' ')).join('\n');
++
++ await check('hostnameOf keeps only the hostname and drops everything else', async () => {
++ assert.strictEqual(host.hostnameOf('https://user:[email protected]:8443/checkout/[email protected]#pay'), 'shop.acme-outdoor.com');
++ for (const u of ['chrome://settings', 'about:blank', 'file:///home/alice/cart.html', 'moz-extension://x/popup.html', 'https://192.168.1.10/', 'http://localhost:3000/', 'https://[::1]/', 'https://printer.local/', 'javascript:alert(1)', '', null]) {
++ assert.strictEqual(host.hostnameOf(u), null, String(u));
++ }
++ });
++
++ await check('opening the popup on a checkout page sends the hostname and a merchant id — nothing from the URL beyond the host', async () => {
++ stub.requests.length = 0;
++ const url = 'https://user:[email protected]/checkout/confirm?email=alice%40mail.example&order=4111&session=abc&utm_source=x#pay';
++ const { controller } = popup({ url, token: GOOD_TOKEN });
++ const vm = await controller.open();
++ assert.strictEqual(vm.state, 'merchant');
++ assert.strictEqual(vm.host, 'www.acme-outdoor.com');
++ assert.strictEqual(vm.connected, true);
++ assert.deepStrictEqual(stub.requests.map((r) => `${r.method} ${r.url}`), [
++ 'GET /api/v1/merchants/resolve?host=www.acme-outdoor.com',
++ `GET /api/v1/merchants/${vm.merchant.id}/coupons`,
++ ]);
++ const all = everything();
++ for (const s of SECRETS) assert.ok(!all.includes(s), `"${s}" left the browser`);
++ for (const r of stub.requests) {
++ assert.strictEqual(r.body, '');
++ assert.strictEqual(r.headers.cookie, undefined);
++ assert.strictEqual(r.headers.referer, undefined);
++ }
++ });
++
++ await check('reporting sends the code id and the outcome only', async () => {
++ stub.requests.length = 0;
++ const { controller } = popup({ url: 'https://acme-outdoor.com/cart?coupon=TRAIL15&[email protected]', token: GOOD_TOKEN });
++ const r = await controller.report(COUPON.id, 'failed', 'invalid');
++ assert.strictEqual(r.ok, true);
++ assert.deepStrictEqual(stub.requests.map((x) => `${x.method} ${x.url} ${x.body}`), [`POST /api/v1/coupons/${COUPON.id}/report {"outcome":"failed","reason":"invalid"}`]);
++ for (const s of SECRETS) assert.ok(!everything().includes(s), s);
++ });
++
++ await check('pages that are not shops send nothing at all', async () => {
++ stub.requests.length = 0;
++ for (const url of ['chrome://extensions', 'about:preferences', 'file:///tmp/x.html', 'https://10.0.0.1/admin', 'http://localhost:8080/', undefined]) {
++ const vm = await popup({ url }).controller.open();
++ assert.strictEqual(vm.state, 'not_a_site', String(url));
++ }
++ assert.strictEqual(stub.requests.length, 0);
++ });
++
++ await check('an unknown shop: one lookup with the hostname, a friendly "no codes" state', async () => {
++ stub.requests.length = 0;
++ const vm = await popup({ url: 'https://www.unknown-store.co.uk/basket/42' }).controller.open();
++ assert.deepStrictEqual(vm, { state: 'no_merchant', host: 'www.unknown-store.co.uk' });
++ assert.deepStrictEqual(stub.requests.map((r) => r.url), ['/api/v1/merchants/resolve?host=www.unknown-store.co.uk']);
++ });
++
++ await check('the only browser APIs touched are tabs.query (active tab), storage.local (the token) — no permission prompts on open', async () => {
++ const p = popup({ url: 'https://acme-outdoor.com/', token: null });
++ await p.controller.open();
++ assert.deepStrictEqual([...new Set(p.ext.log.map((x) => x[0]))], ['tabs.query', 'storage.get']);
++ assert.strictEqual(p.ext.log.filter((x) => x[0] === 'tabs.query').length, 1, 'the tab URL is read once');
++ assert.deepStrictEqual(p.ext.log[0][1], { active: true, currentWindow: true });
++ for (const [op, key] of p.ext.log.slice(1)) assert.deepStrictEqual([op, key], ['storage.get', 'installToken']);
++ });
++
++ await check('without a token the popup still shows codes and asks to connect before reporting; a revoked token says so', async () => {
++ const vm = await popup({ url: 'https://acme-outdoor.com/' }).controller.open();
++ assert.strictEqual(vm.connected, false);
++ assert.strictEqual(vm.coupons.length, 1);
++ const r = await popup({ url: 'https://acme-outdoor.com/' }).controller.report(COUPON.id, 'worked');
++ assert.strictEqual(r.ok, false);
++ assert.match(r.message, /Connect the extension/);
++ const revoked = await popup({ url: 'https://acme-outdoor.com/', token: REVOKED_TOKEN }).controller.open();
++ assert.strictEqual(revoked.state, 'error');
++ assert.match(revoked.message, /disconnected on openvibe\.coupons/);
++ });
++
++ await check('storage: the token lives in storage.local only, and only a cpx_ token is ever saved', async () => {
++ const ext = fakeExt({});
++ const platform = createPlatform(ext, config);
++ await assert.rejects(platform.storage.setToken('eyJhbGciOiJSUzI1NiJ9.network.jwt'), TypeError);
++ await platform.storage.setToken(GOOD_TOKEN);
++ assert.deepStrictEqual(ext.store, { installToken: GOOD_TOKEN });
++ await platform.storage.clearToken();
++ assert.deepStrictEqual(ext.store, {});
++ ext.store.installToken = 'tampered';
++ assert.strictEqual(await platform.storage.getToken(), null, 'a stored value that is not a cpx_ token is ignored');
++ });
++
++ await stub.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..3b1bf1d
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,27 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. No browser, no network:
++ * the API is a local stub server and the browser APIs are fakes.
++ *
++ * npm test # everything
++ * npm test -- manifest # only files whose name contains one of the words
++ */
++'use strict';
++const { spawnSync } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname).filter((f) => f.endsWith('.test.js')).filter((f) => !filters.length || filters.some((w) => f.includes(w))).sort();
++if (!files.length) { console.error('no test files matched'); process.exit(1); }
++let failed = 0;
++for (const f of files) {
++ const started = Date.now();
++ const r = spawnSync(process.execPath, [path.join(__dirname, f)], { cwd: path.join(__dirname, '..'), encoding: 'utf8', timeout: 60000 });
++ const ok = r.status === 0;
++ if (!ok) failed++;
++ console.log(`${ok ? '✓' : '✗'} ${f.padEnd(28)} ${String(Date.now() - started).padStart(6)}ms`);
++ if (!ok || process.env.VERBOSE) console.log((r.stdout || '') + (r.stderr || ''));
++}
++console.log(`\n${files.length - failed}/${files.length} test files passed`);
++process.exit(failed ? 1 : 0);