openvibers/openvibe.extensions Commits
The Companion is ready for the Chrome Web Store and addons.mozilla.org: store listing and privacy answers in store/, reproducible Chrome and Firefox store zips (npm run pack:stores), Mozilla's web-ext lint in CI, data disclosures that match the code, and a current STATUS and README (#7)
Changes
@@@ -12,7 +12,16 @@ jobs:cache: false # and so no lockfilesyntax-dirs: scriptspin-drift: false # no openvibe-* pins-- # The coupon helper's zip, kept 14 days for review.++ # Mozilla's own linter (what addons.mozilla.org runs on upload); a warning fails the build.++ firefox-lint:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ - run: npm run lint:firefox++ # The release zip and the two store zips, kept 14 days for review.package:runs-on: ubuntu-lateststeps:@@@ -21,6 +30,7 @@ jobs:with:node-version: 22.22.1- run: npm run pack++ - run: npm run pack:stores- uses: actions/upload-artifact@v4with:name: openvibe-coupons-helper@@@ -5,10 +5,12 @@**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromiumloads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome nolonger loads unpacked extensions from the command line). 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++the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's++`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices++and runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any++store yet: the store zips, listing text and privacy answers are ready (`npm run pack:stores`,++[store/](store/)), and only the owner's developer-account step is left. OpenVibe.Coupons is++public at `https://openvibe.coupons` 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.**License:** MIT.@@@ -117,7 +119,7 @@ browser/icons/ generated by scripts/icons.jsscripts/audit.js the permission audit (manifest, CSP, pages)-- pack.js zip for loading or store review (runs the audit first)++ pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)icons.js draws the icons (no dependencies)```@@@ -150,6 +152,8 @@ That check is not in CI. Firefox hasn't been tried.```bashnpm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)++npm run pack:stores # dist/openvibe-companion-<version>-chrome.zip and -firefox.zip (store uploads)++npm run lint:firefox # Mozilla web-ext lint, warnings as errors (CI runs it)node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)```@@@ -168,10 +172,13 @@ shows "Allow access to openvibe.coupons".1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since2026-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-- disclosure is the table above: the hostname of the active tab, only on open, sent to-- 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.++2. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`++ (no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for byte++ reproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single++ purpose, permission justifications and data disclosures in [store/privacy.md](store/privacy.md)++ (written from the code; `browser_specific_settings.gecko.data_collection_permissions` declares++ `authenticationInfo`, `browsingActivity` and `websiteContent` to match), and the shots in++ [store/screenshots.md](store/screenshots.md). Never upload from CI.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@@@ -26,21 +26,23 @@"credential": "optional cpx_ install token (coupons.lookup, coupons.report); no Network session","publishedToStores": false,"loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",-- "loadedInFirefox": "not yet verified",++ "loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors","handoffs": ["https://openvibe.rent/board (keep a listing)","https://openvibe.rent/fair (check the rent)","https://openvibe.watch/watches/new (watch a page)","https://openvibe.food/delivery (compare delivery apps)","https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"-- ]++ ],++ "storeBuilds": "npm run pack:stores: dist/openvibe-companion-<version>-chrome.zip and -firefox.zip, reproducible, audited",++ "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion"}},"pending": ["OpenVibe.Coupons launched publicly on 2026-10-09 (https://openvibe.coupons); the database is still empty","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"++ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; then put the Chrome extension id in COUPONS_EXTENSION_ORIGINS"],"deployed": false,-- "updated": "2026-09-28"++ "updated": "2026-10-22"}@@@ -42,7 +42,9 @@"strict_min_version": "128.0","data_collection_permissions": {"required": [-- "browsingActivity"++ "authenticationInfo",++ "browsingActivity",++ "websiteContent"]}}@@@ -6,6 +6,8 @@"scripts": {"test": "node test/run.js","pack": "node scripts/pack.js",++ "pack:stores": "node scripts/pack.js --stores",++ "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors","pack:dev": "node scripts/pack.js --api http://localhost:4850","icons": "node scripts/icons.js"},@@@ -4,6 +4,10 @@* Packs browser/ into a zip for loading or store review. It never uploads anything anywhere.** node scripts/pack.js → dist/openvibe-companion-<version>.zip++ * node scripts/pack.js --stores → dist/openvibe-companion-<version>-chrome.zip and++ * dist/openvibe-companion-<version>-firefox.zip, the store uploads:++ * the Chrome zip drops browser_specific_settings (Chrome warns on it), the Firefox zip drops++ * minimum_chrome_version; nothing else differs from browser/.* node scripts/pack.js --api http://localhost:4850* → dist/openvibe-companion-<version>-dev.zip,* a development build whose API origin (lib/config.js), host permission and CSP connect-src@@@ -68,6 +72,15 @@ function listFiles(dir, base = dir) {}/** Copy of browser/ with the API origin rewritten (dev builds), as { name → Buffer }. */++/** The store manifest for one browser: only drops the other browser's key (the audit runs on the full one). */++function forTarget(files, target) {++ const m = JSON.parse(files.get('manifest.json'));++ if (target === 'chrome') delete m.browser_specific_settings;++ else if (target === 'firefox') delete m.minimum_chrome_version;++ else throw new Error('target must be chrome or firefox');++ files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));++}++function sourceFiles(apiOrigin) {const files = new Map(listFiles(SRC).map((f) => [f, fs.readFileSync(path.join(SRC, f))]));if (apiOrigin !== DEFAULT_ORIGIN) {@@@ -81,10 +94,11 @@ function sourceFiles(apiOrigin) {return files;}--function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {}) {++function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist'), target = null } = {}) {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');}++ if (target && api !== DEFAULT_ORIGIN) throw new Error('store builds use the release API origin only');const files = sourceFiles(api);const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ov-ext-pack-'));try {@@@ -92,10 +106,11 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})const audit = auditDir(tmp);if (audit.problems.length) throw new Error(`permission audit failed:\n - ${audit.problems.join('\n - ')}`);const version = audit.manifest.version;++ if (target) forTarget(files, target);fs.mkdirSync(outDir, { recursive: true });-- const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);++ const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}${target ? '-' + target : ''}.zip`);fs.writeFileSync(out, zip([...files].map(([name, data]) => ({ name, data }))));-- return { out, files: [...files.keys()], origin: api };++ return { out, files: [...files.keys()], origin: api, target };} finally {fs.rmSync(tmp, { recursive: true, force: true });}@@@ -104,8 +119,12 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})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})`);++ const runs = process.argv.includes('--stores') ? [{ target: 'chrome' }, { target: 'firefox' }]++ : [{ api: i > -1 ? String(process.argv[i + 1] || '').replace(/\/+$/, '') : DEFAULT_ORIGIN }];++ for (const opts of runs) {++ const r = build(opts);++ console.log(`wrote ${path.relative(process.cwd(), r.out)} (${r.files.length} files, API ${r.origin}${r.target ? ', ' + r.target : ''})`);++ }} catch (err) {console.error(err.message);process.exit(1);nnew file mode 100644@@@ -0,0 +1,50 @@++# Store listing: OpenVibe Companion++++Copy these fields into the Chrome Web Store developer dashboard and addons.mozilla.org (AMO).++The upload files come from `npm run pack:stores`: `dist/openvibe-companion-<version>-chrome.zip`++and `dist/openvibe-companion-<version>-firefox.zip`. The privacy and permission answers are in++[privacy.md](privacy.md), the screenshots in [screenshots.md](screenshots.md).++++| Field | Value |++|---|---|++| Name | OpenVibe Companion |++| Category | Chrome Web Store: **Shopping**. AMO: **Shopping** |++| Language | English |++| Homepage | https://openvibe.coupons/companion |++| Support URL | https://openvibe.coupons/companion |++| Source code (AMO "source code" question) | Not needed: no minifier, bundler or generated code. The zip is `browser/` as written, and https://github.com/OpenVibers/OpenVibe.Extensions is public (MIT). |++| License (AMO) | MIT |++| Firefox add-on id | `[email protected]` (from `browser_specific_settings.gecko.id`) |++| Minimum versions | Chrome 111, Firefox 128 |++++## Short description++++Chrome Web Store "summary" (132 characters at most; this is 125):++++> Coupon codes for the shop you are on, tried when you ask, plus one-click hand-offs to OpenVibe Rent, Watch, Food and Reviews.++++AMO "summary" (250 at most): the same sentence.++++## Long description++++> OpenVibe Companion helps on the page you are already looking at, and only when you open it.++>++> **Coupon codes for this shop.** Open the Companion on a shop and it asks OpenVibe Coupons (openvibe.coupons) for codes for that site, by its hostname only: never the full address, never the page. Press "Try these codes on this page" and the Companion types them into the shop's coupon box one at a time and tells you which ones lowered the total. It never rewrites links and never adds referral tags.++>++> **Tell other shoppers what worked.** If you connect the Companion with an install token from openvibe.coupons, one click reports whether a code worked or failed, so the list stays honest.++>++> **The same restaurant on every delivery app.** On a delivery app, "Compare delivery apps" opens OpenVibe Food with the restaurant's name, so you can see its price on the other apps.++>++> **Save a review site's rating.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor, "Save its rating to OpenVibe Reviews" reads the rating the page states and opens openvibe.reviews to check and save it.++>++> **Keep a listing, watch a page.** "Keep on my Rent board" and "Check the rent" open OpenVibe Rent with the listing's address; "Watch this page" opens OpenVibe Watch to tell you when the page changes.++>++> **What it can see.** The Companion asks for no access to your sites until you open it on a tab (the browser's "active tab" permission). It has no background worker, no content scripts, and no access to your history, cookies or other tabs. Every hand-off opens a tab you can see, and nothing is saved until you save it there. The only thing it stores is the optional install token, on this device.++>++> Open source (MIT): https://github.com/OpenVibers/OpenVibe.Extensions++++## Notes for the reviewer++++AMO "notes to reviewer" and the Chrome Web Store's test instructions:++++> No account is needed. To see codes, open the Companion on any shop that OpenVibe Coupons lists (https://openvibe.coupons shows them). "Try these codes on this page" runs only on a page with a coupon box. The settings page takes an optional `cpx_` install token from https://openvibe.coupons; without it, everything except reporting works.nnew file mode 100644@@@ -0,0 +1,49 @@++# Store privacy answers: OpenVibe Companion++++Written from the code at this commit (`browser/`), not from intentions. If the code changes what it++reads, sends or stores, change this file, the README's "What it can see" table and++`browser_specific_settings.gecko.data_collection_permissions` together.++++## Single purpose (Chrome Web Store)++++> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.++++## Permission justifications (Chrome Web Store)++++| Permission | Justification |++|---|---|++| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |++| `scripting` | Two buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed) and "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states). Neither makes a request or reads cookies or storage. |++| `storage` | Keeps the optional `cpx_` install token the person pastes on the settings page, in `storage.local` (never synced). Nothing else is stored. |++| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |++| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |++++## Data usage (Chrome Web Store "Privacy practices" tab)++++Tick these data types, because each leaves the device in some case:++++| Type | Ticked | What, when, where |++|---|---|---|++| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |++| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. |++| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. It is issued by openvibe.coupons and sent nowhere else. |++| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |++| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |++| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, from the page's structured data.) |++++Certify all three: the data is not sold to third parties; it is not used or transferred for++purposes unrelated to the single purpose; it is not used or transferred to determine++creditworthiness or for lending.++++**Privacy policy URL:** https://openvibe.coupons/companion (owner: confirm the page carries the++"what it reads and sends" text above, or link the OpenVibe privacy page that does).++++## Firefox (AMO)++++`browser_specific_settings.gecko.data_collection_permissions.required` declares, for the same++reasons as the table above: `authenticationInfo`, `browsingActivity`, `websiteContent`. AMO shows++these to the person at install. Nothing is optional, because nothing is collected that the person++didn't press for, and the token is the person's own choice on the settings page.++++In Firefox, MV3 host permissions can be withheld at install; the settings page then shows "Allow++access to openvibe.coupons".nnew file mode 100644@@@ -0,0 +1,20 @@++# Screenshot plan: OpenVibe Companion++++Chrome Web Store: 1 to 5 screenshots, 1280×800 (or 640×400), PNG or JPEG, no alpha; a 440×280++small promo tile. AMO: any size up to 2400×1800, ideally the same 1280×800 shots. Take them from++the release build (`npm run pack:stores`, loaded unpacked) on real pages, with no personal data++in the address bar or the page.++++| # | Shows | Page | Caption |++|---|---|---|---|++| 1 | The popup open on a shop with codes listed | a shop OpenVibe Coupons lists | Coupon codes for the shop you are on |++| 2 | "Try these codes on this page" finished: which code lowered the total | the same shop's cart, coupon box visible | Tried in the coupon box when you ask |++| 3 | The popup on a review site, the "Save its rating to OpenVibe Reviews" button | a Trustpilot or Yelp business page | Save a review site's rating |++| 4 | The popup on a rental listing, "Keep on my Rent board" and "Check the rent" | a rental listing page | Keep a listing, check the rent |++| 5 | The settings page with the token field empty | `options.html` | No account needed; connect only to report |++++Small promo tile (440×280): the 128 px icon on the OpenVibe blue, the name, and "Coupon codes,++tried when you ask".++++Headless capture works for the popup and settings pages only (a popup can't be opened over a++real tab headless, so shots 1 to 4 are a person's screen captures, cropped to 1280×800).@@@ -66,6 +66,22 @@ function unzip(buf) {assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);});++ await check('the store zips differ from browser/ only by the other browser\'s manifest key, reproducibly', async () => {++ const src = path.join(__dirname, '..', 'browser');++ const full = JSON.parse(fs.readFileSync(path.join(src, 'manifest.json'), 'utf8'));++ for (const [target, dropped] of [['chrome', 'browser_specific_settings'], ['firefox', 'minimum_chrome_version']]) {++ const r = build({ outDir: tmp, target });++ assert.match(path.basename(r.out), new RegExp(`^openvibe-companion-\\d+\\.\\d+\\.\\d+-${target}\\.zip$`));++ const files = unzip(fs.readFileSync(r.out));++ for (const [name, data] of files) if (name !== 'manifest.json') assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);++ const want = { ...full };++ delete want[dropped];++ assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);++ assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));++ }++ assert.throws(() => build({ outDir: tmp, target: 'chrome', api: 'http://localhost:4850' }), /release API origin/);++ });++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);
@@ -12,7 +12,16 @@ jobs:
@@ -12,7 +12,16 @@ jobs:
cache: false # and so no lockfile
cache: false # and so no lockfile
syntax-dirs: scripts
syntax-dirs: scripts
pin-drift: false # no openvibe-* pins
pin-drift: false # no openvibe-* pins
- # The coupon helper's zip, kept 14 days for review.
+ # Mozilla's own linter (what addons.mozilla.org runs on upload); a warning fails the build.
+ firefox-lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ - run: npm run lint:firefox
+ # The release zip and the two store zips, kept 14 days for review.
package:
package:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
steps:
@@ -21,6 +30,7 @@ jobs:
@@ -21,6 +30,7 @@ jobs:
with:
with:
node-version: 22.22.1
node-version: 22.22.1
- run: npm run pack
- run: npm run pack
+ - run: npm run pack:stores
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
with:
name: openvibe-coupons-helper
name: openvibe-coupons-helper
@@ -5,10 +5,12 @@
@@ -5,10 +5,12 @@
**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and Chromium
loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no
loads the unpacked 0.3.0 build with no console or CSP errors (2026-10-15, Playwright's Chromium: branded Chrome no
longer loads unpacked extensions from the command line). A development build of
longer loads unpacked extensions from the command line). 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
+the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's
+`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices
+and runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any
+store yet: the store zips, listing text and privacy answers are ready (`npm run pack:stores`,
+[store/](store/)), and only the owner's developer-account step is left. OpenVibe.Coupons is
+public at `https://openvibe.coupons` 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.
**License:** MIT.
**License:** MIT.
@@ -117,7 +119,7 @@ browser/
@@ -117,7 +119,7 @@ browser/
icons/ generated by scripts/icons.js
icons/ generated by scripts/icons.js
scripts/
scripts/
audit.js the permission audit (manifest, CSP, pages)
audit.js the permission audit (manifest, CSP, pages)
- pack.js zip for loading or store review (runs the audit first)
+ pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)
check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
icons.js draws the icons (no dependencies)
icons.js draws the icons (no dependencies)
```
```
@@ -150,6 +152,8 @@ That check is not in CI. Firefox hasn't been tried.
@@ -150,6 +152,8 @@ That check is not in CI. Firefox hasn't been tried.
```bash
```bash
npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)
npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)
+npm run pack:stores # dist/openvibe-companion-<version>-chrome.zip and -firefox.zip (store uploads)
+npm run lint:firefox # Mozilla web-ext lint, warnings as errors (CI runs it)
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)
```
```
@@ -168,10 +172,13 @@ shows "Allow access to openvibe.coupons".
@@ -168,10 +172,13 @@ shows "Allow access to openvibe.coupons".
1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since
1. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since
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
- disclosure is the table above: the hostname of the active tab, only on open, sent to
- 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.
+2. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`
+ (no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for byte
+ reproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single
+ purpose, permission justifications and data disclosures in [store/privacy.md](store/privacy.md)
+ (written from the code; `browser_specific_settings.gecko.data_collection_permissions` declares
+ `authenticationInfo`, `browsingActivity` and `websiteContent` to match), and the shots in
+ [store/screenshots.md](store/screenshots.md). Never upload from CI.
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,
because its host permission covers the API. CORS only helps when a browser withholds that
because its host permission covers the API. CORS only helps when a browser withholds that
@@ -26,21 +26,23 @@
@@ -26,21 +26,23 @@
"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 Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
"loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
- "loadedInFirefox": "not yet verified",
+ "loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors",
"handoffs": [
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/fair (check the rent)",
"https://openvibe.rent/fair (check the rent)",
"https://openvibe.watch/watches/new (watch a page)",
"https://openvibe.watch/watches/new (watch a page)",
"https://openvibe.food/delivery (compare delivery apps)",
"https://openvibe.food/delivery (compare delivery apps)",
"https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"
"https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"
- ]
+ ],
+ "storeBuilds": "npm run pack:stores: dist/openvibe-companion-<version>-chrome.zip and -firefox.zip, reproducible, audited",
+ "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion"
}
}
},
},
"pending": [
"pending": [
"OpenVibe.Coupons launched publicly on 2026-10-09 (https://openvibe.coupons); the database is still empty",
"OpenVibe.Coupons launched publicly on 2026-10-09 (https://openvibe.coupons); the database is still empty",
"move Live's browser-extension/openvibe-kiosk-companion and hardware/ here (ADR-023), with a redirect README in Live",
"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"
+ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; then put the Chrome extension id in COUPONS_EXTENSION_ORIGINS"
],
],
"deployed": false,
"deployed": false,
- "updated": "2026-09-28"
+ "updated": "2026-10-22"
}
}
@@ -42,7 +42,9 @@
@@ -42,7 +42,9 @@
"strict_min_version": "128.0",
"strict_min_version": "128.0",
"data_collection_permissions": {
"data_collection_permissions": {
"required": [
"required": [
- "browsingActivity"
+ "authenticationInfo",
+ "browsingActivity",
+ "websiteContent"
]
]
}
}
}
}
@@ -6,6 +6,8 @@
@@ -6,6 +6,8 @@
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
"pack": "node scripts/pack.js",
"pack": "node scripts/pack.js",
+ "pack:stores": "node scripts/pack.js --stores",
+ "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors",
"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"
},
},
@@ -4,6 +4,10 @@
@@ -4,6 +4,10 @@
* 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-companion-<version>.zip
* node scripts/pack.js → dist/openvibe-companion-<version>.zip
+ * node scripts/pack.js --stores → dist/openvibe-companion-<version>-chrome.zip and
+ * dist/openvibe-companion-<version>-firefox.zip, the store uploads:
+ * the Chrome zip drops browser_specific_settings (Chrome warns on it), the Firefox zip drops
+ * minimum_chrome_version; nothing else differs from browser/.
* node scripts/pack.js --api http://localhost:4850
* node scripts/pack.js --api http://localhost:4850
* → dist/openvibe-companion-<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
@@ -68,6 +72,15 @@ function listFiles(dir, base = dir) {
@@ -68,6 +72,15 @@ function listFiles(dir, base = dir) {
}
}
/** Copy of browser/ with the API origin rewritten (dev builds), as { name → Buffer }. */
/** Copy of browser/ with the API origin rewritten (dev builds), as { name → Buffer }. */
+/** The store manifest for one browser: only drops the other browser's key (the audit runs on the full one). */
+function forTarget(files, target) {
+ const m = JSON.parse(files.get('manifest.json'));
+ if (target === 'chrome') delete m.browser_specific_settings;
+ else if (target === 'firefox') delete m.minimum_chrome_version;
+ else throw new Error('target must be chrome or firefox');
+ files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
+}
+
function sourceFiles(apiOrigin) {
function sourceFiles(apiOrigin) {
const files = new Map(listFiles(SRC).map((f) => [f, fs.readFileSync(path.join(SRC, f))]));
const files = new Map(listFiles(SRC).map((f) => [f, fs.readFileSync(path.join(SRC, f))]));
if (apiOrigin !== DEFAULT_ORIGIN) {
if (apiOrigin !== DEFAULT_ORIGIN) {
@@ -81,10 +94,11 @@ function sourceFiles(apiOrigin) {
@@ -81,10 +94,11 @@ function sourceFiles(apiOrigin) {
return files;
return files;
}
}
-function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {}) {
+function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist'), target = null } = {}) {
if (!/^https:\/\/[a-z0-9.-]+(:\d+)?$/.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(api)) {
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');
throw new Error('--api must be an https origin, or http://localhost / http://127.0.0.1 for development');
}
}
+ if (target && api !== DEFAULT_ORIGIN) throw new Error('store builds use the release API origin only');
const files = sourceFiles(api);
const files = sourceFiles(api);
const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ov-ext-pack-'));
const tmp = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ov-ext-pack-'));
try {
try {
@@ -92,10 +106,11 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
@@ -92,10 +106,11 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
const audit = auditDir(tmp);
const audit = auditDir(tmp);
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;
+ if (target) forTarget(files, target);
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
- const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}.zip`);
+ const out = path.join(outDir, `openvibe-companion-${version}${api === DEFAULT_ORIGIN ? '' : '-dev'}${target ? '-' + target : ''}.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, target };
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
}
}
@@ -104,8 +119,12 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
@@ -104,8 +119,12 @@ function build({ api = DEFAULT_ORIGIN, outDir = path.join(ROOT, 'dist') } = {})
if (require.main === module) {
if (require.main === module) {
const i = process.argv.indexOf('--api');
const i = process.argv.indexOf('--api');
try {
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})`);
+ const runs = process.argv.includes('--stores') ? [{ target: 'chrome' }, { target: 'firefox' }]
+ : [{ api: i > -1 ? String(process.argv[i + 1] || '').replace(/\/+$/, '') : DEFAULT_ORIGIN }];
+ for (const opts of runs) {
+ const r = build(opts);
+ console.log(`wrote ${path.relative(process.cwd(), r.out)} (${r.files.length} files, API ${r.origin}${r.target ? ', ' + r.target : ''})`);
+ }
} catch (err) {
} catch (err) {
console.error(err.message);
console.error(err.message);
process.exit(1);
process.exit(1);
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+# Store listing: OpenVibe Companion
+
+Copy these fields into the Chrome Web Store developer dashboard and addons.mozilla.org (AMO).
+The upload files come from `npm run pack:stores`: `dist/openvibe-companion-<version>-chrome.zip`
+and `dist/openvibe-companion-<version>-firefox.zip`. The privacy and permission answers are in
+[privacy.md](privacy.md), the screenshots in [screenshots.md](screenshots.md).
+
+| Field | Value |
+|---|---|
+| Name | OpenVibe Companion |
+| Category | Chrome Web Store: **Shopping**. AMO: **Shopping** |
+| Language | English |
+| Homepage | https://openvibe.coupons/companion |
+| Support URL | https://openvibe.coupons/companion |
+| Source code (AMO "source code" question) | Not needed: no minifier, bundler or generated code. The zip is `browser/` as written, and https://github.com/OpenVibers/OpenVibe.Extensions is public (MIT). |
+| License (AMO) | MIT |
+| Firefox add-on id | `[email protected]` (from `browser_specific_settings.gecko.id`) |
+| Minimum versions | Chrome 111, Firefox 128 |
+
+## Short description
+
+Chrome Web Store "summary" (132 characters at most; this is 125):
+
+> Coupon codes for the shop you are on, tried when you ask, plus one-click hand-offs to OpenVibe Rent, Watch, Food and Reviews.
+
+AMO "summary" (250 at most): the same sentence.
+
+## Long description
+
+> OpenVibe Companion helps on the page you are already looking at, and only when you open it.
+>
+> **Coupon codes for this shop.** Open the Companion on a shop and it asks OpenVibe Coupons (openvibe.coupons) for codes for that site, by its hostname only: never the full address, never the page. Press "Try these codes on this page" and the Companion types them into the shop's coupon box one at a time and tells you which ones lowered the total. It never rewrites links and never adds referral tags.
+>
+> **Tell other shoppers what worked.** If you connect the Companion with an install token from openvibe.coupons, one click reports whether a code worked or failed, so the list stays honest.
+>
+> **The same restaurant on every delivery app.** On a delivery app, "Compare delivery apps" opens OpenVibe Food with the restaurant's name, so you can see its price on the other apps.
+>
+> **Save a review site's rating.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or Tripadvisor, "Save its rating to OpenVibe Reviews" reads the rating the page states and opens openvibe.reviews to check and save it.
+>
+> **Keep a listing, watch a page.** "Keep on my Rent board" and "Check the rent" open OpenVibe Rent with the listing's address; "Watch this page" opens OpenVibe Watch to tell you when the page changes.
+>
+> **What it can see.** The Companion asks for no access to your sites until you open it on a tab (the browser's "active tab" permission). It has no background worker, no content scripts, and no access to your history, cookies or other tabs. Every hand-off opens a tab you can see, and nothing is saved until you save it there. The only thing it stores is the optional install token, on this device.
+>
+> Open source (MIT): https://github.com/OpenVibers/OpenVibe.Extensions
+
+## Notes for the reviewer
+
+AMO "notes to reviewer" and the Chrome Web Store's test instructions:
+
+> No account is needed. To see codes, open the Companion on any shop that OpenVibe Coupons lists (https://openvibe.coupons shows them). "Try these codes on this page" runs only on a page with a coupon box. The settings page takes an optional `cpx_` install token from https://openvibe.coupons; without it, everything except reporting works.
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+# Store privacy answers: OpenVibe Companion
+
+Written from the code at this commit (`browser/`), not from intentions. If the code changes what it
+reads, sends or stores, change this file, the README's "What it can see" table and
+`browser_specific_settings.gecko.data_collection_permissions` together.
+
+## Single purpose (Chrome Web Store)
+
+> Help with the page in the active tab, only when the person opens the Companion: coupon codes for the shop they are on (looked up on OpenVibe Coupons by hostname and tried in the page's coupon box on request), and one-press hand-offs of that page to OpenVibe services (Rent, Watch, Food, Reviews) in a tab the person sees.
+
+## Permission justifications (Chrome Web Store)
+
+| Permission | Justification |
+|---|---|
+| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |
+| `scripting` | Two buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed) and "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states). Neither makes a request or reads cookies or storage. |
+| `storage` | Keeps the optional `cpx_` install token the person pastes on the settings page, in `storage.local` (never synced). Nothing else is stored. |
+| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |
+| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |
+
+## Data usage (Chrome Web Store "Privacy practices" tab)
+
+Tick these data types, because each leaves the device in some case:
+
+| Type | Ticked | What, when, where |
+|---|---|---|
+| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
+| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. |
+| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. It is issued by openvibe.coupons and sent nowhere else. |
+| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
+| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
+| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, from the page's structured data.) |
+
+Certify all three: the data is not sold to third parties; it is not used or transferred for
+purposes unrelated to the single purpose; it is not used or transferred to determine
+creditworthiness or for lending.
+
+**Privacy policy URL:** https://openvibe.coupons/companion (owner: confirm the page carries the
+"what it reads and sends" text above, or link the OpenVibe privacy page that does).
+
+## Firefox (AMO)
+
+`browser_specific_settings.gecko.data_collection_permissions.required` declares, for the same
+reasons as the table above: `authenticationInfo`, `browsingActivity`, `websiteContent`. AMO shows
+these to the person at install. Nothing is optional, because nothing is collected that the person
+didn't press for, and the token is the person's own choice on the settings page.
+
+In Firefox, MV3 host permissions can be withheld at install; the settings page then shows "Allow
+access to openvibe.coupons".
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+# Screenshot plan: OpenVibe Companion
+
+Chrome Web Store: 1 to 5 screenshots, 1280×800 (or 640×400), PNG or JPEG, no alpha; a 440×280
+small promo tile. AMO: any size up to 2400×1800, ideally the same 1280×800 shots. Take them from
+the release build (`npm run pack:stores`, loaded unpacked) on real pages, with no personal data
+in the address bar or the page.
+
+| # | Shows | Page | Caption |
+|---|---|---|---|
+| 1 | The popup open on a shop with codes listed | a shop OpenVibe Coupons lists | Coupon codes for the shop you are on |
+| 2 | "Try these codes on this page" finished: which code lowered the total | the same shop's cart, coupon box visible | Tried in the coupon box when you ask |
+| 3 | The popup on a review site, the "Save its rating to OpenVibe Reviews" button | a Trustpilot or Yelp business page | Save a review site's rating |
+| 4 | The popup on a rental listing, "Keep on my Rent board" and "Check the rent" | a rental listing page | Keep a listing, check the rent |
+| 5 | The settings page with the token field empty | `options.html` | No account needed; connect only to report |
+
+Small promo tile (440×280): the 128 px icon on the OpenVibe blue, the name, and "Coupon codes,
+tried when you ask".
+
+Headless capture works for the popup and settings pages only (a popup can't be opened over a
+real tab headless, so shots 1 to 4 are a person's screen captures, cropped to 1280×800).
@@ -66,6 +66,22 @@ function unzip(buf) {
@@ -66,6 +66,22 @@ function unzip(buf) {
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
});
});
+ await check('the store zips differ from browser/ only by the other browser\'s manifest key, reproducibly', async () => {
+ const src = path.join(__dirname, '..', 'browser');
+ const full = JSON.parse(fs.readFileSync(path.join(src, 'manifest.json'), 'utf8'));
+ for (const [target, dropped] of [['chrome', 'browser_specific_settings'], ['firefox', 'minimum_chrome_version']]) {
+ const r = build({ outDir: tmp, target });
+ assert.match(path.basename(r.out), new RegExp(`^openvibe-companion-\\d+\\.\\d+\\.\\d+-${target}\\.zip$`));
+ const files = unzip(fs.readFileSync(r.out));
+ for (const [name, data] of files) if (name !== 'manifest.json') assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
+ const want = { ...full };
+ delete want[dropped];
+ assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
+ assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));
+ }
+ assert.throws(() => build({ outDir: tmp, target: 'chrome', api: 'http://localhost:4850' }), /release API origin/);
+ });
+
await check('refuses remote http origins and anything that is not an origin', async () => {
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', '']) {
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);
assert.throws(() => build({ api: bad, outDir: tmp }), /--api/, bad);