openvibers/openvibe.shared Commits

4.116.1: a transfer of a people-locked item carries the recipient's slot (#300)

OP OpenVibers committed 7 days ago · a938f78609 · 9 files, +62 −8

The sheet wraps the content key for the new owner's key, saves the envelope
with their slot so they can open it while the offer is pending, and sends the
offer with that key_slot (required by openvibe-sdk/share for a people lock).
The Chrome lock test covers the offer with the real crypto.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a0ad2c6..4a6444d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,12 @@
# Changelog
++## 4.116.1 — 2026-11-06
++
++- **A transfer of a people-locked item carries the recipient's slot.** The sheet wraps the content key for the new
++ owner's key, saves the envelope with their slot (so they can open it while the offer is pending, before they accept),
++ and sends the offer with that `key_slot` — which `openvibe-sdk/share` requires for a people lock. Someone without a
++ locker is told so, and nothing is offered.
++
## 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).
ddiff --git a/README.md b/README.md
iindex 1532ef3..d9f3f63 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.116.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.116.1 (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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 65d80f3..a5a8fae 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.116.0",
++ "version": "4.116.1",
"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)",
ddiff --git a/docs/share.md b/docs/share.md
iindex dbd26f5..35bd319 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -80,6 +80,8 @@ page that never touches a locked item never loads the crypto.
- **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.
++- **A transfer** to a person wraps the content key for their key first, saves the envelope with their slot (so they can
++ look before they accept) and sends the offer with it. Someone without a locker cannot be offered 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
ddiff --git a/navbar.js b/navbar.js
iindex eafd696..8f2d47f 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': 'c70f014c25de', 'ov-share.css': 'cbd18e6ce991' };
++ 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': 'e7a4813e203d', '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-share.js b/ov-share.js
iindex e2b0b27..5ffe8cb 100644
---- a/ov-share.js
++++ b/ov-share.js
@@@ -1107,7 +1107,31 @@
if (st.server.owner && to.subject && same(to.subject, st.server.owner.subject)) { st.busy = false; st.giveMsg = 'That is you.'; st.giveBad = true; draw(st); return; }
st.busy = true; draw(st);
try {
-- const out = await call(st.o, 'POST', `${endpoint(st.o).path}/transfer`, { to: to.subject ? { subject: to.subject } : { name: to.name }, keep_access: !!st.keepAccess });
++ // A people-locked item is opened by its slots: the recipient's comes with the offer, and the envelope that
++ // goes up carries it too, so they can look before they accept. Their key is asked of Network, and the
++ // wrapping happens here.
++ let slot = null;
++ if (isLocked(st) && st.server.lock === 'people') {
++ if (!to.subject) throw new Error('That is not an OpenVibe account, so there is no key to lock it for.');
++ 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(to.subject);
++ if (!keys) throw new Error(`${handle(to) || nameOf(to)} has no locker yet: they make one the first time they open a locked item.`);
++ await L.ensure();
++ const bytes = await envelopeBytes(st);
++ const wrapped = await L.rewrap(bytes, { add: [{ subject: to.subject, publicKey: keys.ecdh_public || keys.ecdh }] });
++ slot = (wrapped.header.slots.people || []).find((x) => same(x.subject, to.subject)) || null;
++ if (!slot) throw new Error('The sealed envelope has no key for them; try again.');
++ const members = st.audience === 'private' ? [] : st.members.map((m) => ({
++ subject: m.subject, role: m.role,
++ ...(m.expires_at ? { expires_at: m.expires_at } : {}),
++ ...(m.key_slot ? { key_slot: m.key_slot } : {}),
++ }));
++ take(st, await call(st.o, 'PUT', endpoint(st.o).path, { audience: st.audience, members, envelope: b64(wrapped.bytes) }, st.etag ? { 'if-match': st.etag } : null));
++ }
++ const out = await call(st.o, 'POST', `${endpoint(st.o).path}/transfer`, {
++ to: to.subject ? { subject: to.subject } : { name: to.name }, keep_access: !!st.keepAccess, ...(slot ? { key_slot: slot } : {}),
++ });
if (out && out.audience) take(st, out); else take(st, await call(st.o, 'GET', endpoint(st.o).path));
st.view = 'main'; st.giveTo = null; st.giveDraft = ''; st.giveMsg = '';
st.note = `Offer sent to ${handle(to) || nameOf(to)}. It stays yours until they accept.`;
ddiff --git a/package-lock.json b/package-lock.json
iindex 632d52e..435f395 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.116.0",
++ "version": "4.116.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.116.0",
++ "version": "4.116.1",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 721b201..407e46d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.116.0",
++ "version": "4.116.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/ov-share-lock-chrome.test.js b/test/ov-share-lock-chrome.test.js
iindex afbc472..d724121 100644
---- a/test/ov-share-lock-chrome.test.js
++++ b/test/ov-share-lock-chrome.test.js
@@@ -32,6 +32,7 @@ const keys = {}; // subject id → what Network publishes: { subject, e
const items = {}; // path → envelope bytes
let lockerPut = null; // the browser's PUT /api/v1/me/keys
const puts = []; // every share save body
++const transfers = []; // every POST .../share/transfer 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:,">
@@@ -65,6 +66,12 @@ const server = http.createServer((req, res) => {
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/transfer' && req.method === 'POST') {
++ const b = JSON.parse(body);
++ transfers.push(b);
++ share = { ...share, transfer: { id: '00000000-0000-4000-8000-0000000000aa', to: b.to, keep_access: b.keep_access, expires_at: '2026-11-13T10:00:00Z', state: 'pending' } };
++ res.writeHead(201, { 'content-type': 'application/json' }); 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);
@@@ -193,7 +200,21 @@ const server = http.createServer((req, res) => {
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.
++ // 4. Transfer to maticus: the offer carries the slot they need, and the envelope that goes up opens for them.
++ await page.evaluate("document.getElementById('sh').click()");
++ await wait(400);
++ await page.evaluate(press('give'));
++ await wait(200);
++ await page.evaluate(type('give-name', 'maticus'));
++ await page.evaluate(press('give-send'));
++ await wait(3000);
++ assert.strictEqual(transfers.length, 1, 'the offer was sent');
++ assert.ok(transfers[0].key_slot, 'the offer carries a key slot for the new owner');
++ assert.strictEqual(transfers[0].key_slot.recipient_key, await locker.jwkThumbprint(matPub.ecdh), 'the slot names their key');
++ const forTransfer = Buffer.from(puts[puts.length - 1].envelope, 'base64');
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(forTransfer, { privateKey: mat.ecdh.privateKey, subject: MAT }))], [...Buffer.from(plain)], 'they can open it before accepting');
++
++ // 5. 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 });