diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:52:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:52:03 +0200 |
| commit | 2e973795383b71f63ae9e3bef0e5dfc7930b4c90 (patch) | |
| tree | 4e653033df1f4a28d8de60d838d92d25b63cb8a7 | |
| parent | ad8a08c713dea0e46800ea0aa6bfcf185a69e70e (diff) | |
| download | meshbay-2e973795383b71f63ae9e3bef0e5dfc7930b4c90.tar.gz | |
playlists: the store, and putting it on nodes
playlists.js is IndexedDB, WebCrypto and a transport, and node has no
IndexedDB — so it is driven in Chrome against a node stubbed to record
what it was handed, which is also how what leaves the browser is
checked to be sealed.
Sync asks the node what it holds (user_blob_list) rather than comparing
against the merged watermark, which says nothing about that node: the
first version pushed every body on every sync. A tombstoned playlist's
body is deleted as each node is reached, or the quota fills with graves.
The database version and its stores stay in hub-client.js — two modules
opening one database at versions of their own is a VersionError thrown
at whichever runs second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed, 952 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js index 7ba6f92..dbd5037 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js @@ -12,8 +12,14 @@ const AUTH_KEY = 'mb_auth'; // should not cost a round trip before the first click works. const TOKEN_RENEW_MARGIN_S = 600; const IDB_NAME = 'meshbay'; -const IDB_VERSION = 1; +// **2**, for the `playlists` store (docs/playlists.md §14.2). The version and +// every store this database has live here, in one place, and `openDB` is +// exported so nothing else opens it at a version of its own — two modules +// disagreeing about the version is a `VersionError` thrown at whichever of +// them happens to run second. +const IDB_VERSION = 2; const IDB_STORE = 'group_indexes'; +const IDB_PLAYLISTS = 'playlists'; function navigate(path) { window.location.hash = path; @@ -29,6 +35,13 @@ function openDB() { if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } + // Added at version 2. Out-of-line keys: the records are the playlist + // objects themselves, and the key is `userId|kind` — two accounts on one + // browser is ordinary, and so is a sign-out that never runs, so the + // separation belongs in the key rather than in a cleanup path. + if (!db.objectStoreNames.contains(IDB_PLAYLISTS)) { + db.createObjectStore(IDB_PLAYLISTS); + } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -301,8 +314,11 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { return r.json(); } +// `openDB` and `IDB_PLAYLISTS` are exported so playlists.js reads and writes +// its own store without owning the database's version. export { HUB, navigate, session, + openDB, IDB_PLAYLISTS, cacheGroupIndex, getCachedGroupIndex, getAllCachedIndexes, clearAllCachedIndexes, _storeBundleKey, _loadBundleKey, _storeRecoveryKey, _loadRecoveryKey, _clearKeyDB, loadAuth, saveAuth, setAuth, setAuthChangeListener, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js new file mode 100644 index 0000000..11525eb --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js @@ -0,0 +1,484 @@ +import { + MANIFEST_KIND, bodyKind, FAVORITES_ID, + emptyManifest, emptyBody, mergeManifests, mergeBodies, + toStored, fromStored, livePlaylists, repairTracks, indexTracks, +} from './playlist-merge.js'; +import { derivePlaylistKey, seal, open } from './playlist-crypto.js'; +import { + session, _loadBundleKey, openDB, IDB_PLAYLISTS, +} from './hub-client.js'; + +/** + * Playlists: the store, and putting it on nodes. + * + * `docs/playlists.md` §7 and §14. The rules live in `playlist-merge.js` and the + * sealing in `playlist-crypto.js`, both of which are pure and both of which are + * executed by their own tests. What is here is the part that cannot be: reading + * and writing IndexedDB, and talking to nodes. + * + * **The client is the authority.** The merged state is what is in this + * browser's IndexedDB, and every merge takes the maximum across the local copy + * *plus* whatever each node answers. A node serving a stale copy, or a fresh + * one it never received, can only lose the tie — it can never lower the merged + * state, because the local copy is one of the inputs. That is what makes an + * offline node a non-event rather than a hazard. + * + * **Nothing here dials.** Sync rides connections that were being opened anyway: + * opening a group, the Search page's sweep, the music pool connecting to play a + * track. A sweep of every group costs ten seconds per unreachable one, and + * doing that at sign-in for a feature nobody has asked to use yet is exactly + * what §7 refuses. + */ + +// ── local identity ─────────────────────────────────────────────────────────── + +const DEVICE_KEY = 'meshbay_playlist_device'; + +/** + * A stable id for *this browser*, for breaking merge ties and nothing else. + * + * Not an identity and not a secret: it never leaves the sealed blob, and two + * devices needing different values is the whole requirement. Random rather than + * derived from the account, because two browsers of one account must not + * collide — which is the one case the tie-break exists for. + */ +function deviceId() { + try { + let id = localStorage.getItem(DEVICE_KEY); + if (!id) { + id = Array.from(crypto.getRandomValues(new Uint8Array(8)), + (b) => b.toString(16).padStart(2, '0')).join(''); + localStorage.setItem(DEVICE_KEY, id); + } + return id; + } catch { + // A private window with storage refused. A per-session id still breaks + // ties correctly; it just stops being the *same* device next time, which + // costs nothing but a different arbitrary winner on a tie. + return 'ephemeral'; + } +} + +// ── the key ────────────────────────────────────────────────────────────────── + +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). + * + * `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. + */ +async function playlistKey(userId) { + if (_key && _keyFor === userId) return _key; + let bundleKey = session.bundleKey; + if (!bundleKey) { + bundleKey = await _loadBundleKey(); + if (bundleKey) session.bundleKey = bundleKey; + } + if (!bundleKey || !bundleKey.v2hkdf) return null; + _key = await derivePlaylistKey(bundleKey.v2hkdf); + _keyFor = userId; + return _key; +} + +/** Sign-out: the next account on this browser must not inherit this one's key. */ +function forgetPlaylistKey() { + _key = null; + _keyFor = null; + _state = null; +} + +// ── local storage ──────────────────────────────────────────────────────────── +// +// Plaintext in IndexedDB, exactly as the cached group indexes already are +// (`hub-client.js`'s `group_indexes`). What is sealed is the copy that leaves +// this browser; a second encryption layer over the local cache would protect +// against nothing the index cache is not already exposed to, and would need a +// key held in the same place. +// +// Keyed by `userId|kind`, not by `kind`. Two accounts on one browser is +// ordinary, and a sign-out that fails to run is ordinary too — so the +// separation is in the key rather than in a cleanup path that has to happen. + +// The database, its version and its stores are `hub-client.js`'s: two modules +// opening one database at versions of their own is a `VersionError` thrown at +// whichever runs second, and the store this uses was added there at version 2 +// (docs/playlists.md §14.2). + +async function _idbGet(userId, kind) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_PLAYLISTS, 'readonly'); + const req = tx.objectStore(IDB_PLAYLISTS).get(`${userId}|${kind}`); + const value = await new Promise((res, rej) => { + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); + }); + db.close(); + return value || null; + } catch { return null; } +} + +async function _idbPut(userId, kind, value) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_PLAYLISTS, 'readwrite'); + tx.objectStore(IDB_PLAYLISTS).put(value, `${userId}|${kind}`); + await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = rej; }); + db.close(); + } catch { /* a private window, or storage refused — the session still works */ } +} + +// ── in-memory state ────────────────────────────────────────────────────────── + +let _state = null; // { userId, manifest, bodies: Map(id -> body) } + +async function ensureLoaded(userId) { + if (_state && _state.userId === userId) return _state; + const manifest = (await _idbGet(userId, MANIFEST_KIND)) || emptyManifest(); + _state = { userId, manifest, bodies: new Map() }; + return _state; +} + +async function _body(userId, id) { + const st = await ensureLoaded(userId); + if (st.bodies.has(id)) return st.bodies.get(id); + const body = (await _idbGet(userId, bodyKind(id))) || emptyBody(id); + st.bodies.set(id, body); + return body; +} + +async function _saveManifest(st) { + // Its own counter, separate from the per-playlist revisions: it is what the + // node's `rev` column carries, and what a listing compares without opening + // anything. + st.manifest.rev = (st.manifest.rev || 0) + 1; + await _idbPut(st.userId, MANIFEST_KIND, st.manifest); +} + +async function _saveBody(st, body) { + st.bodies.set(body.id, body); + await _idbPut(st.userId, bodyKind(body.id), body); +} + +/** The manifest entry for `id`, materialised if this is Favourites. */ +function _entry(st, id, name) { + let e = st.manifest.playlists[id]; + if (!e) { + e = { + name: name || '', rev: 1, body_rev: 0, device: deviceId(), + updated_at: Math.floor(Date.now() / 1000), deleted: false, count: 0, + }; + st.manifest.playlists[id] = e; + } + return e; +} + +// ── reading ────────────────────────────────────────────────────────────────── + +/** + * Every playlist a reader should see, Favourites first. + * + * Drawn from the manifest alone — names, counts, a few kilobytes — so the + * "add to playlist" menu opens instantly with every node offline and no body + * fetched. That is what the manifest is for. + */ +async function listPlaylists(userId) { + const st = await ensureLoaded(userId); + return livePlaylists(st.manifest); +} + +/** + * One playlist's tracks, in the shape the player consumes. + * + * `cachedIndexes` is optional and is `[{ groupId, entries }]`: given it, an + * entry whose hash is no longer in its group's index but whose path is gets + * its id rewritten, and the other way round. Repaired in memory and written + * back, once, so the next read costs nothing. + */ +async function getPlaylistTracks(userId, id, cachedIndexes) { + const body = await _body(userId, id); + let tracks = body.tracks || []; + if (cachedIndexes && cachedIndexes.length) { + let changed = 0; + for (const { groupId, entries } of cachedIndexes) { + const res = repairTracks(tracks, groupId, indexTracks(entries)); + tracks = res.tracks; + changed += res.repaired; + } + if (changed) { + const st = await ensureLoaded(userId); + await _saveBody(st, { ...body, tracks }); + } + } + return tracks.map(fromStored); +} + +// ── writing ────────────────────────────────────────────────────────────────── + +function _newId() { + return (crypto.randomUUID && crypto.randomUUID()) + || Array.from(crypto.getRandomValues(new Uint8Array(16)), + (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Create a playlist under a name that is not already taken. + * + * Case- and accent-folded, because "Soirée" and "soiree" being two playlists + * is not something anyone wants and is very easy to do by accident. + */ +async function createPlaylist(userId, name) { + const st = await ensureLoaded(userId); + const wanted = String(name || '').trim(); + if (!wanted) throw new Error('empty name'); + const fold = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); + for (const p of livePlaylists(st.manifest)) { + if (fold(p.name) === fold(wanted)) throw new Error('duplicate name'); + } + const id = _newId(); + _entry(st, id, wanted); + await _saveManifest(st); + await _saveBody(st, emptyBody(id)); + return id; +} + +async function renamePlaylist(userId, id, name) { + const st = await ensureLoaded(userId); + const e = _entry(st, id); + e.name = String(name || '').trim(); + e.rev += 1; + e.device = deviceId(); + e.updated_at = Math.floor(Date.now() / 1000); + await _saveManifest(st); +} + +/** + * Tombstone a playlist. Favourites is refused outright. + * + * Refusing rather than special-casing is what keeps the tombstone rule free of + * an exception: a tombstone on the reserved id would have to be ignored by + * every reader, and a tombstone rule with an exception is how the resurrection + * defect ships. + */ +async function deletePlaylist(userId, id) { + if (id === FAVORITES_ID) throw new Error('favorites cannot be deleted'); + const st = await ensureLoaded(userId); + const e = _entry(st, id); + e.deleted = true; + e.rev += 1; + e.device = deviceId(); + e.updated_at = Math.floor(Date.now() / 1000); + await _saveManifest(st); + st.bodies.delete(id); + await _idbPut(st.userId, bodyKind(id), null); +} + +/** + * Append entries to a playlist, creating it if this is Favourites' first use. + * + * Duplicates are allowed in an ordinary playlist — real ones have them — and + * refused in Favourites, where the gesture is a toggle rather than an append. + * Returns how many were actually added, so the caller can say so. + */ +async function addTracks(userId, id, entries, groupId, name) { + const st = await ensureLoaded(userId); + const e = _entry(st, id, id === FAVORITES_ID ? (name || 'Favorites') : name); + const body = await _body(userId, id); + const existing = new Set(body.tracks.map((tr) => tr.id)); + const incoming = entries + .map((entry) => toStored(entry, groupId)) + .filter((tr) => (id === FAVORITES_ID ? !existing.has(tr.id) : true)); + if (!incoming.length) return 0; + + const next = { + ...body, + rev: (body.rev || 0) + 1, + device: deviceId(), + tracks: [...body.tracks, ...incoming], + }; + await _saveBody(st, next); + e.body_rev = next.rev; + e.count = next.tracks.length; + e.updated_at = Math.floor(Date.now() / 1000); + await _saveManifest(st); + return incoming.length; +} + +async function removeTrackAt(userId, id, at) { + const st = await ensureLoaded(userId); + const body = await _body(userId, id); + if (at < 0 || at >= body.tracks.length) return false; + const next = { + ...body, + rev: (body.rev || 0) + 1, + device: deviceId(), + tracks: [...body.tracks.slice(0, at), ...body.tracks.slice(at + 1)], + }; + await _saveBody(st, next); + const e = _entry(st, id); + e.body_rev = next.rev; + e.count = next.tracks.length; + e.updated_at = Math.floor(Date.now() / 1000); + await _saveManifest(st); + return true; +} + +/** + * The current queue as a new playlist. + * + * The caller passes the tracks in **play order** — what the queue panel is + * showing — rather than in the order they were added, because that is what + * "save what I am listening to" means, shuffle included. + */ +async function saveQueueAsPlaylist(userId, name, entries) { + const id = await createPlaylist(userId, name); + await addTracks(userId, id, entries, null); + return id; +} + +// ── sync ───────────────────────────────────────────────────────────────────── + +/** + * Reconcile with one node, over a connection that was open anyway. + * + * Manifest first, always; bodies only when the merged manifest says this + * browser's copy is behind, or when the node's is. A reader with forty + * playlists who only ever plays two never transfers the other thirty-eight. + * + * Every failure is swallowed and reported in the return value rather than + * thrown: this rides someone else's connection, and a playlist that could not + * be reconciled must never break whatever that connection was opened for. + */ +async function syncWith(transport, userId) { + const result = { ok: false, pulled: 0, pushed: 0, reason: null }; + if (!transport || !transport.connected) { + result.reason = 'offline'; + return result; + } + 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. + result.reason = 'no_key'; + return result; + } + const st = await ensureLoaded(userId); + + // What this node holds, and at what revision — kinds and numbers, no + // payloads, one message. Without it there is no way to tell "the node is + // behind" from "the node is up to date", and the first version of this + // pushed every body on every sync because it could not tell the difference. + const have = new Map(); + try { + for (const b of await transport.listUserBlobs()) have.set(b.kind, b.rev); + } catch (err) { + // A node too old to know these messages at all. Nothing to reconcile with, + // and nothing lost: the local copy is the authority and the next node this + // browser reaches will take the writes. + result.reason = err.message || 'unsupported'; + return result; + } + + let theirs = null; + if (have.has(MANIFEST_KIND)) { + try { + const row = await transport.fetchUserBlob(MANIFEST_KIND); + if (row && row.blob_enc) { + theirs = await open(row.blob_enc, MANIFEST_KIND, userId, key); + } + } catch (err) { + result.reason = err.message || 'fetch failed'; + return result; + } + } + + const merged = mergeManifests(st.manifest, theirs || emptyManifest()); + merged.rev = Math.max(st.manifest.rev || 0, (theirs && theirs.rev) || 0); + const grew = JSON.stringify(merged.playlists) !== JSON.stringify(st.manifest.playlists); + if (grew) { + st.manifest = merged; + await _idbPut(userId, MANIFEST_KIND, merged); + result.pulled += 1; + } + + // Bodies, compared against what *this node* holds rather than against the + // merged watermark: the watermark is the highest revision anybody has seen, + // which says nothing about whether this particular node has it. + // + // A body is fetched only when this node is ahead, and pushed only when it is + // behind. A reader with forty playlists who only ever plays two never + // transfers the other thirty-eight, and a sync with nothing to do writes + // nothing at all. + for (const p of livePlaylists(merged)) { + const local = await _body(userId, p.id); + const kind = bodyKind(p.id); + const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1; + const localRev = local.rev || 0; + + if (nodeRev > localRev) { + try { + const row = await transport.fetchUserBlob(kind); + if (row && row.blob_enc) { + const remote = await open(row.blob_enc, kind, userId, key); + const best = mergeBodies(local, remote); + if (best && (best.rev || 0) > localRev) { + await _saveBody(st, best); + result.pulled += 1; + } + } + } catch { /* one body failing must not stop the rest */ } + } else if (localRev > nodeRev && localRev > 0) { + try { + await transport.storeUserBlob( + kind, localRev, await seal(local, kind, userId, key)); + result.pushed += 1; + } catch { /* a cap, or a node that went away mid-sweep */ } + } + } + + // A tombstoned playlist's body is dropped from every node as it is reached. + // The tombstone in the manifest is what has to survive, not the tracks — + // and without this the body of every playlist ever deleted stays on every + // node for ever, filling the account's quota with graves. + for (const [id, e] of Object.entries(merged.playlists)) { + if (!e.deleted) continue; + const kind = bodyKind(id); + if (!have.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. + const mine = JSON.stringify(st.manifest.playlists); + if (!theirs || mine !== JSON.stringify(theirs.playlists)) { + try { + await transport.storeUserBlob( + MANIFEST_KIND, st.manifest.rev || 1, + await seal(st.manifest, MANIFEST_KIND, userId, key)); + result.pushed += 1; + } catch (err) { + result.reason = err.message || 'store failed'; + return result; + } + } + + result.ok = true; + return result; +} + +export { + FAVORITES_ID, + deviceId, playlistKey, forgetPlaylistKey, ensureLoaded, + listPlaylists, getPlaylistTracks, + createPlaylist, renamePlaylist, deletePlaylist, + addTracks, removeTrackAt, saveQueueAsPlaylist, + syncWith, +}; diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py new file mode 100755 index 0000000..8dfa4b1 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +The playlist store, driven in a real browser against a fake node. + +`playlist-merge.js` and `playlist-crypto.js` are pure and are executed by their +own tests. `playlists.js` is neither: it is IndexedDB, WebCrypto and a +transport, and node has no IndexedDB at all. So this runs the shipped module in +Chrome, with a node stubbed to record what it was handed — which is also the +only way to check that what leaves the browser is sealed. + + playlist_store_probe.py + +Prints JSON: one entry per step. +""" +import http.server +import json +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8753 +RECORDS = [] +socketserver.TCPServer.allow_reuse_address = True + +FRAME = r"""<!doctype html><html><head><meta charset=utf-8></head><body> +<div id="root"></div> +<script type="module"> +import { session } from '/hub-client.js'; +import * as P from '/playlists.js'; +import { open, seal, derivePlaylistKey } from '/playlist-crypto.js'; +import { MANIFEST_KIND, bodyKind } from '/playlist-merge.js'; + +const LOGS = []; +addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e))); +addEventListener('unhandledrejection', + (e) => LOGS.push('rejection: ' + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason))); + +const USER = 'user-1'; +const steps = []; + +// A track as the views hand it over, live junk and all: the Search page +// attaches a transport and a CryptoKey to every entry it renders. +const track = (n, group) => ({ + id: 'hash-' + n, name: `${n} - Titre.flac`, display_title: `Titre ${n}`, + path: 'Artiste/Album', size: 1000 + n, type: 'audio', duration: 200 + n, + artist: 'Artiste', album: 'Album', track_no: n, hash_version: 1, + groupId: group || 'g1', + _tRef: { live: 'transport' }, _gRef: { key: true }, _origPath: 'elsewhere', +}); + +// The node: records everything, answers with whatever it has been given. +function fakeNode() { + const rows = new Map(); + return { + connected: true, + rows, + stored: [], + async fetchUserBlob(kind) { + const r = rows.get(kind); + return r ? { rev: r.rev, blob_enc: r.blob } : { rev: null, blob_enc: null }; + }, + async storeUserBlob(kind, rev, blob) { + rows.set(kind, { rev, blob }); + this.stored.push({ kind, rev, bytes: blob.length }); + return { type: 'ack' }; + }, + async listUserBlobs() { + return [...rows.entries()].map(([kind, r]) => ({ kind, rev: r.rev })); + }, + async deleteUserBlob(kind) { rows.delete(kind); return { type: 'ack' }; }, + }; +} + +(async () => { + const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*'); + try { + // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce + // — the point is that playlists.js gets its key the way it really does. + const raw = new Uint8Array(32).fill(5); + session.bundleKey = { + v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, + ['encrypt', 'decrypt']), + v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']), + }; + const key = await derivePlaylistKey(session.bundleKey.v2hkdf); + + // ── local editing ────────────────────────────────────────────────────── + const eveningId = await P.createPlaylist(USER, 'Soirée'); + await P.addTracks(USER, eveningId, [track(1), track(2)], 'g1'); + await P.addTracks(USER, P.FAVORITES_ID, [track(9)], 'g1', 'Favoris'); + + steps.push({ step: 'after editing', + list: (await P.listPlaylists(USER)).map((p) => ({ id: p.id, name: p.name, count: p.count })) }); + + steps.push({ step: 'tracks read back', + tracks: (await P.getPlaylistTracks(USER, eveningId)).map((t) => ({ + id: t.id, name: t.name, size: t.size, groupId: t.groupId, + title: t.display_title })) }); + + // Favourites is a toggle, not an append. + const again = await P.addTracks(USER, P.FAVORITES_ID, [track(9)], 'g1'); + const favTwice = await P.getPlaylistTracks(USER, P.FAVORITES_ID); + steps.push({ step: 'favourites is idempotent', added: again, count: favTwice.length }); + + // An ordinary playlist takes the same track twice, because real ones do. + await P.addTracks(USER, eveningId, [track(1)], 'g1'); + steps.push({ step: 'an ordinary playlist takes duplicates', + count: (await P.getPlaylistTracks(USER, eveningId)).length }); + + let dup = null; + try { await P.createPlaylist(USER, 'soiree'); } catch (e) { dup = e.message; } + steps.push({ step: 'a folded duplicate name is refused', why: dup }); + + let fav = null; + try { await P.deletePlaylist(USER, P.FAVORITES_ID); } catch (e) { fav = e.message; } + steps.push({ step: 'favourites cannot be deleted', why: fav }); + + // ── sync ─────────────────────────────────────────────────────────────── + const node = fakeNode(); + const r1 = await P.syncWith(node, USER); + steps.push({ step: 'first sync', result: r1, + kinds: node.stored.map((s) => s.kind).sort() }); + + // What actually left the browser: sealed, and openable only with the key. + const manifestRow = node.rows.get(MANIFEST_KIND); + const hay = new TextDecoder('latin1').decode(manifestRow.blob); + const opened = await open(manifestRow.blob, MANIFEST_KIND, USER, key); + steps.push({ + step: 'what the node holds', + leaks: ['Soirée', 'Favoris', 'playlists', 'Titre'].filter((s) => hay.includes(s)), + names: Object.values(opened.playlists).map((p) => p.name).sort(), + }); + + // Syncing again with nothing changed must not rewrite anything. + node.stored.length = 0; + const r2 = await P.syncWith(node, USER); + steps.push({ step: 'second sync is quiet', result: r2, + wrote: node.stored.length }); + + // ── a node that went away and came back with an older copy ───────────── + // + // The rollback case: the local copy is one of the merge inputs, so a stale + // node can only lose. It must never lower what this browser holds. + const stale = fakeNode(); + const oldManifest = { v: 1, rev: 1, playlists: { + [eveningId]: { name: 'Soirée', rev: 1, body_rev: 1, count: 1, + device: 'other', updated_at: 99, deleted: false } } }; + stale.rows.set(MANIFEST_KIND, { + rev: 1, blob: await seal(oldManifest, MANIFEST_KIND, USER, key) }); + await P.syncWith(stale, USER); + steps.push({ + step: 'a stale node cannot lower anything', + list: (await P.listPlaylists(USER)).map((p) => ({ id: p.id, count: p.count })), + tracks: (await P.getPlaylistTracks(USER, eveningId)).length, + }); + + // ── a node with an edit this browser has not seen ────────────────────── + const ahead = fakeNode(); + const driveId = 'drive-from-elsewhere'; + const newer = { v: 1, rev: 9, playlists: { + ...JSON.parse(JSON.stringify(oldManifest.playlists)), + [driveId]: { name: 'Route', rev: 4, body_rev: 2, count: 3, + device: 'aaa-other-device', updated_at: 5, deleted: false } } }; + ahead.rows.set(MANIFEST_KIND, { + rev: 9, blob: await seal(newer, MANIFEST_KIND, USER, key) }); + ahead.rows.set(bodyKind(driveId), { rev: 2, blob: await seal( + { v: 1, id: driveId, rev: 2, device: 'aaa-other-device', + tracks: [{ id: 'r1', g: 'g2', hv: 1, n: 'a.flac', s: 5, p: 'X', + t: 'Route 1', a: 'A', b: 'B', d: 100, tn: 1 }] }, + bodyKind(driveId), USER, key) }); + await P.syncWith(ahead, USER); + steps.push({ + step: 'an edit made elsewhere arrives', + list: (await P.listPlaylists(USER)).map((p) => p.name).sort(), + routeTracks: (await P.getPlaylistTracks(USER, driveId)).map((t) => t.display_title), + }); + + // ── deletion survives a node that still has it ───────────────────────── + await P.deletePlaylist(USER, eveningId); + const resurrector = fakeNode(); + resurrector.rows.set(MANIFEST_KIND, { + rev: 2, blob: await seal(oldManifest, MANIFEST_KIND, USER, key) }); + await P.syncWith(resurrector, USER); + steps.push({ + step: 'a deletion is not resurrected', + list: (await P.listPlaylists(USER)).map((p) => p.name).sort(), + }); + + // And the body it left behind is reclaimed, on every node as it is + // reached — otherwise the account's quota fills up with graves. + const holder = fakeNode(); + holder.rows.set(bodyKind(eveningId), { rev: 3, blob: new Uint8Array([1, 2, 3]) }); + holder.rows.set(MANIFEST_KIND, { + rev: 1, blob: await seal({ v: 1, rev: 1, playlists: {} }, MANIFEST_KIND, USER, key) }); + await P.syncWith(holder, USER); + steps.push({ + step: 'a deleted body is reclaimed', + stillThere: holder.rows.has(bodyKind(eveningId)), + }); + + // ── a session with no HKDF handle degrades rather than failing ───────── + P.forgetPlaylistKey(); + session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session + const r3 = await P.syncWith(fakeNode(), USER); + steps.push({ step: 'a session from before the HKDF handle', result: r3 }); + + parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*'); + } catch (err) { + fail(String((err && err.stack) || err)); + } +})(); +</script></body></html>""" + +PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head> +<body style="margin:0"><div id="frames"></div><script> +addEventListener('message', (e) => { + fetch('/log', { method: 'POST', body: JSON.stringify(e.data) }); +}); +const f = document.createElement('iframe'); +f.src = '/case'; +f.style.cssText = 'width:900px;height:600px;border:0;display:block'; +document.getElementById('frames').appendChild(f); +</script></body></html>""" + + +class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + if self.path == "/log": + RECORDS.append(json.loads(self.rfile.read(length).decode())) + else: + self.rfile.read(length) + self.send_response(204) + self.end_headers() + + def _send(self, body: bytes, ctype: str) -> None: + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + if path == "/": + self._send(PAGE.encode(), "text/html; charset=utf-8") + elif path == "/case": + self._send(FRAME.encode(), "text/html; charset=utf-8") + else: + asset = (STATIC / path.lstrip("/")).resolve() + if not str(asset).startswith(str(STATIC)) or not asset.is_file(): + self.send_response(404) + self.end_headers() + return + self._send(asset.read_bytes(), + "text/css" if asset.suffix == ".css" + else "text/javascript" if asset.suffix == ".js" + else "application/octet-stream") + + +def main() -> int: + with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: + threading.Thread(target=srv.serve_forever, daemon=True).start() + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile: + proc = subprocess.Popen( + ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", + f"--user-data-dir={profile}", "--window-size=900,700", + f"http://127.0.0.1:{PORT}/"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(400): + if RECORDS: + break + time.sleep(0.1) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if not RECORDS: + print(json.dumps({"error": "no measurement"}), file=sys.stderr) + return 1 + print(json.dumps(RECORDS[0], indent=1)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py new file mode 100644 index 0000000..203033c --- /dev/null +++ b/packages/meshbay-hub/tests/test_playlist_store.py @@ -0,0 +1,155 @@ +""" +The playlist store: editing, and putting it on a node. + +The rules (`playlist-merge.js`) and the sealing (`playlist-crypto.js`) are pure +and are executed by their own tests. `playlists.js` is neither — it is +IndexedDB, WebCrypto and a transport, and node has no IndexedDB at all — so the +shipped module is driven in Chrome against a node stubbed to record what it was +handed. That stub is also the only way to check the thing that matters most: +what leaves the browser is sealed. + +Four properties, and none of them is "it round-trips": + + - a **stale node cannot lower** what this browser holds, because the local + copy is one of the merge inputs; + - a **deletion is not resurrected** by a node that still has the playlist; + - an **edit made on another device arrives**, body and all; and + - a sync with nothing to do **writes nothing**, because sync rides someone + else's connection and must not spend it. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "playlist_store_probe.py" +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" + +pytestmark = pytest.mark.skipif( + shutil.which("google-chrome") is None or not (STATIC / "playlists.js").exists(), + reason="Chrome or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def steps(): + proc = subprocess.run(["python3", str(HARNESS)], + capture_output=True, text=True, timeout=300) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, out + assert not out.get("logs"), f"the page logged: {out['logs']}" + return {s["step"]: s for s in out["steps"]} + + +# ── editing ────────────────────────────────────────────────────────────────── + +def test_favourites_exists_without_having_been_created(steps): + """Never created by the user, materialised on first use, always first.""" + listed = steps["after editing"]["list"] + assert listed[0]["id"] == "favorites" + assert [p["count"] for p in listed] == [1, 2] + + +def test_a_stored_track_carries_what_the_player_needs_to_fetch_it(steps): + """`size` and `name` above all: without them the entry cannot be + downloaded at all, and the failure is invisible until playback.""" + tracks = steps["tracks read back"]["tracks"] + assert [t["id"] for t in tracks] == ["hash-1", "hash-2"] + assert [t["size"] for t in tracks] == [1001, 1002] + assert [t["name"] for t in tracks] == ["1 - Titre.flac", "2 - Titre.flac"] + assert all(t["groupId"] == "g1" for t in tracks) + assert [t["title"] for t in tracks] == ["Titre 1", "Titre 2"] + + +def test_favourites_is_a_toggle_and_an_ordinary_playlist_is_not(steps): + """Starring something twice leaves one star. An ordinary playlist takes the + same track twice, because real playlists do.""" + assert steps["favourites is idempotent"]["added"] == 0 + assert steps["favourites is idempotent"]["count"] == 1 + assert steps["an ordinary playlist takes duplicates"]["count"] == 3 + + +def test_a_name_that_differs_only_by_case_or_accent_is_refused(steps): + """"Soirée" and "soiree" being two playlists is nobody's intention and very + easy to do by accident.""" + assert steps["a folded duplicate name is refused"]["why"] == "duplicate name" + + +def test_favourites_cannot_be_deleted(steps): + """Refused outright rather than special-cased, which is what keeps the + tombstone rule free of an exception — and a tombstone rule with an + exception is how the resurrection defect ships.""" + assert steps["favourites cannot be deleted"]["why"] == ( + "favorites cannot be deleted") + + +# ── what leaves the browser ────────────────────────────────────────────────── + +def test_the_node_is_handed_one_blob_per_playlist_plus_a_manifest(steps): + s = steps["first sync"] + assert s["result"]["ok"] is True + assert s["kinds"][0].startswith("playlist:") + assert "playlist:favorites" in s["kinds"] + assert "playlists" in s["kinds"] + + +def test_the_node_gets_ciphertext_and_nothing_else(steps): + """The claim, checked rather than asserted: the names are in the blob and + are not readable in it.""" + s = steps["what the node holds"] + assert s["leaks"] == [] + assert s["names"] == ["Favoris", "Soirée"], ( + "the manifest must still open with the key that sealed it") + + +def test_a_sync_with_nothing_to_do_writes_nothing(steps): + """Sync rides a connection opened for something else. The first version of + this pushed every body on every sync, because it compared against the + merged watermark rather than against what *that node* actually holds.""" + assert steps["second sync is quiet"]["wrote"] == 0 + assert steps["second sync is quiet"]["result"]["pushed"] == 0 + + +# ── nodes that are behind, ahead, or wrong ─────────────────────────────────── + +def test_a_stale_node_cannot_lower_the_merged_state(steps): + """The rollback case. AEAD authenticates a blob; it does not stop a node + handing back an older one it still has. What does is that the local copy is + one of the merge inputs, so a stale node can only lose the tie.""" + s = steps["a stale node cannot lower anything"] + assert s["tracks"] == 3, "a node with an older copy rolled the playlist back" + assert {p["id"]: p["count"] for p in s["list"]}["favorites"] == 1 + + +def test_an_edit_made_on_another_device_arrives_with_its_tracks(steps): + """The manifest says a playlist exists that this browser has never seen; + its body is then fetched because this node is ahead on that kind.""" + s = steps["an edit made elsewhere arrives"] + assert s["list"] == ["Favoris", "Route", "Soirée"] + assert s["routeTracks"] == ["Route 1"], "the body never arrived" + + +def test_a_node_that_still_holds_a_deleted_playlist_does_not_resurrect_it(steps): + """A node rehomed after three weeks. This is the single most likely defect + in the design and it looks like a sync working correctly.""" + assert steps["a deletion is not resurrected"]["list"] == ["Favoris", "Route"] + + +def test_a_deleted_playlists_body_is_reclaimed_from_the_node(steps): + """The tombstone in the manifest is what has to survive, not the tracks. + Without this the body of every playlist ever deleted stays on every node + for ever, and the account's 8 MB quota fills up with graves.""" + assert steps["a deleted body is reclaimed"]["stillThere"] is False + + +def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps): + """A bundle key loaded out of IndexedDB from before `deriveBundleKeys` + existed has no HKDF handle, and the passphrase is not in memory to + re-derive from. Playlists stay local until the next sign-in — reported, + rather than silently doing nothing.""" + r = steps["a session from before the HKDF handle"]["result"] + assert r["ok"] is False and r["reason"] == "no_key" + assert r["pushed"] == 0 |