aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 15:06:14 +0200
commit91297944791a36f30302ef8c86dd69ebeb177671 (patch)
tree568188114baf438059458f1bc87903f4894cec90 /packages/meshbay-hub/src/meshbay_hub/static/playlists.js
parenta55d40b74bda77dff6ec565abdd551607fc665d6 (diff)
downloadmeshbay-91297944791a36f30302ef8c86dd69ebeb177671.tar.gz
feat: bundles sealed per node under the passphrase and the hub's pepper
The session key is M = HKDF(Argon2(passphrase) || pepper, account id); each node's bundle key and the playlist key derive from it. Bundles are MBK3, bound to account and node; MBK1/MBK2 are refused by name, never replaced silently. Playlists move to key v2 and are re-sealed over unreadable node copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/playlists.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js61
1 files changed, 43 insertions, 18 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
index bac9b3d..eec94e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
@@ -92,14 +92,14 @@ let _key = null;
let _keyFor = null;
/**
- * The playlist key, derived once per sign-in from the HKDF handle that rides
- * alongside the bundle key (`keyderive.js`'s deriveBundleKeys).
+ * The playlist key, derived once per sign-in from the session's bundle master
+ * key (`keyderive.js`'s deriveBundleSessionKey).
*
- * `v2hkdf` is absent when this browser's session predates it — a stored bundle
- * key from before the change, loaded out of IndexedDB. There is nothing to do
- * about that here and nothing to fall back to: the passphrase is not in memory
- * to re-derive from. Playlists stay local until the next sign-in, which is a
- * degradation rather than a failure and is reported as one.
+ * Absent when this browser holds no such key — a session stored before the
+ * pepper, whose key `_loadBundleKey` no longer returns. There is nothing to
+ * fall back to: the passphrase is not in memory to re-derive from. Playlists
+ * stay local until the passphrase is entered again, which is a degradation
+ * rather than a failure and is reported as one.
*/
async function playlistKey(userId) {
if (_key && _keyFor === userId) return _key;
@@ -108,8 +108,8 @@ async function playlistKey(userId) {
bundleKey = await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
}
- if (!bundleKey || !bundleKey.v2hkdf) return null;
- _key = await derivePlaylistKey(bundleKey.v2hkdf);
+ if (!bundleKey || !bundleKey.v3) return null;
+ _key = await derivePlaylistKey(bundleKey.v3);
_keyFor = userId;
return _key;
}
@@ -574,8 +574,8 @@ async function syncWith(transport, userId) {
}
const key = await playlistKey(userId);
if (!key) {
- // No HKDF handle: a session from before it existed. Nothing to fall back
- // to, and silently doing nothing would be the worse answer.
+ // No bundle key in this browser (a session from before the pepper). Nothing
+ // to fall back to, and silently doing nothing would be the worse answer.
result.reason = 'no_key';
return result;
}
@@ -597,6 +597,7 @@ async function syncWith(transport, userId) {
}
let theirs = null;
+ let unreadableManifest = false;
if (have.has(MANIFEST_KIND)) {
let row = null;
try {
@@ -620,6 +621,7 @@ async function syncWith(transport, userId) {
console.warn('[MeshBay] playlist manifest on this node will not open:',
err.message, '— overwriting it with the local copy');
result.unreadable = true;
+ unreadableManifest = true;
theirs = null;
}
}
@@ -647,8 +649,15 @@ async function syncWith(transport, userId) {
const kind = bodyKind(p.id);
const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1;
const localRev = local.rev || 0;
+ // A body that will not open is not a newer copy of anything, and the local
+ // copy goes over it now — at a revision no lower than the node's, so every
+ // device still sees the node as current. Waiting for the next write left
+ // it unreadable indefinitely whenever the revisions happened to be equal,
+ // which after the playlist key changed is every body on every node. A
+ // manifest that would not open says the same of every body behind it.
+ let overwrite = unreadableManifest && have.has(kind);
- if (nodeRev > localRev) {
+ if (nodeRev > localRev && !overwrite) {
try {
const row = await transport.fetchUserBlob(kind);
if (row && row.blob_enc) {
@@ -660,12 +669,17 @@ async function syncWith(transport, userId) {
}
}
} catch {
- // Same reasoning as the manifest above, and already the right shape:
- // one body that will not open must not stop the rest, and the local
- // copy is pushed over it on the next write to that playlist.
+ // Same reasoning as the manifest above: one body that will not open
+ // must not stop the rest.
result.unreadable = true;
+ overwrite = true;
}
- } else if (localRev > nodeRev && localRev > 0) {
+ }
+ if ((overwrite || localRev > nodeRev) && localRev > 0) {
+ const pushRev = Math.max(localRev, nodeRev);
+ // The local copy takes the revision it is pushed under, or the next sync
+ // would see the node ahead and fetch it back every time.
+ if (pushRev > localRev) await _saveBody(st, { ...local, rev: pushRev });
// Was: one `catch {}` covering both of the cases below. A node that went
// away mid-sweep and a playlist that can never be sent are not the same
// event, and swallowing the second is the silent loss this whole design
@@ -673,7 +687,7 @@ async function syncWith(transport, userId) {
// stopped leaving the browser, and nothing anywhere says so.
let sealed;
try {
- sealed = await seal(local, kind, userId, key);
+ sealed = await seal({ ...local, rev: pushRev }, kind, userId, key);
} catch {
result.failed.push(p.name);
continue;
@@ -683,7 +697,7 @@ async function syncWith(transport, userId) {
continue;
}
try {
- await transport.storeUserBlob(kind, localRev, sealed);
+ await transport.storeUserBlob(kind, pushRev, sealed);
result.pushed += 1;
} catch {
// A node that went away mid-sweep: the next sync pushes this, because
@@ -704,6 +718,17 @@ async function syncWith(transport, userId) {
try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
}
+ // Behind a manifest that would not open, a body this browser has no playlist
+ // for is unreadable and unknown: nothing can merge it, and it only fills the
+ // account's quota on this node.
+ if (unreadableManifest) {
+ const known = new Set(Object.keys(merged.playlists).map(bodyKind));
+ for (const kind of have.keys()) {
+ if (kind === MANIFEST_KIND || known.has(kind)) continue;
+ try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
+ }
+ }
+
// The manifest goes last, so a node never advertises a body it has not been
// given: a reader on a third device would fetch a watermark, ask for the
// body behind it and be told there is none.