openvibers/openvibe.extensions Commits
Companion 0.8.0: one-click Dock sign-in (OAuth public client) (#11)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++# The Chrome manifest key is the extension's PUBLIC key. It fixes the extension id so the exact++# chromiumapp.org redirect can be registered; the private key is not in this repository. Gitleaks'++# generic-api-key rule sees the base64 blob, so this allowlist is narrow: this file AND this value.++[[allowlists]]++description = "Chrome extension public key in browser/manifest.json (no private key is stored)"++paths = ['''browser/manifest\.json''']++regexTarget = "secret"++regexes = ['''MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt\+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB''']@@@ -75,12 +75,12 @@ the Companion never rewrites links or adds referral tags.| | ||---|---|--| **Permissions** | `activeTab`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |++| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. || **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. || **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. || **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. || **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |--| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |++| **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. || **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |### The credential: an install token, never a session@@@ -112,11 +112,13 @@ released OpenVibe.Shared Dock, bundled under `browser/shared/` because the extenremote script, and it reads and writes the same state on the Network as the Dock on a site, so itopens where the person left off.--It needs the person's own OpenVibe sign-in (the token the Network gives every OpenVibe site). The--Companion had no OpenVibe session of its own — the `cpx_` install token is not one — so the settings--page asks for the OpenVibe token once, the same way it asks for the Coupons one, and the panel says--what is missing instead of pretending. Nothing is sent anywhere but the three OpenVibe origins above,--and the token stays in `storage.local`, on this device.++It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the++Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no++secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.++The access token is kept in `storage.session`; the rotating refresh token in `storage.local`, on this++device. Sign out revokes it at the Network, and the account page (`/my`) lists it under **Signed-in++apps**. The 0.7.0 pasted-token path is still in the settings page as a fallback until a later release++removes it.### Layout@@@ -125,7 +127,7 @@ browser/manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a sitepanel.css the Dock fills the panel (no floating card, no pill)-- shared/ the released OpenVibe.Shared Dock (v4.54.0), bundled: the CSP allows no remote script++ shared/ the released OpenVibe.Shared Dock (v4.57.0), bundled: the CSP allows no remote scriptpopup.html / popup.js DOM wiring onlyoptions.html / .js connect (save a checked token), disconnect, grant the API host permissionlib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)@@@ -136,6 +138,7 @@ browser/lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you presslib/api.js the only code that makes requests to the Coupons APIlib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own token++ lib/auth.js the Dock's one-click OAuth sign-in: PKCE, state, refresh, sign-outlib/controller.js popup logic without the DOM (tested in Node)lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissionslib/format.js wording ("Expiry unknown", "No restrictions stated", …)@@@ -155,7 +158,7 @@ npm test| Requirement | Test ||---|---|--| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` and `lib/network-api.js` fetch. | `test/manifest.test.js` |++| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js` and `lib/auth.js` fetch. | `test/manifest.test.js` || API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` || The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` || The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |@@@ -5,11 +5,11 @@"wave": "W18","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",-- "version": "0.7.0",++ "version": "0.8.0","surfaces": {"browser": {"name": "OpenVibe Companion",-- "version": "0.7.0",++ "version": "0.8.0","manifest": 3,"targets": ["chromium >= 114",@@@ -18,6 +18,7 @@"api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)","permissions": ["activeTab",++ "identity","scripting","storage","sidePanel (Chrome only; Firefox needs no permission for sidebar_action)"@@@ -27,9 +28,9 @@"https://openvibe.network/*","https://openvibe.chat/*"],-- "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the person's own OpenVibe token for the Dock in the side panel (opt-in, in the settings); the Coupons token is never an OpenVibe session",++ "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release","publishedToStores": false,-- "loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",++ "loadedInChrome": "yes: headless Chromium loads the build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js","loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors","handoffs": ["https://openvibe.rent/board (keep a listing)",@@@ -39,11 +40,20 @@"https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"],"storeBuilds": "npm run pack:stores: dist/openvibe-companion-<version>-chrome.zip and -firefox.zip, reproducible, audited",-- "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion"++ "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",++ "features": [++ "OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes"++ ],++ "pending": [++ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",++ "Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",++ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"++ ]}},"pending": [-- "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; then put the Chrome extension id in COUPONS_EXTENSION_ORIGINS",++ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key","Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)","use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"],@@@ -53,8 +63,8 @@"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median","watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food","saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",-- "an optional cpx_ install token, never an OpenVibe session; and, for the Dock in the side panel, the person's own OpenVibe token (opt-in, in the settings)",-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.54.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity)",++ "an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes","store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI","kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live","hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"nnew file mode 100644@@@ -0,0 +1,257 @@++/*++ * The Dock's one-click sign-in (OAuth authorization code + PKCE S256, public client `companion`).++ *++ * The extension never holds a client secret. Sign in opens the browser's own auth flow++ * (identity.launchWebAuthFlow) at the Network; the code comes back to the browser-owned extension++ * redirect URL, is exchanged for an access token and a rotating refresh token, and is never put in a++ * page URL we control. The access token lives in storage.session (memory, this browser run); the++ * refresh token lives in storage.local (this device). Sign out revokes the refresh family at the++ * Network before clearing both.++ *++ * The 0.7.0 pasted `networkToken` is still accepted as a fallback until a later release removes it:++ * token() prefers a live OAuth grant and falls back to that string. It is a normal Network session,++ * not this client's scoped token.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api;++ else root.OVAuth = api;++})(typeof self !== 'undefined' ? self : globalThis, function () {++ 'use strict';++ const root = typeof self !== 'undefined' ? self : globalThis;++++ class AuthError extends Error {++ constructor(code, message) { super(message || code); this.code = code; }++ }++++ const enc = new TextEncoder();++ function bytes(n) {++ const out = new Uint8Array(n);++ root.crypto.getRandomValues(out);++ return out;++ }++ function b64url(input) {++ const b = typeof input === 'string' ? enc.encode(input) : new Uint8Array(input);++ let s = '';++ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);++ return root.btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');++ }++ async function challengeFor(verifier) {++ const digest = await root.crypto.subtle.digest('SHA-256', enc.encode(verifier));++ return b64url(digest);++ }++ function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try {++ const b = parts[1].replace(/-/g, '+').replace(/_/g, '/');++ return JSON.parse(root.atob(b + '='.repeat((4 - b.length % 4) % 4)));++ } catch { return null; }++ }++ const scopeList = (v) => String(v || '').split(/\s+/).filter(Boolean);++ const subset = (a, b) => a.every((s) => b.includes(s));++++ function createAuth(opts) {++ const platform = opts.platform;++ const config = opts.config;++ const doFetch = opts.fetch || root.fetch.bind(root);++ const now = opts.now || (() => Date.now());++ const pending = new Map();++ let refreshing = null;++++ const err = (code, message) => new AuthError(code, message);++++ async function getAccess() {++ const acc = await platform.storage.getSession(config.DOCK_ACCESS_KEY);++ return acc && typeof acc.token === 'string' ? acc : null;++ }++ async function getRefresh() {++ const r = await platform.storage.getLocal(config.DOCK_REFRESH_KEY);++ return r && typeof r.token === 'string' ? r : null;++ }++ async function getAccount() {++ const a = await platform.storage.getLocal(config.DOCK_ACCOUNT_KEY);++ return a && typeof a === 'object' ? a : null;++ }++ async function legacy() {++ return await platform.storage.getNetworkToken();++ }++++ function storeGrant(body, requested) {++ const scope = String(body.scope || '');++ const scopes = scopeList(scope);++ if (requested && !subset(scopes, requested)) throw err('auth.scope', 'the Network granted a scope this extension did not ask for');++ const claims = decodePayload(body.access_token) || {};++ const account = { sub: claims.sub || null, username: claims.username || null, display_name: claims.display_name || null };++ const access = { token: body.access_token, expires_at: now() + (Number(body.expires_in) || 3600) * 1000, scope, ...account };++ const refresh = { token: body.refresh_token, scope, ...account };++ return Promise.resolve()++ .then(() => platform.storage.setSession({ [config.DOCK_ACCESS_KEY]: access }))++ .then(() => platform.storage.setLocal({ [config.DOCK_REFRESH_KEY]: refresh, [config.DOCK_ACCOUNT_KEY]: account }))++ .then(() => access);++ }++++ async function postToken(params) {++ let res;++ try {++ res = await doFetch(config.NETWORK_ORIGIN + '/oauth/token', {++ method: 'POST',++ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },++ body: new URLSearchParams(params).toString(),++ credentials: 'omit',++ referrerPolicy: 'no-referrer',++ });++ } catch {++ throw err('auth.network', 'could not reach OpenVibe.Network');++ }++ const body = await res.json().catch(() => ({}));++ if (!res.ok) throw err(body.error || `auth.http_${res.status}`, body.error_description);++ if (!body.access_token || !body.refresh_token) throw err('auth.invalid', 'the Network did not return a token pair');++ return body;++ }++++ function parseRedirect(finalUrl, state, redirectUri) {++ let got;++ let want;++ try { got = new URL(String(finalUrl)); want = new URL(redirectUri); } catch { throw err('auth.redirect', 'the sign-in returned an address this extension cannot read'); }++ if (got.origin !== want.origin || got.pathname !== want.pathname) throw err('auth.redirect', 'the sign-in returned to an address that is not this extension');++ if (got.searchParams.get('state') !== state) throw err('auth.state', 'the sign-in state did not match');++ const error = got.searchParams.get('error');++ if (error) {++ const code = error === 'access_denied' ? 'auth.cancelled'++ : error === 'login_required' ? 'auth.login_required'++ : error === 'invalid_scope' ? 'auth.scope' : 'auth.server';++ throw err(code, got.searchParams.get('error_description') || error);++ }++ const code = got.searchParams.get('code');++ if (!code) throw err('auth.invalid', 'the sign-in returned no code');++ return code;++ }++++ async function signIn({ interactive = true } = {}) {++ let redirectUri;++ try { redirectUri = platform.identity.getRedirectURL(); } catch { throw err('auth.redirect', 'this browser cannot start the sign-in'); }++ if (!config.OAUTH_REDIRECT_URIS.includes(redirectUri)) throw err('auth.redirect', 'this build’s redirect URL is not registered with OpenVibe.Network');++ const verifier = b64url(bytes(32));++ const state = b64url(bytes(32));++ const url = new URL(config.NETWORK_ORIGIN + '/oauth/authorize');++ url.searchParams.set('client_id', config.OAUTH_CLIENT_ID);++ url.searchParams.set('redirect_uri', redirectUri);++ url.searchParams.set('response_type', 'code');++ url.searchParams.set('scope', config.OAUTH_SCOPES.join(' '));++ url.searchParams.set('state', state);++ url.searchParams.set('code_challenge', await challengeFor(verifier));++ url.searchParams.set('code_challenge_method', 'S256');++ pending.set(state, { verifier, redirectUri, at: now() });++ // A stale flow from a closed panel cannot be redeemed; prune.++ for (const [k, v] of pending) if (now() - v.at > 10 * 60 * 1000) pending.delete(k);++++ let finalUrl;++ try {++ finalUrl = await platform.identity.launchWebAuthFlow({ url: url.toString(), interactive });++ } catch (e) {++ pending.delete(state);++ const m = String(e && e.message || '');++ if (/cancel|did not approve|closed/i.test(m)) throw err('auth.cancelled', 'sign-in was cancelled');++ throw err('auth.network', 'the sign-in window could not be opened');++ }++ const saved = pending.get(state);++ pending.delete(state);++ if (!saved) throw err('auth.state', 'the sign-in state was lost');++ if (!finalUrl) throw err('auth.cancelled', 'sign-in was cancelled');++ const code = parseRedirect(finalUrl, state, redirectUri);++ const body = await postToken({++ grant_type: 'authorization_code',++ client_id: config.OAUTH_CLIENT_ID,++ code,++ redirect_uri: redirectUri,++ code_verifier: verifier,++ });++ return await storeGrant(body, config.OAUTH_SCOPES);++ }++++ async function refresh() {++ if (refreshing) return refreshing;++ refreshing = (async () => {++ const saved = await getRefresh();++ if (!saved) throw err('auth.required', 'sign in to OpenVibe.Network');++ try {++ const body = await postToken({ grant_type: 'refresh_token', client_id: config.OAUTH_CLIENT_ID, refresh_token: saved.token });++ return (await storeGrant(body, config.OAUTH_SCOPES)).token;++ } catch (e) {++ // Two panels of one extension can refresh at once. The Network refuses the loser++ // inside its 10 s grace without revoking the family; if the other panel wrote the new++ // token, use it. A real invalid_grant means sign in again.++ if (e.code === 'invalid_grant') {++ const again = await getRefresh();++ if (again && again.token !== saved.token) {++ const acc = await getAccess();++ if (acc) return acc.token;++ }++ await clearOAuth();++ throw err('auth.required', 'sign in to OpenVibe.Network again');++ }++ throw e;++ }++ })().finally(() => { refreshing = null; });++ return refreshing;++ }++++ async function accessToken() {++ const acc = await getAccess();++ if (acc && acc.expires_at > now() + 60_000) return acc.token;++ try {++ return await refresh();++ } catch (e) {++ // 0.7.0 compatibility: a pasted normal Network token still works until a later release.++ const old = await legacy();++ if (old) return old;++ throw e;++ }++ }++++ async function clearOAuth() {++ await Promise.all([++ platform.storage.removeSession([config.DOCK_ACCESS_KEY]),++ platform.storage.removeLocal([config.DOCK_REFRESH_KEY, config.DOCK_ACCOUNT_KEY]),++ ]);++ }++++ async function signOut() {++ const saved = await getRefresh();++ if (saved) {++ try {++ await doFetch(config.NETWORK_ORIGIN + '/oauth/revoke', {++ method: 'POST',++ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },++ body: new URLSearchParams({ client_id: config.OAUTH_CLIENT_ID, token: saved.token }).toString(),++ credentials: 'omit',++ referrerPolicy: 'no-referrer',++ });++ } catch { /* clear locally anyway; the account page can revoke */ }++ }++ pending.clear();++ await clearOAuth();++ await platform.storage.clearNetworkToken();++ }++++ async function status() {++ const [acc, ref, old] = await Promise.all([getAccess(), getRefresh(), legacy()]);++ if (acc || ref) return { mode: 'oauth', account: await getAccount() };++ if (old) return { mode: 'legacy', account: null };++ return { mode: 'none', account: null };++ }++++ async function scopes() {++ const acc = await getAccess();++ if (acc && acc.scope) return scopeList(acc.scope);++ const ref = await getRefresh();++ if (ref && ref.scope) return scopeList(ref.scope);++ return null; // legacy string: a full session, let the Dock show its normal controls++ }++++ return { signIn, refresh, accessToken, token: accessToken, signOut, status, scopes, account: getAccount, clear: clearOAuth, AuthError };++ }++++ return { createAuth, AuthError, _b64url: b64url, _challengeFor: challengeFor, _decodePayload: decodePayload };++});@@@ -31,6 +31,21 @@// origins the side panel may reach besides the API origin.NETWORK_ORIGIN: 'https://openvibe.network',CHAT_ORIGIN: 'https://openvibe.chat',++ // One-click Dock sign-in (OAuth public client, no secret). The redirect URI is the browser's own++ // extension URL; the exact values are also in the Network client row. A build whose runtime++ // 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']),++ OAUTH_REDIRECT_URIS: Object.freeze([++ 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',++ 'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',++ ]),++ // The OAuth grant: access in storage.session (memory), refresh in storage.local (this device).++ DOCK_ACCESS_KEY: 'dockAccess',++ DOCK_REFRESH_KEY: 'dockRefresh',++ 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',MAX_TRIES: 10,CONNECT_PATH: '/connect-extension',@@@ -32,6 +32,7 @@function createPlatform(ext, config) {const origins = [config.API_ORIGIN + '/*', config.NETWORK_ORIGIN + '/*', config.CHAT_ORIGIN + '/*'];++ const sessionMemory = new Map();const call = function (fn, thisArg, args) {// chrome.* in MV3 and browser.* both return promises.return fn.apply(thisArg, args);@@@ -74,7 +75,40 @@return out && out[0] ? out[0].result : null;},},++ identity: {++ /** The browser's own redirect URL for this extension (Chrome chromiumapp.org / Firefox extensions.allizom.org). */++ getRedirectURL() {++ if (!ext.identity || typeof ext.identity.getRedirectURL !== 'function') throw new TypeError('this browser has no identity API');++ return ext.identity.getRedirectURL();++ },++ /** Open the Network's authorize page; resolves with its final redirect URL. */++ launchWebAuthFlow(opts) {++ if (!ext.identity || typeof ext.identity.launchWebAuthFlow !== 'function') throw new TypeError('this browser has no identity API');++ return call(ext.identity.launchWebAuthFlow, ext.identity, [opts]);++ },++ },storage: {++ // Generic helpers: the OAuth access token is in storage.session (memory, per browser run),++ // the refresh token is in storage.local (this device). If a browser has no session area,++ // the access token stays in a module map rather than falling back to disk.++ async getSession(key) {++ if (ext.storage.session) { const out = await call(ext.storage.session.get, ext.storage.session, [key]); return out ? out[key] : null; }++ return sessionMemory.has(key) ? sessionMemory.get(key) : null;++ },++ async setSession(values) {++ if (ext.storage.session) return call(ext.storage.session.set, ext.storage.session, [values]);++ for (const k of Object.keys(values)) sessionMemory.set(k, values[k]);++ },++ async removeSession(keys) {++ if (ext.storage.session) return call(ext.storage.session.remove, ext.storage.session, [keys]);++ for (const k of [].concat(keys)) sessionMemory.delete(k);++ },++ async getLocal(key) {++ const out = await call(ext.storage.local.get, ext.storage.local, [key]);++ return out ? out[key] : null;++ },++ async setLocal(values) { await call(ext.storage.local.set, ext.storage.local, [values]); },++ async removeLocal(keys) { await call(ext.storage.local.remove, ext.storage.local, [keys]); },async getToken() {const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);const t = out && out[config.TOKEN_KEY];@@@ -2,7 +2,8 @@"manifest_version": 3,"name": "OpenVibe Companion","short_name": "OpenVibe",-- "version": "0.7.0",++ "version": "0.8.0",++ "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.","homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions","author": "OpenVibers",@@@ -30,6 +31,7 @@},"permissions": ["activeTab",++ "identity","scripting","storage"],@@@ -23,17 +23,19 @@<p id="permission-row" hidden><button id="grant" type="button">Allow access to the OpenVibe sites</button> <span class="muted">Needed to report a code and to read your Dock; the Companion never asks for access to other sites.</span></p><h2>The Dock in the side panel</h2><p>The Dock shows what your agents are doing, what your chats wait for and what just happened, on every site, in the side panel. It is the same Dock, and it keeps the same place.</p>-- <ol>-- <li>Sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>.</li>-- <li>Paste your OpenVibe token here. It is the one your browser already has for OpenVibe (in the page, <code>localStorage.ov_token</code>); it stays on this device and goes only to openvibe.network.</li>-- </ol>-- <form id="network-form">-- <label for="network-token">OpenVibe token</label>-- <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">-- <button type="submit">Save</button>-- </form>++ <p>Press <strong>Sign in</strong> once: the Network asks which account to use, and the extension keeps a read-only Dock sign-in that refreshes itself. The access token is kept in memory; the refresh token stays on this device. Sign out revokes it at the Network.</p><p id="network-state" class="muted" aria-live="polite"></p>-- <p><button id="network-disconnect" type="button">Disconnect the Dock</button> <span class="muted">Removes the OpenVibe token from this browser.</span></p>++ <p><button id="network-signin" type="button">Sign in to OpenVibe.Network</button> <button id="network-signout" type="button" hidden>Sign out</button></p>++ <details>++ <summary>Use a pasted token instead (older connection)</summary>++ <p>The 0.7.0 way: sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>, then paste the token your browser has for OpenVibe (in the page, <code>localStorage.ov_token</code>). This is a full OpenVibe session, not the extension's scoped sign-in; it stays on this device and is still accepted until a later release removes it.</p>++ <form id="network-form">++ <label for="network-token">OpenVibe token</label>++ <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">++ <button type="submit">Save</button>++ </form>++ <p><button id="network-disconnect" type="button">Remove the pasted token</button> <span class="muted">This does not sign out the one-click connection; use Sign out for that.</span></p>++ </details><h2>What this extension can see</h2><ul><li>The tab you are on, and only when you open the Companion: its address and title.</li>@@@ -46,6 +48,7 @@<script src="lib/api.js"></script><script src="lib/network-api.js"></script><script src="lib/platform.js"></script>++<script src="lib/auth.js"></script><script src="options.js"></script></body></html>@@@ -7,8 +7,9 @@const ext = typeof browser !== 'undefined' ? browser : 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 api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });-- const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getNetworkToken, timeoutMs: config.TIMEOUT_MS });++ const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: function () { return auth.accessToken(); }, timeoutMs: config.TIMEOUT_MS });const state = document.getElementById('state');const networkState = document.getElementById('network-state');const networkInput = document.getElementById('network-token');@@@ -38,10 +39,43 @@});async function refreshNetwork() {-- const token = await platform.storage.getNetworkToken();-- networkState.textContent = token ? 'Connected (the Dock in the side panel works).' : 'Not connected: the side panel asks for your OpenVibe sign-in.';++ const status = await auth.status();++ const legacy = await platform.storage.getNetworkToken();++ const signIn = document.getElementById('network-signin');++ const signOut = document.getElementById('network-signout');++ if (status.mode === 'oauth') {++ const who = status.account && status.account.username ? ' as @' + status.account.username : '';++ networkState.textContent = 'Connected' + who + ' — the Dock in the side panel works.';++ } else if (legacy) {++ networkState.textContent = 'Connected with a pasted token. Sign in for the one-click, scoped connection; the old token is still accepted for now.';++ } else {++ networkState.textContent = 'Not connected: the side panel asks you to sign in.';++ }++ signIn.hidden = false;++ signOut.hidden = status.mode !== 'oauth' && !legacy;}++ document.getElementById('network-signin').addEventListener('click', async function () {++ const btn = this;++ btn.disabled = true;++ networkState.textContent = 'Opening OpenVibe.Network…';++ try {++ await auth.signIn({ interactive: true });++ } catch (e) {++ networkState.textContent = e && e.code === 'auth.cancelled' ? 'Sign-in was cancelled.' : 'Sign-in could not be completed. Try again.';++ } finally {++ btn.disabled = false;++ await refreshNetwork();++ }++ });++++ document.getElementById('network-signout').addEventListener('click', async function () {++ const btn = this;++ btn.disabled = true;++ try { await auth.signOut(); } catch { /* the local grant is cleared in any case */ }++ finally { btn.disabled = false; await refreshNetwork(); }++ });++document.getElementById('network-form').addEventListener('submit', async function (ev) {ev.preventDefault();const token = networkInput.value.trim();@@@ -11,11 +11,12 @@<section id="gate" hidden><h1 id="gate-title">Sign in to OpenVibe.Network</h1><p id="gate-text">The Dock shows what your agents are doing and what your chats wait for.</p>-- <button id="gate-button" type="button" hidden>Open the settings</button>++ <button id="gate-button" type="button" hidden>Sign in</button></section><script src="lib/config.js"></script><script src="lib/platform.js"></script><script src="lib/network-api.js"></script>++<script src="lib/auth.js"></script><script src="shared/ov-mark.js"></script><script src="shared/dock.js"></script><script src="panel.js"></script>@@@ -7,9 +7,9 @@* person's Network sign-in, so it reads and writes the same state as the Dock on a site: the* same tabs (Agents, Chats, Activity) and the same place.*-- * The extension had no Network sign-in of its own (it holds the Coupons install token, which is-- * not an OpenVibe sign-in), so the settings page asks for the person's Network token once, the-- * same way it asks for the Coupons one. Without it the panel says so instead of pretending.++ * The sign-in is the Network's OAuth public client (lib/auth.js): one press, PKCE, a scoped access++ * token in storage.session and a rotating refresh token in storage.local. The 0.7.0 pasted++ * `networkToken` is still accepted as a fallback until a later release removes it.*/(function (root, factory) {const api = factory();@@@ -33,38 +33,69 @@gate.hidden = false;}++ function messageFor(e) {++ const code = e && e.code;++ if (code === 'auth.cancelled') return 'Sign-in was cancelled.';++ if (code === 'auth.login_required') return 'Sign in to OpenVibe.Network first, then try again.';++ if (code === 'auth.scope') return 'This version of the extension asks for something this Network does not offer.';++ if (code === 'auth.redirect') return 'This build’s sign-in address is not registered. Update the extension.';++ if (code === 'auth.network') return 'Could not reach openvibe.network. Try again.';++ return 'Sign-in could not be completed. Try again.';++ }++/** The Dock, with the Network's state, or the gate that says what is missing. */async function start() {-- const token = await opts.platform.storage.getNetworkToken();-- if (!token) {++ let status;++ try { status = await opts.auth.status(); } catch { status = { mode: 'none' }; }++ if (!status || status.mode === 'none') {showGate('Sign in to OpenVibe.Network','The Dock shows what your agents are doing and what your chats wait for. It needs your OpenVibe.Network sign-in.',-- 'Open the settings');++ 'Sign in');return { mounted: false, reason: 'no-token' };}try {await opts.net.dock();} catch (err) {showGate('OpenVibe.Network said no',-- 'That sign-in was not accepted. Sign in to OpenVibe.Network again and paste a fresh token in the settings.',-- 'Open the settings');++ 'That sign-in was not accepted. Sign in to OpenVibe.Network again.',++ 'Sign in');return { mounted: false, reason: 'refused' };}++ const scopes = await opts.auth.scopes();++ const account = await opts.auth.account();opts.dock.mount({-- token: token,++ token: function () { return opts.auth.accessToken(); },++ scopes: scopes,++ me: account && account.username ? account.username : '',apiBase: opts.config.NETWORK_ORIGIN,chatBase: opts.config.CHAT_ORIGIN,sibling: opts.sibling,});opts.dock.open();gate.hidden = true;-- return { mounted: true, reason: null };++ return { mounted: true, reason: null, mode: status.mode };++ }++++ async function signIn() {++ if (!opts.auth) return;++ gateButton.disabled = true;++ const old = gateButton.textContent;++ gateButton.textContent = 'Signing in…';++ try {++ await opts.auth.signIn({ interactive: true });++ await start();++ } catch (e) {++ showGate('Sign-in was not completed', messageFor(e), 'Try again');++ } finally {++ gateButton.disabled = false;++ if (gate.hidden) gateButton.textContent = old;++ }}if (gateButton && typeof gateButton.addEventListener === 'function') {-- gateButton.addEventListener('click', function () { if (opts.openSettings) opts.openSettings(); });++ gateButton.addEventListener('click', function () { signIn(); });}-- return { start: start, showGate: showGate };++ return { start: start, signIn: signIn, showGate: showGate, messageFor: messageFor };}/** The page wiring: the real browser APIs and the real Dock. */@@@ -72,16 +103,18 @@const ext = root.browser || root.chrome;const config = root.OVConfig;const platform = root.OVPlatform.createPlatform(ext, config);++ const auth = root.OVAuth.createAuth({ platform: platform, config: config, fetch: self.fetch.bind(self) });const net = root.OVNetwork.createNetworkClient({origin: config.NETWORK_ORIGIN,fetch: self.fetch.bind(self),-- getToken: platform.storage.getNetworkToken,++ getToken: function () { return auth.accessToken(); },timeoutMs: config.TIMEOUT_MS,});const panel = createPanel({doc: root.document,config: config,platform: platform,++ auth: auth,net: net,dock: root.OpenVibeDock,sibling: function (name) { return ext.runtime.getURL('shared/' + name); },@@@ -47,20 +47,21 @@const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;++ /** A site's Dock passes no scopes (full behaviour); the extension passes its read-only set. */++ const canDecide = (o = opts) => !Array.isArray(o && o.scopes) || o.scopes.includes('actions.decide');function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };-- function apiWith(o, path, init = {}) {++ async function apiWith(o, path, init = {}) {const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };-- const t = o && (typeof o.token === 'function' ? o.token() : o.token);++ const t = o && (typeof o.token === 'function' ? await o.token() : o.token);if (t) headers.authorization = `Bearer ${t}`;-- return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {-- const body = await r.json().catch(() => ({}));-- if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }-- return body;-- });++ const r = await fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' });++ const body = await r.json().catch(() => ({}));++ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }++ return body;}const api = (path, init) => apiWith(opts, path, init);function ago(iso) {@@@ -207,7 +208,7 @@paint();}-- function itemHTML(it) {++ function itemHTML(it, o = opts) {const tone = TONE[it.bucket] || TONE.running;const meta = [`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,@@@ -216,7 +217,7 @@typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,].join('');-- const decides = it.bucket === 'waiting' && it.actions.includes('approve');++ const decides = canDecide(o) && it.bucket === 'waiting' && it.actions.includes('approve');const btns = decides? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';@@@ -359,7 +360,7 @@return;}list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')-- + items.map(itemHTML).join('')).join('');++ + items.map((it) => itemHTML(it, mo)).join('')).join('');};const refresh = async () => {if (gone || busy) return;@@@ -56,12 +56,15 @@const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);-- function call(path, init = {}) {++ async function call(path, init = {}) {const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');-- const t = typeof o.token === 'function' ? o.token() : o.token;++ const t = await tokenOf();const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };-- return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));++ const r = await fetch(base + path, { ...init, headers, credentials: 'omit' });++ return r.ok ? r.json() : null;}++ /** A token may be a string, a sync function or an async function (the extension refreshes it). */++ async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }const notify = () => { store(doc); paintPill(); };/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */@@@ -78,13 +81,7 @@const body = pending; pending = {};if (!Object.keys(body).length) return;try {-- let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });-- // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,-- // so the Dock's place and its tab still reach the Network.-- if (!r && 'popped' in body) {-- const rest = { ...body }; delete rest.popped;-- r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });-- }++ const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }}@@@ -427,21 +424,21 @@if (!ok) return failed(panel, id);panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;const m = root.OpenVibeActionCenter.mount(panel.firstChild, {-- token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],++ token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },});mounts[id] = m;panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {const anchor = e.currentTarget;close();-- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }++ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }});return;}const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));if (!ok) return failed(panel, id);const N = root.OpenVibeNotifications;-- if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }++ if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }panel.innerHTML = '';N.renderDock(panel);mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };@@@ -458,10 +455,12 @@const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');const me = () => String(o.me || '');++ const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;++ const canWriteChat = () => !scopes() || scopes().includes('chat.write');const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };-- function chatCall(path, init = {}) {-- const t = typeof o.token === 'function' ? o.token() : o.token;++ async function chatCall(path, init = {}) {++ const t = await tokenOf();const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {const b = await r.json().catch(() => ({}));@@@ -559,28 +558,33 @@const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));const err = h('p', 'ovdk-err'); err.hidden = true;form.append(box, go);-- panel.append(head, scroll, err, form);-- const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };-- box.addEventListener('input', fit);-- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });-- form.addEventListener('submit', async (e) => {-- e.preventDefault();-- const text = box.value.trim();-- if (!text || chats.busy) return;-- chats.busy = true; go.disabled = true; err.hidden = true;-- try {-- await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });-- box.value = ''; fit();-- await pull(list, scroll);-- } catch (x) {-- err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';-- err.hidden = false;-- } finally { chats.busy = false; go.disabled = false; box.focus(); }-- });++ panel.append(head, scroll, err);++ if (canWriteChat()) {++ panel.append(form);++ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };++ box.addEventListener('input', fit);++ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });++ form.addEventListener('submit', async (e) => {++ e.preventDefault();++ const text = box.value.trim();++ if (!text || chats.busy) return;++ chats.busy = true; go.disabled = true; err.hidden = true;++ try {++ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });++ box.value = ''; fit();++ await pull(list, scroll);++ } catch (x) {++ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';++ err.hidden = false;++ } finally { chats.busy = false; go.disabled = false; box.focus(); }++ });++ } else {++ panel.append(h('div', 'ovdk-none', 'Reading only. Answer in OpenVibe.Chat.'));++ }list.append(h('li', 'ovdk-wait', null));list.firstChild.innerHTML = '<span></span>';await pull(list, scroll, true);-- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);}/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */async function pull(list, scroll, first) {@@@ -630,20 +634,21 @@// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);}-- function read() {++ async function read() {clearTimeout(readT);if (!pill || document.hidden) return;-- const t = typeof o.token === 'function' ? o.token() : o.token;++ const t = await tokenOf();if (!t) return;-- fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })-- .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })-- .then((next) => {-- if (!next || !next.state || !pill) return;-- update(next);-- if (!isOpen() && next.state.open && !phone()) open();-- const c = next.counts || {};-- if (c.running || c.waiting) readT = setTimeout(read, 30000);-- }).catch(() => { /* offline: the copy stands */ });++ try {++ const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });++ if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }++ const next = r.ok ? await r.json() : null;++ if (!next || !next.state || !pill) return;++ update(next);++ if (!isOpen() && next.state.open && !phone()) open();++ const c = next.counts || {};++ if (c.running || c.waiting) readT = setTimeout(read, 30000);++ } catch { /* offline: the copy stands */ }}/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */function mount(opts) {@@@ -725,7 +730,9 @@win.document.body.appendChild(el);win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });paintPop();-- save({ open: true, popped: true });++ // Whether the Dock is in a window stays with this page: the window belongs to one page in one browser, closes++ // with it, and no other device could open it again (the browser wants a click), so the Network is not told.++ save({ open: true });paintPill();}@@@ -741,7 +748,6 @@el.hidden = false;layout();paintPop();-- save({ popped: false });paintPill();}@@@ -777,7 +783,7 @@document.documentElement.classList.remove('ovdk-sheet-open');clear(0);clearTimeout(chats.timer); clearTimeout(chats.unreadT);-- save(wasPopped ? { open: false, popped: false } : { open: false });++ save({ open: false });if (inside) {const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }@@@ -86,7 +86,7 @@agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],};-- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };++ let _config = { token: null, apiBase: 'https://openvibe.network', scopes: null, soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };let _pollTimer = null;let _lastSeenAt = null; // newest created_at we have toasted / seenlet _unreadCount = 0;@@@ -445,9 +445,14 @@function isCrossOrigin() {try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }}-- function apiFetch(path, opts = {}) {++ /** The token may be a string or a function (the extension refreshes it); resolve it per request. */++ async function tokenValue() {++ return typeof _config.token === 'function' ? await _config.token() : _config.token;++ }++ async function apiFetch(path, opts = {}) {const headers = { ...(opts.headers || {}) };-- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;++ const token = await tokenValue();++ if (token) headers['Authorization'] = `Bearer ${token}`;if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {headers['Content-Type'] = 'application/json';opts = { ...opts, body: JSON.stringify(opts.body) };@@@ -760,7 +765,7 @@}));}function openActions(anchor) {-- loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });++ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => tokenValue(), apiBase: _config.apiBase, scopes: _config.scopes }); });}function createActionsEl() {const b = document.createElement('div');@@@ -1217,7 +1222,7 @@if (!L || _live || !_config.realtime || !hasSession()) return;_live = L.create({ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,-- token: () => _config.token,++ token: () => tokenValue(),credentials: isCrossOrigin() ? 'omit' : 'include',onNotification: () => soon(false),onGap: () => soon(true),@@@ -1,6 +1,6 @@{"name": "openvibe-extensions",-- "version": "0.6.0",++ "version": "0.8.0","private": true,"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.","scripts": {@@@ -3,14 +3,16 @@* 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 + 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)++ * 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 and the Chat origin, all three* https://openvibe.<tld> sites (the coupon API, the Dock's state, the Dock's* Chats tab)++ * 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,* externally_connectable, optional_permissions, optional_host_permissions,* declarative_net_request, chrome_url_overrides, devtools_page, sandbox,@@@ -27,8 +29,8 @@ 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']);--const BASE_PERMISSIONS = ['activeTab', 'scripting', 'storage'];++ 'side_panel', 'sidebar_action', 'key']);++const BASE_PERMISSIONS = ['activeTab', 'identity', 'scripting', 'storage'];const SIDE_PERMISSIONS = ['sidePanel', 'sidebarAction'];const PERMISSIONS = BASE_PERMISSIONS.concat(['sidePanel']);@@@ -84,6 +84,7 @@ function forTarget(files, target) {} else if (target === 'firefox') {delete m.minimum_chrome_version;delete m.side_panel;++ delete m.key; // Chrome's public key is not a Firefox manifest fieldm.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');} else throw new Error('target must be chrome or firefox');files.set('manifest.json', Buffer.from(JSON.stringify(m, null, 2) + '\n'));@@@ -14,7 +14,8 @@ reads, sends or stores, change this file, the README's "What it can see" table a|---|---|| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. || `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |--| `storage` | Keeps the optional `cpx_` install token the person pastes on the settings page, in `storage.local` (never synced). Nothing else is stored. |++| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |++| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. || Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. || Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |@@@ -26,7 +27,7 @@ Tick these data types, because each leaves the device in some case:|---|---|---|| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. || Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |--| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. It is issued by openvibe.coupons and sent nowhere else. |++| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network and openvibe.chat as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. || User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. || Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. || Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++/**++ * The Dock's one-click sign-in (lib/auth.js): PKCE S256, state, no client secret, the access token in++ * storage.session, the rotating refresh token in storage.local, single-flight refresh, Sign out revoke,++ * and the 0.7.0 pasted-token fallback. No browser is started; identity/storage are fakes and fetch is a++ * queue.++ */++const assert = require('assert');++const crypto = require('crypto');++const config = require('../browser/lib/config');++const { createAuth } = require('../browser/lib/auth');++const { check, done } = require('./helpers/harness');++++const CHROME = config.OAUTH_REDIRECT_URIS[0];++const b64 = (buf) => Buffer.from(buf).toString('base64url');++const token = (sub = 7, username = 'ada') => `${b64(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${b64(JSON.stringify({ sub, username, display_name: 'Ada' }))}.sig`;++++function platform({ redirect = CHROME, legacy = null, launch } = {}) {++ const session = new Map(), local = new Map();++ let old = legacy;++ const copy = (map, values) => { for (const k of Object.keys(values)) map.set(k, values[k]); };++ return {++ session, local,++ get legacy() { return old; },++ identity: {++ getRedirectURL() { return redirect; },++ launchWebAuthFlow(opts) { return launch ? launch(opts) : Promise.reject(new Error('no launch')); },++ },++ storage: {++ async getSession(k) { return session.has(k) ? session.get(k) : null; },++ async setSession(v) { copy(session, v); },++ async removeSession(keys) { for (const k of [].concat(keys)) session.delete(k); },++ async getLocal(k) { return local.has(k) ? local.get(k) : null; },++ async setLocal(v) { copy(local, v); },++ async removeLocal(keys) { for (const k of [].concat(keys)) local.delete(k); },++ async getNetworkToken() { return old; },++ async setNetworkToken(t) { old = t; },++ async clearNetworkToken() { old = null; },++ },++ };++}++++function fetcher(responses) {++ const calls = [];++ const fetch = async (url, opts) => {++ const body = new URLSearchParams(opts.body);++ calls.push({ url, body: Object.fromEntries(body), headers: opts.headers });++ const r = responses.shift();++ if (r instanceof Error) throw r;++ return { ok: r.status < 400, status: r.status, json: async () => r.body };++ };++ fetch.calls = calls;++ return fetch;++}++++(async () => {++ await check('sign-in: exact authorize parameters, S256, state, code exchange with no secret', async () => {++ let issuedChallenge = null;++ const p = platform({++ launch(opts) {++ const u = new URL(opts.url);++ const state = u.searchParams.get('state');++ assert.strictEqual(opts.interactive, true);++ assert.strictEqual(u.origin + u.pathname, config.NETWORK_ORIGIN + '/oauth/authorize');++ 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');++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');++ assert.match(state, /^[A-Za-z0-9_-]{43}$/);++ issuedChallenge = u.searchParams.get('code_challenge');++ assert.match(issuedChallenge, /^[A-Za-z0-9_-]{43}$/);++ return Promise.resolve(`${CHROME}?code=code123&state=${encodeURIComponent(state)}`);++ },++ });++ const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r'.repeat(96), expires_in: 3600, scope: 'dock activity chat.read' } }]);++ const auth = createAuth({ platform: p, config, fetch: f });++ const acc = await auth.signIn();++ assert.strictEqual(acc.token, token());++ assert.strictEqual(f.calls.length, 1);++ const ex = f.calls[0];++ assert.strictEqual(ex.url, config.NETWORK_ORIGIN + '/oauth/token');++ assert.strictEqual(ex.body.grant_type, 'authorization_code');++ assert.strictEqual(ex.body.client_id, 'companion');++ assert.strictEqual(ex.body.code, 'code123');++ assert.strictEqual(ex.body.redirect_uri, CHROME);++ assert.ok(!('client_secret' in ex.body), 'a public client never sends a secret');++ assert.match(ex.body.code_verifier, /^[A-Za-z0-9_-]{43}$/);++ const challenge = b64(crypto.createHash('sha256').update(ex.body.code_verifier).digest());++ assert.strictEqual(issuedChallenge, challenge, 'the authorize challenge is S256 of the verifier sent at exchange');++ assert.ok(p.session.get(config.DOCK_ACCESS_KEY).token);++ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY).expires_at > Date.now(), true);++ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY).token, 'r'.repeat(96));++ assert.deepStrictEqual(p.local.get(config.DOCK_ACCOUNT_KEY), { sub: 7, username: 'ada', display_name: 'Ada' });++ assert.deepStrictEqual(await auth.scopes(), ['dock', 'activity', 'chat.read']);++ assert.strictEqual((await auth.status()).mode, 'oauth');++ });++++ await check('state mismatch and a wrong redirect reject before any token exchange', async () => {++ let p = platform({ launch: async (opts) => `${CHROME}?code=c&state=wrong` });++ let f = fetcher([]);++ let auth = createAuth({ platform: p, config, fetch: f });++ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.state');++ assert.strictEqual(f.calls.length, 0);++ p = platform({ launch: async () => 'https://evil.example/cb?code=c&state=x' });++ auth = createAuth({ platform: p, config, fetch: fetcher([]) });++ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.redirect');++ });++++ await check('a cancelled window is auth.cancelled; a redirect error is mapped', async () => {++ let p = platform({ launch: async () => { throw new Error('The user did not approve access.'); } });++ let auth = createAuth({ platform: p, config, fetch: fetcher([]) });++ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.cancelled');++ p = platform({ launch: async (opts) => `${CHROME}?error=access_denied&state=${new URL(opts.url).searchParams.get('state')}` });++ auth = createAuth({ platform: p, config, fetch: fetcher([]) });++ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.cancelled');++ });++++ await check('refresh rotates, single-flight, and never sends a secret', async () => {++ const p = platform();++ p.session.set(config.DOCK_ACCESS_KEY, { token: 'old', expires_at: 0, scope: 'dock activity chat.read', sub: 7, username: 'ada' });++ p.local.set(config.DOCK_REFRESH_KEY, { token: 'r1', scope: 'dock activity chat.read', sub: 7, username: 'ada' });++ const f = fetcher([++ { status: 200, body: { access_token: token(), refresh_token: 'r2', expires_in: 3600, scope: 'dock activity chat.read' } },++ ]);++ const auth = createAuth({ platform: p, config, fetch: f });++ const [a, b] = await Promise.all([auth.refresh(), auth.refresh()]);++ assert.strictEqual(a, b, 'both callers get one refreshed access token');++ assert.strictEqual(f.calls.length, 1, 'single-flight');++ assert.strictEqual(f.calls[0].body.grant_type, 'refresh_token');++ assert.strictEqual(f.calls[0].body.refresh_token, 'r1');++ assert.ok(!('client_secret' in f.calls[0].body));++ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY).token, 'r2');++ });++++ await check('a grant with a scope the extension did not ask for is refused and not stored', async () => {++ const p = platform({ launch: async (opts) => `${CHROME}?code=c&state=${new URL(opts.url).searchParams.get('state')}` });++ const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r', expires_in: 3600, scope: 'dock coins' } }]);++ const auth = createAuth({ platform: p, config, fetch: f });++ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.scope');++ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY), undefined);++ });++++ await check('Sign out revokes the refresh token and clears both stores; the pasted token is cleared too', async () => {++ const p = platform({ legacy: 'old-normal-token' });++ p.session.set(config.DOCK_ACCESS_KEY, { token: 'a', expires_at: Date.now() + 3600e3 });++ p.local.set(config.DOCK_REFRESH_KEY, { token: 'r9' });++ p.local.set(config.DOCK_ACCOUNT_KEY, { username: 'ada' });++ const f = fetcher([{ status: 200, body: {} }]);++ const auth = createAuth({ platform: p, config, fetch: f });++ await auth.signOut();++ assert.strictEqual(f.calls.length, 1);++ assert.strictEqual(f.calls[0].url, config.NETWORK_ORIGIN + '/oauth/revoke');++ assert.deepStrictEqual(f.calls[0].body, { client_id: 'companion', token: 'r9' });++ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY), undefined);++ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY), undefined);++ assert.strictEqual(p.local.get(config.DOCK_ACCOUNT_KEY), undefined);++ assert.strictEqual(p.legacy, null);++ });++++ await check('the 0.7.0 pasted token is a fallback and is reported as legacy', async () => {++ const p = platform({ legacy: 'old-normal-token' });++ const auth = createAuth({ platform: p, config, fetch: fetcher([]) });++ assert.strictEqual(await auth.accessToken(), 'old-normal-token');++ assert.deepStrictEqual(await auth.status(), { mode: 'legacy', account: null });++ assert.strictEqual(await auth.scopes(), null);++ });++++ done();++})();@@@ -16,10 +16,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:(async () => {const { manifest, origin, problems } = auditDir(DIR);-- await check('the shipped manifest passes the audit: activeTab + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {++ await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {assert.deepStrictEqual(problems, []);assert.strictEqual(origin, ORIGIN);-- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', '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.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']) {@@@ -27,6 +27,17 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:}});++ await check('the public key fixes the Chrome id and the gecko id fixes the Firefox redirect', async () => {++ const crypto = require('crypto');++ const der = Buffer.from(manifest.key, 'base64');++ const hash = crypto.createHash('sha256').update(der).digest();++ let id = '';++ for (let i = 0; i < 16; i++) id += String.fromCharCode(97 + (hash[i] >> 4)) + String.fromCharCode(97 + (hash[i] & 15));++ assert.strictEqual(`https://${id}.chromiumapp.org/`, require('../browser/lib/config').OAUTH_REDIRECT_URIS[0], 'Chrome id');++ const ff = crypto.createHash('sha1').update('[email protected]').digest('hex');++ assert.strictEqual(`https://${ff}.extensions.allizom.org/`, require('../browser/lib/config').OAUTH_REDIRECT_URIS[1], 'Firefox redirect');++ });++await check('the side panel is a key in both browsers\' manifests', async () => {const { forTarget } = require('../scripts/pack');const ff = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);@@@ -42,6 +53,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:assert.ok(!('sidebar_action' in chrome), 'and no Firefox key');assert.ok(chrome.permissions.includes('sidePanel'), 'Chrome asks for its side-panel permission');assert.ok(!chrome.permissions.includes('sidebarAction'));++ assert.ok(chrome.key, 'Chrome keeps the public key that fixes the extension id');++ assert.ok(!firefox.key, 'Firefox gets no Chrome key');});await check('the audit fails if anything widens', async () => {@@@ -100,7 +113,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"])\/\/.*$/gm, '$1');for (const re of forbidden) assert.doesNotMatch(src, re, `${path.relative(DIR, f)} uses ${re}`);const rel = path.relative(DIR, f);-- if (rel !== path.join('lib', 'api.js') && rel !== path.join('lib', 'network-api.js')) 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));assert.deepStrictEqual(tabUse, [path.join('lib', 'platform.js')], 'only the platform wrapper touches tabs (it opens only hand-off pages)');@@@ -82,6 +82,7 @@ function unzip(buf) {want.side_panel = { default_path: 'panel.html' };} else {delete want.side_panel;++ delete want.key;want.permissions = want.permissions.filter((p) => p !== 'sidePanel');}assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);@@@ -42,6 +42,19 @@ function fakeDock() {return dock;}++/** The OAuth auth surface the panel uses; `mode:'none'` is the signed-out gate. */++function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {++ const auth = {++ signIns: 0,++ status: async () => ({ mode, account: mode === 'oauth' ? { username: 'ada' } : null }),++ scopes: async () => (mode === 'oauth' ? ['dock', 'activity', 'chat.read'] : null),++ account: async () => (mode === 'oauth' ? { username: 'ada' } : null),++ accessToken: async () => { if (mode === 'none') throw Object.assign(new Error('auth required'), { code: 'auth.required' }); return token; },++ signIn: async () => { auth.signIns++; mode = 'oauth'; },++ };++ return auth;++}++(async () => {const net1 = await startNetwork();try {@@@ -49,11 +62,13 @@ function fakeDock() {const doc = fakeDoc();const dock = fakeDock();const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });-- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => TOKEN } }, net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });const out = await panel.start();assert.strictEqual(out.mounted, true, 'the Dock is mounted');assert.strictEqual(dock.opened, 1, 'and opened');-- assert.strictEqual(dock.mounted.token, TOKEN, "with the person's sign-in");++ assert.strictEqual(await dock.mounted.token(), TOKEN, "with the person's sign-in");++ assert.deepStrictEqual(dock.mounted.scopes, ['dock', 'activity', 'chat.read'], 'read-only scopes');++ assert.strictEqual(dock.mounted.me, 'ada', 'the account name');assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');@@@ -65,12 +80,13 @@ function fakeDock() {const doc = fakeDoc();const dock = fakeDock();const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });-- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ mode: 'none' }), net, dock });const out = await panel.start();assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');assert.match(doc.nodes['gate-title'].textContent, /OpenVibe\.Network/);assert.strictEqual(doc.nodes['gate-button'].hidden, false, 'with the way to fix it');++ assert.strictEqual(doc.nodes['gate-button'].textContent, 'Sign in');});await check('a sign-in the Network refuses is not used', async () => {@@@ -78,12 +94,28 @@ function fakeDock() {const dock = fakeDock();const bad = 'wrong_token_' + 'y'.repeat(40);const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });-- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => bad } }, net, dock });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ token: bad }), net, dock });const out = await panel.start();assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');assert.match(doc.nodes['gate-title'].textContent, /said no/);});++++ await check('the gate button starts the one-click sign-in and mounts after it', async () => {++ const doc = fakeDoc();++ const dock = fakeDock();++ const auth = fakeAuth({ mode: 'none' });++ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: {} }, auth, net, dock });++ await panel.start();++ doc.nodes['gate-button'].onclick();++ // The click handler is async; wait until the grant is "connected" and the Dock mounted.++ const start = Date.now();++ while (!dock.mounted && Date.now() - start < 2000) await new Promise((r) => setTimeout(r, 20));++ assert.strictEqual(auth.signIns, 1, 'the button starts the OAuth flow');++ assert.strictEqual(dock.opened, 1, 'and the Dock mounts');++ assert.strictEqual(doc.nodes.gate.hidden, true);++ });} finally {net1.close();}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+# The Chrome manifest key is the extension's PUBLIC key. It fixes the extension id so the exact
+# chromiumapp.org redirect can be registered; the private key is not in this repository. Gitleaks'
+# generic-api-key rule sees the base64 blob, so this allowlist is narrow: this file AND this value.
+[[allowlists]]
+description = "Chrome extension public key in browser/manifest.json (no private key is stored)"
+paths = ['''browser/manifest\.json''']
+regexTarget = "secret"
+regexes = ['''MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt\+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB''']
@@ -75,12 +75,12 @@ the Companion never rewrites links or adds referral tags.
@@ -75,12 +75,12 @@ the Companion never rewrites links or adds referral tags.
| | |
| | |
|---|---|
|---|---|
-| **Permissions** | `activeTab`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
+| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are the three OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state) and `https://openvibe.chat/*` (the Dock's Chats tab). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, a background worker, web-accessible resources, `externally_connectable`. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Sent by the extension** | To `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
-| **Stored** | Only the optional install token, in `storage.local`. It is never synced to other devices. |
+| **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
| **CSP** | `default-src 'none'; script-src 'self'; connect-src https://openvibe.coupons; object-src 'none'`. There is no inline script, no remote code, no `eval`, and no `innerHTML`: the popup builds DOM nodes and sets text. |
### The credential: an install token, never a session
### The credential: an install token, never a session
@@ -112,11 +112,13 @@ released OpenVibe.Shared Dock, bundled under `browser/shared/` because the exten
@@ -112,11 +112,13 @@ released OpenVibe.Shared Dock, bundled under `browser/shared/` because the exten
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
opens where the person left off.
opens where the person left off.
-It needs the person's own OpenVibe sign-in (the token the Network gives every OpenVibe site). The
-Companion had no OpenVibe session of its own — the `cpx_` install token is not one — so the settings
-page asks for the OpenVibe token once, the same way it asks for the Coupons one, and the panel says
-what is missing instead of pretending. Nothing is sent anywhere but the three OpenVibe origins above,
-and the token stays in `storage.local`, on this device.
+It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
+Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
+secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`) that refreshes itself.
+The access token is kept in `storage.session`; the rotating refresh token in `storage.local`, on this
+device. Sign out revokes it at the Network, and the account page (`/my`) lists it under **Signed-in
+apps**. The 0.7.0 pasted-token path is still in the settings page as a fallback until a later release
+removes it.
### Layout
### Layout
@@ -125,7 +127,7 @@ browser/
@@ -125,7 +127,7 @@ browser/
manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)
manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)
panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a site
panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a site
panel.css the Dock fills the panel (no floating card, no pill)
panel.css the Dock fills the panel (no floating card, no pill)
- shared/ the released OpenVibe.Shared Dock (v4.54.0), bundled: the CSP allows no remote script
+ shared/ the released OpenVibe.Shared Dock (v4.57.0), bundled: the CSP allows no remote script
popup.html / popup.js DOM wiring only
popup.html / popup.js DOM wiring only
options.html / .js connect (save a checked token), disconnect, grant the API host permission
options.html / .js connect (save a checked token), disconnect, grant the API host permission
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
lib/config.js API_ORIGIN (the one origin it sends requests to); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
@@ -136,6 +138,7 @@ browser/
@@ -136,6 +138,7 @@ browser/
lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
lib/api.js the only code that makes requests to the Coupons API
lib/api.js the only code that makes requests to the Coupons API
lib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own token
lib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own token
+ lib/auth.js the Dock's one-click OAuth sign-in: PKCE, state, refresh, sign-out
lib/controller.js popup logic without the DOM (tested in Node)
lib/controller.js popup logic without the DOM (tested in Node)
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
@@ -155,7 +158,7 @@ npm test
@@ -155,7 +158,7 @@ npm test
| Requirement | Test |
| Requirement | Test |
|---|---|
|---|---|
-| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js` and `lib/network-api.js` fetch. | `test/manifest.test.js` |
+| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, a background worker, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, MV2. The API origin agrees across config, host permission and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js` and `lib/auth.js` fetch. | `test/manifest.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
| The extension never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched. Only `cpx_` tokens are stored. | `test/privacy.test.js` |
| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"wave": "W18",
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
- "version": "0.7.0",
+ "version": "0.8.0",
"surfaces": {
"surfaces": {
"browser": {
"browser": {
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
- "version": "0.7.0",
+ "version": "0.8.0",
"manifest": 3,
"manifest": 3,
"targets": [
"targets": [
"chromium >= 114",
"chromium >= 114",
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
"api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)",
"api": "https://openvibe.coupons (OpenVibe.Coupons public API), https://openvibe.network (the Dock's state), https://openvibe.chat (the Dock's Chats tab)",
"permissions": [
"permissions": [
"activeTab",
"activeTab",
+ "identity",
"scripting",
"scripting",
"storage",
"storage",
"sidePanel (Chrome only; Firefox needs no permission for sidebar_action)"
"sidePanel (Chrome only; Firefox needs no permission for sidebar_action)"
@@ -27,9 +28,9 @@
@@ -27,9 +28,9 @@
"https://openvibe.network/*",
"https://openvibe.network/*",
"https://openvibe.chat/*"
"https://openvibe.chat/*"
],
],
- "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the person's own OpenVibe token for the Dock in the side panel (opt-in, in the settings); the Coupons token is never an OpenVibe session",
+ "credential": "optional cpx_ install token (coupons.lookup, coupons.report); the Dock one-click OAuth public client (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local); the 0.7.0 pasted OpenVibe token is still accepted as a fallback until a later release",
"publishedToStores": false,
"publishedToStores": false,
- "loadedInChrome": "yes: headless Chromium loads the 0.3.0 build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome",
+ "loadedInChrome": "yes: headless Chromium loads the build (popup and options render, no console errors); lib/autoapply.js passes three fixture checkout pages in headless Chrome; the one-click Dock sign-in is covered by test/auth.test.js",
"loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors",
"loadedInFirefox": "not yet loaded in a browser; Mozilla's web-ext lint (8.10.0, what AMO runs on upload) passes with 0 errors, 0 warnings and 0 notices, and runs in CI with warnings as errors",
"handoffs": [
"handoffs": [
"https://openvibe.rent/board (keep a listing)",
"https://openvibe.rent/board (keep a listing)",
@@ -39,11 +40,20 @@
@@ -39,11 +40,20 @@
"https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"
"https://openvibe.reviews/save (a review site's rating, checked and saved on Reviews)"
],
],
"storeBuilds": "npm run pack:stores: dist/openvibe-companion-<version>-chrome.zip and -firefox.zip, reproducible, audited",
"storeBuilds": "npm run pack:stores: dist/openvibe-companion-<version>-chrome.zip and -firefox.zip, reproducible, audited",
- "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion"
+ "storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",
+ "features": [
+ "OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",
+ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes"
+ ],
+ "pending": [
+ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
+ "Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
+ "use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"
+ ]
}
}
},
},
"pending": [
"pending": [
- "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; then put the Chrome extension id in COUPONS_EXTENSION_ORIGINS",
+ "owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
"Firefox: the extension loaded and run in a real Firefox (web-ext lint passes; nothing has run it yet)",
"use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"
"use: on 2026-10-22 OpenVibe.Coupons had issued no install token and received no code report; it held 169 codes for 16 of its 37 merchants, all from the DealNews feed through OpenVibe.Sources"
],
],
@@ -53,8 +63,8 @@
@@ -53,8 +63,8 @@
"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median",
"keeps a rental listing from any site on the OpenVibe.Rent board with the price, city and bedrooms the page states (read only when pressed), and checks its rent against the city median",
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
- "an optional cpx_ install token, never an OpenVibe session; and, for the Dock in the side panel, the person's own OpenVibe token (opt-in, in the settings)",
- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.54.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity)",
+ "an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",
+ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes",
"store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI",
"store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI",
"kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live",
"kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live",
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
new file mode 100644
new file mode 100644
@@ -0,0 +1,257 @@
@@ -0,0 +1,257 @@
+/*
+ * The Dock's one-click sign-in (OAuth authorization code + PKCE S256, public client `companion`).
+ *
+ * The extension never holds a client secret. Sign in opens the browser's own auth flow
+ * (identity.launchWebAuthFlow) at the Network; the code comes back to the browser-owned extension
+ * redirect URL, is exchanged for an access token and a rotating refresh token, and is never put in a
+ * page URL we control. The access token lives in storage.session (memory, this browser run); the
+ * refresh token lives in storage.local (this device). Sign out revokes the refresh family at the
+ * Network before clearing both.
+ *
+ * The 0.7.0 pasted `networkToken` is still accepted as a fallback until a later release removes it:
+ * token() prefers a live OAuth grant and falls back to that string. It is a normal Network session,
+ * not this client's scoped token.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.OVAuth = api;
+})(typeof self !== 'undefined' ? self : globalThis, function () {
+ 'use strict';
+ const root = typeof self !== 'undefined' ? self : globalThis;
+
+ class AuthError extends Error {
+ constructor(code, message) { super(message || code); this.code = code; }
+ }
+
+ const enc = new TextEncoder();
+ function bytes(n) {
+ const out = new Uint8Array(n);
+ root.crypto.getRandomValues(out);
+ return out;
+ }
+ function b64url(input) {
+ const b = typeof input === 'string' ? enc.encode(input) : new Uint8Array(input);
+ let s = '';
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
+ return root.btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ }
+ async function challengeFor(verifier) {
+ const digest = await root.crypto.subtle.digest('SHA-256', enc.encode(verifier));
+ return b64url(digest);
+ }
+ function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const b = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(root.atob(b + '='.repeat((4 - b.length % 4) % 4)));
+ } catch { return null; }
+ }
+ const scopeList = (v) => String(v || '').split(/\s+/).filter(Boolean);
+ const subset = (a, b) => a.every((s) => b.includes(s));
+
+ function createAuth(opts) {
+ const platform = opts.platform;
+ const config = opts.config;
+ const doFetch = opts.fetch || root.fetch.bind(root);
+ const now = opts.now || (() => Date.now());
+ const pending = new Map();
+ let refreshing = null;
+
+ const err = (code, message) => new AuthError(code, message);
+
+ async function getAccess() {
+ const acc = await platform.storage.getSession(config.DOCK_ACCESS_KEY);
+ return acc && typeof acc.token === 'string' ? acc : null;
+ }
+ async function getRefresh() {
+ const r = await platform.storage.getLocal(config.DOCK_REFRESH_KEY);
+ return r && typeof r.token === 'string' ? r : null;
+ }
+ async function getAccount() {
+ const a = await platform.storage.getLocal(config.DOCK_ACCOUNT_KEY);
+ return a && typeof a === 'object' ? a : null;
+ }
+ async function legacy() {
+ return await platform.storage.getNetworkToken();
+ }
+
+ function storeGrant(body, requested) {
+ const scope = String(body.scope || '');
+ const scopes = scopeList(scope);
+ if (requested && !subset(scopes, requested)) throw err('auth.scope', 'the Network granted a scope this extension did not ask for');
+ const claims = decodePayload(body.access_token) || {};
+ const account = { sub: claims.sub || null, username: claims.username || null, display_name: claims.display_name || null };
+ const access = { token: body.access_token, expires_at: now() + (Number(body.expires_in) || 3600) * 1000, scope, ...account };
+ const refresh = { token: body.refresh_token, scope, ...account };
+ return Promise.resolve()
+ .then(() => platform.storage.setSession({ [config.DOCK_ACCESS_KEY]: access }))
+ .then(() => platform.storage.setLocal({ [config.DOCK_REFRESH_KEY]: refresh, [config.DOCK_ACCOUNT_KEY]: account }))
+ .then(() => access);
+ }
+
+ async function postToken(params) {
+ let res;
+ try {
+ res = await doFetch(config.NETWORK_ORIGIN + '/oauth/token', {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
+ body: new URLSearchParams(params).toString(),
+ credentials: 'omit',
+ referrerPolicy: 'no-referrer',
+ });
+ } catch {
+ throw err('auth.network', 'could not reach OpenVibe.Network');
+ }
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw err(body.error || `auth.http_${res.status}`, body.error_description);
+ if (!body.access_token || !body.refresh_token) throw err('auth.invalid', 'the Network did not return a token pair');
+ return body;
+ }
+
+ function parseRedirect(finalUrl, state, redirectUri) {
+ let got;
+ let want;
+ try { got = new URL(String(finalUrl)); want = new URL(redirectUri); } catch { throw err('auth.redirect', 'the sign-in returned an address this extension cannot read'); }
+ if (got.origin !== want.origin || got.pathname !== want.pathname) throw err('auth.redirect', 'the sign-in returned to an address that is not this extension');
+ if (got.searchParams.get('state') !== state) throw err('auth.state', 'the sign-in state did not match');
+ const error = got.searchParams.get('error');
+ if (error) {
+ const code = error === 'access_denied' ? 'auth.cancelled'
+ : error === 'login_required' ? 'auth.login_required'
+ : error === 'invalid_scope' ? 'auth.scope' : 'auth.server';
+ throw err(code, got.searchParams.get('error_description') || error);
+ }
+ const code = got.searchParams.get('code');
+ if (!code) throw err('auth.invalid', 'the sign-in returned no code');
+ return code;
+ }
+
+ async function signIn({ interactive = true } = {}) {
+ let redirectUri;
+ try { redirectUri = platform.identity.getRedirectURL(); } catch { throw err('auth.redirect', 'this browser cannot start the sign-in'); }
+ if (!config.OAUTH_REDIRECT_URIS.includes(redirectUri)) throw err('auth.redirect', 'this build’s redirect URL is not registered with OpenVibe.Network');
+ const verifier = b64url(bytes(32));
+ const state = b64url(bytes(32));
+ const url = new URL(config.NETWORK_ORIGIN + '/oauth/authorize');
+ url.searchParams.set('client_id', config.OAUTH_CLIENT_ID);
+ url.searchParams.set('redirect_uri', redirectUri);
+ url.searchParams.set('response_type', 'code');
+ url.searchParams.set('scope', config.OAUTH_SCOPES.join(' '));
+ url.searchParams.set('state', state);
+ url.searchParams.set('code_challenge', await challengeFor(verifier));
+ url.searchParams.set('code_challenge_method', 'S256');
+ pending.set(state, { verifier, redirectUri, at: now() });
+ // A stale flow from a closed panel cannot be redeemed; prune.
+ for (const [k, v] of pending) if (now() - v.at > 10 * 60 * 1000) pending.delete(k);
+
+ let finalUrl;
+ try {
+ finalUrl = await platform.identity.launchWebAuthFlow({ url: url.toString(), interactive });
+ } catch (e) {
+ pending.delete(state);
+ const m = String(e && e.message || '');
+ if (/cancel|did not approve|closed/i.test(m)) throw err('auth.cancelled', 'sign-in was cancelled');
+ throw err('auth.network', 'the sign-in window could not be opened');
+ }
+ const saved = pending.get(state);
+ pending.delete(state);
+ if (!saved) throw err('auth.state', 'the sign-in state was lost');
+ if (!finalUrl) throw err('auth.cancelled', 'sign-in was cancelled');
+ const code = parseRedirect(finalUrl, state, redirectUri);
+ const body = await postToken({
+ grant_type: 'authorization_code',
+ client_id: config.OAUTH_CLIENT_ID,
+ code,
+ redirect_uri: redirectUri,
+ code_verifier: verifier,
+ });
+ return await storeGrant(body, config.OAUTH_SCOPES);
+ }
+
+ async function refresh() {
+ if (refreshing) return refreshing;
+ refreshing = (async () => {
+ const saved = await getRefresh();
+ if (!saved) throw err('auth.required', 'sign in to OpenVibe.Network');
+ try {
+ const body = await postToken({ grant_type: 'refresh_token', client_id: config.OAUTH_CLIENT_ID, refresh_token: saved.token });
+ return (await storeGrant(body, config.OAUTH_SCOPES)).token;
+ } catch (e) {
+ // Two panels of one extension can refresh at once. The Network refuses the loser
+ // inside its 10 s grace without revoking the family; if the other panel wrote the new
+ // token, use it. A real invalid_grant means sign in again.
+ if (e.code === 'invalid_grant') {
+ const again = await getRefresh();
+ if (again && again.token !== saved.token) {
+ const acc = await getAccess();
+ if (acc) return acc.token;
+ }
+ await clearOAuth();
+ throw err('auth.required', 'sign in to OpenVibe.Network again');
+ }
+ throw e;
+ }
+ })().finally(() => { refreshing = null; });
+ return refreshing;
+ }
+
+ async function accessToken() {
+ const acc = await getAccess();
+ if (acc && acc.expires_at > now() + 60_000) return acc.token;
+ try {
+ return await refresh();
+ } catch (e) {
+ // 0.7.0 compatibility: a pasted normal Network token still works until a later release.
+ const old = await legacy();
+ if (old) return old;
+ throw e;
+ }
+ }
+
+ async function clearOAuth() {
+ await Promise.all([
+ platform.storage.removeSession([config.DOCK_ACCESS_KEY]),
+ platform.storage.removeLocal([config.DOCK_REFRESH_KEY, config.DOCK_ACCOUNT_KEY]),
+ ]);
+ }
+
+ async function signOut() {
+ const saved = await getRefresh();
+ if (saved) {
+ try {
+ await doFetch(config.NETWORK_ORIGIN + '/oauth/revoke', {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
+ body: new URLSearchParams({ client_id: config.OAUTH_CLIENT_ID, token: saved.token }).toString(),
+ credentials: 'omit',
+ referrerPolicy: 'no-referrer',
+ });
+ } catch { /* clear locally anyway; the account page can revoke */ }
+ }
+ pending.clear();
+ await clearOAuth();
+ await platform.storage.clearNetworkToken();
+ }
+
+ async function status() {
+ const [acc, ref, old] = await Promise.all([getAccess(), getRefresh(), legacy()]);
+ if (acc || ref) return { mode: 'oauth', account: await getAccount() };
+ if (old) return { mode: 'legacy', account: null };
+ return { mode: 'none', account: null };
+ }
+
+ async function scopes() {
+ const acc = await getAccess();
+ if (acc && acc.scope) return scopeList(acc.scope);
+ const ref = await getRefresh();
+ if (ref && ref.scope) return scopeList(ref.scope);
+ return null; // legacy string: a full session, let the Dock show its normal controls
+ }
+
+ return { signIn, refresh, accessToken, token: accessToken, signOut, status, scopes, account: getAccount, clear: clearOAuth, AuthError };
+ }
+
+ return { createAuth, AuthError, _b64url: b64url, _challengeFor: challengeFor, _decodePayload: decodePayload };
+});
@@ -31,6 +31,21 @@
@@ -31,6 +31,21 @@
// 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',
+ // One-click Dock sign-in (OAuth public client, no secret). The redirect URI is the browser's own
+ // extension URL; the exact values are also in the Network client row. A build whose runtime
+ // 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']),
+ OAUTH_REDIRECT_URIS: Object.freeze([
+ 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
+ 'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
+ ]),
+ // The OAuth grant: access in storage.session (memory), refresh in storage.local (this device).
+ DOCK_ACCESS_KEY: 'dockAccess',
+ DOCK_REFRESH_KEY: 'dockRefresh',
+ 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',
NETWORK_TOKEN_KEY: 'networkToken',
MAX_TRIES: 10,
MAX_TRIES: 10,
CONNECT_PATH: '/connect-extension',
CONNECT_PATH: '/connect-extension',
@@ -32,6 +32,7 @@
@@ -32,6 +32,7 @@
function createPlatform(ext, config) {
function createPlatform(ext, config) {
const origins = [config.API_ORIGIN + '/*', config.NETWORK_ORIGIN + '/*', config.CHAT_ORIGIN + '/*'];
const origins = [config.API_ORIGIN + '/*', config.NETWORK_ORIGIN + '/*', config.CHAT_ORIGIN + '/*'];
+ const sessionMemory = new Map();
const call = function (fn, thisArg, args) {
const call = function (fn, thisArg, args) {
// chrome.* in MV3 and browser.* both return promises.
// chrome.* in MV3 and browser.* both return promises.
return fn.apply(thisArg, args);
return fn.apply(thisArg, args);
@@ -74,7 +75,40 @@
@@ -74,7 +75,40 @@
return out && out[0] ? out[0].result : null;
return out && out[0] ? out[0].result : null;
},
},
},
},
+ identity: {
+ /** The browser's own redirect URL for this extension (Chrome chromiumapp.org / Firefox extensions.allizom.org). */
+ getRedirectURL() {
+ if (!ext.identity || typeof ext.identity.getRedirectURL !== 'function') throw new TypeError('this browser has no identity API');
+ return ext.identity.getRedirectURL();
+ },
+ /** Open the Network's authorize page; resolves with its final redirect URL. */
+ launchWebAuthFlow(opts) {
+ if (!ext.identity || typeof ext.identity.launchWebAuthFlow !== 'function') throw new TypeError('this browser has no identity API');
+ return call(ext.identity.launchWebAuthFlow, ext.identity, [opts]);
+ },
+ },
storage: {
storage: {
+ // Generic helpers: the OAuth access token is in storage.session (memory, per browser run),
+ // the refresh token is in storage.local (this device). If a browser has no session area,
+ // the access token stays in a module map rather than falling back to disk.
+ async getSession(key) {
+ if (ext.storage.session) { const out = await call(ext.storage.session.get, ext.storage.session, [key]); return out ? out[key] : null; }
+ return sessionMemory.has(key) ? sessionMemory.get(key) : null;
+ },
+ async setSession(values) {
+ if (ext.storage.session) return call(ext.storage.session.set, ext.storage.session, [values]);
+ for (const k of Object.keys(values)) sessionMemory.set(k, values[k]);
+ },
+ async removeSession(keys) {
+ if (ext.storage.session) return call(ext.storage.session.remove, ext.storage.session, [keys]);
+ for (const k of [].concat(keys)) sessionMemory.delete(k);
+ },
+ async getLocal(key) {
+ const out = await call(ext.storage.local.get, ext.storage.local, [key]);
+ return out ? out[key] : null;
+ },
+ async setLocal(values) { await call(ext.storage.local.set, ext.storage.local, [values]); },
+ async removeLocal(keys) { await call(ext.storage.local.remove, ext.storage.local, [keys]); },
async getToken() {
async getToken() {
const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);
const out = await call(ext.storage.local.get, ext.storage.local, [config.TOKEN_KEY]);
const t = out && out[config.TOKEN_KEY];
const t = out && out[config.TOKEN_KEY];
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.7.0",
+ "version": "0.8.0",
+ "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; 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",
@@ -30,6 +31,7 @@
@@ -30,6 +31,7 @@
},
},
"permissions": [
"permissions": [
"activeTab",
"activeTab",
+ "identity",
"scripting",
"scripting",
"storage"
"storage"
],
],
@@ -23,17 +23,19 @@
@@ -23,17 +23,19 @@
<p id="permission-row" hidden><button id="grant" type="button">Allow access to the OpenVibe sites</button> <span class="muted">Needed to report a code and to read your Dock; the Companion never asks for access to other sites.</span></p>
<p id="permission-row" hidden><button id="grant" type="button">Allow access to the OpenVibe sites</button> <span class="muted">Needed to report a code and to read your Dock; the Companion never asks for access to other sites.</span></p>
<h2>The Dock in the side panel</h2>
<h2>The Dock in the side panel</h2>
<p>The Dock shows what your agents are doing, what your chats wait for and what just happened, on every site, in the side panel. It is the same Dock, and it keeps the same place.</p>
<p>The Dock shows what your agents are doing, what your chats wait for and what just happened, on every site, in the side panel. It is the same Dock, and it keeps the same place.</p>
- <ol>
- <li>Sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>.</li>
- <li>Paste your OpenVibe token here. It is the one your browser already has for OpenVibe (in the page, <code>localStorage.ov_token</code>); it stays on this device and goes only to openvibe.network.</li>
- </ol>
- <form id="network-form">
- <label for="network-token">OpenVibe token</label>
- <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">
- <button type="submit">Save</button>
- </form>
+ <p>Press <strong>Sign in</strong> once: the Network asks which account to use, and the extension keeps a read-only Dock sign-in that refreshes itself. The access token is kept in memory; the refresh token stays on this device. Sign out revokes it at the Network.</p>
<p id="network-state" class="muted" aria-live="polite"></p>
<p id="network-state" class="muted" aria-live="polite"></p>
- <p><button id="network-disconnect" type="button">Disconnect the Dock</button> <span class="muted">Removes the OpenVibe token from this browser.</span></p>
+ <p><button id="network-signin" type="button">Sign in to OpenVibe.Network</button> <button id="network-signout" type="button" hidden>Sign out</button></p>
+ <details>
+ <summary>Use a pasted token instead (older connection)</summary>
+ <p>The 0.7.0 way: sign in at <a id="network-link" target="_blank" rel="noopener noreferrer" href="https://openvibe.network/my">openvibe.network</a>, then paste the token your browser has for OpenVibe (in the page, <code>localStorage.ov_token</code>). This is a full OpenVibe session, not the extension's scoped sign-in; it stays on this device and is still accepted until a later release removes it.</p>
+ <form id="network-form">
+ <label for="network-token">OpenVibe token</label>
+ <input id="network-token" type="password" autocomplete="off" spellcheck="false" placeholder="eyJ…" size="48">
+ <button type="submit">Save</button>
+ </form>
+ <p><button id="network-disconnect" type="button">Remove the pasted token</button> <span class="muted">This does not sign out the one-click connection; use Sign out for that.</span></p>
+ </details>
<h2>What this extension can see</h2>
<h2>What this extension can see</h2>
<ul>
<ul>
<li>The tab you are on, and only when you open the Companion: its address and title.</li>
<li>The tab you are on, and only when you open the Companion: its address and title.</li>
@@ -46,6 +48,7 @@
@@ -46,6 +48,7 @@
<script src="lib/api.js"></script>
<script src="lib/api.js"></script>
<script src="lib/network-api.js"></script>
<script src="lib/network-api.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/platform.js"></script>
+<script src="lib/auth.js"></script>
<script src="options.js"></script>
<script src="options.js"></script>
</body>
</body>
</html>
</html>
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
const ext = typeof browser !== 'undefined' ? browser : chrome;
const ext = typeof browser !== 'undefined' ? browser : chrome;
const config = self.OVConfig;
const config = self.OVConfig;
const platform = self.OVPlatform.createPlatform(ext, config);
const platform = self.OVPlatform.createPlatform(ext, config);
+ const auth = self.OVAuth.createAuth({ platform: platform, config: config, fetch: self.fetch.bind(self) });
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
const api = self.OVApi.createApiClient({ origin: config.API_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getToken, timeoutMs: config.TIMEOUT_MS });
- const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: platform.storage.getNetworkToken, timeoutMs: config.TIMEOUT_MS });
+ const net = self.OVNetwork.createNetworkClient({ origin: config.NETWORK_ORIGIN, fetch: self.fetch.bind(self), getToken: function () { return auth.accessToken(); }, timeoutMs: config.TIMEOUT_MS });
const state = document.getElementById('state');
const state = document.getElementById('state');
const networkState = document.getElementById('network-state');
const networkState = document.getElementById('network-state');
const networkInput = document.getElementById('network-token');
const networkInput = document.getElementById('network-token');
@@ -38,10 +39,43 @@
@@ -38,10 +39,43 @@
});
});
async function refreshNetwork() {
async function refreshNetwork() {
- const token = await platform.storage.getNetworkToken();
- networkState.textContent = token ? 'Connected (the Dock in the side panel works).' : 'Not connected: the side panel asks for your OpenVibe sign-in.';
+ const status = await auth.status();
+ const legacy = await platform.storage.getNetworkToken();
+ const signIn = document.getElementById('network-signin');
+ const signOut = document.getElementById('network-signout');
+ if (status.mode === 'oauth') {
+ const who = status.account && status.account.username ? ' as @' + status.account.username : '';
+ networkState.textContent = 'Connected' + who + ' — the Dock in the side panel works.';
+ } else if (legacy) {
+ networkState.textContent = 'Connected with a pasted token. Sign in for the one-click, scoped connection; the old token is still accepted for now.';
+ } else {
+ networkState.textContent = 'Not connected: the side panel asks you to sign in.';
+ }
+ signIn.hidden = false;
+ signOut.hidden = status.mode !== 'oauth' && !legacy;
}
}
+ document.getElementById('network-signin').addEventListener('click', async function () {
+ const btn = this;
+ btn.disabled = true;
+ networkState.textContent = 'Opening OpenVibe.Network…';
+ try {
+ await auth.signIn({ interactive: true });
+ } catch (e) {
+ networkState.textContent = e && e.code === 'auth.cancelled' ? 'Sign-in was cancelled.' : 'Sign-in could not be completed. Try again.';
+ } finally {
+ btn.disabled = false;
+ await refreshNetwork();
+ }
+ });
+
+ document.getElementById('network-signout').addEventListener('click', async function () {
+ const btn = this;
+ btn.disabled = true;
+ try { await auth.signOut(); } catch { /* the local grant is cleared in any case */ }
+ finally { btn.disabled = false; await refreshNetwork(); }
+ });
+
document.getElementById('network-form').addEventListener('submit', async function (ev) {
document.getElementById('network-form').addEventListener('submit', async function (ev) {
ev.preventDefault();
ev.preventDefault();
const token = networkInput.value.trim();
const token = networkInput.value.trim();
@@ -11,11 +11,12 @@
@@ -11,11 +11,12 @@
<section id="gate" hidden>
<section id="gate" hidden>
<h1 id="gate-title">Sign in to OpenVibe.Network</h1>
<h1 id="gate-title">Sign in to OpenVibe.Network</h1>
<p id="gate-text">The Dock shows what your agents are doing and what your chats wait for.</p>
<p id="gate-text">The Dock shows what your agents are doing and what your chats wait for.</p>
- <button id="gate-button" type="button" hidden>Open the settings</button>
+ <button id="gate-button" type="button" hidden>Sign in</button>
</section>
</section>
<script src="lib/config.js"></script>
<script src="lib/config.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/platform.js"></script>
<script src="lib/network-api.js"></script>
<script src="lib/network-api.js"></script>
+<script src="lib/auth.js"></script>
<script src="shared/ov-mark.js"></script>
<script src="shared/ov-mark.js"></script>
<script src="shared/dock.js"></script>
<script src="shared/dock.js"></script>
<script src="panel.js"></script>
<script src="panel.js"></script>
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
* person's Network sign-in, so it reads and writes the same state as the Dock on a site: the
* person's Network sign-in, so it reads and writes the same state as the Dock on a site: the
* same tabs (Agents, Chats, Activity) and the same place.
* same tabs (Agents, Chats, Activity) and the same place.
*
*
- * The extension had no Network sign-in of its own (it holds the Coupons install token, which is
- * not an OpenVibe sign-in), so the settings page asks for the person's Network token once, the
- * same way it asks for the Coupons one. Without it the panel says so instead of pretending.
+ * The sign-in is the Network's OAuth public client (lib/auth.js): one press, PKCE, a scoped access
+ * token in storage.session and a rotating refresh token in storage.local. The 0.7.0 pasted
+ * `networkToken` is still accepted as a fallback until a later release removes it.
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
@@ -33,38 +33,69 @@
@@ -33,38 +33,69 @@
gate.hidden = false;
gate.hidden = false;
}
}
+ function messageFor(e) {
+ const code = e && e.code;
+ if (code === 'auth.cancelled') return 'Sign-in was cancelled.';
+ if (code === 'auth.login_required') return 'Sign in to OpenVibe.Network first, then try again.';
+ if (code === 'auth.scope') return 'This version of the extension asks for something this Network does not offer.';
+ if (code === 'auth.redirect') return 'This build’s sign-in address is not registered. Update the extension.';
+ if (code === 'auth.network') return 'Could not reach openvibe.network. Try again.';
+ return 'Sign-in could not be completed. Try again.';
+ }
+
/** The Dock, with the Network's state, or the gate that says what is missing. */
/** The Dock, with the Network's state, or the gate that says what is missing. */
async function start() {
async function start() {
- const token = await opts.platform.storage.getNetworkToken();
- if (!token) {
+ let status;
+ try { status = await opts.auth.status(); } catch { status = { mode: 'none' }; }
+ if (!status || status.mode === 'none') {
showGate('Sign in to OpenVibe.Network',
showGate('Sign in to OpenVibe.Network',
'The Dock shows what your agents are doing and what your chats wait for. It needs your OpenVibe.Network sign-in.',
'The Dock shows what your agents are doing and what your chats wait for. It needs your OpenVibe.Network sign-in.',
- 'Open the settings');
+ 'Sign in');
return { mounted: false, reason: 'no-token' };
return { mounted: false, reason: 'no-token' };
}
}
try {
try {
await opts.net.dock();
await opts.net.dock();
} catch (err) {
} catch (err) {
showGate('OpenVibe.Network said no',
showGate('OpenVibe.Network said no',
- 'That sign-in was not accepted. Sign in to OpenVibe.Network again and paste a fresh token in the settings.',
- 'Open the settings');
+ 'That sign-in was not accepted. Sign in to OpenVibe.Network again.',
+ 'Sign in');
return { mounted: false, reason: 'refused' };
return { mounted: false, reason: 'refused' };
}
}
+ const scopes = await opts.auth.scopes();
+ const account = await opts.auth.account();
opts.dock.mount({
opts.dock.mount({
- token: token,
+ token: function () { return opts.auth.accessToken(); },
+ scopes: scopes,
+ me: account && account.username ? account.username : '',
apiBase: opts.config.NETWORK_ORIGIN,
apiBase: opts.config.NETWORK_ORIGIN,
chatBase: opts.config.CHAT_ORIGIN,
chatBase: opts.config.CHAT_ORIGIN,
sibling: opts.sibling,
sibling: opts.sibling,
});
});
opts.dock.open();
opts.dock.open();
gate.hidden = true;
gate.hidden = true;
- return { mounted: true, reason: null };
+ return { mounted: true, reason: null, mode: status.mode };
+ }
+
+ async function signIn() {
+ if (!opts.auth) return;
+ gateButton.disabled = true;
+ const old = gateButton.textContent;
+ gateButton.textContent = 'Signing in…';
+ try {
+ await opts.auth.signIn({ interactive: true });
+ await start();
+ } catch (e) {
+ showGate('Sign-in was not completed', messageFor(e), 'Try again');
+ } finally {
+ gateButton.disabled = false;
+ if (gate.hidden) gateButton.textContent = old;
+ }
}
}
if (gateButton && typeof gateButton.addEventListener === 'function') {
if (gateButton && typeof gateButton.addEventListener === 'function') {
- gateButton.addEventListener('click', function () { if (opts.openSettings) opts.openSettings(); });
+ gateButton.addEventListener('click', function () { signIn(); });
}
}
- return { start: start, showGate: showGate };
+ return { start: start, signIn: signIn, showGate: showGate, messageFor: messageFor };
}
}
/** The page wiring: the real browser APIs and the real Dock. */
/** The page wiring: the real browser APIs and the real Dock. */
@@ -72,16 +103,18 @@
@@ -72,16 +103,18 @@
const ext = root.browser || root.chrome;
const ext = root.browser || root.chrome;
const config = root.OVConfig;
const config = root.OVConfig;
const platform = root.OVPlatform.createPlatform(ext, config);
const platform = root.OVPlatform.createPlatform(ext, config);
+ const auth = root.OVAuth.createAuth({ platform: platform, config: config, fetch: self.fetch.bind(self) });
const net = root.OVNetwork.createNetworkClient({
const net = root.OVNetwork.createNetworkClient({
origin: config.NETWORK_ORIGIN,
origin: config.NETWORK_ORIGIN,
fetch: self.fetch.bind(self),
fetch: self.fetch.bind(self),
- getToken: platform.storage.getNetworkToken,
+ getToken: function () { return auth.accessToken(); },
timeoutMs: config.TIMEOUT_MS,
timeoutMs: config.TIMEOUT_MS,
});
});
const panel = createPanel({
const panel = createPanel({
doc: root.document,
doc: root.document,
config: config,
config: config,
platform: platform,
platform: platform,
+ auth: auth,
net: net,
net: net,
dock: root.OpenVibeDock,
dock: root.OpenVibeDock,
sibling: function (name) { return ext.runtime.getURL('shared/' + name); },
sibling: function (name) { return ext.runtime.getURL('shared/' + name); },
@@ -47,20 +47,21 @@
@@ -47,20 +47,21 @@
const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
+ /** A site's Dock passes no scopes (full behaviour); the extension passes its read-only set. */
+ const canDecide = (o = opts) => !Array.isArray(o && o.scopes) || o.scopes.includes('actions.decide');
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
- function apiWith(o, path, init = {}) {
+ async function apiWith(o, path, init = {}) {
const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ }
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
- const t = o && (typeof o.token === 'function' ? o.token() : o.token);
+ const t = o && (typeof o.token === 'function' ? await o.token() : o.token);
if (t) headers.authorization = `Bearer ${t}`;
if (t) headers.authorization = `Bearer ${t}`;
- return fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }).then(async (r) => {
- const body = await r.json().catch(() => ({}));
- if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
- return body;
- });
+ const r = await fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' });
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ return body;
}
}
const api = (path, init) => apiWith(opts, path, init);
const api = (path, init) => apiWith(opts, path, init);
function ago(iso) {
function ago(iso) {
@@ -207,7 +208,7 @@
@@ -207,7 +208,7 @@
paint();
paint();
}
}
- function itemHTML(it) {
+ function itemHTML(it, o = opts) {
const tone = TONE[it.bucket] || TONE.running;
const tone = TONE[it.bucket] || TONE.running;
const meta = [
const meta = [
`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
`<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
@@ -216,7 +217,7 @@
@@ -216,7 +217,7 @@
typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
`<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
].join('');
].join('');
- const decides = it.bucket === 'waiting' && it.actions.includes('approve');
+ const decides = canDecide(o) && it.bucket === 'waiting' && it.actions.includes('approve');
const btns = decides
const btns = decides
? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
: it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
@@ -359,7 +360,7 @@
@@ -359,7 +360,7 @@
return;
return;
}
}
list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '')
- + items.map(itemHTML).join('')).join('');
+ + items.map((it) => itemHTML(it, mo)).join('')).join('');
};
};
const refresh = async () => {
const refresh = async () => {
if (gone || busy) return;
if (gone || busy) return;
@@ -56,12 +56,15 @@
@@ -56,12 +56,15 @@
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
- function call(path, init = {}) {
+ async function call(path, init = {}) {
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ const t = await tokenOf();
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
- return fetch(base + path, { ...init, headers, credentials: 'omit' }).then((r) => (r.ok ? r.json() : null));
+ const r = await fetch(base + path, { ...init, headers, credentials: 'omit' });
+ return r.ok ? r.json() : null;
}
}
+ /** A token may be a string, a sync function or an async function (the extension refreshes it). */
+ async function tokenOf() { return typeof o.token === 'function' ? await o.token() : o.token; }
const notify = () => { store(doc); paintPill(); };
const notify = () => { store(doc); paintPill(); };
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
/** A change to the state: painted now, sent to the Network a beat later (several changes go as one PUT). */
@@ -78,13 +81,7 @@
@@ -78,13 +81,7 @@
const body = pending; pending = {};
const body = pending; pending = {};
if (!Object.keys(body).length) return;
if (!Object.keys(body).length) return;
try {
try {
- let r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
- // A Network whose state contract does not know "popped" yet refuses the whole body; ask again without it,
- // so the Dock's place and its tab still reach the Network.
- if (!r && 'popped' in body) {
- const rest = { ...body }; delete rest.popped;
- r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(rest) });
- }
+ const r = await call('/api/v1/me/dock', { method: 'PUT', body: JSON.stringify(body) });
if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
if (r && r.state && !Object.keys(pending).length) { doc = { ...doc, state: r.state, counts: r.counts || counts() }; notify(); badges(); }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
} catch { /* offline, or no route yet: the copy stands and the next change tries again */ }
}
}
@@ -427,21 +424,21 @@
@@ -427,21 +424,21 @@
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
panel.innerHTML = `<div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
- token: o.token, apiBase: o.apiBase, buckets: ['waiting', 'running'],
+ token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
});
mounts[id] = m;
mounts[id] = m;
panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
panel.querySelector('[data-act="all"]').addEventListener('click', (e) => {
const anchor = e.currentTarget;
const anchor = e.currentTarget;
close();
close();
- try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
+ try { root.OpenVibeActionCenter.open({ token: o.token, apiBase: o.apiBase, scopes: o.scopes, anchor: (pill && !pill.hidden) ? pill : anchor }); } catch { /* */ }
});
});
return;
return;
}
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
const N = root.OpenVibeNotifications;
const N = root.OpenVibeNotifications;
- if (!N.__ovNavInit) { try { N.init({ token: typeof o.token === 'function' ? o.token() : o.token, apiBase: o.apiBase || 'https://openvibe.network' }); N.__ovNavInit = true; } catch { /* */ } }
+ if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }
panel.innerHTML = '';
panel.innerHTML = '';
N.renderDock(panel);
N.renderDock(panel);
mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
mounts[id] = { refresh: () => { try { N.refresh(); } catch { /* */ } } };
@@ -458,10 +455,12 @@
@@ -458,10 +455,12 @@
const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
const chats = { panel: null, list: null, open: null, msgs: [], last: 0, timer: 0, unread: 0, unreadT: 0, busy: false };
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const chatBase = () => String(o.chatBase || 'https://openvibe.chat').replace(/\/+$/, '');
const me = () => String(o.me || '');
const me = () => String(o.me || '');
+ const scopes = () => Array.isArray(o.scopes) ? o.scopes : null;
+ const canWriteChat = () => !scopes() || scopes().includes('chat.write');
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
const icon = (path) => { const s = h('span', 'ovdk-i'); s.innerHTML = svg(path); return s; };
- function chatCall(path, init = {}) {
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ async function chatCall(path, init = {}) {
+ const t = await tokenOf();
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
return fetch(chatBase() + path, { ...init, headers, credentials: 'omit' }).then(async (r) => {
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
@@ -559,28 +558,33 @@
@@ -559,28 +558,33 @@
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const go = h('button', 'ovdk-go'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(icon(G.send));
const err = h('p', 'ovdk-err'); err.hidden = true;
const err = h('p', 'ovdk-err'); err.hidden = true;
form.append(box, go);
form.append(box, go);
- panel.append(head, scroll, err, form);
- const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
- box.addEventListener('input', fit);
- box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
- form.addEventListener('submit', async (e) => {
- e.preventDefault();
- const text = box.value.trim();
- if (!text || chats.busy) return;
- chats.busy = true; go.disabled = true; err.hidden = true;
- try {
- await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
- box.value = ''; fit();
- await pull(list, scroll);
- } catch (x) {
- err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
- err.hidden = false;
- } finally { chats.busy = false; go.disabled = false; box.focus(); }
- });
+ panel.append(head, scroll, err);
+ if (canWriteChat()) {
+ panel.append(form);
+ const fit = () => { box.style.height = 'auto'; box.style.height = `${Math.min(box.scrollHeight, 140)}px`; };
+ box.addEventListener('input', fit);
+ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.dispatchEvent(new Event('submit', { cancelable: true })); } });
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const text = box.value.trim();
+ if (!text || chats.busy) return;
+ chats.busy = true; go.disabled = true; err.hidden = true;
+ try {
+ await chatCall(`/api/dm/conversations/${encodeURIComponent(chats.open)}/messages`, { method: 'POST', body: JSON.stringify({ message: text }) });
+ box.value = ''; fit();
+ await pull(list, scroll);
+ } catch (x) {
+ err.textContent = x.status === 429 ? 'Slow down a little, then send it again.' : x.status === 403 ? 'You can’t send messages in this conversation.' : 'That did not send. Try again.';
+ err.hidden = false;
+ } finally { chats.busy = false; go.disabled = false; box.focus(); }
+ });
+ } else {
+ panel.append(h('div', 'ovdk-none', 'Reading only. Answer in OpenVibe.Chat.'));
+ }
list.append(h('li', 'ovdk-wait', null));
list.append(h('li', 'ovdk-wait', null));
list.firstChild.innerHTML = '<span></span>';
list.firstChild.innerHTML = '<span></span>';
await pull(list, scroll, true);
await pull(list, scroll, true);
- setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ if (canWriteChat()) setTimeout(() => { try { box.focus({ preventScroll: true }); } catch { /* */ } }, 0);
}
}
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
/** New messages of the open thread (all of them the first time), drawn in place; read once shown. */
async function pull(list, scroll, first) {
async function pull(list, scroll, first) {
@@ -630,20 +634,21 @@
@@ -630,20 +634,21 @@
// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
// Desktop toasts (bottom right, notification-ui.js) stack above a pill in their corner; an open Dock sets its own.
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
if (!isOpen()) clear(show && pill.dataset.corner === 'bottom-right' && !phone() ? 58 : 0);
}
}
- function read() {
+ async function read() {
clearTimeout(readT);
clearTimeout(readT);
if (!pill || document.hidden) return;
if (!pill || document.hidden) return;
- const t = typeof o.token === 'function' ? o.token() : o.token;
+ const t = await tokenOf();
if (!t) return;
if (!t) return;
- fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' })
- .then((r) => { if (r.status !== 404) return r.ok ? r.json() : null; store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return null; })
- .then((next) => {
- if (!next || !next.state || !pill) return;
- update(next);
- if (!isOpen() && next.state.open && !phone()) open();
- const c = next.counts || {};
- if (c.running || c.waiting) readT = setTimeout(read, 30000);
- }).catch(() => { /* offline: the copy stands */ });
+ try {
+ const r = await fetch(`${String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '')}/api/v1/me/dock`, { headers: { authorization: `Bearer ${t}`, accept: 'application/json' }, credentials: 'omit' });
+ if (r.status === 404) { store({ off: Date.now() + 864e5 }); if (pill) pill.hidden = true; return; }
+ const next = r.ok ? await r.json() : null;
+ if (!next || !next.state || !pill) return;
+ update(next);
+ if (!isOpen() && next.state.open && !phone()) open();
+ const c = next.counts || {};
+ if (c.running || c.waiting) readT = setTimeout(read, 30000);
+ } catch { /* offline: the copy stands */ }
}
}
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
function mount(opts) {
function mount(opts) {
@@ -725,7 +730,9 @@
@@ -725,7 +730,9 @@
win.document.body.appendChild(el);
win.document.body.appendChild(el);
win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });
win.addEventListener('pagehide', () => { if (popped && !pipClosing) backToPage(); });
paintPop();
paintPop();
- save({ open: true, popped: true });
+ // Whether the Dock is in a window stays with this page: the window belongs to one page in one browser, closes
+ // with it, and no other device could open it again (the browser wants a click), so the Network is not told.
+ save({ open: true });
paintPill();
paintPill();
}
}
@@ -741,7 +748,6 @@
@@ -741,7 +748,6 @@
el.hidden = false;
el.hidden = false;
layout();
layout();
paintPop();
paintPop();
- save({ popped: false });
paintPill();
paintPill();
}
}
@@ -777,7 +783,7 @@
@@ -777,7 +783,7 @@
document.documentElement.classList.remove('ovdk-sheet-open');
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clear(0);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
- save(wasPopped ? { open: false, popped: false } : { open: false });
+ save({ open: false });
if (inside) {
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
@@ -86,7 +86,7 @@
@@ -86,7 +86,7 @@
agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
agents: ['No agent work yet', 'When an agent needs your approval or finishes a task, it shows here.'],
};
};
- let _config = { token: null, apiBase: 'https://openvibe.network', soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
+ let _config = { token: null, apiBase: 'https://openvibe.network', scopes: null, soundBase: '/assets/sounds', swPath: '/openvibe-sw.js', onAction: null, inboxUrl: null };
let _pollTimer = null;
let _pollTimer = null;
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _lastSeenAt = null; // newest created_at we have toasted / seen
let _unreadCount = 0;
let _unreadCount = 0;
@@ -445,9 +445,14 @@
@@ -445,9 +445,14 @@
function isCrossOrigin() {
function isCrossOrigin() {
try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
try { return new URL(_config.apiBase).origin !== window.location.origin; } catch { return false; }
}
}
- function apiFetch(path, opts = {}) {
+ /** The token may be a string or a function (the extension refreshes it); resolve it per request. */
+ async function tokenValue() {
+ return typeof _config.token === 'function' ? await _config.token() : _config.token;
+ }
+ async function apiFetch(path, opts = {}) {
const headers = { ...(opts.headers || {}) };
const headers = { ...(opts.headers || {}) };
- if (_config.token) headers['Authorization'] = `Bearer ${_config.token}`;
+ const token = await tokenValue();
+ if (token) headers['Authorization'] = `Bearer ${token}`;
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
headers['Content-Type'] = 'application/json';
opts = { ...opts, body: JSON.stringify(opts.body) };
opts = { ...opts, body: JSON.stringify(opts.body) };
@@ -760,7 +765,7 @@
@@ -760,7 +765,7 @@
}));
}));
}
}
function openActions(anchor) {
function openActions(anchor) {
- loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });
+ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => tokenValue(), apiBase: _config.apiBase, scopes: _config.scopes }); });
}
}
function createActionsEl() {
function createActionsEl() {
const b = document.createElement('div');
const b = document.createElement('div');
@@ -1217,7 +1222,7 @@
@@ -1217,7 +1222,7 @@
if (!L || _live || !_config.realtime || !hasSession()) return;
if (!L || _live || !_config.realtime || !hasSession()) return;
_live = L.create({
_live = L.create({
ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
- token: () => _config.token,
+ token: () => tokenValue(),
credentials: isCrossOrigin() ? 'omit' : 'include',
credentials: isCrossOrigin() ? 'omit' : 'include',
onNotification: () => soon(false),
onNotification: () => soon(false),
onGap: () => soon(true),
onGap: () => soon(true),
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.6.0",
+ "version": "0.8.0",
"private": true,
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
"scripts": {
@@ -3,14 +3,16 @@
@@ -3,14 +3,16 @@
* 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 + 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
* (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
* 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
* Reviews": activeTab is the only host access; Firefox's sidebar_action, the
- * side panel there, needs no permission of its own)
+ * 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 and the Chat origin, all three
* host_permissions exactly the API origin, the Network origin and the Chat origin, all three
* https://openvibe.<tld> sites (the coupon API, the Dock's state, the Dock's
* https://openvibe.<tld> sites (the coupon API, the Dock's state, the Dock's
* Chats tab)
* Chats tab)
+ * 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, background, 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,
@@ -27,8 +29,8 @@ const path = require('path');
@@ -27,8 +29,8 @@ 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']);
-const BASE_PERMISSIONS = ['activeTab', 'scripting', 'storage'];
+ 'side_panel', 'sidebar_action', 'key']);
+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']);
@@ -84,6 +84,7 @@ function forTarget(files, target) {
@@ -84,6 +84,7 @@ function forTarget(files, target) {
} else if (target === 'firefox') {
} else if (target === 'firefox') {
delete m.minimum_chrome_version;
delete m.minimum_chrome_version;
delete m.side_panel;
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');
m.permissions = (m.permissions || []).filter((p) => p !== 'sidePanel');
} 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'));
@@ -14,7 +14,8 @@ reads, sends or stores, change this file, the README's "What it can see" table a
@@ -14,7 +14,8 @@ reads, sends or stores, change this file, the README's "What it can see" table a
|---|---|
|---|---|
| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |
| `activeTab` | When the person opens the popup, it reads the active tab's address and title, to look up coupon codes for that site by its hostname and to fill the hand-off buttons. No access to any tab before that click, and none to other tabs. |
| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
| `scripting` | Four buttons inject a script packaged in the extension into the active tab's top frame, only after the press: "Try these codes on this page" runs `lib/autoapply.js` (finds the coupon box, types each code, reads whether the order total changed), "Save its rating to OpenVibe Reviews" runs `lib/rating.js` (reads the rating the page states), and "Keep on my Rent board" and "Check the rent" run `lib/listing.js` (reads the price, city and bedrooms a listing states). None makes a request or reads cookies or storage. |
-| `storage` | Keeps the optional `cpx_` install token the person pastes on the settings page, in `storage.local` (never synced). Nothing else is stored. |
+| `storage` | Keeps the optional `cpx_` install token, and the Dock's sign-in: the OAuth access token in `storage.session` (memory) and the rotating refresh token in `storage.local` (never synced). A pasted 0.7.0 `networkToken` is kept only as that fallback. |
+| `identity` | Starts the Dock's one-click sign-in (`launchWebAuthFlow`) at openvibe.network; the code returns to the browser's own extension redirect URL. No password is read by the extension. |
| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |
| Host permission `https://openvibe.coupons/*` | The one API the extension calls: look up a merchant by hostname, list its codes, and report whether a code worked. |
| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |
| Remote code | **No.** All JavaScript is in the package; the CSP is `script-src 'self'` with no `eval`. |
@@ -26,7 +27,7 @@ Tick these data types, because each leaves the device in some case:
@@ -26,7 +27,7 @@ Tick these data types, because each leaves the device in some case:
|---|---|---|
|---|---|---|
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
| Web history | **Yes** | The active tab's **hostname** goes to openvibe.coupons when the popup opens on a site. On a hand-off press, the page's address (credentials, fragment and tracking parameters removed) and title go to openvibe.rent, openvibe.watch, openvibe.food or openvibe.reviews in the address of a tab the person sees. |
| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
| Website content | **Yes** | On "Save its rating to OpenVibe Reviews": the business name, rating, scale, count of ratings and city the page states, in the address of the openvibe.reviews tab. On "Compare delivery apps": the restaurant or store name taken from the page title, to openvibe.food. On "Keep on my Rent board" and "Check the rent": the price, its currency, the city and region (never the street) and the bedrooms the listing states, in the address of the openvibe.rent tab. |
-| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. It is issued by openvibe.coupons and sent nowhere else. |
+| Authentication information | **Yes** | Only if the person connects the extension: the `cpx_` install token they pasted is sent to openvibe.coupons as `Authorization: Bearer` on its API calls. The Dock's one-click sign-in sends an OAuth access token to openvibe.network and openvibe.chat as `Authorization: Bearer` (scopes `dock`, `activity`, `chat.read`) and keeps a refresh token on this device. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| User activity | No | No clicks, keystrokes or scrolls are recorded. A report sends a code id, `worked`/`failed` and an optional reason the person chose, which is the person's own feedback. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Financial and payment information | No | `lib/autoapply.js` reads the order total in the page to tell which codes lowered it; the total stays in the popup and is never sent. |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
| Personally identifiable information, health, personal communications, location | No | Not read. (The city sent with a rating is the business's, and with a listing the home's, from the page; never the person's.) |
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+/**
+ * The Dock's one-click sign-in (lib/auth.js): PKCE S256, state, no client secret, the access token in
+ * storage.session, the rotating refresh token in storage.local, single-flight refresh, Sign out revoke,
+ * and the 0.7.0 pasted-token fallback. No browser is started; identity/storage are fakes and fetch is a
+ * queue.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const config = require('../browser/lib/config');
+const { createAuth } = require('../browser/lib/auth');
+const { check, done } = require('./helpers/harness');
+
+const CHROME = config.OAUTH_REDIRECT_URIS[0];
+const b64 = (buf) => Buffer.from(buf).toString('base64url');
+const token = (sub = 7, username = 'ada') => `${b64(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${b64(JSON.stringify({ sub, username, display_name: 'Ada' }))}.sig`;
+
+function platform({ redirect = CHROME, legacy = null, launch } = {}) {
+ const session = new Map(), local = new Map();
+ let old = legacy;
+ const copy = (map, values) => { for (const k of Object.keys(values)) map.set(k, values[k]); };
+ return {
+ session, local,
+ get legacy() { return old; },
+ identity: {
+ getRedirectURL() { return redirect; },
+ launchWebAuthFlow(opts) { return launch ? launch(opts) : Promise.reject(new Error('no launch')); },
+ },
+ storage: {
+ async getSession(k) { return session.has(k) ? session.get(k) : null; },
+ async setSession(v) { copy(session, v); },
+ async removeSession(keys) { for (const k of [].concat(keys)) session.delete(k); },
+ async getLocal(k) { return local.has(k) ? local.get(k) : null; },
+ async setLocal(v) { copy(local, v); },
+ async removeLocal(keys) { for (const k of [].concat(keys)) local.delete(k); },
+ async getNetworkToken() { return old; },
+ async setNetworkToken(t) { old = t; },
+ async clearNetworkToken() { old = null; },
+ },
+ };
+}
+
+function fetcher(responses) {
+ const calls = [];
+ const fetch = async (url, opts) => {
+ const body = new URLSearchParams(opts.body);
+ calls.push({ url, body: Object.fromEntries(body), headers: opts.headers });
+ const r = responses.shift();
+ if (r instanceof Error) throw r;
+ return { ok: r.status < 400, status: r.status, json: async () => r.body };
+ };
+ fetch.calls = calls;
+ return fetch;
+}
+
+(async () => {
+ await check('sign-in: exact authorize parameters, S256, state, code exchange with no secret', async () => {
+ let issuedChallenge = null;
+ const p = platform({
+ launch(opts) {
+ const u = new URL(opts.url);
+ const state = u.searchParams.get('state');
+ assert.strictEqual(opts.interactive, true);
+ assert.strictEqual(u.origin + u.pathname, config.NETWORK_ORIGIN + '/oauth/authorize');
+ 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');
+ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(state, /^[A-Za-z0-9_-]{43}$/);
+ issuedChallenge = u.searchParams.get('code_challenge');
+ assert.match(issuedChallenge, /^[A-Za-z0-9_-]{43}$/);
+ return Promise.resolve(`${CHROME}?code=code123&state=${encodeURIComponent(state)}`);
+ },
+ });
+ const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r'.repeat(96), expires_in: 3600, scope: 'dock activity chat.read' } }]);
+ const auth = createAuth({ platform: p, config, fetch: f });
+ const acc = await auth.signIn();
+ assert.strictEqual(acc.token, token());
+ assert.strictEqual(f.calls.length, 1);
+ const ex = f.calls[0];
+ assert.strictEqual(ex.url, config.NETWORK_ORIGIN + '/oauth/token');
+ assert.strictEqual(ex.body.grant_type, 'authorization_code');
+ assert.strictEqual(ex.body.client_id, 'companion');
+ assert.strictEqual(ex.body.code, 'code123');
+ assert.strictEqual(ex.body.redirect_uri, CHROME);
+ assert.ok(!('client_secret' in ex.body), 'a public client never sends a secret');
+ assert.match(ex.body.code_verifier, /^[A-Za-z0-9_-]{43}$/);
+ const challenge = b64(crypto.createHash('sha256').update(ex.body.code_verifier).digest());
+ assert.strictEqual(issuedChallenge, challenge, 'the authorize challenge is S256 of the verifier sent at exchange');
+ assert.ok(p.session.get(config.DOCK_ACCESS_KEY).token);
+ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY).expires_at > Date.now(), true);
+ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY).token, 'r'.repeat(96));
+ assert.deepStrictEqual(p.local.get(config.DOCK_ACCOUNT_KEY), { sub: 7, username: 'ada', display_name: 'Ada' });
+ assert.deepStrictEqual(await auth.scopes(), ['dock', 'activity', 'chat.read']);
+ assert.strictEqual((await auth.status()).mode, 'oauth');
+ });
+
+ await check('state mismatch and a wrong redirect reject before any token exchange', async () => {
+ let p = platform({ launch: async (opts) => `${CHROME}?code=c&state=wrong` });
+ let f = fetcher([]);
+ let auth = createAuth({ platform: p, config, fetch: f });
+ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.state');
+ assert.strictEqual(f.calls.length, 0);
+ p = platform({ launch: async () => 'https://evil.example/cb?code=c&state=x' });
+ auth = createAuth({ platform: p, config, fetch: fetcher([]) });
+ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.redirect');
+ });
+
+ await check('a cancelled window is auth.cancelled; a redirect error is mapped', async () => {
+ let p = platform({ launch: async () => { throw new Error('The user did not approve access.'); } });
+ let auth = createAuth({ platform: p, config, fetch: fetcher([]) });
+ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.cancelled');
+ p = platform({ launch: async (opts) => `${CHROME}?error=access_denied&state=${new URL(opts.url).searchParams.get('state')}` });
+ auth = createAuth({ platform: p, config, fetch: fetcher([]) });
+ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.cancelled');
+ });
+
+ await check('refresh rotates, single-flight, and never sends a secret', async () => {
+ const p = platform();
+ p.session.set(config.DOCK_ACCESS_KEY, { token: 'old', expires_at: 0, scope: 'dock activity chat.read', sub: 7, username: 'ada' });
+ p.local.set(config.DOCK_REFRESH_KEY, { token: 'r1', scope: 'dock activity chat.read', sub: 7, username: 'ada' });
+ const f = fetcher([
+ { status: 200, body: { access_token: token(), refresh_token: 'r2', expires_in: 3600, scope: 'dock activity chat.read' } },
+ ]);
+ const auth = createAuth({ platform: p, config, fetch: f });
+ const [a, b] = await Promise.all([auth.refresh(), auth.refresh()]);
+ assert.strictEqual(a, b, 'both callers get one refreshed access token');
+ assert.strictEqual(f.calls.length, 1, 'single-flight');
+ assert.strictEqual(f.calls[0].body.grant_type, 'refresh_token');
+ assert.strictEqual(f.calls[0].body.refresh_token, 'r1');
+ assert.ok(!('client_secret' in f.calls[0].body));
+ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY).token, 'r2');
+ });
+
+ await check('a grant with a scope the extension did not ask for is refused and not stored', async () => {
+ const p = platform({ launch: async (opts) => `${CHROME}?code=c&state=${new URL(opts.url).searchParams.get('state')}` });
+ const f = fetcher([{ status: 200, body: { access_token: token(), refresh_token: 'r', expires_in: 3600, scope: 'dock coins' } }]);
+ const auth = createAuth({ platform: p, config, fetch: f });
+ await assert.rejects(auth.signIn(), (e) => e.code === 'auth.scope');
+ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY), undefined);
+ });
+
+ await check('Sign out revokes the refresh token and clears both stores; the pasted token is cleared too', async () => {
+ const p = platform({ legacy: 'old-normal-token' });
+ p.session.set(config.DOCK_ACCESS_KEY, { token: 'a', expires_at: Date.now() + 3600e3 });
+ p.local.set(config.DOCK_REFRESH_KEY, { token: 'r9' });
+ p.local.set(config.DOCK_ACCOUNT_KEY, { username: 'ada' });
+ const f = fetcher([{ status: 200, body: {} }]);
+ const auth = createAuth({ platform: p, config, fetch: f });
+ await auth.signOut();
+ assert.strictEqual(f.calls.length, 1);
+ assert.strictEqual(f.calls[0].url, config.NETWORK_ORIGIN + '/oauth/revoke');
+ assert.deepStrictEqual(f.calls[0].body, { client_id: 'companion', token: 'r9' });
+ assert.strictEqual(p.session.get(config.DOCK_ACCESS_KEY), undefined);
+ assert.strictEqual(p.local.get(config.DOCK_REFRESH_KEY), undefined);
+ assert.strictEqual(p.local.get(config.DOCK_ACCOUNT_KEY), undefined);
+ assert.strictEqual(p.legacy, null);
+ });
+
+ await check('the 0.7.0 pasted token is a fallback and is reported as legacy', async () => {
+ const p = platform({ legacy: 'old-normal-token' });
+ const auth = createAuth({ platform: p, config, fetch: fetcher([]) });
+ assert.strictEqual(await auth.accessToken(), 'old-normal-token');
+ assert.deepStrictEqual(await auth.status(), { mode: 'legacy', account: null });
+ assert.strictEqual(await auth.scopes(), null);
+ });
+
+ done();
+})();
@@ -16,10 +16,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -16,10 +16,10 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
(async () => {
(async () => {
const { manifest, origin, problems } = auditDir(DIR);
const { manifest, origin, problems } = auditDir(DIR);
- await check('the shipped manifest passes the audit: activeTab + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {
+ await check('the shipped manifest passes the audit: activeTab + identity + scripting + storage + sidePanel, three OpenVibe host permissions, MV3', async () => {
assert.deepStrictEqual(problems, []);
assert.deepStrictEqual(problems, []);
assert.strictEqual(origin, ORIGIN);
assert.strictEqual(origin, ORIGIN);
- assert.deepStrictEqual(manifest.permissions.slice().sort(), ['activeTab', '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.chat/*']);
assert.deepStrictEqual(manifest.host_permissions, ['https://openvibe.coupons/*', 'https://openvibe.network/*', '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', 'background', 'web_accessible_resources', 'externally_connectable', 'optional_permissions', 'optional_host_permissions']) {
@@ -27,6 +27,17 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -27,6 +27,17 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
}
}
});
});
+ await check('the public key fixes the Chrome id and the gecko id fixes the Firefox redirect', async () => {
+ const crypto = require('crypto');
+ const der = Buffer.from(manifest.key, 'base64');
+ const hash = crypto.createHash('sha256').update(der).digest();
+ let id = '';
+ for (let i = 0; i < 16; i++) id += String.fromCharCode(97 + (hash[i] >> 4)) + String.fromCharCode(97 + (hash[i] & 15));
+ assert.strictEqual(`https://${id}.chromiumapp.org/`, require('../browser/lib/config').OAUTH_REDIRECT_URIS[0], 'Chrome id');
+ const ff = crypto.createHash('sha1').update('[email protected]').digest('hex');
+ assert.strictEqual(`https://${ff}.extensions.allizom.org/`, require('../browser/lib/config').OAUTH_REDIRECT_URIS[1], 'Firefox redirect');
+ });
+
await check('the side panel is a key in both browsers\' manifests', async () => {
await check('the side panel is a key in both browsers\' manifests', async () => {
const { forTarget } = require('../scripts/pack');
const { forTarget } = require('../scripts/pack');
const ff = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);
const ff = new Map([['manifest.json', Buffer.from(JSON.stringify(manifest))]]);
@@ -42,6 +53,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -42,6 +53,8 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
assert.ok(!('sidebar_action' in chrome), 'and no Firefox key');
assert.ok(!('sidebar_action' in chrome), 'and no Firefox key');
assert.ok(chrome.permissions.includes('sidePanel'), 'Chrome asks for its side-panel permission');
assert.ok(chrome.permissions.includes('sidePanel'), 'Chrome asks for its side-panel permission');
assert.ok(!chrome.permissions.includes('sidebarAction'));
assert.ok(!chrome.permissions.includes('sidebarAction'));
+ assert.ok(chrome.key, 'Chrome keeps the public key that fixes the extension id');
+ assert.ok(!firefox.key, 'Firefox gets no Chrome key');
});
});
await check('the audit fails if anything widens', async () => {
await check('the audit fails if anything widens', async () => {
@@ -100,7 +113,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
@@ -100,7 +113,7 @@ const ORIGINS = ['https://openvibe.coupons', 'https://openvibe.network', 'https:
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 rel = path.relative(DIR, f);
const rel = path.relative(DIR, f);
- if (rel !== path.join('lib', 'api.js') && rel !== path.join('lib', 'network-api.js')) 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)');
@@ -82,6 +82,7 @@ function unzip(buf) {
@@ -82,6 +82,7 @@ function unzip(buf) {
want.side_panel = { default_path: 'panel.html' };
want.side_panel = { default_path: 'panel.html' };
} else {
} else {
delete want.side_panel;
delete want.side_panel;
+ delete want.key;
want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
want.permissions = want.permissions.filter((p) => p !== 'sidePanel');
}
}
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
assert.deepStrictEqual(JSON.parse(files.get('manifest.json')), want);
@@ -42,6 +42,19 @@ function fakeDock() {
@@ -42,6 +42,19 @@ function fakeDock() {
return dock;
return dock;
}
}
+/** The OAuth auth surface the panel uses; `mode:'none'` is the signed-out gate. */
+function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
+ const auth = {
+ signIns: 0,
+ status: async () => ({ mode, account: mode === 'oauth' ? { username: 'ada' } : null }),
+ scopes: async () => (mode === 'oauth' ? ['dock', 'activity', 'chat.read'] : null),
+ account: async () => (mode === 'oauth' ? { username: 'ada' } : null),
+ accessToken: async () => { if (mode === 'none') throw Object.assign(new Error('auth required'), { code: 'auth.required' }); return token; },
+ signIn: async () => { auth.signIns++; mode = 'oauth'; },
+ };
+ return auth;
+}
+
(async () => {
(async () => {
const net1 = await startNetwork();
const net1 = await startNetwork();
try {
try {
@@ -49,11 +62,13 @@ function fakeDock() {
@@ -49,11 +62,13 @@ function fakeDock() {
const doc = fakeDoc();
const doc = fakeDoc();
const dock = fakeDock();
const dock = fakeDock();
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => TOKEN } }, net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock, sibling: (n) => 'chrome-extension://abc/shared/' + n });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(dock.opened, 1, 'and opened');
assert.strictEqual(dock.opened, 1, 'and opened');
- assert.strictEqual(dock.mounted.token, TOKEN, "with the person's sign-in");
+ assert.strictEqual(await dock.mounted.token(), TOKEN, "with the person's sign-in");
+ assert.deepStrictEqual(dock.mounted.scopes, ['dock', 'activity', 'chat.read'], 'read-only scopes');
+ assert.strictEqual(dock.mounted.me, 'ada', 'the account name');
assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");
assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");
assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');
assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');
assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');
assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');
@@ -65,12 +80,13 @@ function fakeDock() {
@@ -65,12 +80,13 @@ function fakeDock() {
const doc = fakeDoc();
const doc = fakeDoc();
const dock = fakeDock();
const dock = fakeDock();
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => null });
- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ mode: 'none' }), net, dock });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');
assert.strictEqual(out.mounted, false, 'no Dock without a sign-in');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate is shown');
assert.match(doc.nodes['gate-title'].textContent, /OpenVibe\.Network/);
assert.match(doc.nodes['gate-title'].textContent, /OpenVibe\.Network/);
assert.strictEqual(doc.nodes['gate-button'].hidden, false, 'with the way to fix it');
assert.strictEqual(doc.nodes['gate-button'].hidden, false, 'with the way to fix it');
+ assert.strictEqual(doc.nodes['gate-button'].textContent, 'Sign in');
});
});
await check('a sign-in the Network refuses is not used', async () => {
await check('a sign-in the Network refuses is not used', async () => {
@@ -78,12 +94,28 @@ function fakeDock() {
@@ -78,12 +94,28 @@ function fakeDock() {
const dock = fakeDock();
const dock = fakeDock();
const bad = 'wrong_token_' + 'y'.repeat(40);
const bad = 'wrong_token_' + 'y'.repeat(40);
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });
const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => bad });
- const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => bad } }, net, dock });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth({ token: bad }), net, dock });
const out = await panel.start();
const out = await panel.start();
assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');
assert.strictEqual(out.mounted, false, 'no Dock with a sign-in the Network refused');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');
assert.strictEqual(doc.nodes.gate.hidden, false, 'the gate says so');
assert.match(doc.nodes['gate-title'].textContent, /said no/);
assert.match(doc.nodes['gate-title'].textContent, /said no/);
});
});
+
+ await check('the gate button starts the one-click sign-in and mounts after it', async () => {
+ const doc = fakeDoc();
+ const dock = fakeDock();
+ const auth = fakeAuth({ mode: 'none' });
+ const net = createNetworkClient({ origin: net1.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
+ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: {} }, auth, net, dock });
+ await panel.start();
+ doc.nodes['gate-button'].onclick();
+ // The click handler is async; wait until the grant is "connected" and the Dock mounted.
+ const start = Date.now();
+ while (!dock.mounted && Date.now() - start < 2000) await new Promise((r) => setTimeout(r, 20));
+ assert.strictEqual(auth.signIns, 1, 'the button starts the OAuth flow');
+ assert.strictEqual(dock.opened, 1, 'and the Dock mounts');
+ assert.strictEqual(doc.nodes.gate.hidden, true);
+ });
} finally {
} finally {
net1.close();
net1.close();
}
}