openvibers/openvibe.shared Commits

4.116.0: the Share sheet's Locked part (people, password and link locks) (#299)

OP OpenVibers committed 7 days ago · 348d26a846 · 13 files, +700 −69

* 4.116.0: the Share sheet's Locked part (people, password and link locks)

A locked item gets its own part in the sheet: what the lock means, the
people's eight-word fingerprints with a warning when a key changed, adding
someone (their content key wrapped in the browser) and removing them (the
content sealed again under a fresh key, saying they keep what they opened).
ov-locker.js loads only when the part is first pressed; it gains ensure,
keys, publicKeys, rewrap and inspect, and the browser core is regenerated
from openvibe-sdk 0.89.0.

Checked: test/ov-share-lock-chrome.test.js (real crypto, add/remove/link),
the existing share and locker Chrome tests, the core vectors, and the module
and browser-file checks.

* Locker: seal() in the browser, for a page that makes a locked item

* 4.116.0: the version in STATUS, the README block and the navbar's sibling revs

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 469c888..a0ad2c6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,27 @@
# Changelog
++## 4.116.0 — 2026-11-06
++
++- **The Share sheet's Locked part** (`ov-share.js`, `ov-share.css`; Locker design 2026-11-06, the pilot; docs/share.md).
++ A locked item (`lock: people | password | link` in the share API's answer) gets its own part between Send it and Who
++ can open it:
++ - the lock in plain words, and a link lock says when the link in front of it is missing its `#k=` key;
++ - a people lock shows each person's eight-word fingerprint, warns when their key changed since the slot was made,
++ and offers **Use their new key**;
++ - adding someone asks Network for their public key and wraps their content key on this device (the member row and
++ the slot are saved together); someone without a locker is told so, not added;
++ - removing someone seals the content again under a fresh key for those who stay, uploads the new envelope with the
++ members, and says "They had the key before you removed them: they may still have a copy of what they already
++ opened."
++ - `ov-locker.js` loads on the first press of the part, never with the sheet.
++- **`ov-locker.js`** gains the calls the sheet needs, all keeping the keys in the tab: `ensure()`, `keys(who)` (Network's
++ key directory), `publicKeys()`, `rewrap(bytes, { add, remove, keys })` and `inspect(bytes)` (docs/locker.md).
++- **`ov-locker-core.js`** regenerated from openvibe-sdk 0.89.0 (`seal.js` and `server.js` moved since 0.86.0).
++ openvibe-sdk devDependency 0.86.0 → 0.89.0.
++- test/ov-share-lock-chrome.test.js: the Locked part with the real crypto — lazy load, fingerprints, adding wraps for
++ them (and the envelope that goes up opens for them), removing re-seals (and the removed person's key opens nothing),
++ and a link lock keeps its key after `#`.
++
## 4.115.1 — 2026-11-06
- **Share sheet:** its headings no longer take a site's own `h2` rule (Wiki's bottom border and margin showed under the sheet's title). Every property a site might set on an `h2` is reset inside the sheet and the access gate.
ddiff --git a/README.md b/README.md
iindex 19fd193..1532ef3 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.115.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.116.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
@@@ -1086,5 +1086,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
- openvibe-contracts: v0.289.1
--- openvibe-sdk: v0.86.0
++- openvibe-sdk: v0.89.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 037f3b1..65d80f3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,12 +4,12 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.115.1",
++ "version": "4.116.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.289.1 (devDependency: the release manifest contract)",
-- "sdk": "openvibe-sdk v0.86.0",
++ "sdk": "openvibe-sdk v0.89.0",
"features": [
"the OpenVibe Frame: navbar, footer, panels, account switcher, notification UI with realtime",
"the launcher beside the brand lists every site of the network, in groups; Ask Actor and the Vibes chip in the account menu",
ddiff --git a/docs/locker.md b/docs/locker.md
iindex 9b68324..87aebf5 100644
---- a/docs/locker.md
++++ b/docs/locker.md
@@@ -42,6 +42,17 @@ OVLocker.unlock({ onDone }); // unlock it o
OVLocker.state(); // { unlocked, fingerprint, subject }
OVLocker.forget(); // drop the unlocked keys
OVLocker.keyFromLink(); // the 32-byte key after #k=, or null
++OVLocker.ensure(); // resolves once unlocked here, or opens the unlock sheet; rejects when declined
++OVLocker.keys(subject|'@name'); // Network's public keys for one person, or null when they have no locker
++OVLocker.publicKeys(); // my public keys (for a people lock that wraps for me too)
++OVLocker.rewrap(bytes, { add, remove, keys });
++ // a people-locked envelope re-wrapped here: add [{ subject, publicKey }] wraps the
++ // content key for someone; remove [subject] seals the content under a fresh key for
++ // `keys` (the public keys of those who stay). → { bytes, header }
++OVLocker.seal(content, { lock, keys, password });
++ // seal content here (a new locked item): the key is made and kept in this tab; a link
++ // lock comes back with linkKey, the 32 bytes that go in the link after #k=
++OVLocker.inspect(bytes); // the envelope's header, without a key
window.OpenVibeLockerConfig = { network: 'https://openvibe.network' };
```
ddiff --git a/docs/share.md b/docs/share.md
iindex 96c753c..dbd26f5 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -13,6 +13,7 @@ computer. It has two parts and no tabs:
|---|---|---|
| **Send it** | everyone | the link and **Copy**; on a phone, **Send with your phone** (the phone's own share sheet: contacts, messages, every app); X, Bluesky, WhatsApp, Reddit, Discord, Telegram, Threads, Mastodon, Facebook, Email, Messages (phones), LinkedIn, Pinterest (with a picture), Tumblr, Hacker News, LINE; **QR code**; **Embed**; **Start at 1:23** for a video or a recording |
| **Who can open it** | the owner (and staff) of a thing with a share API | Everyone · Anyone with the link · Specific people · Only me; people by @name, each Can view or Can edit, with an end date; people who asked; **Transfer** |
++| **Locked** | the owner of a locked item (one sealed with [Locker](locker.md)) | what the lock means in plain words; each person's eight-word fingerprint; add someone (their key wrapped on this device) or remove them (the content sealed again) |
The first eight places show at once, with the person's three most recent first. The rest are behind a counted fold
(**More (n)**), never hidden.
@@@ -62,6 +63,31 @@ OVShare.fromButton(button);
A site's analytics may count these.
- `ov-share:change` `{ kind, id, state }` after a save, a transfer or an answer.
++## Locked items (Locker)
++
++A locked item carries the lock's kind in the share API's answer (`lock: people | password | link`) and the sheet draws a
++**Locked** part between the two above. It opens on the first press, and only then does the sheet load `ov-locker.js`: a
++page that never touches a locked item never loads the crypto.
++
++- **For the people on the list** — the sheet shows each person's eight-word fingerprint (the one Network publishes with
++ their keys). It warns when their key changed since the slot was made, with **Use their new key**; nothing is wrapped
++ for a key the person has not seen.
++ - Adding someone asks Network for their public key first. Their content key is wrapped for it on this device, and the
++ member row and the slot are saved together. Someone without a locker cannot be added, and the sheet says so in
++ words.
++ - Removing someone seals the content again under a fresh key for those who stay, then uploads the new envelope. The
++ sheet says: "They had the key before you removed them: they may still have a copy of what they already opened."
++- **With a link** — the key is the part of the link after `#`. The page's address has it, so **Copy** and every place
++ the sheet sends keep the whole link; the sheet never invents a key, and says plainly when the link in front of it is
++ missing one.
++- **With a password** — nothing is stored: the password opens the envelope on the reader's device. Lose it and nobody
++ can open the item, including OpenVibe (the sheet says this at the moment it matters).
++- The envelope's URL comes from the service's `describe(ref)` as `envelope_url`; the sheet fetches it, re-wraps or
++ re-seals it with `OVLocker.rewrap()`, and sends the base64 in the save's `envelope` field. The content key and the
++ plaintext never leave the browser.
++- The service must register the lock when the item is made (`register(ref, { lock })`), report it in the GET, store a
++ re-seal through `onReseal(ref, bytes, tx)`, and keep the item out of public lists: a locked item can never be public.
++
## What it promises
- **The service decides.** The sheet only asks. Who may open a thing is enforced by the service that holds it
ddiff --git a/navbar.js b/navbar.js
iindex eca6170..eafd696 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -491,7 +491,7 @@
const NAV_ICONS_REV = 'd9a4febbbad7'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'd5bbb14b108e', 'ov-share.css': 'f694a088190f' };
++ const SIBLING_REV = { 'ov-mark.js': '1e91c9c0f347', 'ov-glyphs.js': '715972edd968', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2a6c53a7b176', 'ov-fx.js': '46fce1e2b8db', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '068ce1b1c4d5', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': 'c70f014c25de', 'ov-share.css': 'cbd18e6ce991' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
ddiff --git a/ov-locker-core.js b/ov-locker-core.js
iindex 45f4596..88824de 100644
---- a/ov-locker-core.js
++++ b/ov-locker-core.js
@@@ -1,4 +1,4 @@
--/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.86.0 (src/locker). Do not edit: change the SDK,
++/* Generated by scripts/build-locker-core.js from openvibe-sdk 0.89.0 (src/locker). Do not edit: change the SDK,
* release it, bump the pin, and run the script. The SDK's files, unchanged but for the names of require and module:
* bytes.js 80a5a560c683f489
* errors.js e73f94b7711452e7
@@@ -8,8 +8,8 @@
* keys.js 18663474b10352ed
* padme.js e0dd4b08e0d0f24f
* recovery.js 2829d07a02561674
-- * seal.js b70d01522636486d
-- * server.js 44a92f3cbb3e6bc1
++ * seal.js ff6ad448082c636c
++ * server.js 56414198b01fbecd
* words.js 8f4f51abdadfbfd1
*/
(function (root) {
@@@ -1103,6 +1103,15 @@ function readCore(header) {
/** The content key out of the header, with the credentials the reader has. */
async function openContentKey(header, credentials = {}) {
++ // A caller that still holds the content key needs no private key: an agent that sealed the item
++ // can open it, or wrap it for the people who stay, with the key it kept. A string is base64url,
++ // as the link key travels.
++ if (credentials.contentKey) {
++ const given = typeof credentials.contentKey === 'string'
++ ? fromBase64url(credentials.contentKey) : toBytes(credentials.contentKey);
++ if (given.length !== KEY_BYTES) fail('locker.invalid', 'The content key is 32 bytes');
++ return given;
++ }
if (header.lock === 'people') {
// A person may be named by their key instead of their subject: the slot says which key
// it is for, so the reader does not need to know who they are.
@@@ -1625,7 +1634,7 @@ function inspectEnvelope(bytes, { maxHeaderBytes = MAX_HEADER_BYTES } = {}) {
};
}
--__ovLockerModule.exports = { inspectEnvelope, checkHeader };
++__ovLockerModule.exports = { inspectEnvelope, checkHeader, peopleSlotOk };
},
"words": function (__ovLockerModule, exports, __ovLockerReq) {
@@@ -1686,5 +1695,5 @@ __ovLockerModule.exports = { WORDS };
if (!cache[key]) { const m = { exports: {} }; cache[key] = m; defs[key](m, m.exports, req); }
return cache[key].exports;
}
-- root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.86.0" });
++ root.OVLockerCore = Object.assign({}, req('index'), { SDK_VERSION: "0.89.0" });
})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/ov-locker.js b/ov-locker.js
iindex 9c3d183..4320e9b 100644
---- a/ov-locker.js
++++ b/ov-locker.js
@@@ -10,6 +10,12 @@
* OVLocker.state() { unlocked, fingerprint, subject }
* OVLocker.forget() drop the unlocked keys from this tab
* OVLocker.keyFromLink(href?) the content key a link carries after #k= (link locks)
++ * OVLocker.ensure() resolves once the locker is unlocked here (the unlock sheet when it is not)
++ * OVLocker.keys(who) a person's public keys from Network: null when they have no locker yet
++ * OVLocker.publicKeys() my public keys, for locking something for myself as well as for others
++ * OVLocker.rewrap(bytes, { add, remove, keys }) a people-locked envelope re-wrapped, the content never leaving
++ * OVLocker.seal(content, { lock, keys, password }) seal content here: the lock is made in this tab
++ * OVLocker.inspect(bytes) the header of an envelope, without a key
* window.OpenVibeLockerConfig = { network: 'https://openvibe.network' } where the key directory is
*
* The cryptography is ov-locker-core.js beside this file (generated from openvibe-sdk/locker: WebCrypto only, the
@@@ -372,6 +378,55 @@
return s.finished;
}
++ // ── The locker as a tool: a public key, a lookup, and a re-wrap ─────────────────────────────────────────
++ /** The locker, unlocked on this device: unlocks it (the sheet) when it is not, and says so when that is declined. */
++ async function ensure() {
++ if (session) return state();
++ const st = await unlock();
++ if (!st || !st.unlocked) throw Object.assign(new Error('Unlock your locker to do this.'), { code: 'locker.locked' });
++ return st;
++ }
++ /** My public keys, as they are published: what a people lock wraps for me too. */
++ async function publicKeys() {
++ if (!session) return null;
++ const c = await core();
++ return await c.exportPersonKeys(session.person);
++ }
++ /**
++ * A people-locked envelope, its key slots wrapped again: `add` [{ subject, publicKey }] keeps the content and
++ * the chunks and adds their slots; `remove` [subject] seals the content under a fresh key for `keys` (the public
++ * keys of those who stay), so the person who left opens nothing. The content key and the plaintext stay here.
++ */
++ async function rewrap(bytes, { add = [], remove = [], keys = [] } = {}) {
++ await ensure();
++ const c = await core();
++ const out = await c.rewrap(bytes, { add, remove, keys, credentials: { person: session.person } });
++ return { bytes: out.bytes, header: out.header };
++ }
++ /** A person's public keys from Network's directory (by subject or by @name): null when they have no locker yet. */
++ async function keys(who) {
++ const id = who && typeof who === 'object' ? (who.subject && who.subject.id) || who.id : null;
++ const path = id
++ ? `/api/v1/people/${encodeURIComponent(id)}/keys`
++ : `/api/v1/people/@${encodeURIComponent(String(who || '').replace(/^@+/, ''))}/keys`;
++ try { return await network('GET', path); } catch (e) { if (e.status === 404) return null; throw e; }
++ }
++ /**
++ * Seal content on this device (a new locked item): `{ lock: 'people', keys: [{ subject, publicKey }] }`,
++ * `{ lock: 'password', password }`, or `{ lock: 'link' }`, whose key comes back in `linkKey` for the link's #k=.
++ * The content key is made and kept here; only the envelope leaves.
++ */
++ async function seal(content, opts = {}) {
++ const c = await core();
++ const out = await c.seal(content, opts);
++ return { bytes: out.bytes, header: out.header, linkKey: out.linkKey || null };
++ }
++ /** An envelope's header, without any key. */
++ async function inspectBytes(bytes) {
++ const c = await core();
++ return c.inspect(bytes);
++ }
++
// ── The card a locked item shows ────────────────────────────────────────────────────────────────────────
async function open(container, opts) {
const o = Object.assign({}, opts || {});
@@@ -450,5 +505,8 @@
});
}
-- root.OVLocker = { open, setup, unlock, state, forget, keyFromLink, strength, version: 1 };
++ root.OVLocker = {
++ open, setup, unlock, ensure, keys, publicKeys, rewrap, seal, inspect: inspectBytes,
++ state, forget, keyFromLink, strength, version: 2,
++ };
})(typeof window !== 'undefined' ? window : null);
ddiff --git a/ov-share.css b/ov-share.css
iindex d66ec3a..afe68ff 100644
---- a/ov-share.css
++++ b/ov-share.css
@@@ -130,6 +130,27 @@ html.ov-share-locked, html.ov-share-locked body { overflow: hidden; overscroll-b
.ov-share-seglabel { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-share-aud:disabled .ov-share-segopt:not(:has(input:checked)) { opacity: .55; }
++/* ── Locked: what the lock means, and the fingerprints it is wrapped for ───────────────────────────────────── */
++.ov-share-lock { display: grid; gap: 8px; min-width: 0; padding: 10px 12px; border-radius: var(--ovs-radius); background: color-mix(in srgb, var(--ovs-accent) 6%, var(--ovs-hover)); }
++.ov-share-lockhead {
++ -webkit-appearance: none; appearance: none; display: flex; align-items: center; gap: 10px; width: 100%; min-height: 34px;
++ margin: 0; padding: 0; border: 0; background: none; color: var(--ovs-text); font: inherit; text-align: left; cursor: pointer;
++}
++.ov-share-lockhead .ov-share-ico { color: var(--ovs-accent); }
++.ov-share-lockhead:focus-visible { outline: 3px solid color-mix(in srgb, var(--ovs-accent) 40%, transparent); outline-offset: 3px; border-radius: 8px; }
++.ov-share-locklabel { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
++.ov-share-locktitle { font-weight: 700; font-size: 14px; }
++.ov-share-lockcaret { flex: none; width: 20px; text-align: center; color: var(--ovs-muted); font-size: 17px; line-height: 1; }
++.ov-share-lockshort { font-size: 12.5px; }
++.ov-share-lockbody { display: grid; gap: 8px; min-width: 0; }
++.ov-share-lockmeans { margin: 0; font-size: 13.5px; color: var(--ovs-text); }
++.ov-share-fps { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
++.ov-share-fprow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; font-size: 12.5px; }
++.ov-share-fpwho { flex: none; color: var(--ovs-text); font-weight: 650; }
++.ov-share-fp { min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--ovs-muted); white-space: normal; overflow-wrap: anywhere; }
++.ov-share-fp.is-changed { color: var(--warning-text, var(--ovs-warn)); }
++.ov-share-tag.is-warn { color: var(--warning-text, var(--ovs-warn)); background: color-mix(in srgb, var(--ovs-warn) 16%, transparent); }
++
/* ── People ─────────────────────────────────────────────────────────────────────────────────────────────────── */
.ov-share-people { display: grid; gap: 10px; min-width: 0; }
.ov-share-people .ov-share-label { margin: 0; }
ddiff --git a/ov-share.js b/ov-share.js
iindex 328887a..e2b0b27 100644
---- a/ov-share.js
++++ b/ov-share.js
@@@ -12,6 +12,13 @@
* Who can open it for the owner, when the thing has kind and id: Everyone, Anyone with the link, Specific people
* (by @name, each Can view or Can edit, with an end date), Only me; people who asked; Transfer.
*
++ * A locked item (Locker, ov-locker.js) gets one more part between them: what its lock means in plain words, each
++ * person's eight-word fingerprint (and a warning when their key changed), and, for a people lock, adding someone (their
++ * content key is wrapped for them on this device) and removing someone (the content is sealed again under a fresh key,
++ * and the sheet says they keep what they already opened). A link lock's key is the part of the link after #, which
++ * browsers never send; the sheet never invents it, it copies the whole link. ov-locker.js is loaded on the first press
++ * of that part, never before.
++ *
* <button type="button" data-ov-share-sheet data-title="…">Share</button> the page's own address
* data-url="https://…" the link to share (else the page's address)
* data-text="…" a line to send with it (email, the phone's sheet)
@@@ -54,6 +61,22 @@
private: { label: 'Only me', title: 'Only me', desc: 'Only you can open it.' },
};
const ROLE_WORDS = { viewer: 'Can view', editor: 'Can edit' };
++ // What each lock means, in the words the person choosing it reads (Locker design 2026-11-06: "OpenVibe can't read
++ // what's inside"). The same three sentences are on the page that makes a locked item.
++ const LOCK_WORDS = {
++ people: {
++ label: 'For the people on the list',
++ means: 'Only you and the people below can open it, each with their own keys. OpenVibe can’t read what’s inside.',
++ },
++ password: {
++ label: 'With a password',
++ means: 'Anyone you give the password to can open it. OpenVibe can’t read what’s inside, and if the password is lost, nobody can open it, including us.',
++ },
++ link: {
++ label: 'With the link',
++ means: 'The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.',
++ },
++ };
const svg = (d, extra) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"${extra || ''}>${d}</svg>`;
const ICON = {
public: svg('<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17M12 3.5c2.6 2.4 3.8 5.3 3.8 8.5s-1.2 6.1-3.8 8.5c-2.6-2.4-3.8-5.3-3.8-8.5s1.2-6.1 3.8-8.5Z"/>'),
@@@ -183,11 +206,20 @@
return [...recent.map((id) => list.find((t) => t[0] === id)), ...list.filter((t) => !recent.includes(t[0]))];
}
const hms = (s) => { s = Math.max(0, Math.floor(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };
++ /** The key a link carries after #k= (a link lock): base64url, 32 bytes. The same rule as ov-locker.js. */
++ function linkKey(href) {
++ const h = String(href == null ? root.location.hash : href).replace(/^#/, '');
++ const m = h.match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/) || String(root.location.hash || '').match(/(?:^|&)k=([A-Za-z0-9_-]{43})(?:&|$)/);
++ return m ? m[1] : null;
++ }
function theLink(st) {
const raw = (st.server && st.server.link) || st.o.link || st.o.url || root.location.href;
let u;
try { u = new URL(raw, root.location.href); } catch (_) { return raw; }
if (st.atTime && st.o.time > 0) u.searchParams.set(st.o.timeParam || 't', String(Math.floor(st.o.time)));
++ // A link-locked item is opened by the part after #; the service's own link never has it, the page's address does.
++ const key = st.server && st.server.lock === 'link' ? linkKey() : null;
++ if (key && !linkKey(u.href)) u.hash = `k=${key}`;
return u.href;
}
const shownLink = (href) => href.replace(/^https?:\/\//, '').replace(/\/$/, '');
@@@ -428,6 +460,7 @@
o, server: null, audience: 'private', members: [], notify: true, view: 'main', busy: false,
note: '', error: '', confirmLeave: false, saved: null, resolve, giveTo: null, keepAccess: false,
access: !!(o.kind && o.id), first: true, atTime: false, panel: '', allTargets: false,
++ lockOpen: false, reseal: false,
};
st.dlg = el('dialog', 'ov-share', { 'aria-labelledby': 'ov-share-title' });
st.close = (force) => close(st, force);
@@@ -468,6 +501,7 @@
function memberKey(m) { return m.subject ? sid(m.subject) : `name:${String(m.name || m.username).toLowerCase()}`; }
function dirty(st) {
if (!st.server) return false;
++ if (st.reseal) return true;
if (st.audience !== (st.server.audience || 'private')) return true;
const a = (st.server.members || []).map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
const b = st.members.map((m) => `${memberKey(m)}|${m.role}|${m.expires_at || ''}`).sort().join(',');
@@@ -546,7 +580,9 @@
if (s.transfer && s.transfer.state === 'pending' && mine) body.appendChild(pendingBanner(st));
// Who can open this: four choices, one line saying what the chosen one means.
-- const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES)).filter((a) => AUDIENCES[a]);
++ // A locked item is never public (the service refuses it too): the choice is not offered.
++ const offered = (Array.isArray(s.audiences) && s.audiences.length ? s.audiences : Object.keys(AUDIENCES))
++ .filter((a) => AUDIENCES[a] && !(isLocked(st) && a === 'public'));
const fs = el('fieldset', 'ov-share-aud', { disabled: !mine || st.busy });
fs.appendChild(el('legend', 'ov-share-label', null, 'Who can open this'));
const seg = el('div', 'ov-share-seg', { style: `--n:${offered.length}` });
@@@ -567,6 +603,9 @@
fs.appendChild(desc);
body.appendChild(fs);
++ // Locked items: what the lock means, the fingerprints, and the people they are wrapped for.
++ if (isLocked(st)) body.appendChild(lockSection(st));
++
// People: who has access (or, on an open item, who may edit it).
const open = st.audience === 'public' || st.audience === 'link';
const canEditRole = roles(st).includes('editor');
@@@ -619,6 +658,183 @@
if (st.error) body.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.error));
}
++ // ── Locked items (Locker) ────────────────────────────────────────────────────────────────────────────────
++ // ov-locker.js loads on the first press of the Locked part, never before: the lock's words and the fingerprints
++ // come from the service's own answer; only the wrapping and the re-seal need the keys, and they live in that file.
++ const isLocked = (st) => !!(st.server && st.server.lock && st.server.lock !== 'none');
++ async function locker() {
++ if (root.OVLocker) return root.OVLocker;
++ const ok = await loadSibling('ov-locker.js', 'OVLocker');
++ return ok && root.OVLocker ? root.OVLocker : null;
++ }
++ const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return root.btoa(s); };
++ /** The people whose slot was in the stored envelope and who are no longer on the list. */
++ function removedMembers(st) {
++ if (!st.server) return [];
++ return (st.server.members || []).filter((m) => m.key_slot && !st.members.some((x) => same(x.subject, m.subject)));
++ }
++ function lockSection(st) {
++ const kind = st.server.lock;
++ const words = LOCK_WORDS[kind] || { label: kind, means: '' };
++ const sec = el('section', 'ov-share-lock', { 'aria-labelledby': 'ov-share-lock-h' });
++ const head = el('button', 'ov-share-lockhead', { type: 'button', 'data-k': 'lock-open', 'aria-expanded': st.lockOpen ? 'true' : 'false', 'aria-controls': 'ov-share-lock-body' });
++ head.appendChild(iconEl('private'));
++ const w = el('span', 'ov-share-locklabel');
++ w.appendChild(el('span', 'ov-share-locktitle', { id: 'ov-share-lock-h' }, 'Locked'));
++ w.appendChild(el('span', 'ov-share-muted', null, words.label));
++ head.appendChild(w);
++ head.appendChild(el('span', 'ov-share-lockcaret', { 'aria-hidden': 'true' }, st.lockOpen ? '−' : '+'));
++ head.addEventListener('click', () => {
++ st.lockOpen = !st.lockOpen;
++ if (st.lockOpen && !root.OVLocker) locker(); // the words need nothing; the sealing later does
++ draw(st);
++ });
++ sec.appendChild(head);
++ if (!st.lockOpen) {
++ sec.appendChild(el('p', 'ov-share-muted ov-share-lockshort', null, 'OpenVibe can’t read what’s inside.'));
++ return sec;
++ }
++ const inner = el('div', 'ov-share-lockbody', { id: 'ov-share-lock-body' });
++ inner.appendChild(el('p', 'ov-share-lockmeans', null, words.means));
++ if (kind === 'link') {
++ const key = linkKey(theLink(st));
++ inner.appendChild(el('p', key ? 'ov-share-muted' : 'ov-share-warn', { role: key ? null : 'status' }, key
++ ? 'The whole link, including the part after #, is the key. Copy sends the whole link.'
++ : 'This link is missing its key: a locked link ends with #k=… Ask whoever shared it for the whole link. OpenVibe never had the key, so it cannot send it.'));
++ }
++ if (kind === 'password') {
++ inner.appendChild(el('p', 'ov-share-muted', null, 'The password is not stored anywhere. If you lose it, nobody can open this: there is no reset.'));
++ }
++ if (kind === 'people') {
++ inner.appendChild(el('p', 'ov-share-muted', null, 'Each person’s content key is wrapped for their locker on this device. OpenVibe never sees a key.'));
++ const known = st.members.filter((m) => m.key_fingerprint);
++ if (known.length) {
++ const ul = el('ul', 'ov-share-fps', { 'aria-label': 'Fingerprints' });
++ for (const m of known) {
++ const li = el('li', 'ov-share-fprow');
++ li.appendChild(el('span', 'ov-share-fpwho', null, handle(m) || nameOf(m)));
++ li.appendChild(el('span', 'ov-share-fp', null, m.key_fingerprint));
++ if (m.fingerprint_changed) li.appendChild(el('span', 'ov-share-tag is-warn', null, 'key changed'));
++ ul.appendChild(li);
++ }
++ inner.appendChild(ul);
++ }
++ const changed = st.members.filter((m) => m.fingerprint_changed);
++ if (changed.length) {
++ const who = changed.map((m) => handle(m) || nameOf(m)).join(', ');
++ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' }, `${who} changed locker keys since this was shared for them. Compare the new fingerprint with them, in person or on a call, before you use it.`));
++ const use = el('button', 'ov-share-btn is-small is-quiet', { type: 'button', 'data-k': 'use-new-key' }, 'Use their new key');
++ use.addEventListener('click', async () => {
++ use.disabled = true;
++ try {
++ const L = await locker();
++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
++ for (const m of changed) {
++ const keys = await L.keys(m.subject);
++ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);
++ m._publicKey = keys.ecdh_public || keys.ecdh;
++ m._keyFingerprint = keys.fingerprint || null;
++ m.fingerprint_changed = false;
++ m.useNewKey = true;
++ }
++ st.reseal = true; st.lockNote = 'Their new key is used when you save.';
++ st.lockError = '';
++ } catch (e) { st.lockError = e.message; }
++ draw(st);
++ });
++ inner.appendChild(use);
++ }
++ const gone = removedMembers(st);
++ if (gone.length) {
++ const who = gone.map((m) => handle(m) || nameOf(m)).join(', ');
++ inner.appendChild(el('p', 'ov-share-warn', { role: 'status' },
++ `They had the key before you removed them (${who}). They may still have a copy of what they already opened.`));
++ }
++ if (st.lockNote) inner.appendChild(el('p', 'ov-share-note', { role: 'status' }, st.lockNote));
++ if (st.lockError) inner.appendChild(el('p', 'ov-share-error', { role: 'alert' }, st.lockError));
++ }
++ sec.appendChild(inner);
++ return sec;
++ }
++
++ /** The person's public key, asked of Network through the Locker: a people lock cannot be made without it. */
++ async function attachKey(st, p) {
++ if (!p || !p.subject) { st.addMsg = `We could not confirm ${handle(p) || nameOf(p)}’s account, so no key is known.`; st.addMsgBad = true; return false; }
++ try {
++ const L = await locker();
++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
++ const keys = await L.keys(p.subject);
++ if (!keys) {
++ st.addMsg = `${handle(p) || nameOf(p)} has no locker yet: they make one the first time they open a locked item.`;
++ st.addMsgBad = true;
++ return false;
++ }
++ p._publicKey = keys.ecdh_public || keys.ecdh;
++ p._keyFingerprint = keys.fingerprint || null;
++ p.needsKey = true;
++ return true;
++ } catch (e) {
++ st.addMsg = `Could not get ${handle(p) || nameOf(p)}’s key: ${e.message}`;
++ st.addMsgBad = true;
++ return false;
++ }
++ }
++
++ /** The bytes a locked item serves: its envelope. */
++ async function envelopeBytes(st) {
++ const url = (st.server && st.server.envelope_url) || st.o.envelope_url;
++ if (!url) throw new Error('This service did not say where the locked item is.');
++ const r = await fetch(url, { credentials: st.o.base ? 'include' : 'same-origin', headers: { accept: 'application/octet-stream' } });
++ if (!r.ok) throw new Error(`The locked item answered ${r.status}.`);
++ return new Uint8Array(await r.arrayBuffer());
++ }
++
++ /**
++ * For a people lock whose list changed: the envelope re-wrapped here. Someone added gets their slot (the content key
++ * never leaves this device); someone removed means the content is sealed again under a fresh key for those who stay,
++ * so the key the person leaving holds opens nothing. The service stores the bytes and never reads them.
++ */
++ async function resealIfNeeded(st) {
++ const before = st.server.members || [];
++ const after = st.audience === 'private' ? [] : st.members;
++ const gone = before.filter((m) => !after.some((x) => same(x.subject, m.subject)));
++ const needing = after.filter((m) => m.needsKey || m.fingerprint_changed || m.useNewKey);
++ // A slot that is already there has to go when it is wrapped again for the same person: two slots for one
++ // person would leave the old key in the envelope.
++ const stale = needing.filter((m) => m.key_slot);
++ const remove = [...gone, ...stale].map((m) => m.subject);
++ if (!remove.length && !needing.length && !st.reseal) return null;
++ const L = await locker();
++ if (!L) throw new Error('The Locker could not load. Check your connection and try again.');
++ await L.ensure();
++ const bytes = await envelopeBytes(st);
++ const publicKey = async (m) => {
++ if (m._publicKey) return m._publicKey;
++ const keys = await L.keys(m.subject);
++ if (!keys) throw new Error(`${handle(m) || nameOf(m)} has no locker now.`);
++ m._publicKey = keys.ecdh_public || keys.ecdh;
++ m._keyFingerprint = keys.fingerprint || null;
++ return m._publicKey;
++ };
++ const add = [];
++ for (const m of needing) add.push({ subject: m.subject, publicKey: await publicKey(m) });
++ const keys = [];
++ const me = await L.publicKeys();
++ const my = st.server.you && st.server.you.subject;
++ if (remove.length && my && me) keys.push({ subject: my, publicKey: me.ecdh });
++ if (remove.length) for (const m of after) keys.push({ subject: m.subject, publicKey: await publicKey(m) });
++ const out = await L.rewrap(bytes, { add, remove, keys });
++ const slots = new Map((out.header.slots.people || []).map((slot) => [sid(slot.subject), slot]));
++ const list = after.map((m) => {
++ const slot = slots.get(sid(m.subject));
++ if (!slot) throw new Error(`The sealed envelope has no key for ${handle(m) || nameOf(m)}.`);
++ const r = { subject: m.subject, role: m.role, key_slot: slot };
++ if (m.expires_at) r.expires_at = m.expires_at;
++ return r;
++ });
++ return { members: list, envelope: b64(out.bytes) };
++ }
++
// The @name field: Enter or Add checks the name with Network (through the service), then adds a chip-row.
function adder(st) {
const wrap = el('form', 'ov-share-add', { novalidate: true });
@@@ -667,15 +883,20 @@
st.checking = true; st.addMsg = ''; draw(st);
const missing = [];
try {
++ const peopleLock = !!(st.server && st.server.lock === 'people');
for (const n of names) {
const p = await lookup(st.o, n);
if (p === null) missing.push(n);
++ else if (p === undefined && peopleLock) { st.addMsg = `We could not check @${n}, so no key is known for them. Try again.`; st.addMsgBad = true; }
else if (p === undefined) addPerson(st, { name: n, username: n, unchecked: true }, true);
++ else if (peopleLock) { if (await attachKey(st, p)) addPerson(st, p, true); }
else addPerson(st, p, true);
}
st.draftName = missing.join(' ');
-- st.addMsg = missing.length ? `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.` : '';
-- st.addMsgBad = !!missing.length;
++ if (missing.length) {
++ st.addMsg = `No OpenVibe account is called ${missing.map((n) => `@${n}`).join(', ')}.`;
++ st.addMsgBad = true;
++ }
} catch (e) {
st.addMsg = e.message; st.addMsgBad = true;
}
@@@ -688,7 +909,14 @@
if (st.members.some((m) => memberKey(m) === memberKey(p))) { st.addMsg = `${handle(p)} is on the list already.`; st.addMsgBad = false; if (!quiet) draw(st); return; }
const open = st.audience === 'public' || st.audience === 'link';
const role = open && roles(st).includes('editor') ? 'editor' : 'viewer';
-- st.members.push({ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name, avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked });
++ st.members.push({
++ subject: p.subject, name: p.subject ? undefined : p.name, username: p.username, display_name: p.display_name,
++ avatar_url: p.avatar_url, role, expires_at: null, isNew: true, unchecked: !!p.unchecked,
++ ...(p.key_slot ? { key_slot: p.key_slot } : {}),
++ ...(p.needsKey ? { needsKey: true } : {}),
++ ...(p._publicKey ? { _publicKey: p._publicKey } : {}),
++ ...(p._keyFingerprint || p.key_fingerprint ? { key_fingerprint: p._keyFingerprint || p.key_fingerprint } : {}),
++ });
if (st.audience === 'private') { st.audience = 'people'; st.note = 'Switched to Specific people, so they can open it.'; }
if (!quiet) { st.draftName = ''; draw(st); }
}
@@@ -706,6 +934,11 @@
if (p.username) sub.appendChild(el('span', null, null, handle(p)));
if (x.member && x.member.unchecked) sub.appendChild(el('span', 'ov-share-soft', null, 'checked when you save'));
if (x.member) sub.appendChild(expiry(st, x.member));
++ // A people lock shows which key the slot is for: the eight words a person can compare out of band.
++ if (x.member && isLocked(st) && st.server.lock === 'people' && x.member.key_fingerprint) {
++ sub.appendChild(el('span', `ov-share-fp${x.member.fingerprint_changed ? ' is-changed' : ''}`, { title: 'Their locker’s fingerprint' }, x.member.key_fingerprint));
++ if (x.member.fingerprint_changed) sub.appendChild(el('span', 'ov-share-soft', null, 'key changed'));
++ }
words.appendChild(sub);
li.appendChild(words);
if (x.tag) { li.appendChild(el('span', 'ov-share-role is-fixed', null, x.tag)); return li; }
@@@ -724,7 +957,9 @@
rm.appendChild(iconEl('close'));
rm.addEventListener('click', () => {
st.members.splice(st.members.indexOf(m), 1);
-- st.note = m.isNew ? '' : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;
++ st.note = m.isNew ? '' : isLocked(st) && st.server.lock === 'people'
++ ? `${handle(m) || nameOf(m)} loses access when you save. They had the key before you removed them: they may still have a copy of what they already opened.`
++ : `${handle(m) || nameOf(m)} loses access when you save. They may still have a copy of what they already opened.`;
draw(st);
});
li.appendChild(rm);
@@@ -922,7 +1157,7 @@
}
async function save(st) {
-- st.busy = true; st.error = ''; draw(st);
++ st.busy = true; st.error = ''; st.lockError = ''; draw(st);
const members = st.audience === 'private' ? [] : st.members.map((m) => {
const r = { role: m.role };
if (m.expires_at) r.expires_at = m.expires_at;
@@@ -930,9 +1165,16 @@
return r;
});
try {
-- const out = await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, notify: !!st.notify }, st.etag ? { 'if-match': st.etag } : null);
++ const body = { audience: st.audience, members, notify: !!st.notify };
++ // A people lock follows its people: someone added is wrapped for now, someone removed means the content is
++ // sealed again. The bytes go up with the list, and this browser is the only place they were opened.
++ if (isLocked(st) && st.server.lock === 'people') {
++ const sealed = await resealIfNeeded(st);
++ if (sealed) { body.members = sealed.members; body.envelope = sealed.envelope; }
++ }
++ const out = await call(st.o, 'PUT', endpoint(st.o).path, body, st.etag ? { 'if-match': st.etag } : null);
take(st, out && out.audience ? out : await call(st.o, 'GET', endpoint(st.o).path));
-- st.saved = st.server; st.note = 'Saved.';
++ st.saved = st.server; st.note = 'Saved.'; st.reseal = false; st.lockNote = '';
announce(st.o, st.server);
} catch (e) {
if (e.status === 412) {
@@@ -941,7 +1183,12 @@
draw(st); return;
}
const names = e.body && Array.isArray(e.body.names) ? e.body.names : null;
-- st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;
++ const code = (e.body && e.body.code) || e.code;
++ if (code === 'share.key_changed') st.error = 'Someone’s locker key changed. Open the Locked part and use their new key, then save again.';
++ else if (code === 'share.no_key') st.error = 'Someone on the list has no locker yet. They make one the first time they open a locked item.';
++ else if (code === 'share.reseal_required') st.error = 'Removing someone from a locked item seals it again here. Unlock your locker and save again.';
++ else if (code === 'share.slot_required') st.error = 'Every person on a locked item needs their key. Add them again, then save.';
++ else st.error = names && names.length ? `No OpenVibe account is called ${names.map((n) => `@${n}`).join(', ')}. Remove them, then save.` : `Could not save: ${e.message}`;
}
st.busy = false; draw(st);
}
@@@ -1046,5 +1293,5 @@
time: time >= 5 ? time : 0, timeParam: d.timeParam, base: d.base, path: d.path };
}
-- root.OVShare = { open, offer, gate, fromButton, version: 2 };
++ root.OVShare = { open, offer, gate, fromButton, version: 3 };
})(typeof window !== 'undefined' ? window : null);
ddiff --git a/package-lock.json b/package-lock.json
iindex 84115cd..632d52e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.115.1",
++ "version": "4.116.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.115.1",
++ "version": "4.116.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
@@@ -19,13 +19,13 @@
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"
}
},
"node_modules/@electric-sql/pglite": {
-- "version": "0.5.8",
-- "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
-- "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "version": "0.5.15",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",
++ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==",
"dev": true,
"license": "Apache-2.0"
},
@@@ -86,9 +86,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
-- "version": "1.20.8",
-- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
-- "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "version": "1.20.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@@ -243,11 +243,14 @@
"url": "https://github.com/sponsors/fb55"
}
},
-- "node_modules/cssom": {
-- "version": "0.5.0",
-- "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
-- "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
-- "license": "MIT"
++ "node_modules/cssomnom": {
++ "version": "0.1.11",
++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=20.0.0"
++ }
},
"node_modules/debug": {
"version": "2.6.9",
@@@ -259,13 +262,6 @@
"ms": "2.0.0"
}
},
-- "node_modules/debug/node_modules/ms": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-- "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-- "dev": true,
-- "license": "MIT"
-- },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -386,13 +382,17 @@
"license": "MIT"
},
"node_modules/encodeurl": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
-- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
}
},
"node_modules/entities": {
@@@ -512,9 +512,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
-- "version": "3.1.8",
-- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
-- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "version": "3.1.9",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true,
"funding": [
{
@@@ -839,6 +839,12 @@
"npm": ">=6"
}
},
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@@ -861,13 +867,13 @@
}
},
"node_modules/linkedom": {
-- "version": "0.18.13",
-- "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.13.tgz",
-- "integrity": "sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==",
++ "version": "0.18.15",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
"license": "ISC",
"dependencies": {
"css-select": "^7.0.0",
-- "cssom": "^0.5.0",
++ "cssomnom": "^0.1.11",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.1.0",
"uhyphen": "^0.2.0"
@@@ -1003,9 +1009,10 @@
}
},
"node_modules/ms": {
-- "version": "2.1.3",
-- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
@@@ -1048,9 +1055,9 @@
}
},
"node_modules/on-finished": {
-- "version": "2.4.1",
-- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
-- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "version": "2.4.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@@ -1058,6 +1065,10 @@
},
"engines": {
"node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
}
},
"node_modules/openvibe-contracts": {
@@@ -1078,9 +1089,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.86.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
-- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",
++ "version": "0.89.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0",
++ "integrity": "sha512-vZLwKIrjHPHnCXCJ9tHoKPGmm4jQLzzXxjdRgxWTwBks9vyhnWXkjCvA+SvPL45VDTeGJBQjZZsQPZVjduTsCQ==",
"dev": true,
"license": "MIT",
"bin": {
@@@ -1142,9 +1153,9 @@
}
},
"node_modules/qs": {
-- "version": "6.16.0",
-- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
-- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "version": "6.16.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@@ -1222,9 +1233,9 @@
"license": "MIT"
},
"node_modules/semver": {
-- "version": "7.8.5",
-- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
-- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "version": "7.8.6",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@@ -1258,6 +1269,13 @@
"node": ">= 0.8.0"
}
},
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "dev": true,
++ "license": "MIT"
++ },
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
ddiff --git a/package.json b/package.json
iindex 78dc021..721b201 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.115.1",
++ "version": "4.116.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -148,6 +148,6 @@
"@electric-sql/pglite": "^0.5.8",
"express": "^4.22.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.89.0"
}
}
ddiff --git a/test/ov-share-lock-chrome.test.js b/test/ov-share-lock-chrome.test.js
nnew file mode 100644
iindex 0000000..afbc472
---- /dev/null
++++ b/test/ov-share-lock-chrome.test.js
@@@ -0,0 +1,219 @@
++'use strict';
++// The Share sheet's Locked part, end to end in a real headless Chrome with the REAL crypto (ov-locker-core.js from
++// openvibe-sdk/locker) and a stand-in service + Network key directory:
++// ov-locker.js is not loaded until the Locked part is pressed;
++// a people-locked paste names its lock and its people's fingerprints in plain words;
++// adding @quinn wraps the content key for quinn on this device, and the envelope that goes up opens for quinn;
++// removing @maticus seals the content again under a fresh key: the new bytes open for quinn, not for maticus;
++// a link-locked paste's link keeps the key after #k=, and says so when the key is missing.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('ov-share-lock-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const locker = require('openvibe-sdk/locker');
++const PASS = 'violet lantern river cobalt';
++const OWN = { type: 'user', id: 'usr_own' };
++const MAT = { type: 'user', id: 'usr_mat' };
++const QUI = { type: 'user', id: 'usr_q' };
++const PEOPLE = {
++ maticus: { subject: MAT, username: 'maticus', display_name: 'Maticus' },
++ quinn: { subject: QUI, username: 'quinn', display_name: 'Quinn' },
++};
++const keys = {}; // subject id → what Network publishes: { subject, ecdh_public, fingerprint }
++const items = {}; // path → envelope bytes
++let lockerPut = null; // the browser's PUT /api/v1/me/keys
++const puts = []; // every share save body
++let share = null; // the share GET body
++
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Locked</title><link rel="icon" href="data:,">
++<link rel="stylesheet" href="/ov-share.css"></head><body style="font:15px system-ui;padding:16px">
++<button type="button" id="sh" data-ov-share-sheet data-kind="paste" data-id="locked" data-title="Locked note">Share</button>
++<div id="item"></div>
++<script>window.OpenVibeLockerConfig = { network: '' };</script><script src="/ov-share.js"></script></body></html>`;
++
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ const json = (code, o) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
++ const file = { '/ov-share.js': 'text/javascript', '/ov-share.css': 'text/css', '/ov-locker.js': 'text/javascript', '/ov-locker.css': 'text/css', '/ov-locker-core.js': 'text/javascript' }[u.pathname];
++ if (file) { res.writeHead(200, { 'content-type': `${file}; charset=utf-8` }); return res.end(fs.readFileSync(path.join(ROOT, u.pathname.slice(1)))); }
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (u.pathname === '/api/v1/me/keys' && req.method === 'PUT') { lockerPut = JSON.parse(body); return json(200, { ok: true }); }
++ if (u.pathname.startsWith('/p/') && u.pathname.endsWith('/raw') && items[u.pathname]) {
++ res.writeHead(200, { 'content-type': 'application/octet-stream' }); return res.end(Buffer.from(items[u.pathname]));
++ }
++ const who = u.pathname.match(/^\/api\/v1\/people\/([^/]+)\/keys$/);
++ if (who) {
++ const found = keys[decodeURIComponent(who[1])];
++ return found ? json(200, found) : json(404, { code: 'locker.no_keys', error: 'no keys' });
++ }
++ if (u.pathname === '/api/v1/share/people') {
++ const n = String(u.searchParams.get('name') || '').toLowerCase();
++ return json(200, { person: PEOPLE[n] || null });
++ }
++ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'GET') {
++ res.writeHead(200, { 'content-type': 'application/json', etag: '"4"' }); return res.end(JSON.stringify(share));
++ }
++ if (u.pathname === '/api/v1/paste/locked/share' && req.method === 'PUT') {
++ const b = JSON.parse(body);
++ puts.push(b);
++ // What the store does: the member rows as they were sent, with what the key directory has for them.
++ share = {
++ ...share,
++ audience: b.audience,
++ members: (b.members || []).map((m) => {
++ const id = m.subject && m.subject.id;
++ const known = Object.values(PEOPLE).find((p) => p.subject.id === id);
++ return { ...(known || { subject: m.subject, username: m.subject ? null : String(m.name) }), role: m.role, key_slot: m.key_slot };
++ }),
++ };
++ return json(200, share);
++ }
++ res.writeHead(404); res.end();
++ });
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const mat = await locker.generatePersonKeys();
++ const matPub = await locker.exportPersonKeys(mat);
++ const qui = await locker.generatePersonKeys();
++ const quiPub = await locker.exportPersonKeys(qui);
++ keys[MAT.id] = { subject: MAT, ecdh_public: matPub.ecdh, signing_public: matPub.signing, fingerprint: matPub.fingerprint };
++ keys[QUI.id] = { subject: QUI, ecdh_public: quiPub.ecdh, signing_public: quiPub.signing, fingerprint: quiPub.fingerprint };
++
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-share-lock-'));
++ const browser = await h.launch({ tmpDir });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const q = (k) => `document.querySelector('[data-k="${k}"]')`;
++ const type = (k, v) => `(() => { const i = ${q(k)}; i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true })); })()`;
++ const press = (k) => `${q(k)}.click()`;
++ const text = (sel) => `(document.querySelector(${JSON.stringify(sel)}) || {}).textContent || ''`;
++ try {
++ const page = await h.openPage(browser, await browser.newContext(), { width: 1280 });
++ await page.goto(`${base}/`, { maxMs: 5000 });
++
++ // The owner's locker, with the real crypto (two PBKDF2 seals). The sheet has already loaded ov-locker.js in
++ // that tab; here it is loaded the way a page that unlocks a locker does.
++ await page.evaluate(`(() => { const s = document.createElement('script'); s.src = '/ov-locker.js'; document.head.appendChild(s); })()`);
++ await wait(600);
++ await page.evaluate('(() => { OVLocker.setup(); })()'); await wait(300);
++ await page.evaluate(type('pass', PASS)); await page.evaluate(type('pass2', PASS)); await page.evaluate(press('next'));
++ await wait(1500);
++ const words = await page.evaluate("[...document.querySelectorAll('.ovl-words li')].map((li) => li.textContent)");
++ const labels = await page.evaluate("[...document.querySelectorAll('dialog .ovl-pair .ovl-label')].map((l) => Number(l.textContent.replace(/\\D/g, '')))");
++ await page.evaluate(type('w1', words[labels[0] - 1])); await page.evaluate(type('w2', words[labels[1] - 1])); await page.evaluate(press('make'));
++ await wait(9000);
++ await page.evaluate(press('done')); // the locker's own Done: the setup sheet closes
++ await wait(300);
++ assert.ok(lockerPut && lockerPut.ecdh_public, 'the owner has a locker');
++ keys[OWN.id] = { subject: OWN, ecdh_public: lockerPut.ecdh_public, signing_public: lockerPut.signing_public, fingerprint: lockerPut.fingerprint };
++
++ // The paste as it was made: a people lock for the owner and maticus, sealed before it was uploaded.
++ const plain = JSON.stringify({ v: 1, type: 'paste', title: 'Locked note', content: 'the secret words', language: 'text' });
++ const sealed = await locker.seal(plain, { lock: 'people', keys: [{ subject: OWN, publicKey: keys[OWN.id].ecdh_public }, { subject: MAT, publicKey: matPub.ecdh }] });
++ items['/p/locked/raw'] = sealed.bytes;
++ share = {
++ audience: 'people', audiences: ['people', 'private'], roles: ['viewer'], title: 'Locked note', link: `${base}/p/locked`,
++ lock: 'people', envelope_url: '/p/locked/raw',
++ you: { subject: OWN, role: 'owner' }, owner: { subject: OWN, username: 'owner', display_name: 'Owner' },
++ members: [{
++ ...PEOPLE.maticus, role: 'viewer', added_at: '2026-11-06T10:00:00Z', added_by: OWN,
++ key_slot: sealed.header.slots.people.find((s) => s.subject.id === MAT.id),
++ key_fingerprint: matPub.fingerprint, fingerprint_changed: false,
++ }],
++ requests: [], transfer: null,
++ };
++
++ // The sheet itself loads no Locker: a fresh tab proves it, and that the first press of the part does.
++ const fresh = await h.openPage(browser, await browser.newContext(), { width: 390 });
++ await fresh.goto(`${base}/`, { maxMs: 5000 });
++ assert.strictEqual(await fresh.evaluate('!!window.OVLocker'), false, 'ov-share.js alone loads no Locker');
++ await fresh.evaluate("document.getElementById('sh').click()");
++ await wait(400);
++ assert.match(await fresh.evaluate(text('.ov-share-lock')), /Locked/, 'a fresh tab sees the item is locked');
++ await fresh.evaluate(press('lock-open'));
++ await wait(600);
++ assert.ok(await fresh.evaluate('!!window.OVLocker'), 'pressing Locked loads ov-locker.js');
++ await fresh.evaluate(press('close'));
++
++ // 1. The Locked part says what the lock means, in plain words, and whose keys it is wrapped for.
++ await page.evaluate("document.getElementById('sh').click()");
++ await wait(400);
++ assert.match(await page.evaluate(text('.ov-share-lock')), /Locked/, 'the sheet says the item is locked');
++ assert.match(await page.evaluate(text('.ov-share-lockshort')), /can’t read what’s inside/, 'and says it in plain words');
++ await page.evaluate(press('lock-open'));
++ await wait(500);
++ assert.match(await page.evaluate(text('.ov-share-lockmeans')), /Only you and the people below can open it/, 'what a people lock means');
++ assert.ok((await page.evaluate(text('.ov-share-fp'))).includes(matPub.fingerprint.split(' · ')[0]), 'the member’s fingerprint is shown');
++
++ // 2. Add @quinn: their public key is wrapped for them here.
++ await page.evaluate(type('add', 'quinn'));
++ await page.evaluate(press('add-btn'));
++ await wait(600);
++ assert.match(await page.evaluate(text('.ov-share-list')), /Quinn/, 'quinn is on the list');
++ await page.evaluate(press('done'));
++ await wait(2500); // the re-wrap
++ assert.strictEqual(puts.length, 1, 'one save');
++ const addPut = puts[0];
++ assert.ok(addPut.envelope, 'the save carries the envelope sealed again');
++ const quinnRow = addPut.members.find((m) => m.subject && m.subject.id === QUI.id);
++ assert.ok(quinnRow && quinnRow.key_slot, 'quinn’s member row carries their key slot');
++ assert.strictEqual(quinnRow.key_slot.recipient_key, await locker.jwkThumbprint(quiPub.ecdh), 'the slot names quinn’s key');
++ const afterAdd = Buffer.from(addPut.envelope, 'base64');
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'the envelope that went up opens for quinn');
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterAdd, { privateKey: mat.ecdh.privateKey, subject: MAT }))], [...Buffer.from(plain)], 'and for maticus, whose slot stayed');
++
++ // 3. Remove @maticus: fresh key, and the bytes that went up do not open for them.
++ items['/p/locked/raw'] = afterAdd;
++ await page.evaluate("document.getElementById('sh').click()");
++ await wait(400);
++ await page.evaluate(press('rm-user:usr_mat'));
++ await wait(200);
++ assert.match(await page.evaluate(text('.ov-share-note')), /They had the key before you removed them/, 'removal says they keep what they opened');
++ await page.evaluate(press('done'));
++ await wait(3000);
++ assert.strictEqual(puts.length, 2, 'the removal saved');
++ const removePut = puts[1];
++ assert.ok(removePut.envelope, 'the removal carries a re-sealed envelope');
++ const afterRemove = Buffer.from(removePut.envelope, 'base64');
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(afterRemove, { privateKey: qui.ecdh.privateKey, subject: QUI }))], [...Buffer.from(plain)], 'quinn still opens it');
++ await assert.rejects(async () => await locker.open(afterRemove, { privateKey: mat.ecdh.privateKey, subject: MAT }), 'maticus cannot open the new envelope');
++ assert.ok(!removePut.members.some((m) => m.subject && m.subject.id === MAT.id), 'maticus is not a member');
++
++ // 4. A link lock: the key after # is part of the link the sheet copies, and a link without it says so.
++ const linkSealed = await locker.seal(plain, { lock: 'link' });
++ const linkPage = await h.openPage(browser, await browser.newContext(), { width: 390 });
++ await linkPage.goto(`${base}/#k=${linkSealed.linkKey}`, { maxMs: 5000 });
++ share = { ...share, lock: 'link', members: [], audiences: ['link', 'private'] };
++ items['/p/locked/raw'] = linkSealed.bytes;
++ await linkPage.evaluate(`(() => { OVShare.open({ kind: 'paste', id: 'locked', title: 'Locked note' }); })()`);
++ await wait(500);
++ assert.match(await linkPage.evaluate(`${q('link')}.value`), /#k=[A-Za-z0-9_-]{43}/, 'the copied link keeps the key after #');
++ await linkPage.evaluate(press('lock-open'));
++ await wait(400);
++ await linkPage.evaluate('history.replaceState(null, "", location.pathname)');
++ await linkPage.evaluate(press('lock-open'));
++ await linkPage.evaluate(press('lock-open'));
++ await wait(500);
++ assert.match(await linkPage.evaluate(text('.ov-share-lock')), /missing its key/i, 'a link with no key says so');
++
++ console.log('ov-share-lock-chrome: the Locked part with real crypto: lazy load, fingerprints, add wraps for them, removal re-seals, the link lock keeps its key');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });