openvibers/openvibe.extensions Commits
Companion: a mask in the email box of a sign-up form (#17)
Changes
nnew file mode 100644@@@ -0,0 +1,57 @@++/*++ * The Companion's background worker: the one place that answers the page.++ *++ * The button the page draws (browser/lib/mask-fill.js) asks this worker two things: may a mask be++ * offered here, and which address should this field get. The worker holds the person's sign-in, talks++ * to OpenVibe.Mail and never puts the token into the page: the page is told an address and the words++ * that go with it, nothing else.++ *++ * The message boundary lives in lib/mask-router.js (so it can be tested on its own): a page may ask++ * only for an offer and for an address, and the site is taken from the tab's own address, never from++ * the page's word. An address only ever comes after a real click on the button: the page script checks++ * that itself, and a script on the page cannot forge a click.++ */++import './lib/config.js';++import './lib/platform.js';++import './lib/auth.js';++import './lib/mask.js';++import './lib/mask-service.js';++import './lib/mail-api.js';++import './lib/mask-router.js';++++const ext = self.browser || self.chrome;++const config = self.OVConfig;++const platform = self.OVPlatform.createPlatform(ext, config);++const auth = self.OVAuth.createAuth({ platform: platform, config: config, fetch: self.fetch.bind(self) });++const mail = self.OVMail.createMailClient({++ origin: config.MAIL_ORIGIN,++ fetch: self.fetch.bind(self),++ getToken: function () { return auth.accessToken(); },++ timeoutMs: config.TIMEOUT_MS,++});++const service = self.OVMaskService.createMaskService({++ rules: self.OVMask,++ auth: auth,++ mail: mail,++ storage: platform.storage,++ scope: config.MAIL_SCOPE,++ muteKey: config.MASK_MUTE_KEY,++});++++/** One of our own pages (the popup), not a page of the web. */++function isOwnPage(sender) {++ if (!sender || !ext.runtime || !ext.runtime.id) return false;++ if (sender.id && sender.id !== ext.runtime.id) return false;++ const url = String(sender.url || '');++ return url.indexOf(ext.runtime.getURL('')) === 0;++}++++const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });++++ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {++ router.handle(message, sender).then(++ function (answer) { sendResponse(answer || { ok: false }); },++ function () { sendResponse({ ok: false, message: 'Something went wrong. Try again later.' }); }++ );++ return true;++});@@@ -32,6 +32,9 @@// origins the side panel may reach besides the API origin.NETWORK_ORIGIN: 'https://openvibe.network',CHAT_ORIGIN: 'https://openvibe.chat',++ // MAIL_ORIGIN is OpenVibe.Mail's API, where the person's masks live (GET/POST /api/v1/masks). The++ // Companion asks it for one mask per site, and never for anything else.++ MAIL_ORIGIN: 'https://openvibe.email',// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the// four controls), and the still Codes serves when nothing is live.CODES_ORIGIN: 'https://openvibe.codes',@@@ -40,7 +43,7 @@// identity.getRedirectURL() is not one of these refuses to sign in rather than send an// unregistered redirect.OAUTH_CLIENT_ID: 'companion',-- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view']),++ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage']),OAUTH_REDIRECT_URIS: Object.freeze(['https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/','https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',@@@ -51,6 +54,12 @@DOCK_ACCOUNT_KEY: 'dockAccount',// Kept for the 0.7.0 pasted-token path until a later release removes it (see README).NETWORK_TOKEN_KEY: 'networkToken',++ // MAIL_SCOPE is the scope OpenVibe.Mail asks the Network for before it lets the Companion manage++ // the person's masks. Without it the Companion offers nothing and creates nothing.++ MAIL_SCOPE: 'mail.mask.manage',++ // MASK_MUTE_KEY is where the sites the person asked not to be offered a mask on are kept, on this++ // device only.++ MASK_MUTE_KEY: 'maskMutes',MAX_TRIES: 10,CONNECT_PATH: '/connect-extension',TOKEN_KEY: 'installToken',@@@ -89,6 +89,11 @@}},++ /** The tab this popup is about: its id (the mask button is put in it) and its address. */++ tabInfo() {++ return { id: tab.id, url: tab.url, title: tab.title };++ },++/** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board; a delivery app offers the comparison. */page() {const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));nnew file mode 100644@@@ -0,0 +1,89 @@++/*++ * The OpenVibe.Mail client the Companion uses: the person's masks, with their own sign-in as a++ * Bearer token, and never a cookie.++ *++ * const mail = createMailClient({ origin, fetch, getToken });++ * await mail.masks() GET /api/v1/masks → { masks }++ * await mail.createMask(label, purpose) POST /api/v1/masks → { mask }++ * await mail.disableMask(id) POST /api/v1/masks/:id/disable → { mask }++ *++ * What it sends: a label (the site's name), a purpose, a mask id, and the person's Bearer token.++ * Never a cookie (credentials: 'omit'), never a Referer, never a page URL.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMail = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ class MailError extends Error {++ constructor(status, code, detail) {++ super(detail || code || ('HTTP ' + status));++ this.name = 'MailError';++ this.status = status;++ this.code = code || null;++ }++ }++++ function createMailClient(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) {++ const token = await getToken();++ const headers = { Accept: 'application/json' };++ if (token) headers.Authorization = 'Bearer ' + token;++ 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',++ signal: ctrl ? ctrl.signal : undefined,++ });++ } catch (err) {++ throw new MailError(0, err && err.name === 'AbortError' ? 'network.timeout' : 'network.error', 'could not reach OpenVibe.Mail');++ } finally {++ if (timer) clearTimeout(timer);++ }++ let data = null;++ try { data = await res.json(); } catch (e) { data = null; }++ if (!res.ok) throw new MailError(res.status, data && data.code, data && (data.detail || data.error));++ return data;++ }++++ return {++ origin: origin,++ /** The person's masks. */++ async masks() {++ const out = await request('GET', '/api/v1/masks');++ return (out && out.masks) || [];++ },++ /** A mask for the person, labelled with the site. */++ async createMask(label, purpose) {++ const body = { label: String(label == null ? '' : label).slice(0, 120) };++ if (purpose) body.purpose = String(purpose).slice(0, 120);++ const out = await request('POST', '/api/v1/masks', body);++ return out && out.mask;++ },++ /** Turn a mask off: it bounces from then on. */++ async disableMask(id) {++ const out = await request('POST', '/api/v1/masks/' + encodeURIComponent(String(id || '')) + '/disable');++ return out && out.mask;++ },++ };++ }++++ return { createMailClient, MailError };++});nnew file mode 100644@@@ -0,0 +1,205 @@++/*++ * The mask button in an email field: the Companion's own "Use a mask for <site>" control.++ *++ * It runs in the extension's isolated world (the Companion injects it into that one tab), so the++ * page can neither read it nor reach the extension through it: the only way into the extension is the++ * click on this button, and the only thing the page ever sees is the address that went into the field++ * and the words that go with it. The person's token never comes here.++ *++ * It draws nothing until the person focuses an email field on a sign-up, sign-in or newsletter form.++ * It asks the worker whether a mask may be offered here (the worker decides: the person may have++ * turned this site off, and it knows whether the person is signed in) and fills the field when the++ * worker answers with an address.++ *++ * The button is drawn in a layer over the field, never around it: the page's own markup is left++ * exactly as it was, so a form keeps the layout it had. The layer's words live in a closed shadow++ * root, so the page cannot read them either.++ *++ * The only extension API it uses is chrome.runtime.sendMessage: it reads no stored data, makes no++ * request of its own, and never learns the site from the page (the worker takes the site from the++ * tab's own address).++ */++(function (root, factory) {++ const api = factory(root);++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMaskFill = api;++})(typeof self !== 'undefined' ? self : this, function (root) {++ 'use strict';++++ const LAYER_CLASS = 'ov-mask-layer';++ const BUTTON_CLASS = 'ov-mask-button';++ const NOTE_CLASS = 'ov-mask-note';++++ /** Ask the extension's worker; the page never sees anything but the answer. */++ function ask(message) {++ return new Promise(function (resolve) {++ try {++ const runtime = (typeof chrome !== 'undefined' && chrome.runtime) || (typeof browser !== 'undefined' && browser.runtime);++ if (!runtime || !runtime.sendMessage) return resolve(null);++ runtime.sendMessage(message, function (answer) { resolve(answer || null); });++ } catch (e) {++ resolve(null);++ }++ });++ }++++ /** What the form around the field is: a sign-up, a sign-in, a newsletter, or nothing we offer on. */++ function contextOf(field) {++ const form = (field.form || (field.closest && field.closest('form'))) || null;++ const scope = form || document;++ let passwordCount = 0;++ const passwords = scope.querySelectorAll ? scope.querySelectorAll('input[type=password]') : [];++ for (let i = 0; i < passwords.length; i++) passwordCount++;++ let submitText = '';++ const submits = scope.querySelectorAll ? scope.querySelectorAll('button[type=submit], input[type=submit], button') : [];++ for (let i = 0; i < submits.length; i++) {++ const t = String(submits[i].textContent || submits[i].value || '').replace(/\s+/g, ' ').trim();++ if (t) { submitText = t.slice(0, 60); break; }++ }++ const text = String(scope.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 600);++ return { passwordCount: passwordCount, texts: text, submitText: submitText };++ }++++ /** The field the person focused, if it is one we may offer a mask on. */++ function candidate(target) {++ const r = root.OVMask;++ if (!r || !target || target.tagName !== 'INPUT') return null;++ const decision = r.offerFor(target, contextOf(target));++ if (!decision || !decision.offer) return null;++ return decision;++ }++++ /** The layer over the field: a fixed box on top of it, with the button's words in a closed shadow root. */++ function layerFor(field) {++ const layer = document.createElement('div');++ layer.className = LAYER_CLASS;++ layer.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483000;pointer-events:none;';++ const shadow = layer.attachShadow({ mode: 'closed' });++ layer.__ovShadow = shadow;++ document.body.appendChild(layer);++ place(layer, field);++ return layer;++ }++++ /** Put the layer over the field, and keep it there while the page scrolls. */++ function place(layer, field) {++ if (!layer || !field || !field.getBoundingClientRect) return;++ const r = field.getBoundingClientRect();++ layer.style.left = r.left + 'px';++ layer.style.top = r.top + 'px';++ layer.style.width = r.width + 'px';++ layer.style.height = r.height + 'px';++ }++++ function remove(layer) {++ if (!layer) return;++ if (layer.parentNode) layer.parentNode.removeChild(layer);++ }++++ /** Set the field's value so React, Vue and plain pages all see it. */++ function setValue(input, value) {++ const proto = Object.getPrototypeOf(input);++ const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');++ if (desc && desc.set) desc.set.call(input, value); else input.value = value;++ input.dispatchEvent(new Event('input', { bubbles: true }));++ input.dispatchEvent(new Event('change', { bubbles: true }));++ }++++ function button(layer, field, label) {++ const shadow = layer.__ovShadow;++ const old = shadow.querySelector('.' + BUTTON_CLASS);++ if (old && old.parentNode) old.parentNode.removeChild(old);++ const btn = document.createElement('button');++ btn.type = 'button';++ btn.className = BUTTON_CLASS;++ btn.textContent = label;++ btn.style.cssText = 'position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:auto;'++ + 'padding:2px 9px;border-radius:999px;border:1px solid #1d4ed8;background:#eff6ff;color:#1d4ed8;'++ + 'font:600 11px/1.7 system-ui,-apple-system,sans-serif;cursor:pointer;white-space:nowrap;';++ btn.addEventListener('mousedown', function (event) { event.preventDefault(); });++ btn.addEventListener('click', function (event) {++ // Only a real press by the person counts: a script on the page cannot make the extension act.++ if (!event.isTrusted) return;++ event.preventDefault();++ event.stopPropagation();++ btn.disabled = true;++ ask({ type: 'mask:fill' }).then(function (answer) {++ btn.disabled = false;++ if (answer && answer.address) {++ setValue(field, answer.address);++ note(layer, answer.message || '');++ } else if (answer && answer.signedOut) {++ note(layer, answer.message || 'Sign in to use masks.');++ } else {++ note(layer, (answer && answer.message) || 'OpenVibe.Mail could not be reached.');++ }++ });++ });++ shadow.appendChild(btn);++ }++++ /** The words under the field: what the person is told about the address that went in. */++ function note(layer, text) {++ const shadow = layer.__ovShadow;++ const old = shadow.querySelector('.' + NOTE_CLASS);++ if (old && old.parentNode) old.parentNode.removeChild(old);++ const el = document.createElement('div');++ el.className = NOTE_CLASS;++ el.setAttribute('role', 'status');++ el.textContent = text;++ el.style.cssText = 'position:absolute;top:100%;left:0;max-width:320px;margin-top:5px;pointer-events:auto;'++ + 'padding:5px 9px;border-radius:7px;border:1px solid #d6dce6;background:#ffffff;color:#1d2433;'++ + 'font:12px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 2px 8px rgba(15,21,34,.18);';++ shadow.appendChild(el);++ }++++ /** Put the button in the field the person focused, when the worker says a mask may be offered here. */++ async function offer(field, layer) {++ const answer = await ask({ type: 'mask:offer' });++ if (!answer || !answer.offer) { remove(layer); return false; }++ button(layer, field, answer.label || '');++ return true;++ }++++ function install() {++ if (root.__ovMaskFillInstalled) return { installed: false, already: true };++ root.__ovMaskFillInstalled = true;++ let layer = null;++ let field = null;++++ const drop = function () {++ remove(layer);++ layer = null;++ field = null;++ };++++ document.addEventListener('focusin', function (event) {++ const target = event.target;++ if (!candidate(target)) return;++ if (layer) drop();++ field = target;++ layer = layerFor(field);++ offer(field, layer);++ }, true);++++ document.addEventListener('focusout', function (event) {++ // The press on the button itself is not the end of the offer.++ window.setTimeout(function () {++ const active = document.activeElement;++ if (active && layer && layer.contains && layer.contains(active)) return;++ if (event.target !== field) return;++ drop();++ }, 0);++ }, true);++++ window.addEventListener('scroll', function () { if (layer && field) place(layer, field); }, true);++ window.addEventListener('resize', function () { if (layer && field) place(layer, field); });++++ return { installed: true };++ }++++ // In a page (the isolated world) the script starts by itself; in Node (the tests) it does not.++ if (typeof module !== 'object' && typeof document !== 'undefined' && document.addEventListener) install();++++ return { install, contextOf, candidate, setValue, offer, place, LAYER_CLASS, BUTTON_CLASS, NOTE_CLASS };++});nnew file mode 100644@@@ -0,0 +1,74 @@++/*++ * The popup's masks section: the person's masks for the site of the tab they are on, with Copy and++ * Turn off, and the per-site "don't offer here" switch.++ *++ * It is DOM only (no innerHTML: every value is set as text) and it knows nothing about the++ * browser: the caller hands it the answer and the two actions, so the same code draws the section++ * for the popup and for the local screenshot page.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMaskPanel = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ 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 === 'checked') node.checked = !!props[k];++ else if (k === 'onclick') node.addEventListener('click', props[k]);++ else if (k === 'onchange') node.addEventListener('change', 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;++ }++++ /**++ * Draw the section.++ * data.site the site's name (the hostname without its www.)++ * data.signedOut true when the person is not signed in (or has not given the scope)++ * data.muted true when the person asked not to be offered a mask on this site++ * data.masks [{ id, address, label, enabled }]++ * onCopy(address), onDisable(mask), onMute(muted)++ */++ function render(container, data, actions) {++ const d = data || {};++ const act = actions || {};++ while (container.firstChild) container.removeChild(container.firstChild);++++ container.appendChild(el('h2', { text: 'Masks for ' + (d.site || 'this site') }));++++ if (d.signedOut) {++ container.appendChild(el('p', { className: 'muted', text: 'Sign in to use masks: open the Companion and press Sign in.' }));++ return;++ }++++ if (!d.masks || !d.masks.length) {++ container.appendChild(el('p', { className: 'muted', text: 'No mask for this site yet. The button in an email field on a sign-up form makes one.' }));++ } else {++ for (const m of d.masks) {++ const copy = el('button', { type: 'button', text: 'Copy', onclick: function () { if (act.onCopy) act.onCopy(m.address, copy); } });++ const off = el('button', { type: 'button', text: 'Turn off', onclick: function () { if (act.onDisable) act.onDisable(m); } });++ container.appendChild(el('article', { className: 'coupon' },++ el('div', { className: 'row' }, el('span', { className: 'code', text: m.address }), copy, off),++ el('p', { className: 'muted', text: m.enabled === false ? 'Turned off: mail to this address bounces.' : 'On: mail to this address reaches your inbox.' })));++ }++ }++++ const mute = el('input', { type: 'checkbox', id: 'masks-mute' });++ mute.checked = !!d.muted;++ mute.addEventListener('change', function () { if (act.onMute) act.onMute(mute.checked); });++ container.appendChild(el('p', { className: 'note' }, mute, ' Don\u2019t offer masks on this site'));++ }++++ return { render };++});nnew file mode 100644@@@ -0,0 +1,68 @@++/*++ * Who may ask the Companion for what.++ *++ * This is the message boundary, kept apart from the browser so it can be read and tested on its own++ * (test/mask.test.js). The background worker (background.js) hands it the sender of every message;++ * it answers with what that sender is allowed to have.++ *++ * a page (a message with a tab behind it) may ask for an offer and for an address, and nothing++ * else. The site is the tab's own address, never the++ * page's word for it.++ * one of our own pages (the popup) may ask for the masks of a site, turn one off, and++ * set the per-site switch.++ * anything else is refused.++ *++ * The page never learns anything but an address and the words that go with it: the person's token++ * stays in the worker.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMaskRouter = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ function createRouter(opts) {++ const rules = opts.rules;++ const service = opts.service;++ // isOwnPage(sender): is this one of the Companion's own pages (the popup)?++ const isOwnPage = opts.isOwnPage || function () { return false; };++++ /** The site of the page that is talking to us: the tab's own address, not the page's word. */++ function siteOf(sender) {++ const url = (sender && (sender.url || (sender.tab && sender.tab.url))) || '';++ let host = '';++ try { host = new URL(url).hostname; } catch (e) { host = ''; }++ return rules.siteLabel(host);++ }++++ async function handle(message, sender) {++ const m = message || {};++ // A page may ask for an offer and for an address. Nothing else.++ if (sender && sender.tab) {++ const site = siteOf(sender);++ if (m.type === 'mask:offer') return service.offer(site);++ if (m.type === 'mask:fill') return service.fill(site);++ return { ok: false };++ }++ // Our own pages may list, turn off and mute.++ if (isOwnPage(sender)) {++ const site = rules.siteLabel(m.site);++ if (m.type === 'mask:list') {++ const masks = await service.masks(site);++ const muted = (await service.mutedSites()).indexOf(site) !== -1;++ return { site: site, signedOut: !!masks.signedOut, masks: masks.masks || [], muted: muted };++ }++ if (m.type === 'mask:disable') return service.disable(m.id);++ if (m.type === 'mask:mute') return service.mute(site);++ if (m.type === 'mask:unmute') return service.unmute(site);++ return { ok: false };++ }++ return { ok: false };++ }++++ return { handle, siteOf };++ }++++ return { createRouter };++});nnew file mode 100644@@@ -0,0 +1,120 @@++/*++ * The Companion's mask service: when a mask may be offered on a site, which address that site++ * gets, and what the person is told.++ *++ * It has no DOM and no browser API of its own: it is given the person's sign-in, the OpenVibe.Mail++ * client and the place the muted sites are kept, and it answers questions. The background worker++ * (background.js) wires it to the browser; the tests (test/mask.test.js) wire it to fakes.++ *++ * The rules it follows:++ *++ * - a mask is never offered on an openvibe.* site: the person is already ours there++ * - a site the person muted ("don't offer here") is never offered one either++ * - no mask is created until the person presses the button: this service creates one only when++ * it is asked to fill a field++ * - the site that already has a mask keeps it: a second mask for the same site is never made++ * - the page is told an address and the words that go with it, never the person's token++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMaskService = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ const MUTED_MESSAGE = 'You turned masks off for this site.';++ const SIGNOUT_MESSAGE = 'Sign in to use masks: open the Companion and sign in.';++ const PURPOSE = 'companion';++++ function createMaskService(opts) {++ const rules = opts.rules;++ const auth = opts.auth;++ const mail = opts.mail;++ const storage = opts.storage;++ const scope = opts.scope;++ const muteKey = opts.muteKey;++++ /** The sites the person asked not to be offered a mask on. */++ async function mutedSites() {++ const raw = await storage.getLocal(muteKey);++ return Array.isArray(raw) ? raw : [];++ }++++ /** Signed in, and holding the scope OpenVibe.Mail asks for. */++ async function signedIn() {++ let status = null;++ try { status = await auth.status(); } catch (e) { status = null; }++ if (!status || status.mode === 'none') return false;++ const scopes = await auth.scopes();++ if (Array.isArray(scopes) && scopes.indexOf(scope) === -1) return false;++ return true;++ }++++ /** May a mask be offered on this site, and what does the button say? */++ async function offer(site) {++ const label = rules.siteLabel(site);++ if (!label) return { offer: false, reason: 'no_site' };++ if (rules.isOpenVibeSite(label)) return { offer: false, reason: 'openvibe_site' };++ if ((await mutedSites()).indexOf(label) !== -1) return { offer: false, reason: 'muted' };++ if (!(await signedIn())) return { offer: true, label: rules.SIGNED_OUT_LABEL, signedOut: true, site: label };++ return { offer: true, label: rules.OFFER_LABEL + label, signedOut: false, site: label };++ }++++ /** The address this site's field gets: the mask it already has, or a new one labelled with it. */++ async function fill(site) {++ const label = rules.siteLabel(site);++ if (!label) return { message: 'This page has no site name.' };++ if (rules.isOpenVibeSite(label)) return { message: 'This is an OpenVibe site.' };++ if ((await mutedSites()).indexOf(label) !== -1) return { message: MUTED_MESSAGE };++ if (!(await signedIn())) return { signedOut: true, label: rules.SIGNED_OUT_LABEL, message: SIGNOUT_MESSAGE };++ const masks = await mail.masks();++ const plan = rules.planFor(masks, label);++ const mask = plan.action === 'reuse' ? plan.mask : await mail.createMask(plan.label, PURPOSE);++ if (!mask || !mask.address) return { message: 'OpenVibe.Mail did not answer with an address.' };++ return { address: mask.address, message: rules.OFFER_MESSAGE, reused: plan.action === 'reuse', site: label };++ }++++ /** The person's masks for this site, newest first, whether they are on or off. */++ async function masks(site) {++ const label = rules.siteLabel(site);++ if (!label) return { signedOut: true, masks: [] };++ if (!(await signedIn())) return { signedOut: true, masks: [] };++ const all = await mail.masks();++ return {++ signedOut: false,++ masks: rules.masksForSite(all, label).map(function (m) {++ return { id: m.id, address: m.address, label: m.label, enabled: m.enabled !== false };++ }),++ };++ }++++ /** "Don't offer here": the site is remembered on this device only. */++ async function mute(site) {++ const label = rules.siteLabel(site);++ if (!label) return { ok: false };++ const list = await mutedSites();++ if (list.indexOf(label) === -1) list.push(label);++ await storage.setLocal({ [muteKey]: list });++ return { ok: true, muted: true, site: label };++ }++++ async function unmute(site) {++ const label = rules.siteLabel(site);++ if (!label) return { ok: false };++ const list = (await mutedSites()).filter(function (s) { return s !== label; });++ await storage.setLocal({ [muteKey]: list });++ return { ok: true, muted: false, site: label };++ }++++ /** Turn a mask off: the address stops resolving at once. */++ async function disable(id) {++ if (!id) return { ok: false };++ await mail.disableMask(id);++ return { ok: true };++ }++++ return { offer, fill, masks, mute, unmute, disable, signedIn, mutedSites };++ }++++ return { createMaskService, MUTED_MESSAGE, SIGNOUT_MESSAGE, PURPOSE };++});nnew file mode 100644@@@ -0,0 +1,129 @@++/*++ * The rules of the mask fill, with no DOM: which field is an email field on a form worth offering a++ * mask on, what a site's mask is called, which of the person's masks is that site's, and the words++ * the button and the note use.++ *++ * siteLabel('www.Acme-Shop.com') → 'acme-shop.com'++ * isOpenVibeSite('openvibe.network') → true++ * isEmailField(field) → true for an email input, never for a password field++ * classify({ passwordCount, texts }) → 'signup' | 'signin' | 'newsletter' | null++ * maskForSite(masks, 'acme-shop.com') → the person's mask for that site, or null++ *++ * The site label is the hostname without its leading "www.": the mask a person gets for a site is++ * the site's, and the same label finds it again the next time (lib/mail-api.js and background.js).++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVMask = api;++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ const OPENVIBE_RE = /(^|\.)openvibe\.[a-z]{2,}$/;++ const EMAIL_RE = /e-?mail/i;++ const SIGNUP_RE = /sign\s*up|create\s+(an\s+)?account|register|join\s+now|get\s+started/i;++ const SIGNIN_RE = /sign\s*in|log\s*in|login/i;++ const NEWSLETTER_RE = /subscribe|newsletter|mailing\s*list|weekly\s+digest/i;++++ // The words the button and the note use.++ const OFFER_LABEL = 'Use a mask for ';++ const SIGNED_OUT_LABEL = 'Sign in to use masks';++ const OFFER_MESSAGE = 'Mail to this address reaches your inbox; you can turn it off any time';++++ /** The site's name: the hostname without its leading "www.". */++ function siteLabel(hostname) {++ const host = String(hostname == null ? '' : hostname).trim().toLowerCase().replace(/\.$/, '');++ if (!host) return null;++ return host.replace(/^www\./, '');++ }++++ /** Our own sites are never offered a mask: the person is already ours there. */++ function isOpenVibeSite(hostname) {++ const host = String(hostname == null ? '' : hostname).trim().toLowerCase().replace(/\.$/, '');++ return OPENVIBE_RE.test(host);++ }++++ /** Everything that names the field: name, id, placeholder, aria-label, autocomplete and its <label>s. */++ function nameText(field) {++ if (!field) return '';++ const get = field.getAttribute ? function (k) { return field.getAttribute(k); } : function () { return null; };++ const bits = [field.name, field.id, field.placeholder, get('placeholder'), get('aria-label'), get('autocomplete')];++ if (field.labels) for (let i = 0; i < field.labels.length; i++) bits.push(field.labels[i] && field.labels[i].textContent);++ return bits.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();++ }++++ /**++ * An email field is an input of type email (or a text box that calls itself one). A password++ * field never is, whatever it is called.++ */++ function isEmailField(field) {++ if (!field || field.tagName !== 'INPUT') return false;++ const type = String(field.type || 'text').toLowerCase();++ if (type === 'password') return false;++ if (type === 'email') return true;++ if (type !== 'text' && type !== '') return false;++ return EMAIL_RE.test(nameText(field));++ }++++ /**++ * What the form around the field is: a sign-up, a sign-in or a newsletter (all three hand an++ * address to a site that does not need to know it), or nothing we should offer on.++ */++ function classify(ctx) {++ const c = ctx || {};++ const text = String(c.texts || '') + ' ' + String(c.submitText || '');++ if (NEWSLETTER_RE.test(text)) return 'newsletter';++ const passwords = Number(c.passwordCount || 0);++ if (passwords > 0) return SIGNIN_RE.test(text) && !SIGNUP_RE.test(text) ? 'signin' : 'signup';++ return SIGNUP_RE.test(text) ? 'signup' : null;++ }++++ /** The person's mask for that site: the enabled one labelled with the site, or null. */++ function maskForSite(masks, site) {++ const label = String(site == null ? '' : site).toLowerCase();++ const list = Array.isArray(masks) ? masks : [];++ for (const m of list) {++ if (!m || m.enabled === false) continue;++ if (String(m.label || '').toLowerCase() === label) return m;++ }++ return null;++ }++++ /** The masks of that site, newest first, whether they are on or off. */++ function masksForSite(masks, site) {++ const label = String(site == null ? '' : site).toLowerCase();++ const list = Array.isArray(masks) ? masks : [];++ return list.filter(function (m) { return m && String(m.label || '').toLowerCase() === label; });++ }++++ /**++ * What to do for this site: the mask the person already has for it (reuse it — never make a second++ * one for the same site), or a new one labelled with the site.++ * { action: 'reuse', mask }++ * { action: 'create', label: 'acme-shop.com' }++ */++ function planFor(masks, site) {++ const existing = maskForSite(masks, site);++ if (existing) return { action: 'reuse', mask: existing };++ return { action: 'create', label: String(site == null ? '' : site) };++ }++++ /**++ * Whether to offer a mask on this field, and why not when we do not.++ * { offer: true, kind: 'signup' | 'signin' | 'newsletter' }++ * { offer: false, reason: 'not_a_field' | 'password' | 'not_email' | 'other_form' }++ */++ function offerFor(field, ctx) {++ if (!field || field.tagName !== 'INPUT') return { offer: false, reason: 'not_a_field' };++ if (String(field.type || '').toLowerCase() === 'password') return { offer: false, reason: 'password' };++ if (!isEmailField(field)) return { offer: false, reason: 'not_email' };++ const kind = classify(ctx);++ if (!kind) return { offer: false, reason: 'other_form' };++ return { offer: true, kind: kind };++ }++++ return {++ siteLabel, isOpenVibeSite, isEmailField, nameText, classify, maskForSite, masksForSite, planFor, offerFor,++ OFFER_LABEL, SIGNED_OUT_LABEL, OFFER_MESSAGE, OPENVIBE_RE,++ };++});@@@ -18,6 +18,8 @@* that click) and run it with the codes; returns what it saw* scripting.readRating(tabId) when the person presses "Save its rating to OpenVibe Reviews" on a review* site: inject lib/rating.js the same way and return the rating the page states++ * scripting.installMaskFill(tabId) when the person opens the Companion on a tab: put the "Use a mask for <site>"++ * button in the email fields of that tab (activeTab grants it for that one tab)* scripting.readListing(tabId) when the person presses "Keep on my Rent board" or "Check the rent": inject* lib/listing.js the same way and return the price, city and bedrooms the page states*@@@ -68,6 +70,17 @@const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVRating.read(document); } }]);return out && out[0] ? out[0].result : null;},++ /**++ * Put the mask button in a tab: the rules and the page script, in the extension's own++ * isolated world. The page can neither read them nor reach the extension through them.++ * The person must have opened the Companion on that tab (activeTab), so this runs in++ * the tab they are looking at and nowhere else.++ */++ async installMaskFill(tabId) {++ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the mask button');++ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/mask.js', 'lib/mask-fill.js'] }]);++ return true;++ },async readListing(tabId) {if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot read the page');await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/listing.js'] }]);@@@ -4,7 +4,7 @@"short_name": "OpenVibe","version": "0.9.1","key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",-- "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",++ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; a mask in the email box of a sign-up form; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.","homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions","author": "OpenVibers","icons": {@@@ -29,6 +29,10 @@"default_panel": "panel.html","open_in_sidebar": true},++ "background": {++ "service_worker": "background.js",++ "type": "module"++ },"permissions": ["activeTab","identity",@@@ -42,7 +46,7 @@"https://openvibe.chat/*"],"content_security_policy": {-- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"++ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"},"minimum_chrome_version": "114","browser_specific_settings": {@@@ -10,6 +10,9 @@<h1>OpenVibe Companion</h1><p id="site" class="muted"></p></header>++<section id="masks" class="masks" aria-label="Masks for this site" hidden>++ <div id="masks-body"></div>++</section><section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe"><p id="actions-lede" class="lede"></p><div class="row">@@@ -33,6 +36,8 @@<script src="lib/format.js"></script><script src="lib/platform.js"></script><script src="lib/controller.js"></script>++<script src="lib/mask.js"></script>++<script src="lib/mask-panel.js"></script><script src="popup.js"></script></body></html>@@@ -13,6 +13,21 @@const fmt = self.OVFormat;const content = document.getElementById('content');const site = document.getElementById('site');++ const masksSection = document.getElementById('masks');++ const masksBody = document.getElementById('masks-body');++ const rules = self.OVMask;++ const panel = self.OVMaskPanel;++++ /** Ask the background worker (the only holder of the person's sign-in). */++ function askWorker(message) {++ return new Promise(function (resolve) {++ try {++ ext.runtime.sendMessage(message, function (answer) { resolve(answer || null); });++ } catch (e) {++ resolve(null);++ }++ });++ }function el(tag, attrs) {const node = document.createElement(tag);@@@ -159,10 +174,54 @@return el('section', { className: 'try', 'aria-label': 'Try the codes on this page' }, run, note, status, list, send);}++ /**++ * The masks for this site: the addresses the person already has for it, with Copy and Turn off, and++ * the per-site switch that stops the Companion offering a mask here.++ *++ * Opening the popup also puts the "Use a mask for <site>" button in the tab's email fields: the++ * page is touched only after the person opened the Companion on it (activeTab), and only in the++ * fields of a form worth offering a mask on.++ */++ async function renderMasks() {++ const info = controller.tabInfo();++ const label = rules.siteLabel(self.OVHost.hostnameOf(info.url));++ if (!label || rules.isOpenVibeSite(label)) return;++ masksSection.hidden = false;++ const data = await askWorker({ type: 'mask:list', site: label });++ if (!data || !data.site) { masksSection.hidden = true; return; }++ const draw = function () { panel.render(masksBody, data, actions); };++ const actions = {++ onCopy: function (address) {++ navigator.clipboard.writeText(address).then(++ function () { masksBody.querySelectorAll('button').forEach(function (b) { if (b.textContent === 'Copy') b.textContent = 'Copied'; }); },++ function () { /* the address stays on screen to copy by hand */ }++ );++ },++ onDisable: async function (mask) {++ await askWorker({ type: 'mask:disable', id: mask.id });++ const fresh = await askWorker({ type: 'mask:list', site: label });++ if (fresh && fresh.site) { data.masks = fresh.masks; data.muted = fresh.muted; }++ draw();++ },++ onMute: async function (muted) {++ await askWorker({ type: muted ? 'mask:mute' : 'mask:unmute', site: label });++ data.muted = muted;++ draw();++ },++ };++ draw();++ try {++ await platform.scripting.installMaskFill(info.id);++ } catch (e) {++ // The browser would not let us touch that tab: the popup still lists the masks.++ }++ }++async function render() {const vm = await controller.open();if (vm.host) site.textContent = vm.host;renderActions();++ renderMasks();if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a delivery app to compare with the others, a rental listing for your board, any page to watch.' }));if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));if (vm.state === 'error') return show(el('p', { text: vm.message }));@@@ -7,7 +7,7 @@"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 --ignore-files \"shared/**\"",++ "lint:firefox": "node scripts/lint-firefox.js","pack:dev": "node scripts/pack.js --api http://localhost:4850","icons": "node scripts/icons.js"},@@@ -1,28 +1,32 @@++#!/usr/bin/env node'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 + identity + scripting + storage, plus sidePanel in the Chrome build-- * (scripting runs lib/autoapply.js or lib/rating.js in the active tab only, when the-- * person presses "Try these codes on this page" or "Save its rating to OpenVibe-- * Reviews": activeTab is the only host access; Firefox's sidebar_action, the-- * side panel there, needs no permission of its own; identity starts the Dock's one-click-- * OAuth sign-in)++ * (scripting runs lib/autoapply.js, lib/rating.js, lib/listing.js or lib/mask-fill.js in the++ * active tab only, when the person presses a button of the Companion: activeTab is the only++ * host access; Firefox's sidebar_action, the side panel there, needs no permission of its own;++ * identity starts the Dock's one-click OAuth sign-in)* host_permissions exactly the API origin, the Network origin, the Chat origin and the Codes* origin, all https://openvibe.<tld> sites (the coupon API, the Dock's state,-- * the Dock's Chats tab, and the Dock's agent view)++ * the Dock's Chats tab, and the Dock's agent view). OpenVibe.Mail is not among them: the++ * Companion reaches it with CORS, the way the Coupons API lets a first-party extension in.++ * background the service worker that holds the person's sign-in and talks to OpenVibe.Mail, and++ * nothing else: one key, one file, one type* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)-- * forbidden keys content_scripts, background, web_accessible_resources,++ * forbidden keys content_scripts, web_accessible_resources,* externally_connectable, optional_permissions, optional_host_permissions,* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,* omnibox, … (anything not on the allow-list of keys; the side panel's own keys,* side_panel and sidebar_action, are on it)* CSP script-src 'self' only; style-src 'self' 'unsafe-inline' (the Dock draws its* own styles in a <style> element); img-src 'self' blob: (the agent view's-- * still is a local object URL); connect-src only the OpenVibe origins;-- * frame-src only Live's agent embed; no other unsafe-*, wildcard, data: or-- * remote http++ * still is a local object URL); connect-src only the OpenVibe origins (the four++ * the extension may reach with a host permission, and OpenVibe.Mail, which it++ * reaches with CORS); frame-src only Live's agent embed; no other unsafe-*, wildcard,++ * data: or remote http* pages popup/options/panel HTML load only local scripts, no inline script* config lib/config.js API_ORIGIN equals the host permission's origin* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the@@@ -33,14 +37,16 @@ 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',-- 'side_panel', 'sidebar_action', 'key']);++ 'side_panel', 'sidebar_action', 'key', 'background']);const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);++const BACKGROUND = { service_worker: 'background.js', type: 'module' };--function auditManifest(m, { apiOrigin, origins } = {}) {++function auditManifest(m, { apiOrigin, origins, connectOrigins } = {}) {const problems = [];const os = (origins && origins.length ? origins : [apiOrigin]).filter(Boolean);++ const cs = (connectOrigins && connectOrigins.length ? connectOrigins : os).filter(Boolean);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 : [];@@@ -56,6 +62,10 @@ function auditManifest(m, { apiOrigin, origins } = {}) {if (hosts.length !== want.length || hosts.slice().sort().join() !== want.join()) {problems.push(`host_permissions must be exactly ${want.join(', ')} (got ${hosts.join(', ') || 'none'})`);}++ if (!m.background || m.background.service_worker !== BACKGROUND.service_worker || m.background.type !== BACKGROUND.type++ || Object.keys(m.background).length !== Object.keys(BACKGROUND).length) {++ problems.push(`background must be exactly ${JSON.stringify(BACKGROUND)}`);++ }const panel = m.side_panel || m.sidebar_action;if (!panel) problems.push('the side panel needs side_panel (Chrome) or sidebar_action (Firefox)');else if ((m.side_panel && m.side_panel.default_path !== 'panel.html') || (m.sidebar_action && m.sidebar_action.default_panel !== 'panel.html')) {@@@ -68,7 +78,7 @@ function auditManifest(m, { apiOrigin, origins } = {}) {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'].slice().sort()) !== JSON.stringify(os.slice().sort())) problems.push(`CSP connect-src must be exactly ${os.join(' ')}`);++ if (JSON.stringify(dirs['connect-src'].slice().sort()) !== JSON.stringify(cs.slice().sort())) problems.push(`CSP connect-src must be exactly ${cs.join(' ')}`);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 (JSON.stringify(dirs['style-src']) !== JSON.stringify(["'self'", "'unsafe-inline'"])) problems.push("CSP style-src must be exactly 'self' 'unsafe-inline' (the Dock's own <style>)");@@@ -108,16 +118,18 @@ function auditDir(dir) {const network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];++ const mail = (configSrc.match(/MAIL_ORIGIN:\s*'([^']+)'/) || [])[1];const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);const problems = [];if (!origin) problems.push('lib/config.js has no API_ORIGIN');if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);const origins = [origin, network, chat, codes].filter(Boolean);-- for (const o of [network, chat, codes].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);-- problems.push(...auditManifest(manifest, { apiOrigin: origin, origins }));++ for (const o of [network, chat, codes, mail].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);++ const connectOrigins = origins.concat(mail ? [mail] : []);++ problems.push(...auditManifest(manifest, { apiOrigin: origin, origins, connectOrigins }));for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));-- return { manifest, origin, origins, problems };++ return { manifest, origin, origins, connectOrigins, problems };}--module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS };++module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS, BACKGROUND };nnew file mode 100644@@@ -0,0 +1,40 @@++#!/usr/bin/env node++'use strict';++/**++ * Lints the extension the way Firefox sees it.++ *++ * Firefox has no background service worker: it runs the background file as a background page. So the++ * Firefox manifest is the one the store build carries (`scripts/pack.js` forTarget), and this lint++ * runs over that manifest and not over the Chrome one in browser/.++ *++ * The extension's own tree is copied to a temporary directory, the Firefox manifest is written there,++ * and web-ext lint is run over it with warnings as errors.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++const { sourceFiles, forTarget } = require('./pack');++++const ROOT = path.join(__dirname, '..');++++function main() {++ const files = sourceFiles('https://openvibe.coupons');++ forTarget(files, 'firefox');++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ext-firefox-'));++ 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 cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';++ const run = spawnSync(cmd, ['--yes', '[email protected]', 'lint', '--source-dir', tmp, '--warnings-as-errors', '--ignore-files', 'shared/**'], { stdio: 'inherit', cwd: ROOT });++ process.exit(run.status == null ? 1 : run.status);++ } finally {++ fs.rmSync(tmp, { recursive: true, force: true });++ }++}++++if (require.main === module) main();++++module.exports = { main };@@@ -72,7 +72,7 @@ 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). */++/** The store manifest for one browser: drops the other browser's key and gives Firefox its background page (the audit runs on the full one). */function forTarget(files, target) {const m = JSON.parse(files.get('manifest.json'));if (target === 'chrome') {@@@ -86,6 +86,8 @@ function forTarget(files, target) {delete m.side_panel;delete m.key; // Chrome's public key is not a Firefox manifest fieldm.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');++ // Firefox has no background service worker: it runs the same file as a background page.++ m.background = { scripts: ['background.js'], type: 'module' };} else throw new Error('target must be chrome or firefox');files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));}@@@ -140,4 +142,4 @@ if (require.main === module) {}}--module.exports = { build, zip, crc32, forTarget };++module.exports = { build, zip, crc32, forTarget, sourceFiles };@@@ -65,7 +65,7 @@ function fetcher(responses) {assert.strictEqual(u.searchParams.get('client_id'), 'companion');assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);assert.strictEqual(u.searchParams.get('response_type'), 'code');-- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view');++ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage');assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');assert.match(state, /^[A-Za-z0-9_-]{43}$/);issuedChallenge = u.searchParams.get('code_challenge');@@@ -22,9 +22,12 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');-- for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {++ for (const k of ['content_scripts', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {assert.ok(!(k in manifest), `${k} must not be present`);}++ // The background worker is the one place that holds the person's sign-in and talks to++ // OpenVibe.Mail: one key, one file, one type (scripts/audit.js fails on any other shape).++ assert.deepStrictEqual(manifest.background, { service_worker: 'background.js', type: 'module' });});await check('the public key fixes the Chrome id and the gecko id fixes the Firefox redirect', async () => {@@@ -90,7 +93,9 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https: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 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);++ // OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS,++ // the way the Coupons API lets a first-party extension in) but it is not a host permission.++ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);assert.match(csp, /style-src 'self' 'unsafe-inline';/);assert.match(csp, /img-src 'self' blob:;/);assert.match(csp, /frame-src https:\/\/openvibe\.live;/);@@@ -109,23 +114,31 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:// browser/shared/ is the released OpenVibe.Shared Dock, bundled as it is: it is audited in its own// repository (OpenVibe.Shared), not here.(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'shared') walk(p); } else if (p.endsWith('.js')) files.push(p); } })(DIR);++ // The background worker and the page script that draws the mask button are the only files that++ // talk to the extension's own messaging (background.js answers the page, mask-fill.js asks the++ // worker). Nothing else may message, and no page may message the extension from outside.const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,-- /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];++ /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /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 rel0 = path.relative(DIR, f);++ if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js')].includes(rel0)) {++ assert.doesNotMatch(src, /sendMessage/, `${rel0} messages the extension`);++ }const rel = path.relative(DIR, f);if (!['lib/api.js', 'lib/network-api.js', 'lib/auth.js'].map((p) => path.join(...p.split('/'))).includes(rel)) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);}const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');-- // The page is touched only by "Try these codes on this page", "Save its rating to OpenVibe Reviews" and the two Rent-- // buttons ("Keep on my Rent board", "Check the rent"): platform.js injects the packaged file for that button and calls it.-- const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));++ // The page is touched only by "Try these codes on this page", "Save its rating to OpenVibe Reviews", the two Rent++ // buttons ("Keep on my Rent board", "Check the rent") and the mask button: platform.js injects the packaged file for that button and calls it.++ const code = (f) => fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');++ const injects = files.filter((f) => /executeScript|ext\.scripting/.test(code(f))).map((f) => path.relative(DIR, f));assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');-- assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/listing.js'"], 'the three files it injects');++ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/mask.js', 'lib/mask-fill.js'", "'lib/listing.js'"], 'the files it injects');assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');// What runs in the page makes no request and reads no stored data.for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {@@@ -134,6 +147,20 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:assert.doesNotMatch(src, re, `lib/${file} uses ${re}`);}}++ // The mask button is the one script that runs in a page and talks to the extension: it asks the++ // background worker (chrome.runtime.sendMessage) and nothing else. It never fetches, never++ // reads stored data, and never learns anything but an address and the words that go with it.++ const fillSrc = fs.readFileSync(path.join(DIR, 'lib', 'mask-fill.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');++ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/]) {++ assert.doesNotMatch(fillSrc, re, `lib/mask-fill.js uses ${re}`);++ }++ const fillApis = [...new Set([...fillSrc.matchAll(/\b(?:chrome|browser)\.[a-zA-Z.]+/g)].map((m) => m[0]))];++ for (const api of fillApis) assert.match(api, /^(?:chrome|browser)\.runtime$/, `lib/mask-fill.js uses ${api}`);++ assert.match(fillSrc, /runtime\.sendMessage\(message,/, 'the page script asks the worker and nothing else');++ assert.doesNotMatch(fillSrc, /onMessageExternal/, 'the page cannot listen to the extension');++ // The worker answers only the two messages a page may ask, and only a page (a tab behind it).++ const workerSrc = fs.readFileSync(path.join(DIR, 'background.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');++ assert.doesNotMatch(workerSrc, /externally_connectable|onMessageExternal/, 'the worker listens to our own pages only');// Reading a rating or a listing presses nothing and types nothing.for (const file of ['rating.js', 'listing.js']) {assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', file), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, `lib/${file} only reads`);nnew file mode 100644@@@ -0,0 +1,265 @@++'use strict';++/**++ * The mask fill: which field is worth offering a mask on, which mask that site's field gets, and++ * who may ask the Companion for what.++ *++ * The rules are DOM-free (browser/lib/mask.js), so a field is described here the way the browser++ * describes one: its tag, its type, its name and what it is called. The service (lib/mask-service.js)++ * is given a fake sign-in, a fake OpenVibe.Mail and a fake place to keep the muted sites.++ */++const assert = require('assert');++const rules = require('../browser/lib/mask');++const { createMaskService } = require('../browser/lib/mask-service');++const { createRouter } = require('../browser/lib/mask-router');++const { check, done } = require('./helpers/harness');++++/** A field as the browser hands it over. */++function field(props) {++ const p = props || {};++ return {++ tagName: 'INPUT',++ type: p.type || 'text',++ name: p.name || '',++ id: p.id || '',++ placeholder: p.placeholder || '',++ labels: p.labels || [],++ getAttribute: function (k) { return Object.prototype.hasOwnProperty.call(p, k) ? p[k] : null; },++ };++}++++const SIGNUP = { passwordCount: 1, texts: 'Create an account with your email and a password', submitText: 'Create account' };++const SIGNIN = { passwordCount: 1, texts: 'Sign in to your account', submitText: 'Sign in' };++const NEWSLETTER = { passwordCount: 0, texts: 'Subscribe to our newsletter for the weekly digest', submitText: 'Subscribe' };++const OTHER = { passwordCount: 0, texts: 'Search the catalogue of tents and sleeping bags', submitText: 'Search' };++++/** A fake OpenVibe.Mail that records what it was asked. */++function fakeMail(masks) {++ const calls = { masks: 0, created: [], disabled: [] };++ let list = (masks || []).slice();++ return {++ calls: calls,++ async masks() { calls.masks++; return list.slice(); },++ async createMask(label, purpose) {++ calls.created.push({ label: label, purpose: purpose });++ const mask = { id: 'msk_' + (calls.created.length + 1), address: label.split('.').join('-') + '@mask.openvibe.email', label: label, enabled: true };++ list.push(mask);++ return mask;++ },++ async disableMask(id) { calls.disabled.push(id); const m = list.find(function (x) { return x.id === id; }); if (m) m.enabled = false; return m; },++ };++}++++/** A fake sign-in: signed in with the scope, or not. */++function fakeAuth(signedIn, scopes) {++ return {++ async status() { return signedIn ? { mode: 'network' } : { mode: 'none' }; },++ async scopes() { return scopes || (signedIn ? ['dock', 'mail.mask.manage'] : []); },++ async accessToken() { return signedIn ? 'tok' : null; },++ };++}++++function fakeStorage() {++ const store = {};++ return {++ store: store,++ async getLocal(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },++ async setLocal(v) { Object.keys(v).forEach(function (k) { store[k] = v[k]; }); },++ };++}++++function service(opts) {++ const o = opts || {};++ const mail = o.mail || fakeMail(o.masks);++ const storage = o.storage || fakeStorage();++ const svc = createMaskService({++ rules: rules,++ auth: o.auth || fakeAuth(o.signedIn !== false),++ mail: mail,++ storage: storage,++ scope: 'mail.mask.manage',++ muteKey: 'maskMutes',++ });++ return { svc: svc, mail: mail, storage: storage };++}++++(async () => {++ await check('the site is the hostname without its www.', async () => {++ assert.strictEqual(rules.siteLabel('www.Acme-Shop.com'), 'acme-shop.com');++ assert.strictEqual(rules.siteLabel('acme-shop.com'), 'acme-shop.com');++ assert.strictEqual(rules.siteLabel('shop.acme-shop.com'), 'shop.acme-shop.com');++ assert.strictEqual(rules.siteLabel(''), null);++ assert.strictEqual(rules.siteLabel(null), null);++ });++++ await check('our own sites are never offered a mask', async () => {++ for (const h of ['openvibe.network', 'openvibe.coupons', 'www.openvibe.email', 'mail.openvibe.chat']) assert.ok(rules.isOpenVibeSite(h), h);++ for (const h of ['acme-shop.com', 'notopenvibe.network', 'openvibe.network.example.com']) assert.ok(!rules.isOpenVibeSite(h), h);++ });++++ await check('an email field is an email or a text box that calls itself one', async () => {++ assert.ok(rules.isEmailField(field({ type: 'email' })));++ assert.ok(rules.isEmailField(field({ type: 'text', name: 'email' })));++ assert.ok(rules.isEmailField(field({ type: 'text', id: 'user_email' })));++ assert.ok(rules.isEmailField(field({ type: 'text', placeholder: 'Your e-mail address' })));++ assert.ok(rules.isEmailField(field({ type: 'text', labels: [{ textContent: 'Email address' }] })));++ assert.ok(!rules.isEmailField(field({ type: 'text', name: 'search' })));++ assert.ok(!rules.isEmailField(field({ type: 'tel' })));++ });++++ await check('a password field is never an email field, whatever it is called', async () => {++ assert.ok(!rules.isEmailField(field({ type: 'password', name: 'email' })));++ assert.ok(!rules.isEmailField(field({ type: 'password', id: 'email' })));++ assert.ok(!rules.isEmailField(field({ type: 'password', placeholder: 'email' })));++ const d = rules.offerFor(field({ type: 'password', name: 'email' }), SIGNIN);++ assert.deepStrictEqual(d, { offer: false, reason: 'password' });++ });++++ await check('a sign-up form is offered a mask, and so is a sign-in and a newsletter', async () => {++ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), SIGNUP), { offer: true, kind: 'signup' });++ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), SIGNIN), { offer: true, kind: 'signin' });++ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), NEWSLETTER), { offer: true, kind: 'newsletter' });++ });++++ await check('a form that asks for nothing we can help with is left alone', async () => {++ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), OTHER), { offer: false, reason: 'other_form' });++ assert.deepStrictEqual(rules.offerFor(field({ type: 'text', name: 'search' }), SIGNUP), { offer: false, reason: 'not_email' });++ assert.deepStrictEqual(rules.offerFor({ tagName: 'DIV' }, SIGNUP), { offer: false, reason: 'not_a_field' });++ });++++ await check('the site that already has a mask keeps it; a new site gets a new one labelled with the site', async () => {++ const masks = [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }];++ const plan = rules.planFor(masks, 'acme-shop.com');++ assert.strictEqual(plan.action, 'reuse');++ assert.strictEqual(plan.mask.id, 'msk_1');++ const fresh = rules.planFor(masks, 'other-shop.com');++ assert.strictEqual(fresh.action, 'create');++ assert.strictEqual(fresh.label, 'other-shop.com');++ // A mask that was turned off is not the site's mask: the site gets a fresh one.++ const off = rules.planFor([{ id: 'msk_2', address: 'x@y', label: 'acme-shop.com', enabled: false }], 'acme-shop.com');++ assert.strictEqual(off.action, 'create');++ });++++ await check('filling a field reuses that site\'s mask and never makes a second one', async () => {++ const { svc, mail } = service({ masks: [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }] });++ const out = await svc.fill('www.acme-shop.com');++ assert.strictEqual(out.address, '[email protected]');++ assert.strictEqual(out.reused, true);++ assert.strictEqual(out.message, rules.OFFER_MESSAGE);++ assert.strictEqual(mail.calls.created.length, 0, 'no mask was made');++ });++++ await check('a site without a mask gets one, labelled with the site', async () => {++ const { svc, mail } = service({ masks: [] });++ const out = await svc.fill('acme-shop.com');++ assert.strictEqual(out.reused, false);++ assert.strictEqual(out.message, rules.OFFER_MESSAGE);++ assert.deepStrictEqual(mail.calls.created, [{ label: 'acme-shop.com', purpose: 'companion' }]);++ });++++ await check('offering a mask never creates one', async () => {++ const { svc, mail } = service({ masks: [] });++ const out = await svc.offer('acme-shop.com');++ assert.strictEqual(out.offer, true);++ assert.strictEqual(out.label, 'Use a mask for acme-shop.com');++ assert.strictEqual(mail.calls.created.length, 0);++ assert.strictEqual(mail.calls.masks, 0, 'offering asks OpenVibe.Mail for nothing');++ });++++ await check('signed out, the button asks the person to sign in and nothing is created', async () => {++ const { svc, mail } = service({ signedIn: false });++ const out = await svc.offer('acme-shop.com');++ assert.strictEqual(out.offer, true);++ assert.strictEqual(out.signedOut, true);++ assert.strictEqual(out.label, rules.SIGNED_OUT_LABEL);++ const fill = await svc.fill('acme-shop.com');++ assert.strictEqual(fill.signedOut, true);++ assert.strictEqual(fill.address, undefined);++ assert.strictEqual(mail.calls.created.length, 0);++ });++++ await check('signed in without the mask scope, the button asks the person to sign in again', async () => {++ const { svc } = service({ auth: fakeAuth(true, ['dock', 'activity']) });++ const out = await svc.offer('acme-shop.com');++ assert.strictEqual(out.signedOut, true);++ assert.strictEqual(out.label, rules.SIGNED_OUT_LABEL);++ });++++ await check('a muted site is never offered a mask', async () => {++ const { svc, mail } = service({ masks: [] });++ await svc.mute('acme-shop.com');++ const out = await svc.offer('acme-shop.com');++ assert.deepStrictEqual(out, { offer: false, reason: 'muted' });++ const fill = await svc.fill('acme-shop.com');++ assert.strictEqual(fill.address, undefined);++ assert.strictEqual(mail.calls.created.length, 0);++ await svc.unmute('acme-shop.com');++ assert.strictEqual((await svc.offer('acme-shop.com')).offer, true);++ });++++ await check('an OpenVibe site is never offered a mask', async () => {++ const { svc } = service({ masks: [] });++ assert.deepStrictEqual(await svc.offer('openvibe.network'), { offer: false, reason: 'openvibe_site' });++ });++++ await check('a page may ask for an offer and for an address, and for nothing else', async () => {++ const { svc, mail } = service({ masks: [] });++ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });++ const page = { tab: { id: 7, url: 'https://www.acme-shop.com/sign-up' }, url: 'https://www.acme-shop.com/sign-up' };++ const offer = await router.handle({ type: 'mask:offer' }, page);++ assert.strictEqual(offer.offer, true);++ assert.strictEqual(offer.label, 'Use a mask for acme-shop.com');++ const fill = await router.handle({ type: 'mask:fill' }, page);++ assert.strictEqual(fill.address, '[email protected]');++ assert.strictEqual(mail.calls.created.length, 1);++ // The list of masks, turning one off and the per-site switch are not the page's to ask for.++ for (const type of ['mask:list', 'mask:disable', 'mask:mute', 'mask:unmute']) {++ assert.deepStrictEqual(await router.handle({ type: type, site: 'acme-shop.com', id: 'msk_1' }, page), { ok: false }, type);++ }++ });++++ await check('the site is the tab\'s own address, never the page\'s word for it', async () => {++ const { svc } = service({ masks: [] });++ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });++ const page = { tab: { id: 7, url: 'https://www.acme-shop.com/sign-up' }, url: 'https://www.acme-shop.com/sign-up' };++ const out = await router.handle({ type: 'mask:offer', site: 'some-other-site.com' }, page);++ assert.strictEqual(out.label, 'Use a mask for acme-shop.com');++ });++++ await check('a stranger (no tab, not one of our pages) is refused', async () => {++ const { svc } = service({ masks: [] });++ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });++ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, { id: 'other' }), { ok: false });++ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, {}), { ok: false });++ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, null), { ok: false });++ });++++ await check('the popup may list the masks of a site, turn one off and set the per-site switch', async () => {++ const { svc, mail } = service({ masks: [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }] });++ const router = createRouter({ rules: rules, service: svc, isOwnPage: function (sender) { return sender && sender.own === true; } });++ const popup = { own: true };++ const list = await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup);++ assert.strictEqual(list.site, 'acme-shop.com');++ assert.strictEqual(list.signedOut, false);++ assert.deepStrictEqual(list.masks.map(function (m) { return m.address; }), ['[email protected]']);++ assert.strictEqual(list.muted, false);++ const muted = await router.handle({ type: 'mask:mute', site: 'acme-shop.com' }, popup);++ assert.strictEqual(muted.muted, true);++ assert.strictEqual((await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup)).muted, true);++ await router.handle({ type: 'mask:unmute', site: 'acme-shop.com' }, popup);++ assert.strictEqual((await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup)).muted, false);++ await router.handle({ type: 'mask:disable', id: 'msk_1' }, popup);++ assert.deepStrictEqual(mail.calls.disabled, ['msk_1']);++ });++++ await check('the popup is told what it may show, and nothing more', async () => {++ const { svc } = service({ signedIn: false });++ const router = createRouter({ rules: rules, service: svc, isOwnPage: function (sender) { return sender && sender.own === true; } });++ const list = await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, { own: true });++ assert.strictEqual(list.signedOut, true);++ assert.deepStrictEqual(list.masks, []);++ assert.ok(!('token' in list));++ assert.ok(!JSON.stringify(list).includes('tok'));++ });++++ done();++})();@@@ -45,7 +45,9 @@ function unzip(buf) {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', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'] }), []);++ // OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS) but it is++ // not one of the four host permissions.++ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email'] }), []);});await check('the build is reproducible', async () => {@@@ -60,7 +62,7 @@ function unzip(buf) {const files = unzip(fs.readFileSync(r.out));const m = JSON.parse(files.get('manifest.json'));assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);-- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);++ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);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\//);@@@ -84,6 +86,8 @@ function unzip(buf) {delete want.side_panel;delete want.key;want.permissions = want.permissions.filter((p) => p !== 'sidePanel');++ // Firefox has no background service worker: it runs the same file as a background page.++ want.background = { scripts: ['background.js'], type: 'module' };}assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+/*
+ * The Companion's background worker: the one place that answers the page.
+ *
+ * The button the page draws (browser/lib/mask-fill.js) asks this worker two things: may a mask be
+ * offered here, and which address should this field get. The worker holds the person's sign-in, talks
+ * to OpenVibe.Mail and never puts the token into the page: the page is told an address and the words
+ * that go with it, nothing else.
+ *
+ * The message boundary lives in lib/mask-router.js (so it can be tested on its own): a page may ask
+ * only for an offer and for an address, and the site is taken from the tab's own address, never from
+ * the page's word. An address only ever comes after a real click on the button: the page script checks
+ * that itself, and a script on the page cannot forge a click.
+ */
+import './lib/config.js';
+import './lib/platform.js';
+import './lib/auth.js';
+import './lib/mask.js';
+import './lib/mask-service.js';
+import './lib/mail-api.js';
+import './lib/mask-router.js';
+
+const ext = self.browser || self.chrome;
+const config = self.OVConfig;
+const platform = self.OVPlatform.createPlatform(ext, config);
+const auth = self.OVAuth.createAuth({ platform: platform, config: config, fetch: self.fetch.bind(self) });
+const mail = self.OVMail.createMailClient({
+ origin: config.MAIL_ORIGIN,
+ fetch: self.fetch.bind(self),
+ getToken: function () { return auth.accessToken(); },
+ timeoutMs: config.TIMEOUT_MS,
+});
+const service = self.OVMaskService.createMaskService({
+ rules: self.OVMask,
+ auth: auth,
+ mail: mail,
+ storage: platform.storage,
+ scope: config.MAIL_SCOPE,
+ muteKey: config.MASK_MUTE_KEY,
+});
+
+/** One of our own pages (the popup), not a page of the web. */
+function isOwnPage(sender) {
+ if (!sender || !ext.runtime || !ext.runtime.id) return false;
+ if (sender.id && sender.id !== ext.runtime.id) return false;
+ const url = String(sender.url || '');
+ return url.indexOf(ext.runtime.getURL('')) === 0;
+}
+
+const router = self.OVMaskRouter.createRouter({ rules: self.OVMask, service: service, isOwnPage: isOwnPage });
+
+ext.runtime.onMessage.addListener(function (message, sender, sendResponse) {
+ router.handle(message, sender).then(
+ function (answer) { sendResponse(answer || { ok: false }); },
+ function () { sendResponse({ ok: false, message: 'Something went wrong. Try again later.' }); }
+ );
+ return true;
+});
@@ -32,6 +32,9 @@
@@ -32,6 +32,9 @@
// origins the side panel may reach besides the API origin.
// origins the side panel may reach besides the API origin.
NETWORK_ORIGIN: 'https://openvibe.network',
NETWORK_ORIGIN: 'https://openvibe.network',
CHAT_ORIGIN: 'https://openvibe.chat',
CHAT_ORIGIN: 'https://openvibe.chat',
+ // MAIL_ORIGIN is OpenVibe.Mail's API, where the person's masks live (GET/POST /api/v1/masks). The
+ // Companion asks it for one mask per site, and never for anything else.
+ MAIL_ORIGIN: 'https://openvibe.email',
// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the
// CODES_ORIGIN is OpenVibe.Codes' API: the Dock's agent view (GET /api/v1/me/agent-view and the
// four controls), and the still Codes serves when nothing is live.
// four controls), and the still Codes serves when nothing is live.
CODES_ORIGIN: 'https://openvibe.codes',
CODES_ORIGIN: 'https://openvibe.codes',
@@ -40,7 +43,7 @@
@@ -40,7 +43,7 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
OAUTH_CLIENT_ID: 'companion',
- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view']),
+ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view', 'mail.mask.manage']),
OAUTH_REDIRECT_URIS: Object.freeze([
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
@@ -51,6 +54,12 @@
@@ -51,6 +54,12 @@
DOCK_ACCOUNT_KEY: 'dockAccount',
DOCK_ACCOUNT_KEY: 'dockAccount',
// Kept for the 0.7.0 pasted-token path until a later release removes it (see README).
// Kept for the 0.7.0 pasted-token path until a later release removes it (see README).
NETWORK_TOKEN_KEY: 'networkToken',
NETWORK_TOKEN_KEY: 'networkToken',
+ // MAIL_SCOPE is the scope OpenVibe.Mail asks the Network for before it lets the Companion manage
+ // the person's masks. Without it the Companion offers nothing and creates nothing.
+ MAIL_SCOPE: 'mail.mask.manage',
+ // MASK_MUTE_KEY is where the sites the person asked not to be offered a mask on are kept, on this
+ // device only.
+ MASK_MUTE_KEY: 'maskMutes',
MAX_TRIES: 10,
MAX_TRIES: 10,
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
TOKEN_KEY: 'installToken',
TOKEN_KEY: 'installToken',
@@ -89,6 +89,11 @@
@@ -89,6 +89,11 @@
}
}
},
},
+ /** The tab this popup is about: its id (the mask button is put in it) and its address. */
+ tabInfo() {
+ return { id: tab.id, url: tab.url, title: tab.title };
+ },
+
/** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board; a delivery app offers the comparison. */
/** Which hand-offs make sense for this page: any web page can go to the board or a watch; a rental site leads with the board; a delivery app offers the comparison. */
page() {
page() {
const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));
const ok = Boolean(handoff && host.hostnameOf(tab.url) && handoff.boardUrl(tab.url, tab.title));
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+/*
+ * The OpenVibe.Mail client the Companion uses: the person's masks, with their own sign-in as a
+ * Bearer token, and never a cookie.
+ *
+ * const mail = createMailClient({ origin, fetch, getToken });
+ * await mail.masks() GET /api/v1/masks → { masks }
+ * await mail.createMask(label, purpose) POST /api/v1/masks → { mask }
+ * await mail.disableMask(id) POST /api/v1/masks/:id/disable → { mask }
+ *
+ * What it sends: a label (the site's name), a purpose, a mask id, and the person's Bearer token.
+ * Never a cookie (credentials: 'omit'), never a Referer, never a page URL.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMail = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ class MailError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code || ('HTTP ' + status));
+ this.name = 'MailError';
+ this.status = status;
+ this.code = code || null;
+ }
+ }
+
+ function createMailClient(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) {
+ const token = await getToken();
+ const headers = { Accept: 'application/json' };
+ if (token) headers.Authorization = 'Bearer ' + token;
+ 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',
+ signal: ctrl ? ctrl.signal : undefined,
+ });
+ } catch (err) {
+ throw new MailError(0, err && err.name === 'AbortError' ? 'network.timeout' : 'network.error', 'could not reach OpenVibe.Mail');
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ let data = null;
+ try { data = await res.json(); } catch (e) { data = null; }
+ if (!res.ok) throw new MailError(res.status, data && data.code, data && (data.detail || data.error));
+ return data;
+ }
+
+ return {
+ origin: origin,
+ /** The person's masks. */
+ async masks() {
+ const out = await request('GET', '/api/v1/masks');
+ return (out && out.masks) || [];
+ },
+ /** A mask for the person, labelled with the site. */
+ async createMask(label, purpose) {
+ const body = { label: String(label == null ? '' : label).slice(0, 120) };
+ if (purpose) body.purpose = String(purpose).slice(0, 120);
+ const out = await request('POST', '/api/v1/masks', body);
+ return out && out.mask;
+ },
+ /** Turn a mask off: it bounces from then on. */
+ async disableMask(id) {
+ const out = await request('POST', '/api/v1/masks/' + encodeURIComponent(String(id || '')) + '/disable');
+ return out && out.mask;
+ },
+ };
+ }
+
+ return { createMailClient, MailError };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -0,0 +1,205 @@
+/*
+ * The mask button in an email field: the Companion's own "Use a mask for <site>" control.
+ *
+ * It runs in the extension's isolated world (the Companion injects it into that one tab), so the
+ * page can neither read it nor reach the extension through it: the only way into the extension is the
+ * click on this button, and the only thing the page ever sees is the address that went into the field
+ * and the words that go with it. The person's token never comes here.
+ *
+ * It draws nothing until the person focuses an email field on a sign-up, sign-in or newsletter form.
+ * It asks the worker whether a mask may be offered here (the worker decides: the person may have
+ * turned this site off, and it knows whether the person is signed in) and fills the field when the
+ * worker answers with an address.
+ *
+ * The button is drawn in a layer over the field, never around it: the page's own markup is left
+ * exactly as it was, so a form keeps the layout it had. The layer's words live in a closed shadow
+ * root, so the page cannot read them either.
+ *
+ * The only extension API it uses is chrome.runtime.sendMessage: it reads no stored data, makes no
+ * request of its own, and never learns the site from the page (the worker takes the site from the
+ * tab's own address).
+ */
+(function (root, factory) {
+ const api = factory(root);
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMaskFill = api;
+})(typeof self !== 'undefined' ? self : this, function (root) {
+ 'use strict';
+
+ const LAYER_CLASS = 'ov-mask-layer';
+ const BUTTON_CLASS = 'ov-mask-button';
+ const NOTE_CLASS = 'ov-mask-note';
+
+ /** Ask the extension's worker; the page never sees anything but the answer. */
+ function ask(message) {
+ return new Promise(function (resolve) {
+ try {
+ const runtime = (typeof chrome !== 'undefined' && chrome.runtime) || (typeof browser !== 'undefined' && browser.runtime);
+ if (!runtime || !runtime.sendMessage) return resolve(null);
+ runtime.sendMessage(message, function (answer) { resolve(answer || null); });
+ } catch (e) {
+ resolve(null);
+ }
+ });
+ }
+
+ /** What the form around the field is: a sign-up, a sign-in, a newsletter, or nothing we offer on. */
+ function contextOf(field) {
+ const form = (field.form || (field.closest && field.closest('form'))) || null;
+ const scope = form || document;
+ let passwordCount = 0;
+ const passwords = scope.querySelectorAll ? scope.querySelectorAll('input[type=password]') : [];
+ for (let i = 0; i < passwords.length; i++) passwordCount++;
+ let submitText = '';
+ const submits = scope.querySelectorAll ? scope.querySelectorAll('button[type=submit], input[type=submit], button') : [];
+ for (let i = 0; i < submits.length; i++) {
+ const t = String(submits[i].textContent || submits[i].value || '').replace(/\s+/g, ' ').trim();
+ if (t) { submitText = t.slice(0, 60); break; }
+ }
+ const text = String(scope.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 600);
+ return { passwordCount: passwordCount, texts: text, submitText: submitText };
+ }
+
+ /** The field the person focused, if it is one we may offer a mask on. */
+ function candidate(target) {
+ const r = root.OVMask;
+ if (!r || !target || target.tagName !== 'INPUT') return null;
+ const decision = r.offerFor(target, contextOf(target));
+ if (!decision || !decision.offer) return null;
+ return decision;
+ }
+
+ /** The layer over the field: a fixed box on top of it, with the button's words in a closed shadow root. */
+ function layerFor(field) {
+ const layer = document.createElement('div');
+ layer.className = LAYER_CLASS;
+ layer.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483000;pointer-events:none;';
+ const shadow = layer.attachShadow({ mode: 'closed' });
+ layer.__ovShadow = shadow;
+ document.body.appendChild(layer);
+ place(layer, field);
+ return layer;
+ }
+
+ /** Put the layer over the field, and keep it there while the page scrolls. */
+ function place(layer, field) {
+ if (!layer || !field || !field.getBoundingClientRect) return;
+ const r = field.getBoundingClientRect();
+ layer.style.left = r.left + 'px';
+ layer.style.top = r.top + 'px';
+ layer.style.width = r.width + 'px';
+ layer.style.height = r.height + 'px';
+ }
+
+ function remove(layer) {
+ if (!layer) return;
+ if (layer.parentNode) layer.parentNode.removeChild(layer);
+ }
+
+ /** Set the field's value so React, Vue and plain pages all see it. */
+ function setValue(input, value) {
+ const proto = Object.getPrototypeOf(input);
+ const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');
+ if (desc && desc.set) desc.set.call(input, value); else input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ function button(layer, field, label) {
+ const shadow = layer.__ovShadow;
+ const old = shadow.querySelector('.' + BUTTON_CLASS);
+ if (old && old.parentNode) old.parentNode.removeChild(old);
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = BUTTON_CLASS;
+ btn.textContent = label;
+ btn.style.cssText = 'position:absolute;right:6px;top:50%;transform:translateY(-50%);pointer-events:auto;'
+ + 'padding:2px 9px;border-radius:999px;border:1px solid #1d4ed8;background:#eff6ff;color:#1d4ed8;'
+ + 'font:600 11px/1.7 system-ui,-apple-system,sans-serif;cursor:pointer;white-space:nowrap;';
+ btn.addEventListener('mousedown', function (event) { event.preventDefault(); });
+ btn.addEventListener('click', function (event) {
+ // Only a real press by the person counts: a script on the page cannot make the extension act.
+ if (!event.isTrusted) return;
+ event.preventDefault();
+ event.stopPropagation();
+ btn.disabled = true;
+ ask({ type: 'mask:fill' }).then(function (answer) {
+ btn.disabled = false;
+ if (answer && answer.address) {
+ setValue(field, answer.address);
+ note(layer, answer.message || '');
+ } else if (answer && answer.signedOut) {
+ note(layer, answer.message || 'Sign in to use masks.');
+ } else {
+ note(layer, (answer && answer.message) || 'OpenVibe.Mail could not be reached.');
+ }
+ });
+ });
+ shadow.appendChild(btn);
+ }
+
+ /** The words under the field: what the person is told about the address that went in. */
+ function note(layer, text) {
+ const shadow = layer.__ovShadow;
+ const old = shadow.querySelector('.' + NOTE_CLASS);
+ if (old && old.parentNode) old.parentNode.removeChild(old);
+ const el = document.createElement('div');
+ el.className = NOTE_CLASS;
+ el.setAttribute('role', 'status');
+ el.textContent = text;
+ el.style.cssText = 'position:absolute;top:100%;left:0;max-width:320px;margin-top:5px;pointer-events:auto;'
+ + 'padding:5px 9px;border-radius:7px;border:1px solid #d6dce6;background:#ffffff;color:#1d2433;'
+ + 'font:12px/1.4 system-ui,-apple-system,sans-serif;box-shadow:0 2px 8px rgba(15,21,34,.18);';
+ shadow.appendChild(el);
+ }
+
+ /** Put the button in the field the person focused, when the worker says a mask may be offered here. */
+ async function offer(field, layer) {
+ const answer = await ask({ type: 'mask:offer' });
+ if (!answer || !answer.offer) { remove(layer); return false; }
+ button(layer, field, answer.label || '');
+ return true;
+ }
+
+ function install() {
+ if (root.__ovMaskFillInstalled) return { installed: false, already: true };
+ root.__ovMaskFillInstalled = true;
+ let layer = null;
+ let field = null;
+
+ const drop = function () {
+ remove(layer);
+ layer = null;
+ field = null;
+ };
+
+ document.addEventListener('focusin', function (event) {
+ const target = event.target;
+ if (!candidate(target)) return;
+ if (layer) drop();
+ field = target;
+ layer = layerFor(field);
+ offer(field, layer);
+ }, true);
+
+ document.addEventListener('focusout', function (event) {
+ // The press on the button itself is not the end of the offer.
+ window.setTimeout(function () {
+ const active = document.activeElement;
+ if (active && layer && layer.contains && layer.contains(active)) return;
+ if (event.target !== field) return;
+ drop();
+ }, 0);
+ }, true);
+
+ window.addEventListener('scroll', function () { if (layer && field) place(layer, field); }, true);
+ window.addEventListener('resize', function () { if (layer && field) place(layer, field); });
+
+ return { installed: true };
+ }
+
+ // In a page (the isolated world) the script starts by itself; in Node (the tests) it does not.
+ if (typeof module !== 'object' && typeof document !== 'undefined' && document.addEventListener) install();
+
+ return { install, contextOf, candidate, setValue, offer, place, LAYER_CLASS, BUTTON_CLASS, NOTE_CLASS };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+/*
+ * The popup's masks section: the person's masks for the site of the tab they are on, with Copy and
+ * Turn off, and the per-site "don't offer here" switch.
+ *
+ * It is DOM only (no innerHTML: every value is set as text) and it knows nothing about the
+ * browser: the caller hands it the answer and the two actions, so the same code draws the section
+ * for the popup and for the local screenshot page.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMaskPanel = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ 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 === 'checked') node.checked = !!props[k];
+ else if (k === 'onclick') node.addEventListener('click', props[k]);
+ else if (k === 'onchange') node.addEventListener('change', 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;
+ }
+
+ /**
+ * Draw the section.
+ * data.site the site's name (the hostname without its www.)
+ * data.signedOut true when the person is not signed in (or has not given the scope)
+ * data.muted true when the person asked not to be offered a mask on this site
+ * data.masks [{ id, address, label, enabled }]
+ * onCopy(address), onDisable(mask), onMute(muted)
+ */
+ function render(container, data, actions) {
+ const d = data || {};
+ const act = actions || {};
+ while (container.firstChild) container.removeChild(container.firstChild);
+
+ container.appendChild(el('h2', { text: 'Masks for ' + (d.site || 'this site') }));
+
+ if (d.signedOut) {
+ container.appendChild(el('p', { className: 'muted', text: 'Sign in to use masks: open the Companion and press Sign in.' }));
+ return;
+ }
+
+ if (!d.masks || !d.masks.length) {
+ container.appendChild(el('p', { className: 'muted', text: 'No mask for this site yet. The button in an email field on a sign-up form makes one.' }));
+ } else {
+ for (const m of d.masks) {
+ const copy = el('button', { type: 'button', text: 'Copy', onclick: function () { if (act.onCopy) act.onCopy(m.address, copy); } });
+ const off = el('button', { type: 'button', text: 'Turn off', onclick: function () { if (act.onDisable) act.onDisable(m); } });
+ container.appendChild(el('article', { className: 'coupon' },
+ el('div', { className: 'row' }, el('span', { className: 'code', text: m.address }), copy, off),
+ el('p', { className: 'muted', text: m.enabled === false ? 'Turned off: mail to this address bounces.' : 'On: mail to this address reaches your inbox.' })));
+ }
+ }
+
+ const mute = el('input', { type: 'checkbox', id: 'masks-mute' });
+ mute.checked = !!d.muted;
+ mute.addEventListener('change', function () { if (act.onMute) act.onMute(mute.checked); });
+ container.appendChild(el('p', { className: 'note' }, mute, ' Don\u2019t offer masks on this site'));
+ }
+
+ return { render };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+/*
+ * Who may ask the Companion for what.
+ *
+ * This is the message boundary, kept apart from the browser so it can be read and tested on its own
+ * (test/mask.test.js). The background worker (background.js) hands it the sender of every message;
+ * it answers with what that sender is allowed to have.
+ *
+ * a page (a message with a tab behind it) may ask for an offer and for an address, and nothing
+ * else. The site is the tab's own address, never the
+ * page's word for it.
+ * one of our own pages (the popup) may ask for the masks of a site, turn one off, and
+ * set the per-site switch.
+ * anything else is refused.
+ *
+ * The page never learns anything but an address and the words that go with it: the person's token
+ * stays in the worker.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMaskRouter = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ function createRouter(opts) {
+ const rules = opts.rules;
+ const service = opts.service;
+ // isOwnPage(sender): is this one of the Companion's own pages (the popup)?
+ const isOwnPage = opts.isOwnPage || function () { return false; };
+
+ /** The site of the page that is talking to us: the tab's own address, not the page's word. */
+ function siteOf(sender) {
+ const url = (sender && (sender.url || (sender.tab && sender.tab.url))) || '';
+ let host = '';
+ try { host = new URL(url).hostname; } catch (e) { host = ''; }
+ return rules.siteLabel(host);
+ }
+
+ async function handle(message, sender) {
+ const m = message || {};
+ // A page may ask for an offer and for an address. Nothing else.
+ if (sender && sender.tab) {
+ const site = siteOf(sender);
+ if (m.type === 'mask:offer') return service.offer(site);
+ if (m.type === 'mask:fill') return service.fill(site);
+ return { ok: false };
+ }
+ // Our own pages may list, turn off and mute.
+ if (isOwnPage(sender)) {
+ const site = rules.siteLabel(m.site);
+ if (m.type === 'mask:list') {
+ const masks = await service.masks(site);
+ const muted = (await service.mutedSites()).indexOf(site) !== -1;
+ return { site: site, signedOut: !!masks.signedOut, masks: masks.masks || [], muted: muted };
+ }
+ if (m.type === 'mask:disable') return service.disable(m.id);
+ if (m.type === 'mask:mute') return service.mute(site);
+ if (m.type === 'mask:unmute') return service.unmute(site);
+ return { ok: false };
+ }
+ return { ok: false };
+ }
+
+ return { handle, siteOf };
+ }
+
+ return { createRouter };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+/*
+ * The Companion's mask service: when a mask may be offered on a site, which address that site
+ * gets, and what the person is told.
+ *
+ * It has no DOM and no browser API of its own: it is given the person's sign-in, the OpenVibe.Mail
+ * client and the place the muted sites are kept, and it answers questions. The background worker
+ * (background.js) wires it to the browser; the tests (test/mask.test.js) wire it to fakes.
+ *
+ * The rules it follows:
+ *
+ * - a mask is never offered on an openvibe.* site: the person is already ours there
+ * - a site the person muted ("don't offer here") is never offered one either
+ * - no mask is created until the person presses the button: this service creates one only when
+ * it is asked to fill a field
+ * - the site that already has a mask keeps it: a second mask for the same site is never made
+ * - the page is told an address and the words that go with it, never the person's token
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMaskService = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ const MUTED_MESSAGE = 'You turned masks off for this site.';
+ const SIGNOUT_MESSAGE = 'Sign in to use masks: open the Companion and sign in.';
+ const PURPOSE = 'companion';
+
+ function createMaskService(opts) {
+ const rules = opts.rules;
+ const auth = opts.auth;
+ const mail = opts.mail;
+ const storage = opts.storage;
+ const scope = opts.scope;
+ const muteKey = opts.muteKey;
+
+ /** The sites the person asked not to be offered a mask on. */
+ async function mutedSites() {
+ const raw = await storage.getLocal(muteKey);
+ return Array.isArray(raw) ? raw : [];
+ }
+
+ /** Signed in, and holding the scope OpenVibe.Mail asks for. */
+ async function signedIn() {
+ let status = null;
+ try { status = await auth.status(); } catch (e) { status = null; }
+ if (!status || status.mode === 'none') return false;
+ const scopes = await auth.scopes();
+ if (Array.isArray(scopes) && scopes.indexOf(scope) === -1) return false;
+ return true;
+ }
+
+ /** May a mask be offered on this site, and what does the button say? */
+ async function offer(site) {
+ const label = rules.siteLabel(site);
+ if (!label) return { offer: false, reason: 'no_site' };
+ if (rules.isOpenVibeSite(label)) return { offer: false, reason: 'openvibe_site' };
+ if ((await mutedSites()).indexOf(label) !== -1) return { offer: false, reason: 'muted' };
+ if (!(await signedIn())) return { offer: true, label: rules.SIGNED_OUT_LABEL, signedOut: true, site: label };
+ return { offer: true, label: rules.OFFER_LABEL + label, signedOut: false, site: label };
+ }
+
+ /** The address this site's field gets: the mask it already has, or a new one labelled with it. */
+ async function fill(site) {
+ const label = rules.siteLabel(site);
+ if (!label) return { message: 'This page has no site name.' };
+ if (rules.isOpenVibeSite(label)) return { message: 'This is an OpenVibe site.' };
+ if ((await mutedSites()).indexOf(label) !== -1) return { message: MUTED_MESSAGE };
+ if (!(await signedIn())) return { signedOut: true, label: rules.SIGNED_OUT_LABEL, message: SIGNOUT_MESSAGE };
+ const masks = await mail.masks();
+ const plan = rules.planFor(masks, label);
+ const mask = plan.action === 'reuse' ? plan.mask : await mail.createMask(plan.label, PURPOSE);
+ if (!mask || !mask.address) return { message: 'OpenVibe.Mail did not answer with an address.' };
+ return { address: mask.address, message: rules.OFFER_MESSAGE, reused: plan.action === 'reuse', site: label };
+ }
+
+ /** The person's masks for this site, newest first, whether they are on or off. */
+ async function masks(site) {
+ const label = rules.siteLabel(site);
+ if (!label) return { signedOut: true, masks: [] };
+ if (!(await signedIn())) return { signedOut: true, masks: [] };
+ const all = await mail.masks();
+ return {
+ signedOut: false,
+ masks: rules.masksForSite(all, label).map(function (m) {
+ return { id: m.id, address: m.address, label: m.label, enabled: m.enabled !== false };
+ }),
+ };
+ }
+
+ /** "Don't offer here": the site is remembered on this device only. */
+ async function mute(site) {
+ const label = rules.siteLabel(site);
+ if (!label) return { ok: false };
+ const list = await mutedSites();
+ if (list.indexOf(label) === -1) list.push(label);
+ await storage.setLocal({ [muteKey]: list });
+ return { ok: true, muted: true, site: label };
+ }
+
+ async function unmute(site) {
+ const label = rules.siteLabel(site);
+ if (!label) return { ok: false };
+ const list = (await mutedSites()).filter(function (s) { return s !== label; });
+ await storage.setLocal({ [muteKey]: list });
+ return { ok: true, muted: false, site: label };
+ }
+
+ /** Turn a mask off: the address stops resolving at once. */
+ async function disable(id) {
+ if (!id) return { ok: false };
+ await mail.disableMask(id);
+ return { ok: true };
+ }
+
+ return { offer, fill, masks, mute, unmute, disable, signedIn, mutedSites };
+ }
+
+ return { createMaskService, MUTED_MESSAGE, SIGNOUT_MESSAGE, PURPOSE };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+/*
+ * The rules of the mask fill, with no DOM: which field is an email field on a form worth offering a
+ * mask on, what a site's mask is called, which of the person's masks is that site's, and the words
+ * the button and the note use.
+ *
+ * siteLabel('www.Acme-Shop.com') → 'acme-shop.com'
+ * isOpenVibeSite('openvibe.network') → true
+ * isEmailField(field) → true for an email input, never for a password field
+ * classify({ passwordCount, texts }) → 'signup' | 'signin' | 'newsletter' | null
+ * maskForSite(masks, 'acme-shop.com') → the person's mask for that site, or null
+ *
+ * The site label is the hostname without its leading "www.": the mask a person gets for a site is
+ * the site's, and the same label finds it again the next time (lib/mail-api.js and background.js).
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVMask = api;
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ const OPENVIBE_RE = /(^|\.)openvibe\.[a-z]{2,}$/;
+ const EMAIL_RE = /e-?mail/i;
+ const SIGNUP_RE = /sign\s*up|create\s+(an\s+)?account|register|join\s+now|get\s+started/i;
+ const SIGNIN_RE = /sign\s*in|log\s*in|login/i;
+ const NEWSLETTER_RE = /subscribe|newsletter|mailing\s*list|weekly\s+digest/i;
+
+ // The words the button and the note use.
+ const OFFER_LABEL = 'Use a mask for ';
+ const SIGNED_OUT_LABEL = 'Sign in to use masks';
+ const OFFER_MESSAGE = 'Mail to this address reaches your inbox; you can turn it off any time';
+
+ /** The site's name: the hostname without its leading "www.". */
+ function siteLabel(hostname) {
+ const host = String(hostname == null ? '' : hostname).trim().toLowerCase().replace(/\.$/, '');
+ if (!host) return null;
+ return host.replace(/^www\./, '');
+ }
+
+ /** Our own sites are never offered a mask: the person is already ours there. */
+ function isOpenVibeSite(hostname) {
+ const host = String(hostname == null ? '' : hostname).trim().toLowerCase().replace(/\.$/, '');
+ return OPENVIBE_RE.test(host);
+ }
+
+ /** Everything that names the field: name, id, placeholder, aria-label, autocomplete and its <label>s. */
+ function nameText(field) {
+ if (!field) return '';
+ const get = field.getAttribute ? function (k) { return field.getAttribute(k); } : function () { return null; };
+ const bits = [field.name, field.id, field.placeholder, get('placeholder'), get('aria-label'), get('autocomplete')];
+ if (field.labels) for (let i = 0; i < field.labels.length; i++) bits.push(field.labels[i] && field.labels[i].textContent);
+ return bits.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
+ }
+
+ /**
+ * An email field is an input of type email (or a text box that calls itself one). A password
+ * field never is, whatever it is called.
+ */
+ function isEmailField(field) {
+ if (!field || field.tagName !== 'INPUT') return false;
+ const type = String(field.type || 'text').toLowerCase();
+ if (type === 'password') return false;
+ if (type === 'email') return true;
+ if (type !== 'text' && type !== '') return false;
+ return EMAIL_RE.test(nameText(field));
+ }
+
+ /**
+ * What the form around the field is: a sign-up, a sign-in or a newsletter (all three hand an
+ * address to a site that does not need to know it), or nothing we should offer on.
+ */
+ function classify(ctx) {
+ const c = ctx || {};
+ const text = String(c.texts || '') + ' ' + String(c.submitText || '');
+ if (NEWSLETTER_RE.test(text)) return 'newsletter';
+ const passwords = Number(c.passwordCount || 0);
+ if (passwords > 0) return SIGNIN_RE.test(text) && !SIGNUP_RE.test(text) ? 'signin' : 'signup';
+ return SIGNUP_RE.test(text) ? 'signup' : null;
+ }
+
+ /** The person's mask for that site: the enabled one labelled with the site, or null. */
+ function maskForSite(masks, site) {
+ const label = String(site == null ? '' : site).toLowerCase();
+ const list = Array.isArray(masks) ? masks : [];
+ for (const m of list) {
+ if (!m || m.enabled === false) continue;
+ if (String(m.label || '').toLowerCase() === label) return m;
+ }
+ return null;
+ }
+
+ /** The masks of that site, newest first, whether they are on or off. */
+ function masksForSite(masks, site) {
+ const label = String(site == null ? '' : site).toLowerCase();
+ const list = Array.isArray(masks) ? masks : [];
+ return list.filter(function (m) { return m && String(m.label || '').toLowerCase() === label; });
+ }
+
+ /**
+ * What to do for this site: the mask the person already has for it (reuse it — never make a second
+ * one for the same site), or a new one labelled with the site.
+ * { action: 'reuse', mask }
+ * { action: 'create', label: 'acme-shop.com' }
+ */
+ function planFor(masks, site) {
+ const existing = maskForSite(masks, site);
+ if (existing) return { action: 'reuse', mask: existing };
+ return { action: 'create', label: String(site == null ? '' : site) };
+ }
+
+ /**
+ * Whether to offer a mask on this field, and why not when we do not.
+ * { offer: true, kind: 'signup' | 'signin' | 'newsletter' }
+ * { offer: false, reason: 'not_a_field' | 'password' | 'not_email' | 'other_form' }
+ */
+ function offerFor(field, ctx) {
+ if (!field || field.tagName !== 'INPUT') return { offer: false, reason: 'not_a_field' };
+ if (String(field.type || '').toLowerCase() === 'password') return { offer: false, reason: 'password' };
+ if (!isEmailField(field)) return { offer: false, reason: 'not_email' };
+ const kind = classify(ctx);
+ if (!kind) return { offer: false, reason: 'other_form' };
+ return { offer: true, kind: kind };
+ }
+
+ return {
+ siteLabel, isOpenVibeSite, isEmailField, nameText, classify, maskForSite, masksForSite, planFor, offerFor,
+ OFFER_LABEL, SIGNED_OUT_LABEL, OFFER_MESSAGE, OPENVIBE_RE,
+ };
+});
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
* that click) and run it with the codes; returns what it saw
* that click) and run it with the codes; returns what it saw
* scripting.readRating(tabId) when the person presses "Save its rating to OpenVibe Reviews" on a review
* scripting.readRating(tabId) when the person presses "Save its rating to OpenVibe Reviews" on a review
* site: inject lib/rating.js the same way and return the rating the page states
* site: inject lib/rating.js the same way and return the rating the page states
+ * scripting.installMaskFill(tabId) when the person opens the Companion on a tab: put the "Use a mask for <site>"
+ * button in the email fields of that tab (activeTab grants it for that one tab)
* scripting.readListing(tabId) when the person presses "Keep on my Rent board" or "Check the rent": inject
* scripting.readListing(tabId) when the person presses "Keep on my Rent board" or "Check the rent": inject
* lib/listing.js the same way and return the price, city and bedrooms the page states
* lib/listing.js the same way and return the price, city and bedrooms the page states
*
*
@@ -68,6 +70,17 @@
@@ -68,6 +70,17 @@
const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVRating.read(document); } }]);
const out = await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, func: function () { return self.OVRating.read(document); } }]);
return out && out[0] ? out[0].result : null;
return out && out[0] ? out[0].result : null;
},
},
+ /**
+ * Put the mask button in a tab: the rules and the page script, in the extension's own
+ * isolated world. The page can neither read them nor reach the extension through them.
+ * The person must have opened the Companion on that tab (activeTab), so this runs in
+ * the tab they are looking at and nowhere else.
+ */
+ async installMaskFill(tabId) {
+ if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot run the mask button');
+ await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/mask.js', 'lib/mask-fill.js'] }]);
+ return true;
+ },
async readListing(tabId) {
async readListing(tabId) {
if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot read the page');
if (!ext.scripting || !Number.isInteger(tabId)) throw new TypeError('this browser cannot read the page');
await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/listing.js'] }]);
await call(ext.scripting.executeScript, ext.scripting, [{ target: { tabId: tabId }, files: ['lib/listing.js'] }]);
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"short_name": "OpenVibe",
"short_name": "OpenVibe",
"version": "0.9.1",
"version": "0.9.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
- "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",
+ "description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; a mask in the email box of a sign-up form; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
"author": "OpenVibers",
"author": "OpenVibers",
"icons": {
"icons": {
@@ -29,6 +29,10 @@
@@ -29,6 +29,10 @@
"default_panel": "panel.html",
"default_panel": "panel.html",
"open_in_sidebar": true
"open_in_sidebar": true
},
},
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
"permissions": [
"permissions": [
"activeTab",
"activeTab",
"identity",
"identity",
@@ -42,7 +46,7 @@
@@ -42,7 +46,7 @@
"https://openvibe.chat/*"
"https://openvibe.chat/*"
],
],
"content_security_policy": {
"content_security_policy": {
- "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"
+ "extension_pages": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes https://openvibe.email; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'"
},
},
"minimum_chrome_version": "114",
"minimum_chrome_version": "114",
"browser_specific_settings": {
"browser_specific_settings": {
@@ -10,6 +10,9 @@
@@ -10,6 +10,9 @@
<h1>OpenVibe Companion</h1>
<h1>OpenVibe Companion</h1>
<p id="site" class="muted"></p>
<p id="site" class="muted"></p>
</header>
</header>
+<section id="masks" class="masks" aria-label="Masks for this site" hidden>
+ <div id="masks-body"></div>
+</section>
<section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe">
<section id="actions" class="actions" hidden aria-label="Send this page to OpenVibe">
<p id="actions-lede" class="lede"></p>
<p id="actions-lede" class="lede"></p>
<div class="row">
<div class="row">
@@ -33,6 +36,8 @@
@@ -33,6 +36,8 @@
<script src="lib/format.js"></script>
<script src="lib/format.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/controller.js"></script>
<script src="lib/controller.js"></script>
+<script src="lib/mask.js"></script>
+<script src="lib/mask-panel.js"></script>
<script src="popup.js"></script>
<script src="popup.js"></script>
</body>
</body>
</html>
</html>
@@ -13,6 +13,21 @@
@@ -13,6 +13,21 @@
const fmt = self.OVFormat;
const fmt = self.OVFormat;
const content = document.getElementById('content');
const content = document.getElementById('content');
const site = document.getElementById('site');
const site = document.getElementById('site');
+ const masksSection = document.getElementById('masks');
+ const masksBody = document.getElementById('masks-body');
+ const rules = self.OVMask;
+ const panel = self.OVMaskPanel;
+
+ /** Ask the background worker (the only holder of the person's sign-in). */
+ function askWorker(message) {
+ return new Promise(function (resolve) {
+ try {
+ ext.runtime.sendMessage(message, function (answer) { resolve(answer || null); });
+ } catch (e) {
+ resolve(null);
+ }
+ });
+ }
function el(tag, attrs) {
function el(tag, attrs) {
const node = document.createElement(tag);
const node = document.createElement(tag);
@@ -159,10 +174,54 @@
@@ -159,10 +174,54 @@
return el('section', { className: 'try', 'aria-label': 'Try the codes on this page' }, run, note, status, list, send);
return el('section', { className: 'try', 'aria-label': 'Try the codes on this page' }, run, note, status, list, send);
}
}
+ /**
+ * The masks for this site: the addresses the person already has for it, with Copy and Turn off, and
+ * the per-site switch that stops the Companion offering a mask here.
+ *
+ * Opening the popup also puts the "Use a mask for <site>" button in the tab's email fields: the
+ * page is touched only after the person opened the Companion on it (activeTab), and only in the
+ * fields of a form worth offering a mask on.
+ */
+ async function renderMasks() {
+ const info = controller.tabInfo();
+ const label = rules.siteLabel(self.OVHost.hostnameOf(info.url));
+ if (!label || rules.isOpenVibeSite(label)) return;
+ masksSection.hidden = false;
+ const data = await askWorker({ type: 'mask:list', site: label });
+ if (!data || !data.site) { masksSection.hidden = true; return; }
+ const draw = function () { panel.render(masksBody, data, actions); };
+ const actions = {
+ onCopy: function (address) {
+ navigator.clipboard.writeText(address).then(
+ function () { masksBody.querySelectorAll('button').forEach(function (b) { if (b.textContent === 'Copy') b.textContent = 'Copied'; }); },
+ function () { /* the address stays on screen to copy by hand */ }
+ );
+ },
+ onDisable: async function (mask) {
+ await askWorker({ type: 'mask:disable', id: mask.id });
+ const fresh = await askWorker({ type: 'mask:list', site: label });
+ if (fresh && fresh.site) { data.masks = fresh.masks; data.muted = fresh.muted; }
+ draw();
+ },
+ onMute: async function (muted) {
+ await askWorker({ type: muted ? 'mask:mute' : 'mask:unmute', site: label });
+ data.muted = muted;
+ draw();
+ },
+ };
+ draw();
+ try {
+ await platform.scripting.installMaskFill(info.id);
+ } catch (e) {
+ // The browser would not let us touch that tab: the popup still lists the masks.
+ }
+ }
+
async function render() {
async function render() {
const vm = await controller.open();
const vm = await controller.open();
if (vm.host) site.textContent = vm.host;
if (vm.host) site.textContent = vm.host;
renderActions();
renderActions();
+ renderMasks();
if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a delivery app to compare with the others, a rental listing for your board, any page to watch.' }));
if (vm.state === 'not_a_site') return show(el('p', { text: 'Open this on a website: a shop for its coupon codes, a delivery app to compare with the others, a rental listing for your board, any page to watch.' }));
if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));
if (vm.state === 'no_merchant') return show(el('p', { className: 'muted', text: 'No coupon codes for ' + vm.host + ' on OpenVibe.Coupons. ' }, link('Submit one', '/submit')));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
if (vm.state === 'error') return show(el('p', { text: vm.message }));
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"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",
"pack:stores": "node scripts/pack.js --stores",
- "lint:firefox": "npx --yes [email protected] lint --source-dir browser --warnings-as-errors --ignore-files \"shared/**\"",
+ "lint:firefox": "node scripts/lint-firefox.js",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"pack:dev": "node scripts/pack.js --api http://localhost:4850",
"icons": "node scripts/icons.js"
"icons": "node scripts/icons.js"
},
},
@@ -1,28 +1,32 @@
@@ -1,28 +1,32 @@
+#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
* The permission audit for browser/manifest.json. test/manifest.test.js runs it, and so does
* 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:
* scripts/pack.js before it writes a zip. It fails when anything widens:
*
*
* permissions exactly activeTab + identity + scripting + storage, plus sidePanel in the Chrome build
* permissions exactly activeTab + identity + scripting + storage, plus sidePanel in the Chrome build
- * (scripting runs lib/autoapply.js or lib/rating.js in the active tab only, when the
- * person presses "Try these codes on this page" or "Save its rating to OpenVibe
- * Reviews": activeTab is the only host access; Firefox's sidebar_action, the
- * side panel there, needs no permission of its own; identity starts the Dock's one-click
- * OAuth sign-in)
+ * (scripting runs lib/autoapply.js, lib/rating.js, lib/listing.js or lib/mask-fill.js in the
+ * active tab only, when the person presses a button of the Companion: activeTab is the only
+ * host access; Firefox's sidebar_action, the side panel there, needs no permission of its own;
+ * identity starts the Dock's one-click OAuth sign-in)
* host_permissions exactly the API origin, the Network origin, the Chat origin and the Codes
* host_permissions exactly the API origin, the Network origin, the Chat origin and the Codes
* origin, all https://openvibe.<tld> sites (the coupon API, the Dock's state,
* origin, all https://openvibe.<tld> sites (the coupon API, the Dock's state,
- * the Dock's Chats tab, and the Dock's agent view)
+ * the Dock's Chats tab, and the Dock's agent view). OpenVibe.Mail is not among them: the
+ * Companion reaches it with CORS, the way the Coupons API lets a first-party extension in.
+ * background the service worker that holds the person's sign-in and talks to OpenVibe.Mail, and
+ * nothing else: one key, one file, one type
* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)
* allowed key key (Chrome's public key that fixes the extension id; dropped from the Firefox zip)
- * forbidden keys content_scripts, background, web_accessible_resources,
+ * forbidden keys content_scripts, web_accessible_resources,
* externally_connectable, optional_permissions, optional_host_permissions,
* externally_connectable, optional_permissions, optional_host_permissions,
* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,
* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,
* omnibox, … (anything not on the allow-list of keys; the side panel's own keys,
* omnibox, … (anything not on the allow-list of keys; the side panel's own keys,
* side_panel and sidebar_action, are on it)
* side_panel and sidebar_action, are on it)
* CSP script-src 'self' only; style-src 'self' 'unsafe-inline' (the Dock draws its
* CSP script-src 'self' only; style-src 'self' 'unsafe-inline' (the Dock draws its
* own styles in a <style> element); img-src 'self' blob: (the agent view's
* own styles in a <style> element); img-src 'self' blob: (the agent view's
- * still is a local object URL); connect-src only the OpenVibe origins;
- * frame-src only Live's agent embed; no other unsafe-*, wildcard, data: or
- * remote http
+ * still is a local object URL); connect-src only the OpenVibe origins (the four
+ * the extension may reach with a host permission, and OpenVibe.Mail, which it
+ * reaches with CORS); frame-src only Live's agent embed; no other unsafe-*, wildcard,
+ * data: or remote http
* pages popup/options/panel HTML load only local scripts, no inline script
* pages popup/options/panel HTML load only local scripts, no inline script
* config lib/config.js API_ORIGIN equals the host permission's origin
* config lib/config.js API_ORIGIN equals the host permission's origin
* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
* hand-offs lib/config.js HANDOFF_ORIGINS are https://openvibe.<tld> sites only (the
@@ -33,14 +37,16 @@ const path = require('path');
@@ -33,14 +37,16 @@ const path = require('path');
const ALLOWED_KEYS = new Set(['manifest_version', 'name', 'short_name', 'version', 'description', 'homepage_url', 'author', 'icons',
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',
'action', 'options_ui', 'permissions', 'host_permissions', 'content_security_policy', 'minimum_chrome_version', 'browser_specific_settings',
- 'side_panel', 'sidebar_action', 'key']);
+ 'side_panel', 'sidebar_action', 'key', 'background']);
const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];
const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];
const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];
const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];
const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);
const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);
+const BACKGROUND = { service_worker: 'background.js', type: 'module' };
-function auditManifest(m, { apiOrigin, origins } = {}) {
+function auditManifest(m, { apiOrigin, origins, connectOrigins } = {}) {
const problems = [];
const problems = [];
const os = (origins && origins.length ? origins : [apiOrigin]).filter(Boolean);
const os = (origins && origins.length ? origins : [apiOrigin]).filter(Boolean);
+ const cs = (connectOrigins && connectOrigins.length ? connectOrigins : os).filter(Boolean);
if (m.manifest_version !== 3) problems.push('manifest_version must be 3');
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`);
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 : [];
const perms = Array.isArray(m.permissions) ? m.permissions : [];
@@ -56,6 +62,10 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
@@ -56,6 +62,10 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
if (hosts.length !== want.length || hosts.slice().sort().join() !== want.join()) {
if (hosts.length !== want.length || hosts.slice().sort().join() !== want.join()) {
problems.push(`host_permissions must be exactly ${want.join(', ')} (got ${hosts.join(', ') || 'none'})`);
problems.push(`host_permissions must be exactly ${want.join(', ')} (got ${hosts.join(', ') || 'none'})`);
}
}
+ if (!m.background || m.background.service_worker !== BACKGROUND.service_worker || m.background.type !== BACKGROUND.type
+ || Object.keys(m.background).length !== Object.keys(BACKGROUND).length) {
+ problems.push(`background must be exactly ${JSON.stringify(BACKGROUND)}`);
+ }
const panel = m.side_panel || m.sidebar_action;
const panel = m.side_panel || m.sidebar_action;
if (!panel) problems.push('the side panel needs side_panel (Chrome) or sidebar_action (Firefox)');
if (!panel) problems.push('the side panel needs side_panel (Chrome) or sidebar_action (Firefox)');
else if ((m.side_panel && m.side_panel.default_path !== 'panel.html') || (m.sidebar_action && m.sidebar_action.default_panel !== 'panel.html')) {
else if ((m.side_panel && m.side_panel.default_path !== 'panel.html') || (m.sidebar_action && m.sidebar_action.default_panel !== 'panel.html')) {
@@ -68,7 +78,7 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
@@ -68,7 +78,7 @@ function auditManifest(m, { apiOrigin, origins } = {}) {
else {
else {
const dirs = Object.fromEntries(csp.split(';').map((d) => d.trim()).filter(Boolean).map((d) => { const [k, ...v] = d.split(/\s+/); return [k, v]; }));
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['script-src']) !== JSON.stringify(["'self'"])) problems.push("CSP script-src must be exactly 'self'");
- if (JSON.stringify(dirs['connect-src'].slice().sort()) !== JSON.stringify(os.slice().sort())) problems.push(`CSP connect-src must be exactly ${os.join(' ')}`);
+ if (JSON.stringify(dirs['connect-src'].slice().sort()) !== JSON.stringify(cs.slice().sort())) problems.push(`CSP connect-src must be exactly ${cs.join(' ')}`);
if (JSON.stringify(dirs['default-src']) !== JSON.stringify(["'none'"])) problems.push("CSP default-src must be 'none'");
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 (JSON.stringify(dirs['object-src']) !== JSON.stringify(["'none'"])) problems.push("CSP object-src must be 'none'");
if (JSON.stringify(dirs['style-src']) !== JSON.stringify(["'self'", "'unsafe-inline'"])) problems.push("CSP style-src must be exactly 'self' 'unsafe-inline' (the Dock's own <style>)");
if (JSON.stringify(dirs['style-src']) !== JSON.stringify(["'self'", "'unsafe-inline'"])) problems.push("CSP style-src must be exactly 'self' 'unsafe-inline' (the Dock's own <style>)");
@@ -108,16 +118,18 @@ function auditDir(dir) {
@@ -108,16 +118,18 @@ function auditDir(dir) {
const network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];
const network = (configSrc.match(/NETWORK_ORIGIN:\s*'([^']+)'/) || [])[1];
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const chat = (configSrc.match(/CHAT_ORIGIN:\s*'([^']+)'/) || [])[1];
const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];
const codes = (configSrc.match(/CODES_ORIGIN:\s*'([^']+)'/) || [])[1];
+ const mail = (configSrc.match(/MAIL_ORIGIN:\s*'([^']+)'/) || [])[1];
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const handoffs = ((configSrc.match(/HANDOFF_ORIGINS:\s*Object\.freeze\(\[([^\]]*)\]\)/) || [])[1] || '').split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
const problems = [];
const problems = [];
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!origin) problems.push('lib/config.js has no API_ORIGIN');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
if (!handoffs.length) problems.push('lib/config.js has no HANDOFF_ORIGINS');
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
for (const h of handoffs) if (!/^https:\/\/openvibe\.[a-z]+$/.test(h)) problems.push(`hand-off origin ${h} is not an https://openvibe.<tld> site`);
const origins = [origin, network, chat, codes].filter(Boolean);
const origins = [origin, network, chat, codes].filter(Boolean);
- for (const o of [network, chat, codes].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);
- problems.push(...auditManifest(manifest, { apiOrigin: origin, origins }));
+ for (const o of [network, chat, codes, mail].filter(Boolean)) if (!/^https:\/\/openvibe\.[a-z]+$/.test(o)) problems.push(`${o} is not an https://openvibe.<tld> site`);
+ const connectOrigins = origins.concat(mail ? [mail] : []);
+ problems.push(...auditManifest(manifest, { apiOrigin: origin, origins, connectOrigins }));
for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
for (const page of ['popup.html', 'options.html', 'panel.html']) problems.push(...auditPage(fs.readFileSync(path.join(dir, page), 'utf8'), page));
- return { manifest, origin, origins, problems };
+ return { manifest, origin, origins, connectOrigins, problems };
}
}
-module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS };
+module.exports = { auditManifest, auditPage, auditDir, PERMISSIONS, BASE_PERMISSIONS, ALLOWED_KEYS, BACKGROUND };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Lints the extension the way Firefox sees it.
+ *
+ * Firefox has no background service worker: it runs the background file as a background page. So the
+ * Firefox manifest is the one the store build carries (`scripts/pack.js` forTarget), and this lint
+ * runs over that manifest and not over the Chrome one in browser/.
+ *
+ * The extension's own tree is copied to a temporary directory, the Firefox manifest is written there,
+ * and web-ext lint is run over it with warnings as errors.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { sourceFiles, forTarget } = require('./pack');
+
+const ROOT = path.join(__dirname, '..');
+
+function main() {
+ const files = sourceFiles('https://openvibe.coupons');
+ forTarget(files, 'firefox');
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-ext-firefox-'));
+ 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 cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
+ const run = spawnSync(cmd, ['--yes', '[email protected]', 'lint', '--source-dir', tmp, '--warnings-as-errors', '--ignore-files', 'shared/**'], { stdio: 'inherit', cwd: ROOT });
+ process.exit(run.status == null ? 1 : run.status);
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+}
+
+if (require.main === module) main();
+
+module.exports = { main };
@@ -72,7 +72,7 @@ function listFiles(dir, base = dir) {
@@ -72,7 +72,7 @@ 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). */
+/** The store manifest for one browser: drops the other browser's key and gives Firefox its background page (the audit runs on the full one). */
function forTarget(files, target) {
function forTarget(files, target) {
const m = JSON.parse(files.get('manifest.json'));
const m = JSON.parse(files.get('manifest.json'));
if (target === 'chrome') {
if (target === 'chrome') {
@@ -86,6 +86,8 @@ function forTarget(files, target) {
@@ -86,6 +86,8 @@ function forTarget(files, target) {
delete m.side_panel;
delete m.side_panel;
delete m.key; // Chrome's public key is not a Firefox manifest field
delete m.key; // Chrome's public key is not a Firefox manifest field
m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');
m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');
+ // Firefox has no background service worker: it runs the same file as a background page.
+ m.background = { scripts: ['background.js'], type: 'module' };
} else throw new Error('target must be chrome or firefox');
} else throw new Error('target must be chrome or firefox');
files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));
}
}
@@ -140,4 +142,4 @@ if (require.main === module) {
@@ -140,4 +142,4 @@ if (require.main === module) {
}
}
}
}
-module.exports = { build, zip, crc32, forTarget };
+module.exports = { build, zip, crc32, forTarget, sourceFiles };
@@ -65,7 +65,7 @@ function fetcher(responses) {
@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
assert.strictEqual(u.searchParams.get('response_type'), 'code');
- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view');
+ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view mail.mask.manage');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
issuedChallenge = u.searchParams.get('code_challenge');
@@ -22,9 +22,12 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -22,9 +22,12 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);
assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', 'identity', 'scripting', 'storage']);
assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
assert.strictEqual(manifest.sidebar_action.default_panel, 'panel.html', 'the Dock lives in the side panel');
- for (const k of ['content_scripts', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
+ for (const k of ['content_scripts', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
assert.ok(!(k in manifest), `${k} must not be present`);
assert.ok(!(k in manifest), `${k} must not be present`);
}
}
+ // The background worker is the one place that holds the person's sign-in and talks to
+ // OpenVibe.Mail: one key, one file, one type (scripts/audit.js fails on any other shape).
+ assert.deepStrictEqual(manifest.background, { service_worker: 'background.js', type: 'module' });
});
});
await check('the public key fixes the Chrome id and the gecko id fixes the Firefox redirect', async () => {
await check('the public key fixes the Chrome id and the gecko id fixes the Firefox redirect', async () => {
@@ -90,7 +93,9 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -90,7 +93,9 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
await check('the API origin agrees across lib/config.js, host_permissions and the CSP connect-src', async () => {
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;
const csp = manifest.content_security_policy.extension_pages;
- assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);
+ // OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS,
+ // the way the Coupons API lets a first-party extension in) but it is not a host permission.
+ assert.match(csp, /connect-src https:\/\/openvibe\.coupons https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);
assert.match(csp, /style-src 'self' 'unsafe-inline';/);
assert.match(csp, /style-src 'self' 'unsafe-inline';/);
assert.match(csp, /img-src 'self' blob:;/);
assert.match(csp, /img-src 'self' blob:;/);
assert.match(csp, /frame-src https:\/\/openvibe\.live;/);
assert.match(csp, /frame-src https:\/\/openvibe\.live;/);
@@ -109,23 +114,31 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -109,23 +114,31 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
// browser/shared/ is the released OpenVibe.Shared Dock, bundled as it is: it is audited in its own
// browser/shared/ is the released OpenVibe.Shared Dock, bundled as it is: it is audited in its own
// repository (OpenVibe.Shared), not here.
// repository (OpenVibe.Shared), not here.
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'shared') walk(p); } else if (p.endsWith('.js')) files.push(p); } })(DIR);
(function walk(d) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { if (e.name !== 'shared') walk(p); } else if (p.endsWith('.js')) files.push(p); } })(DIR);
+ // The background worker and the page script that draws the mask button are the only files that
+ // talk to the extension's own messaging (background.js answers the page, mask-fill.js asks the
+ // worker). Nothing else may message, and no page may message the extension from outside.
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
const forbidden = [/\binnerHTML\b/, /\bouterHTML\b/, /insertAdjacentHTML/, /document\.write/, /\beval\s*\(/, /new Function\s*\(/, /\bcookies\b/, /document\.cookie/,
- /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /sendMessage|onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
+ /\bhistory\.(search|getVisits)/, /webRequest/, /storage\.sync/, /onMessageExternal|runtime\.connect/, /XMLHttpRequest/, /importScripts/];
for (const f of files) {
for (const f of files) {
// Comments may name what the code must not do; only code is scanned.
// 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');
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}`);
for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);
+ const rel0 = path.relative(DIR, f);
+ if (![path.join('background.js'), path.join('lib', 'mask-fill.js'), path.join('popup.js')].includes(rel0)) {
+ assert.doesNotMatch(src, /sendMessage/, `${rel0} messages the extension`);
+ }
const rel = path.relative(DIR, f);
const rel = path.relative(DIR, f);
if (!['lib/api.js', 'lib/network-api.js', 'lib/auth.js'].map((p) => path.join(...p.split('/'))).includes(rel)) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
if (!['lib/api.js', 'lib/network-api.js', 'lib/auth.js'].map((p) => path.join(...p.split('/'))).includes(rel)) assert.doesNotMatch(src.replace(/fetch: self\.fetch\.bind\(self\)/g, ''), /\bfetch\s*\(/, `${rel} makes a request itself`);
}
}
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
const tabUse = files.filter((f) => /\btabs\.(query|create|update)\b/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');
- // The page is touched only by "Try these codes on this page", "Save its rating to OpenVibe Reviews" and the two Rent
- // buttons ("Keep on my Rent board", "Check the rent"): platform.js injects the packaged file for that button and calls it.
- const injects = files.filter((f) => /executeScript|ext\.scripting/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(DIR, f));
+ // The page is touched only by "Try these codes on this page", "Save its rating to OpenVibe Reviews", the two Rent
+ // buttons ("Keep on my Rent board", "Check the rent") and the mask button: platform.js injects the packaged file for that button and calls it.
+ const code = (f) => fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');
+ const injects = files.filter((f) => /executeScript|ext\.scripting/.test(code(f))).map((f) => path.relative(DIR, f));
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
assert.deepStrictEqual(injects, [path.join('lib', 'platform.js')], 'only the platform wrapper runs a script in a page');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
const plat = fs.readFileSync(path.join(DIR, 'lib', 'platform.js'), 'utf8');
- assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/listing.js'"], 'the three files it injects');
+ assert.deepStrictEqual([...plat.matchAll(/files:\s*\[([^\]]*)\]/g)].map((m) => m[1].trim()), ["'lib/autoapply.js'", "'lib/rating.js'", "'lib/mask.js', 'lib/mask-fill.js'", "'lib/listing.js'"], 'the files it injects');
assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
assert.doesNotMatch(plat, /allFrames|world:\s*['"]MAIN|tabId:(?!\s*tabId\b)/, 'the active tab only, its top frame, the isolated world');
// What runs in the page makes no request and reads no stored data.
// What runs in the page makes no request and reads no stored data.
for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {
for (const file of ['autoapply.js', 'rating.js', 'listing.js']) {
@@ -134,6 +147,20 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -134,6 +147,20 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
assert.doesNotMatch(src, re, `lib/${file} uses ${re}`);
assert.doesNotMatch(src, re, `lib/${file} uses ${re}`);
}
}
}
}
+ // The mask button is the one script that runs in a page and talks to the extension: it asks the
+ // background worker (chrome.runtime.sendMessage) and nothing else. It never fetches, never
+ // reads stored data, and never learns anything but an address and the words that go with it.
+ const fillSrc = fs.readFileSync(path.join(DIR, 'lib', 'mask-fill.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ for (const re of [/\bfetch\s*\(/, /XMLHttpRequest/, /WebSocket/, /sendBeacon/, /localStorage|sessionStorage|indexedDB/, /document\.cookie/, /\.submit\s*\(|requestSubmit/, /KeyboardEvent|keydown|keypress/]) {
+ assert.doesNotMatch(fillSrc, re, `lib/mask-fill.js uses ${re}`);
+ }
+ const fillApis = [...new Set([...fillSrc.matchAll(/\b(?:chrome|browser)\.[a-zA-Z.]+/g)].map((m) => m[0]))];
+ for (const api of fillApis) assert.match(api, /^(?:chrome|browser)\.runtime$/, `lib/mask-fill.js uses ${api}`);
+ assert.match(fillSrc, /runtime\.sendMessage\(message,/, 'the page script asks the worker and nothing else');
+ assert.doesNotMatch(fillSrc, /onMessageExternal/, 'the page cannot listen to the extension');
+ // The worker answers only the two messages a page may ask, and only a page (a tab behind it).
+ const workerSrc = fs.readFileSync(path.join(DIR, 'background.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
+ assert.doesNotMatch(workerSrc, /externally_connectable|onMessageExternal/, 'the worker listens to our own pages only');
// Reading a rating or a listing presses nothing and types nothing.
// Reading a rating or a listing presses nothing and types nothing.
for (const file of ['rating.js', 'listing.js']) {
for (const file of ['rating.js', 'listing.js']) {
assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', file), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, `lib/${file} only reads`);
assert.doesNotMatch(fs.readFileSync(path.join(DIR, 'lib', file), 'utf8'), /\.click\s*\(|dispatchEvent|\.value\s*=|\.focus\s*\(/, `lib/${file} only reads`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,265 @@
@@ -0,0 +1,265 @@
+'use strict';
+/**
+ * The mask fill: which field is worth offering a mask on, which mask that site's field gets, and
+ * who may ask the Companion for what.
+ *
+ * The rules are DOM-free (browser/lib/mask.js), so a field is described here the way the browser
+ * describes one: its tag, its type, its name and what it is called. The service (lib/mask-service.js)
+ * is given a fake sign-in, a fake OpenVibe.Mail and a fake place to keep the muted sites.
+ */
+const assert = require('assert');
+const rules = require('../browser/lib/mask');
+const { createMaskService } = require('../browser/lib/mask-service');
+const { createRouter } = require('../browser/lib/mask-router');
+const { check, done } = require('./helpers/harness');
+
+/** A field as the browser hands it over. */
+function field(props) {
+ const p = props || {};
+ return {
+ tagName: 'INPUT',
+ type: p.type || 'text',
+ name: p.name || '',
+ id: p.id || '',
+ placeholder: p.placeholder || '',
+ labels: p.labels || [],
+ getAttribute: function (k) { return Object.prototype.hasOwnProperty.call(p, k) ? p[k] : null; },
+ };
+}
+
+const SIGNUP = { passwordCount: 1, texts: 'Create an account with your email and a password', submitText: 'Create account' };
+const SIGNIN = { passwordCount: 1, texts: 'Sign in to your account', submitText: 'Sign in' };
+const NEWSLETTER = { passwordCount: 0, texts: 'Subscribe to our newsletter for the weekly digest', submitText: 'Subscribe' };
+const OTHER = { passwordCount: 0, texts: 'Search the catalogue of tents and sleeping bags', submitText: 'Search' };
+
+/** A fake OpenVibe.Mail that records what it was asked. */
+function fakeMail(masks) {
+ const calls = { masks: 0, created: [], disabled: [] };
+ let list = (masks || []).slice();
+ return {
+ calls: calls,
+ async masks() { calls.masks++; return list.slice(); },
+ async createMask(label, purpose) {
+ calls.created.push({ label: label, purpose: purpose });
+ const mask = { id: 'msk_' + (calls.created.length + 1), address: label.split('.').join('-') + '@mask.openvibe.email', label: label, enabled: true };
+ list.push(mask);
+ return mask;
+ },
+ async disableMask(id) { calls.disabled.push(id); const m = list.find(function (x) { return x.id === id; }); if (m) m.enabled = false; return m; },
+ };
+}
+
+/** A fake sign-in: signed in with the scope, or not. */
+function fakeAuth(signedIn, scopes) {
+ return {
+ async status() { return signedIn ? { mode: 'network' } : { mode: 'none' }; },
+ async scopes() { return scopes || (signedIn ? ['dock', 'mail.mask.manage'] : []); },
+ async accessToken() { return signedIn ? 'tok' : null; },
+ };
+}
+
+function fakeStorage() {
+ const store = {};
+ return {
+ store: store,
+ async getLocal(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
+ async setLocal(v) { Object.keys(v).forEach(function (k) { store[k] = v[k]; }); },
+ };
+}
+
+function service(opts) {
+ const o = opts || {};
+ const mail = o.mail || fakeMail(o.masks);
+ const storage = o.storage || fakeStorage();
+ const svc = createMaskService({
+ rules: rules,
+ auth: o.auth || fakeAuth(o.signedIn !== false),
+ mail: mail,
+ storage: storage,
+ scope: 'mail.mask.manage',
+ muteKey: 'maskMutes',
+ });
+ return { svc: svc, mail: mail, storage: storage };
+}
+
+(async () => {
+ await check('the site is the hostname without its www.', async () => {
+ assert.strictEqual(rules.siteLabel('www.Acme-Shop.com'), 'acme-shop.com');
+ assert.strictEqual(rules.siteLabel('acme-shop.com'), 'acme-shop.com');
+ assert.strictEqual(rules.siteLabel('shop.acme-shop.com'), 'shop.acme-shop.com');
+ assert.strictEqual(rules.siteLabel(''), null);
+ assert.strictEqual(rules.siteLabel(null), null);
+ });
+
+ await check('our own sites are never offered a mask', async () => {
+ for (const h of ['openvibe.network', 'openvibe.coupons', 'www.openvibe.email', 'mail.openvibe.chat']) assert.ok(rules.isOpenVibeSite(h), h);
+ for (const h of ['acme-shop.com', 'notopenvibe.network', 'openvibe.network.example.com']) assert.ok(!rules.isOpenVibeSite(h), h);
+ });
+
+ await check('an email field is an email or a text box that calls itself one', async () => {
+ assert.ok(rules.isEmailField(field({ type: 'email' })));
+ assert.ok(rules.isEmailField(field({ type: 'text', name: 'email' })));
+ assert.ok(rules.isEmailField(field({ type: 'text', id: 'user_email' })));
+ assert.ok(rules.isEmailField(field({ type: 'text', placeholder: 'Your e-mail address' })));
+ assert.ok(rules.isEmailField(field({ type: 'text', labels: [{ textContent: 'Email address' }] })));
+ assert.ok(!rules.isEmailField(field({ type: 'text', name: 'search' })));
+ assert.ok(!rules.isEmailField(field({ type: 'tel' })));
+ });
+
+ await check('a password field is never an email field, whatever it is called', async () => {
+ assert.ok(!rules.isEmailField(field({ type: 'password', name: 'email' })));
+ assert.ok(!rules.isEmailField(field({ type: 'password', id: 'email' })));
+ assert.ok(!rules.isEmailField(field({ type: 'password', placeholder: 'email' })));
+ const d = rules.offerFor(field({ type: 'password', name: 'email' }), SIGNIN);
+ assert.deepStrictEqual(d, { offer: false, reason: 'password' });
+ });
+
+ await check('a sign-up form is offered a mask, and so is a sign-in and a newsletter', async () => {
+ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), SIGNUP), { offer: true, kind: 'signup' });
+ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), SIGNIN), { offer: true, kind: 'signin' });
+ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), NEWSLETTER), { offer: true, kind: 'newsletter' });
+ });
+
+ await check('a form that asks for nothing we can help with is left alone', async () => {
+ assert.deepStrictEqual(rules.offerFor(field({ type: 'email', name: 'email' }), OTHER), { offer: false, reason: 'other_form' });
+ assert.deepStrictEqual(rules.offerFor(field({ type: 'text', name: 'search' }), SIGNUP), { offer: false, reason: 'not_email' });
+ assert.deepStrictEqual(rules.offerFor({ tagName: 'DIV' }, SIGNUP), { offer: false, reason: 'not_a_field' });
+ });
+
+ await check('the site that already has a mask keeps it; a new site gets a new one labelled with the site', async () => {
+ const masks = [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }];
+ const plan = rules.planFor(masks, 'acme-shop.com');
+ assert.strictEqual(plan.action, 'reuse');
+ assert.strictEqual(plan.mask.id, 'msk_1');
+ const fresh = rules.planFor(masks, 'other-shop.com');
+ assert.strictEqual(fresh.action, 'create');
+ assert.strictEqual(fresh.label, 'other-shop.com');
+ // A mask that was turned off is not the site's mask: the site gets a fresh one.
+ const off = rules.planFor([{ id: 'msk_2', address: 'x@y', label: 'acme-shop.com', enabled: false }], 'acme-shop.com');
+ assert.strictEqual(off.action, 'create');
+ });
+
+ await check('filling a field reuses that site\'s mask and never makes a second one', async () => {
+ const { svc, mail } = service({ masks: [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }] });
+ const out = await svc.fill('www.acme-shop.com');
+ assert.strictEqual(out.address, '[email protected]');
+ assert.strictEqual(out.reused, true);
+ assert.strictEqual(out.message, rules.OFFER_MESSAGE);
+ assert.strictEqual(mail.calls.created.length, 0, 'no mask was made');
+ });
+
+ await check('a site without a mask gets one, labelled with the site', async () => {
+ const { svc, mail } = service({ masks: [] });
+ const out = await svc.fill('acme-shop.com');
+ assert.strictEqual(out.reused, false);
+ assert.strictEqual(out.message, rules.OFFER_MESSAGE);
+ assert.deepStrictEqual(mail.calls.created, [{ label: 'acme-shop.com', purpose: 'companion' }]);
+ });
+
+ await check('offering a mask never creates one', async () => {
+ const { svc, mail } = service({ masks: [] });
+ const out = await svc.offer('acme-shop.com');
+ assert.strictEqual(out.offer, true);
+ assert.strictEqual(out.label, 'Use a mask for acme-shop.com');
+ assert.strictEqual(mail.calls.created.length, 0);
+ assert.strictEqual(mail.calls.masks, 0, 'offering asks OpenVibe.Mail for nothing');
+ });
+
+ await check('signed out, the button asks the person to sign in and nothing is created', async () => {
+ const { svc, mail } = service({ signedIn: false });
+ const out = await svc.offer('acme-shop.com');
+ assert.strictEqual(out.offer, true);
+ assert.strictEqual(out.signedOut, true);
+ assert.strictEqual(out.label, rules.SIGNED_OUT_LABEL);
+ const fill = await svc.fill('acme-shop.com');
+ assert.strictEqual(fill.signedOut, true);
+ assert.strictEqual(fill.address, undefined);
+ assert.strictEqual(mail.calls.created.length, 0);
+ });
+
+ await check('signed in without the mask scope, the button asks the person to sign in again', async () => {
+ const { svc } = service({ auth: fakeAuth(true, ['dock', 'activity']) });
+ const out = await svc.offer('acme-shop.com');
+ assert.strictEqual(out.signedOut, true);
+ assert.strictEqual(out.label, rules.SIGNED_OUT_LABEL);
+ });
+
+ await check('a muted site is never offered a mask', async () => {
+ const { svc, mail } = service({ masks: [] });
+ await svc.mute('acme-shop.com');
+ const out = await svc.offer('acme-shop.com');
+ assert.deepStrictEqual(out, { offer: false, reason: 'muted' });
+ const fill = await svc.fill('acme-shop.com');
+ assert.strictEqual(fill.address, undefined);
+ assert.strictEqual(mail.calls.created.length, 0);
+ await svc.unmute('acme-shop.com');
+ assert.strictEqual((await svc.offer('acme-shop.com')).offer, true);
+ });
+
+ await check('an OpenVibe site is never offered a mask', async () => {
+ const { svc } = service({ masks: [] });
+ assert.deepStrictEqual(await svc.offer('openvibe.network'), { offer: false, reason: 'openvibe_site' });
+ });
+
+ await check('a page may ask for an offer and for an address, and for nothing else', async () => {
+ const { svc, mail } = service({ masks: [] });
+ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });
+ const page = { tab: { id: 7, url: 'https://www.acme-shop.com/sign-up' }, url: 'https://www.acme-shop.com/sign-up' };
+ const offer = await router.handle({ type: 'mask:offer' }, page);
+ assert.strictEqual(offer.offer, true);
+ assert.strictEqual(offer.label, 'Use a mask for acme-shop.com');
+ const fill = await router.handle({ type: 'mask:fill' }, page);
+ assert.strictEqual(fill.address, '[email protected]');
+ assert.strictEqual(mail.calls.created.length, 1);
+ // The list of masks, turning one off and the per-site switch are not the page's to ask for.
+ for (const type of ['mask:list', 'mask:disable', 'mask:mute', 'mask:unmute']) {
+ assert.deepStrictEqual(await router.handle({ type: type, site: 'acme-shop.com', id: 'msk_1' }, page), { ok: false }, type);
+ }
+ });
+
+ await check('the site is the tab\'s own address, never the page\'s word for it', async () => {
+ const { svc } = service({ masks: [] });
+ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });
+ const page = { tab: { id: 7, url: 'https://www.acme-shop.com/sign-up' }, url: 'https://www.acme-shop.com/sign-up' };
+ const out = await router.handle({ type: 'mask:offer', site: 'some-other-site.com' }, page);
+ assert.strictEqual(out.label, 'Use a mask for acme-shop.com');
+ });
+
+ await check('a stranger (no tab, not one of our pages) is refused', async () => {
+ const { svc } = service({ masks: [] });
+ const router = createRouter({ rules: rules, service: svc, isOwnPage: function () { return false; } });
+ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, { id: 'other' }), { ok: false });
+ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, {}), { ok: false });
+ assert.deepStrictEqual(await router.handle({ type: 'mask:fill' }, null), { ok: false });
+ });
+
+ await check('the popup may list the masks of a site, turn one off and set the per-site switch', async () => {
+ const { svc, mail } = service({ masks: [{ id: 'msk_1', address: '[email protected]', label: 'acme-shop.com', enabled: true }] });
+ const router = createRouter({ rules: rules, service: svc, isOwnPage: function (sender) { return sender && sender.own === true; } });
+ const popup = { own: true };
+ const list = await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup);
+ assert.strictEqual(list.site, 'acme-shop.com');
+ assert.strictEqual(list.signedOut, false);
+ assert.deepStrictEqual(list.masks.map(function (m) { return m.address; }), ['[email protected]']);
+ assert.strictEqual(list.muted, false);
+ const muted = await router.handle({ type: 'mask:mute', site: 'acme-shop.com' }, popup);
+ assert.strictEqual(muted.muted, true);
+ assert.strictEqual((await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup)).muted, true);
+ await router.handle({ type: 'mask:unmute', site: 'acme-shop.com' }, popup);
+ assert.strictEqual((await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, popup)).muted, false);
+ await router.handle({ type: 'mask:disable', id: 'msk_1' }, popup);
+ assert.deepStrictEqual(mail.calls.disabled, ['msk_1']);
+ });
+
+ await check('the popup is told what it may show, and nothing more', async () => {
+ const { svc } = service({ signedIn: false });
+ const router = createRouter({ rules: rules, service: svc, isOwnPage: function (sender) { return sender && sender.own === true; } });
+ const list = await router.handle({ type: 'mask:list', site: 'acme-shop.com' }, { own: true });
+ assert.strictEqual(list.signedOut, true);
+ assert.deepStrictEqual(list.masks, []);
+ assert.ok(!('token' in list));
+ assert.ok(!JSON.stringify(list).includes('tok'));
+ });
+
+ done();
+})();
@@ -45,7 +45,9 @@ function unzip(buf) {
@@ -45,7 +45,9 @@ function unzip(buf) {
const src = path.join(__dirname, '..', 'browser');
const src = path.join(__dirname, '..', 'browser');
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
for (const [name, data] of files) assert.ok(data.equals(fs.readFileSync(path.join(src, name))), name);
assert.ok(files.has('manifest.json') && files.has('popup.html') && files.has('lib/api.js') && files.has('icons/icon-128.png'));
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', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'] }), []);
+ // OpenVibe.Mail is in the CSP's connect-src (the Companion reaches the masks with CORS) but it is
+ // not one of the four host permissions.
+ assert.deepStrictEqual(auditManifest(JSON.parse(files.get('manifest.json')), { apiOrigin: 'https://openvibe.coupons', origins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes'], connectOrigins: ['https://openvibe.coupons', 'https://openvibe.network', 'https://openvibe.chat', 'https://openvibe.codes', 'https://openvibe.email'] }), []);
});
});
await check('the build is reproducible', async () => {
await check('the build is reproducible', async () => {
@@ -60,7 +62,7 @@ function unzip(buf) {
@@ -60,7 +62,7 @@ function unzip(buf) {
const files = unzip(fs.readFileSync(r.out));
const files = unzip(fs.readFileSync(r.out));
const m = JSON.parse(files.get('manifest.json'));
const m = JSON.parse(files.get('manifest.json'));
assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
assert.deepStrictEqual(m.host_permissions, ['http://localhost:4850/*', 'https://openvibe.network/*', 'https://openvibe.codes/*', 'https://openvibe.chat/*']);
- assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes;/);
+ assert.match(m.content_security_policy.extension_pages, /connect-src http:\/\/localhost:4850 https:\/\/openvibe\.network https:\/\/openvibe\.chat https:\/\/openvibe\.codes https:\/\/openvibe\.email;/);
assert.match(m.name, /development/);
assert.match(m.name, /development/);
assert.match(files.get('lib/config.js').toString(), /API_ORIGIN: 'http:\/\/localhost:4850'/);
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\//);
assert.doesNotMatch(files.get('manifest.json').toString() + files.get('lib/config.js').toString(), /openvibe\.coupons\//);
@@ -84,6 +86,8 @@ function unzip(buf) {
@@ -84,6 +86,8 @@ function unzip(buf) {
delete want.side_panel;
delete want.side_panel;
delete want.key;
delete want.key;
want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
+ // Firefox has no background service worker: it runs the same file as a background page.
+ want.background = { scripts: ['background.js'], type: 'module' };
}
}
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));
assert.ok(fs.readFileSync(build({ outDir: tmp, target }).out).equals(fs.readFileSync(r.out)));