openvibers/openvibe.extensions Commits

Companion: a mask in the email box of a sign-up form (#17)

OP OpenVibers committed 4 days ago · da13ec1ed7 · 21 files, +1220 −33

* Companion: a mask in the email box of a sign-up form

Focusing an email field on a sign-up, sign-in or newsletter form shows a small
OpenVibe button inside the field: 'Use a mask for <site>'. It reuses that site's
mask or makes one labelled with the site, fills the field, and says that mail to
the address reaches the inbox and can be turned off any time. Never on an
openvibe.* site, never on a password field, and never on a site the person
turned it off for. Signed out, the button asks the person to sign in and no mask
is made without a press.

The popup lists the person's masks for the current site with Copy and Turn off,
and the per-site switch.

The background worker holds the sign-in and is the only caller of OpenVibe.Mail;
the token never enters the page. The page script runs in the extension's isolated
world, asks the worker over chrome.runtime.sendMessage, and acts only on a real
press. The site comes from the tab's own address, never from the page's word.

The permission surface grows by one key (the background worker) and one host in
the CSP (openvibe.email, reached with CORS, not with a host permission). The house
audit and manifest test are widened deliberately: the background key, the
runtime messaging in three files, the fourth injected file, and the closed shadow
root the button's words live in.

* Firefox: the background worker is a background page there

Firefox has no background service worker. The Firefox store build now carries the
same file as a background page, and the Firefox lint runs over that manifest
instead of the Chrome one in browser/.

Changes

ddiff --git a/browser/background.js b/browser/background.js
nnew file mode 100644
iindex 0000000..00b6f7e
---- /dev/null
++++ b/browser/background.js
@@@ -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;
++});
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
iindex 591d010..2ec5509 100644
---- a/browser/lib/config.js
++++ b/browser/lib/config.js
@@@ -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',
ddiff --git a/browser/lib/controller.js b/browser/lib/controller.js
iindex 7fc5d92..9f56b53 100644
---- a/browser/lib/controller.js
++++ b/browser/lib/controller.js
@@@ -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));
ddiff --git a/browser/lib/mail-api.js b/browser/lib/mail-api.js
nnew file mode 100644
iindex 0000000..d426fc4
---- /dev/null
++++ b/browser/lib/mail-api.js
@@@ -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 };
++});
ddiff --git a/browser/lib/mask-fill.js b/browser/lib/mask-fill.js
nnew file mode 100644
iindex 0000000..b9e89fc
---- /dev/null
++++ b/browser/lib/mask-fill.js
@@@ -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 };
++});
ddiff --git a/browser/lib/mask-panel.js b/browser/lib/mask-panel.js
nnew file mode 100644
iindex 0000000..1b3d01d
---- /dev/null
++++ b/browser/lib/mask-panel.js
@@@ -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 };
++});
ddiff --git a/browser/lib/mask-router.js b/browser/lib/mask-router.js
nnew file mode 100644
iindex 0000000..ea3d273
---- /dev/null
++++ b/browser/lib/mask-router.js
@@@ -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 };
++});
ddiff --git a/browser/lib/mask-service.js b/browser/lib/mask-service.js
nnew file mode 100644
iindex 0000000..af64a5b
---- /dev/null
++++ b/browser/lib/mask-service.js
@@@ -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 };
++});
ddiff --git a/browser/lib/mask.js b/browser/lib/mask.js
nnew file mode 100644
iindex 0000000..0a69c02
---- /dev/null
++++ b/browser/lib/mask.js
@@@ -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,
++ };
++});
ddiff --git a/browser/lib/platform.js b/browser/lib/platform.js
iindex 1d66526..07c52ae 100644
---- a/browser/lib/platform.js
++++ b/browser/lib/platform.js
@@@ -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'] }]);
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 11f4288..ea68fb9 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -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": {
ddiff --git a/browser/popup.html b/browser/popup.html
iindex 2d6d40f..d8164b4 100644
---- a/browser/popup.html
++++ b/browser/popup.html
@@@ -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>
ddiff --git a/browser/popup.js b/browser/popup.js
iindex cc033ec..aea2063 100644
---- a/browser/popup.js
++++ b/browser/popup.js
@@@ -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 }));
ddiff --git a/package.json b/package.json
iindex dbcbfae..5ac22b3 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/scripts/audit.js b/scripts/audit.js
iindex bee9d0f..e8e326a 100644
---- a/scripts/audit.js
++++ b/scripts/audit.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 };
ddiff --git a/scripts/lint-firefox.js b/scripts/lint-firefox.js
nnew file mode 100644
iindex 0000000..8426767
---- /dev/null
++++ b/scripts/lint-firefox.js
@@@ -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 };
ddiff --git a/scripts/pack.js b/scripts/pack.js
iindex 4423d65..7fff0d5 100644
---- a/scripts/pack.js
++++ b/scripts/pack.js
@@@ -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 field
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');
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 };
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex a232f00..7bfa558 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -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');
ddiff --git a/test/manifest.test.js b/test/manifest.test.js
iindex a5d3d3c..af4bed2 100644
---- a/test/manifest.test.js
++++ b/test/manifest.test.js
@@@ -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`);
ddiff --git a/test/mask.test.js b/test/mask.test.js
nnew file mode 100644
iindex 0000000..c41f192
---- /dev/null
++++ b/test/mask.test.js
@@@ -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();
++})();
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex ea92b67..e16b231 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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)));