aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js484
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py296
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py155
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