aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:37:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:37:58 +0200
commitad8a08c713dea0e46800ea0aa6bfcf185a69e70e (patch)
treea1f9289b3a55e30853d22a95a5ec858cf04ab272 /packages
parentcde57423e04812fe2c939fdf983e3b45a77fd82d (diff)
downloadmeshbay-ad8a08c713dea0e46800ea0aa6bfcf185a69e70e.tar.gz
playlists: merge rules, sealing, and the key
playlist-merge.js and playlist-crypto.js have no imports and are run by their tests, which is the only real evidence this feature can have. Two revision counters per playlist, not one: a rename on one device and a track added on another both write n+1, and a single counter makes two edits that do not overlap collide. One Argon2 run at sign-in, two handles. The AES handle is imported non-extractably, so nothing can be derived from it — hence a second import of the same bytes as HKDF rather than a derivation. Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so the 1 MB body cap holds about 17000 tracks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js55
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js155
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js258
-rw-r--r--packages/meshbay-hub/tests/test_playlist_crypto.py255
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py221
-rw-r--r--packages/meshbay-hub/tests/test_playlist_merge.py398
7 files changed, 1340 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 060fd6e..205fce2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -242,7 +242,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// that opens node bundles is missing here. Persisted so this browser is
// set up from now on.
session.bundleKey = {
- v2: await window.MeshBayKeys.deriveEncryptionKey(pass, username),
+ ...(await window.MeshBayKeys.bundleKeyPairFields(pass, username)),
v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username),
};
await _storeBundleKey(session.bundleKey);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 918ade3..edfa109 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -142,7 +142,7 @@ async function deriveEncryptionKeyV1(password, username) {
* passphrase in memory to re-derive it whenever a bundle turns up. It is unique
* per account, so it does what a salt is for — no shared precomputation.
*/
-async function deriveEncryptionKey(password, username) {
+async function _bundleKeyBytes(password, username) {
const enc = new TextEncoder();
const salt = new Uint8Array(await crypto.subtle.digest(
'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16);
@@ -151,8 +151,42 @@ async function deriveEncryptionKey(password, username) {
time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES,
hashLen: 32, type: _argon2().ArgonType.Argon2id,
});
+ return out.hash;
+}
+
+async function deriveEncryptionKey(password, username) {
return crypto.subtle.importKey(
- 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+ 'raw', await _bundleKeyBytes(password, username),
+ { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
+/**
+ * The bundle key as **two handles over one Argon2 run**.
+ *
+ * `aes` is what has always been returned: the key that opens a node's identity
+ * bundle. `hkdf` is the same 32 bytes imported a second time as an HKDF key,
+ * from which purpose-separated subkeys can be derived — playlists are the
+ * first (docs/playlists.md §3.4).
+ *
+ * It has to be a second import of the same bytes, and not a derivation from
+ * `aes`: that one is imported non-extractably with `['encrypt','decrypt']`, so
+ * nothing can be derived from it at all. And it has to be one Argon2 run: a
+ * second call would put another ~650 ms on the sign-in path for a key that is
+ * mathematically identical.
+ *
+ * A subkey rather than the bundle key reused with a different AAD, for the
+ * reason `groupbox.py` already writes down for chunk keys — purpose separation
+ * is what stops one use's mistake becoming every use's.
+ */
+async function deriveBundleKeys(password, username) {
+ const raw = await _bundleKeyBytes(password, username);
+ return {
+ aes: await crypto.subtle.importKey(
+ 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']),
+ // HKDF keys are non-extractable by specification; `false` is the only
+ // value this accepts.
+ hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ };
}
// ── Account recovery key ─────────────────────────────────────────────────────
@@ -393,7 +427,7 @@ async function loginAndRecover(username, password) {
// Both, so a bundle written before the KDF changed can still be opened —
// and re-written with the new one on the next backup.
bundleKey: {
- v2: await deriveEncryptionKey(password, username),
+ ...(await _bundleKeyPairFields(password, username)),
v1: await deriveEncryptionKeyV1(password, username),
},
};
@@ -417,6 +451,17 @@ async function signBytes(skEdPkcs8B64, message) {
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
+/**
+ * `{ v2, v2hkdf }` — the two fields every `session.bundleKey` carries for the
+ * current KDF. One helper because there are two places that build that object
+ * and they must not drift: a `v2hkdf` missing from one of them is a playlist
+ * store that silently does nothing on whichever sign-in path skipped it.
+ */
+async function _bundleKeyPairFields(password, username) {
+ const { aes, hkdf } = await deriveBundleKeys(password, username);
+ return { v2: aes, v2hkdf: hkdf };
+}
+
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
@@ -424,6 +469,10 @@ window.MeshBayKeys = {
// node's identity bundle needs the old key (a {v2,v1} pair, since an old
// bundle may be v1) to read it and the new v2 key to write it back.
deriveEncryptionKey, deriveEncryptionKeyV1,
+ // One Argon2 run, an AES handle and an HKDF handle. Whatever builds a
+ // `session.bundleKey` uses this, so `v2hkdf` is never the field one sign-in
+ // path forgot (docs/playlists.md §3.4).
+ deriveBundleKeys, bundleKeyPairFields: _bundleKeyPairFields,
// Account recovery key (docs/auth-confirm.md §4.3).
generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
new file mode 100644
index 0000000..0f41d1e
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
@@ -0,0 +1,155 @@
+/**
+ * Sealing a playlist blob, and opening one.
+ *
+ * `docs/playlists.md` §3.4 and §4.3. The node stores bytes it cannot read; this
+ * is what makes them unreadable, and what makes them small enough to be worth
+ * storing at all.
+ *
+ * The order is **compress → pad → seal**, and none of the three is optional:
+ *
+ * - *Compress*, because the payload is about as compressible as data gets —
+ * eleven keys repeat on every track, the group id repeats across the whole
+ * list, and path prefixes repeat per album. Three times is conservative for
+ * this shape, and without it a realistic Favourites list does not fit under
+ * any cap worth setting.
+ * - *Pad*, because the ciphertext length otherwise tells the operator roughly
+ * how many tracks this account has collected, and — once compression is in —
+ * roughly how repetitive its paths are. 4 KB granularity, which is cheap and
+ * is the only metadata this design leaks to a node that the node cannot
+ * already see.
+ * - *Seal*, last. Sealing first and compressing after would be compressing
+ * ciphertext, which does not compress; padding outside the AEAD would be
+ * padding nobody authenticated.
+ *
+ * `CompressionStream('deflate-raw')` is native in Chromium, Firefox 113+ and
+ * Safari 16.4+, and therefore in the Electron build. No dependency: the one
+ * compression helper this project has (`zipstream.js`) deliberately stores
+ * rather than deflates, for a different reason — archives are already
+ * compressed and this is text.
+ *
+ * No imports, and there must be none: the whole module is executed standalone
+ * by `test_playlist_crypto.py` against node's own WebCrypto, and a crypto layer
+ * that cannot be executed is a crypto layer nobody has checked.
+ */
+
+// The framing, ahead of the compressed bytes and inside the AEAD. One byte, so
+// a later change to the compression or the padding can be told from a blob
+// written before it rather than mis-parsed into nonsense.
+const FORMAT_V1 = 1;
+
+// The plaintext is padded up to a multiple of this before sealing.
+const PAD_TO = 4096;
+
+// AES-GCM. Ninety-six random bits, **never** a counter: two devices of one
+// account derive the same playlist key — that is the entire point — so a
+// counter would repeat, and a repeated nonce under one key is the one thing
+// GCM does not survive. Same reasoning already recorded for chat subkeys.
+const NONCE_BYTES = 12;
+
+/**
+ * The playlist key, from the HKDF handle over the bundle key.
+ *
+ * A purpose-separated subkey rather than the bundle key reused with a different
+ * AAD — the rule `groupbox.py` writes down for chunk keys, for the same reason.
+ * v2 only: playlists are new, so there is no legacy blob and no v1 branch to
+ * take by mistake.
+ */
+async function derivePlaylistKey(hkdfHandle) {
+ return crypto.subtle.deriveKey(
+ {
+ name: 'HKDF', hash: 'SHA-256',
+ salt: new Uint8Array(0),
+ info: new TextEncoder().encode('meshbay:playlists:v1'),
+ },
+ hkdfHandle, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+}
+
+/**
+ * What binds a sealed blob to its owner and its kind.
+ *
+ * `kind` is in here, not just `"playlists"`, so one playlist's body cannot be
+ * served in place of another's — a substitution that a bare kind would have
+ * allowed the moment there was more than one row. Mirrors
+ * `groupbox.associated_data`. It cannot prevent rollback; §6.4 is what does.
+ */
+function associatedData(kind, userId) {
+ return new TextEncoder().encode(`user_blob|${kind}|${userId}`);
+}
+
+async function _deflate(bytes) {
+ const cs = new CompressionStream('deflate-raw');
+ const stream = new Blob([bytes]).stream().pipeThrough(cs);
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+}
+
+async function _inflate(bytes) {
+ const ds = new DecompressionStream('deflate-raw');
+ const stream = new Blob([bytes]).stream().pipeThrough(ds);
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+}
+
+/**
+ * One object as the bytes the node stores.
+ *
+ * Layout, all of it inside the AEAD except the nonce, which is prepended:
+ *
+ * nonce(12) || AES-GCM( fmt(1) || len(4, BE) || deflate(json) || zeros )
+ *
+ * `len` is what lets the padding be stripped: the deflate stream would stop on
+ * its own, but a trailing run of zeros is a valid thing for the next reader to
+ * be confused by, and guessing is not a plan.
+ */
+async function seal(obj, kind, userId, key) {
+ const json = new TextEncoder().encode(JSON.stringify(obj));
+ const body = await _deflate(json);
+
+ const framed = new Uint8Array(5 + body.length);
+ framed[0] = FORMAT_V1;
+ new DataView(framed.buffer).setUint32(1, body.length, false);
+ framed.set(body, 5);
+
+ const padded = new Uint8Array(Math.ceil(framed.length / PAD_TO) * PAD_TO);
+ padded.set(framed);
+
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: associatedData(kind, userId) },
+ key, padded);
+
+ const out = new Uint8Array(NONCE_BYTES + ct.byteLength);
+ out.set(nonce);
+ out.set(new Uint8Array(ct), NONCE_BYTES);
+ return out;
+}
+
+/**
+ * The object back, or a throw.
+ *
+ * Every failure here throws rather than returning null: a blob that does not
+ * open is a wrong key, a tampered-with row or a kind served in place of
+ * another, and none of those should be quietly indistinguishable from "this
+ * account has no playlists yet" — which is what a `null` return would make them.
+ */
+async function open(bytes, kind, userId, key) {
+ const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+ if (buf.length <= NONCE_BYTES) throw new Error('playlist blob is truncated');
+
+ const plain = new Uint8Array(await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: buf.slice(0, NONCE_BYTES),
+ additionalData: associatedData(kind, userId) },
+ key, buf.slice(NONCE_BYTES)));
+
+ if (plain[0] !== FORMAT_V1) {
+ throw new Error(`playlist blob format ${plain[0]} is not readable here`);
+ }
+ const len = new DataView(plain.buffer, plain.byteOffset).getUint32(1, false);
+ if (len > plain.length - 5) throw new Error('playlist blob length is wrong');
+
+ const json = await _inflate(plain.slice(5, 5 + len));
+ return JSON.parse(new TextDecoder().decode(json));
+}
+
+export {
+ FORMAT_V1, PAD_TO, NONCE_BYTES,
+ derivePlaylistKey, associatedData, seal, open,
+};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js
new file mode 100644
index 0000000..ab67aa8
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-merge.js
@@ -0,0 +1,258 @@
+/**
+ * Playlists: the shapes, and reconciling N copies of them.
+ *
+ * `docs/playlists.md` §5 and §6. A playlist is stored as two things — one
+ * small **manifest** naming every playlist, and one **body** per playlist
+ * holding its tracks — and every device holds its own copy of both while
+ * nodes come and go. This is where the copies are put back together.
+ *
+ * The stated fear is correct for a single blob under last-writer-wins: node A
+ * is off while an edit is made, node B is off while the next one is, and one
+ * edit disappears with nothing to show for it. Four rules remove it, and they
+ * are the whole of this file:
+ *
+ * - **The unit is one playlist, not the collection.** Two playlists edited on
+ * two devices never collide.
+ * - **Revision counters order writes, never the wall clock.** Clocks across
+ * devices are not trustworthy, and `updated_at` is carried for display and
+ * never read here.
+ * - **A deletion is a tombstone, never an absence.** An absence means "this
+ * copy is older than the one that created it". Drop that and a node rehomed
+ * after three weeks resurrects every deleted playlist — the single most
+ * likely defect in the design, and it looks like a sync working correctly
+ * right up until it doesn't.
+ * - **The client is the authority.** Merge takes the maximum across local
+ * *plus* every node answering, so a node serving a stale copy can only lose
+ * the tie. It can never lower the merged state.
+ *
+ * No imports, and there must be none: the whole module is executed standalone
+ * by `test_playlist_merge.py`, the way `source-merge.js` is. The merge is the
+ * one part of this feature that can be properly tested, and it is untested
+ * from the moment it cannot be executed.
+ *
+ * JSON, not msgpack, for what goes inside a blob. The node never parses it —
+ * it stores bytes — so the encoding is this client's private choice rather
+ * than a wire format, and MNP's codec is private to transport.js, which is a
+ * classic script and cannot be imported here. Keeping the payload in reach of
+ * `JSON.parse` is what lets this module be run by `node` with nothing around it.
+ */
+
+// The reserved playlist. Favourites is a playlist, not a second mechanism —
+// deciding that here is what stops a parallel store being built beside this
+// one in three months. It is never created by the user, is materialised on
+// first use, and cannot be deleted (which is what keeps the tombstone rule
+// above free of an exception).
+const FAVORITES_ID = 'favorites';
+
+const MANIFEST_KIND = 'playlists';
+const bodyKind = (id) => `playlist:${id}`;
+
+// What a stored track reference carries, and nothing else. The entries the UI
+// hands over are live objects: `_tRef` is a transport and `_gRef` a CryptoKey,
+// attached by the Search page's merge. JSON-encoding a transport throws at
+// best; storing a stale one and reading it back a week later is worse, because
+// it looks like a connection and is not.
+//
+// Short keys because the same eleven repeat on every entry — see docs §4.1 for
+// what one costs.
+const TRACK_FIELDS = ['id', 'g', 'hv', 'n', 's', 'p', 't', 'a', 'b', 'd', 'tn'];
+
+/**
+ * An index entry (plus its `groupId`) as a stored track reference.
+ *
+ * `s` and `n` are not optional and their absence is not cosmetic: the player
+ * computes its chunk count from `entry.size` and reads `entry.name` both for
+ * the MIME type and to decide whether the file needs the node-side conversion
+ * first. Without them a playlist entry cannot be downloaded at all.
+ */
+function toStored(entry, groupId) {
+ return {
+ id: entry.id,
+ g: entry.groupId || groupId || '',
+ hv: entry.hash_version || 1,
+ n: entry.name || '',
+ s: entry.size || 0,
+ p: entry.path || '',
+ t: entry.display_title || entry.name || '',
+ a: entry.artist || '',
+ b: entry.album || '',
+ d: entry.duration || 0,
+ tn: entry.track_no || 0,
+ };
+}
+
+/** A stored reference back in the shape the player and the views consume. */
+function fromStored(e) {
+ return {
+ id: e.id,
+ groupId: e.g,
+ hash_version: e.hv || 1,
+ name: e.n,
+ size: e.s,
+ path: e.p,
+ display_title: e.t || e.n,
+ artist: e.a || null,
+ album: e.b || null,
+ duration: e.d || 0,
+ track_no: e.tn || null,
+ type: 'audio',
+ };
+}
+
+function emptyManifest() {
+ return { v: 1, playlists: {} };
+}
+
+function emptyBody(id) {
+ return { v: 1, id, rev: 0, device: '', tracks: [] };
+}
+
+/**
+ * Which of two revisions of the same thing wins.
+ *
+ * The higher `rev`; a tie goes to the lexicographically smaller `device`, so
+ * every device reaches the same answer without talking to any other. A tie is
+ * two devices writing the same revision from the same starting point, and one
+ * of the two edits is lost — stated plainly in docs §6.5 rather than papered
+ * over, because the alternative is an OR-Set and a much larger design.
+ */
+function _wins(x, y) {
+ const rx = x.rev || 0;
+ const ry = y.rev || 0;
+ if (rx !== ry) return rx > ry ? x : y;
+ return String(x.device || '') <= String(y.device || '') ? x : y;
+}
+
+/**
+ * Two manifests into one, per playlist.
+ *
+ * Two counters, deliberately. `rev` governs what the manifest itself says — the
+ * name, and whether this playlist is deleted. `body_rev` is a **watermark**: the
+ * highest body revision anybody has seen, merged by taking the maximum and never
+ * decreasing, with `count` following whichever side carries it.
+ *
+ * One counter would be simpler and is wrong. Rename a playlist on one device
+ * while adding a track to it on another and both write revision n+1; a single
+ * counter makes those collide and one of the two is lost, for two edits that
+ * do not overlap at all. Two counters let them merge independently, which is
+ * the common case rather than an exotic one.
+ */
+function mergeManifests(a, b) {
+ const pa = (a && a.playlists) || {};
+ const pb = (b && b.playlists) || {};
+ const out = { v: 1, playlists: {} };
+ for (const id of new Set([...Object.keys(pa), ...Object.keys(pb)])) {
+ const ea = pa[id];
+ const eb = pb[id];
+ if (!ea || !eb) { out.playlists[id] = ea || eb; continue; }
+ const named = _wins(ea, eb);
+ // The body watermark moves on its own, and only ever forward.
+ const bodied = (eb.body_rev || 0) > (ea.body_rev || 0) ? eb : ea;
+ out.playlists[id] = {
+ ...named,
+ body_rev: bodied.body_rev || 0,
+ count: bodied.count || 0,
+ };
+ }
+ return out;
+}
+
+/**
+ * Two copies of one playlist's tracks into one.
+ *
+ * A body whose manifest entry is a tombstone is not resurrected here: the
+ * manifest is the authority on existence, and `liveBodies` below is what drops
+ * the orphans. Keeping that decision out of this function is deliberate —
+ * a body merge that also had to know about deletion would need the manifest
+ * passed to it, and the two would drift.
+ */
+function mergeBodies(a, b) {
+ if (!a) return b || null;
+ if (!b) return a;
+ return _wins(a, b);
+}
+
+/** Every merged copy folded together — local first, then whatever each node had. */
+function mergeAllManifests(copies) {
+ return (copies || []).filter(Boolean).reduce(mergeManifests, emptyManifest());
+}
+
+/**
+ * Is this playlist gone?
+ *
+ * A tombstone is `deleted: true`, kept. Collected only once every known node
+ * reports a `rev` at or above the deleting one — immediate for a single-node
+ * account, eventual for a multi-node one. A tombstone is about forty bytes, so
+ * there is no hurry, and hurrying is what resurrects playlists.
+ */
+function isDeleted(entry) {
+ return !!(entry && entry.deleted);
+}
+
+/** The playlists a reader should see, newest name order, favourites first. */
+function livePlaylists(manifest) {
+ const ps = (manifest && manifest.playlists) || {};
+ const live = Object.entries(ps)
+ .filter(([, e]) => !isDeleted(e))
+ .map(([id, e]) => ({ id, ...e }));
+ live.sort((x, y) => {
+ // Favourites is always first, as the menus promise, whatever it is called
+ // in this reader's language.
+ if (x.id === FAVORITES_ID) return -1;
+ if (y.id === FAVORITES_ID) return 1;
+ return String(x.name || '').localeCompare(String(y.name || ''));
+ });
+ return live;
+}
+
+/**
+ * Repair entries against a live index, once, on the client.
+ *
+ * Content addressing survives a move; a path survives a re-encode. Keeping
+ * both means either can repair the other — an entry whose `id` is no longer in
+ * the index but whose path and filename are gets its id rewritten, and an
+ * entry whose id is there under a different path gets its path rewritten.
+ *
+ * `index` is `{ byId: Map(id -> {p, n}), byPath: Map("p/n" -> id) }` for one
+ * group. Entries from other groups are left alone: this index says nothing
+ * about them, and "not in the index I happen to be holding" is not evidence
+ * that a track is gone.
+ */
+function repairTracks(tracks, groupId, index) {
+ let repaired = 0;
+ const out = (tracks || []).map((e) => {
+ if (e.g !== groupId) return e;
+ const here = index.byId.get(e.id);
+ if (here) {
+ if (here.p === e.p && here.n === e.n) return e;
+ repaired++;
+ return { ...e, p: here.p, n: here.n };
+ }
+ const byPath = index.byPath.get(`${e.p}/${e.n}`);
+ if (byPath && byPath !== e.id) {
+ repaired++;
+ return { ...e, id: byPath };
+ }
+ return e;
+ });
+ return { tracks: out, repaired };
+}
+
+/** The `{ byId, byPath }` shape repairTracks wants, from a group's entries. */
+function indexTracks(entries) {
+ const byId = new Map();
+ const byPath = new Map();
+ for (const e of entries || []) {
+ if (e.type !== 'audio') continue;
+ byId.set(e.id, { p: e.path || '', n: e.name || '' });
+ byPath.set(`${e.path || ''}/${e.name || ''}`, e.id);
+ }
+ return { byId, byPath };
+}
+
+export {
+ FAVORITES_ID, MANIFEST_KIND, bodyKind, TRACK_FIELDS,
+ toStored, fromStored, emptyManifest, emptyBody,
+ mergeManifests, mergeBodies, mergeAllManifests,
+ isDeleted, livePlaylists, repairTracks, indexTracks,
+};
diff --git a/packages/meshbay-hub/tests/test_playlist_crypto.py b/packages/meshbay-hub/tests/test_playlist_crypto.py
new file mode 100644
index 0000000..4ff3080
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_crypto.py
@@ -0,0 +1,255 @@
+"""
+Sealing a playlist, and the three properties that make it worth doing.
+
+`playlist-crypto.js` is executed here against node's own WebCrypto — the real
+AES-GCM, the real deflate — because a crypto layer that cannot be executed is a
+crypto layer nobody has checked.
+
+What is asserted is not "it round-trips". That is the easy part and it would
+still pass with the compression, the padding and the AAD all removed. What is
+asserted is that each of the three is actually doing its job:
+
+ - **compression**, because without it a realistic Favourites list does not fit
+ under any cap worth setting (docs/playlists.md §4.2);
+ - **padding**, because the ciphertext length otherwise counts somebody's
+ tracks for the operator;
+ - **the AAD**, because it names the *kind*, and without that one playlist's
+ body can be served in place of another's.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SRC = STATIC / "playlist-crypto.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SRC.exists(),
+ reason="node or the SPA sources are not available")
+
+PRELUDE = """
+const KEY = await crypto.subtle.importKey(
+ 'raw', new Uint8Array(32).fill(7), { name: 'AES-GCM' }, false,
+ ['encrypt', 'decrypt']);
+const OTHER = await crypto.subtle.importKey(
+ 'raw', new Uint8Array(32).fill(9), { name: 'AES-GCM' }, false,
+ ['encrypt', 'decrypt']);
+
+// A playlist the shape people actually have. The repetition is real — a few
+// groups, albums of a dozen tracks, artists and paths that recur — but **every
+// `id` is a distinct random hex string**, because a blake3 hash is, and 64
+// random hex characters per entry do not compress at all.
+//
+// The first version of this fixture repeated one id on every track and
+// measured deflate at 23x. That number was a property of the fixture, not of a
+// playlist, and sizing the caps on it would have sized them on nothing.
+const hex = (n) => Array.from(
+ crypto.getRandomValues(new Uint8Array(n / 2)),
+ (b) => b.toString(16).padStart(2, '0')).join('');
+const GROUPS = Array.from({ length: 4 }, () => hex(32));
+const bigBody = (n) => ({
+ v: 1, id: 'favorites', rev: 3, device: 'dev-a',
+ tracks: Array.from({ length: n }, (_, i) => {
+ const al = Math.floor(i / 12);
+ return {
+ id: hex(64), g: GROUPS[al % GROUPS.length], hv: 1,
+ n: `${String((i % 12) + 1).padStart(2, '0')} - Un titre de morceau ${i}.flac`,
+ s: 30000000 + i,
+ p: `Quelque Artiste ${al % 40}/Un Album Assez Long ${al} (2019)`,
+ t: `Un titre de morceau ${i}`, a: `Quelque Artiste ${al % 40}`,
+ b: `Un Album Assez Long ${al}`, d: 180 + (i % 200), tn: (i % 12) + 1,
+ };
+ }),
+});
+"""
+
+
+def _run(tmp_path, body):
+ src = SRC.read_text().replace("export {", "const _unused_export = {")
+ script = tmp_path / "case.mjs"
+ script.write_text(f"{src}\n{PRELUDE}\n{body}\n")
+ out = subprocess.run(["node", str(script)],
+ capture_output=True, text=True, timeout=60)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+def test_a_playlist_round_trips(tmp_path):
+ out = _run(tmp_path, """
+ const body = bigBody(12);
+ const sealed = await seal(body, 'playlist:favorites', 'u1', KEY);
+ const back = await open(sealed, 'playlist:favorites', 'u1', KEY);
+ console.log(JSON.stringify({
+ same: JSON.stringify(back) === JSON.stringify(body),
+ tracks: back.tracks.length,
+ title: back.tracks[3].t,
+ }));
+ """)
+ assert out["same"] and out["tracks"] == 12
+ assert out["title"] == "Un titre de morceau 3"
+
+
+def test_compression_is_worth_the_factor_the_caps_assume(tmp_path):
+ """The caps are sized on deflate being worth about three on this shape. If
+ it stopped being applied — or the payload stopped being compressible — a
+ realistic Favourites list would silently stop fitting."""
+ out = _run(tmp_path, """
+ const body = bigBody(2000);
+ const raw = new TextEncoder().encode(JSON.stringify(body)).length;
+ const sealed = (await seal(body, 'playlist:favorites', 'u1', KEY)).length;
+ console.log(JSON.stringify({ raw, sealed, ratio: raw / sealed }));
+ """)
+ # Measured at about 4.5x on this shape (~270 bytes a track raw, ~60 sealed).
+ # The caps in webrtc_server.py are sized on three, so this is the margin.
+ assert out["ratio"] > 3, (
+ f"deflate is only worth {out['ratio']:.1f}x here; the caps in "
+ f"webrtc_server.py assume about three")
+ # 2000 tracks is a realistic Favourites list and must fit the 1 MB body cap
+ # with room to spare — at this ratio the cap is reached around 17000.
+ assert out["sealed"] < 1024 * 1024 / 4
+
+
+def test_the_sealed_length_is_padded_and_so_counts_nothing(tmp_path):
+ """Two playlists whose sizes differ by hundreds of tracks may share a
+ ciphertext length; one that differs by one track always does. What the
+ operator can read off the row is a 4 KB bucket, not a track count."""
+ out = _run(tmp_path, """
+ const lens = {};
+ for (const n of [1, 2, 10, 30]) {
+ lens[n] = (await seal(bigBody(n), 'playlist:x', 'u1', KEY)).length;
+ }
+ console.log(JSON.stringify(lens));
+ """)
+ lengths = {int(k): v for k, v in out.items()}
+ # Every length is the padding granularity plus the nonce and the tag.
+ for n, size in lengths.items():
+ assert (size - 12 - 16) % 4096 == 0, f"{n} tracks sealed to {size}"
+ assert lengths[1] == lengths[2] == lengths[10], (
+ "small playlists must be indistinguishable by length")
+
+
+def test_the_same_playlist_sealed_twice_is_two_different_blobs(tmp_path):
+ """A random nonce, never a counter: two devices of one account derive the
+ *same* key, so a counter would repeat — and a repeated nonce under one key
+ is the one thing GCM does not survive."""
+ out = _run(tmp_path, """
+ const body = bigBody(5);
+ const a = await seal(body, 'playlist:x', 'u1', KEY);
+ const b = await seal(body, 'playlist:x', 'u1', KEY);
+ const nonceA = Array.from(a.slice(0, 12)).join(',');
+ const nonceB = Array.from(b.slice(0, 12)).join(',');
+ console.log(JSON.stringify({
+ sameNonce: nonceA === nonceB,
+ sameBytes: Array.from(a).join(',') === Array.from(b).join(','),
+ allZero: nonceA === new Array(12).fill(0).join(','),
+ }));
+ """)
+ assert out["sameNonce"] is False
+ assert out["sameBytes"] is False
+ assert out["allZero"] is False
+
+
+def test_the_plaintext_is_not_in_the_sealed_bytes(tmp_path):
+ """The claim, checked rather than assumed."""
+ out = _run(tmp_path, """
+ const body = bigBody(20);
+ const sealed = await seal(body, 'playlist:x', 'u1', KEY);
+ const hay = new TextDecoder('latin1').decode(sealed);
+ console.log(JSON.stringify({
+ leaks: ['Quelque Artiste', 'Un Album Assez Long', 'tracks', 'favorites']
+ .filter((s) => hay.includes(s)),
+ }));
+ """)
+ assert out["leaks"] == []
+
+
+def test_another_key_cannot_open_it(tmp_path):
+ out = _run(tmp_path, """
+ const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY);
+ let opened = true;
+ try { await open(sealed, 'playlist:x', 'u1', OTHER); } catch { opened = false; }
+ console.log(JSON.stringify({ opened }));
+ """)
+ assert out["opened"] is False
+
+
+def test_one_playlists_body_cannot_be_served_as_another(tmp_path):
+ """Why the AAD names the kind and not just "playlists". The moment there
+ was more than one row, a bare kind let a node hand back the wrong body —
+ authenticated, and wrong."""
+ out = _run(tmp_path, """
+ const sealed = await seal(bigBody(3), 'playlist:evening', 'u1', KEY);
+ let asOther = true;
+ try { await open(sealed, 'playlist:drive', 'u1', KEY); } catch { asOther = false; }
+ let asManifest = true;
+ try { await open(sealed, 'playlists', 'u1', KEY); } catch { asManifest = false; }
+ console.log(JSON.stringify({ asOther, asManifest }));
+ """)
+ assert out["asOther"] is False
+ assert out["asManifest"] is False
+
+
+def test_another_account_is_named_in_the_aad_too(tmp_path):
+ out = _run(tmp_path, """
+ const sealed = await seal(bigBody(3), 'playlist:x', 'alice', KEY);
+ let opened = true;
+ try { await open(sealed, 'playlist:x', 'bob', KEY); } catch { opened = false; }
+ console.log(JSON.stringify({ opened }));
+ """)
+ assert out["opened"] is False
+
+
+def test_a_flipped_byte_is_refused_rather_than_returned(tmp_path):
+ out = _run(tmp_path, """
+ const sealed = await seal(bigBody(3), 'playlist:x', 'u1', KEY);
+ sealed[40] ^= 0xff;
+ let opened = true;
+ try { await open(sealed, 'playlist:x', 'u1', KEY); } catch { opened = false; }
+ console.log(JSON.stringify({ opened }));
+ """)
+ assert out["opened"] is False
+
+
+def test_an_unreadable_blob_throws_rather_than_reading_as_empty(tmp_path):
+ """A wrong key, a tampered row and a kind served in place of another must
+ not be quietly indistinguishable from "this account has no playlists yet" —
+ which is exactly what returning null would make them."""
+ out = _run(tmp_path, """
+ const cases = {};
+ for (const [name, bytes] of Object.entries({
+ empty: new Uint8Array(0),
+ short: new Uint8Array(8),
+ garbage: crypto.getRandomValues(new Uint8Array(200)),
+ })) {
+ try { await open(bytes, 'playlists', 'u1', KEY); cases[name] = 'returned'; }
+ catch { cases[name] = 'threw'; }
+ }
+ console.log(JSON.stringify(cases));
+ """)
+ assert out == {"empty": "threw", "short": "threw", "garbage": "threw"}
+
+
+def test_a_blob_from_a_future_format_is_refused_by_name(tmp_path):
+ """The framing byte exists so a later change to the compression or the
+ padding can be told from a blob written before it, rather than mis-parsed
+ into nonsense."""
+ out = _run(tmp_path, """
+ // A well-formed blob whose framing byte says a version this build does
+ // not know: sealed correctly, so it is the framing check that refuses it.
+ const padded = new Uint8Array(PAD_TO);
+ padded[0] = 99;
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: associatedData('playlists', 'u1') },
+ KEY, padded);
+ const blob = new Uint8Array(NONCE_BYTES + ct.byteLength);
+ blob.set(nonce); blob.set(new Uint8Array(ct), NONCE_BYTES);
+ let why = null;
+ try { await open(blob, 'playlists', 'u1', KEY); } catch (e) { why = e.message; }
+ console.log(JSON.stringify({ why }));
+ """)
+ assert out["why"] and "format 99" in out["why"]
diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py
new file mode 100644
index 0000000..dbed8ae
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_key.py
@@ -0,0 +1,221 @@
+"""
+The playlist key: one Argon2 run, two handles, one subkey.
+
+Identity keys are per node, so a blob encrypted under one is unreadable from
+every other node — the precise opposite of what a playlist needs. The only
+secret an account holds *everywhere* is the bundle key, so the playlist key is
+derived from it with HKDF (docs/playlists.md §3.4).
+
+Three things have to hold, and getting any of them wrong is quiet:
+
+ - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that
+ path. A second call is mathematically pointless and doubles it, and nothing
+ on screen would say so.
+ - **The HKDF handle is a second import of the same bytes**, not a derivation
+ from the AES one — that is imported non-extractably with
+ `['encrypt','decrypt']`, from which nothing can be derived at all.
+ - **A purpose-separated subkey**, not the bundle key with a different AAD.
+ `groupbox.py` writes that rule down for chunk keys; it is the same rule.
+
+Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and
+stubbed precisely so the calls can be counted.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+KEYDERIVE = STATIC / "keyderive.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not KEYDERIVE.exists(),
+ reason="node or the SPA sources are not available")
+
+# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has
+# neither, and a counted stub is the whole point.
+PRELUDE = """
+globalThis.window = globalThis;
+let argonCalls = 0;
+globalThis.argon2 = {
+ ArgonType: { Argon2id: 2 },
+ async hash(opts) {
+ argonCalls++;
+ // Deterministic, and a function of what was actually passed, so a changed
+ // salt domain or cost parameter shows up as different bytes rather than
+ // silently agreeing.
+ const seed = new TextEncoder().encode(
+ opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time);
+ const digest = new Uint8Array(
+ await crypto.subtle.digest('SHA-256', seed));
+ return { hash: digest };
+ },
+};
+"""
+
+
+def _run(tmp_path, body):
+ src = KEYDERIVE.read_text()
+ script = tmp_path / "case.mjs"
+ script.write_text(f"{PRELUDE}\n{src}\n{body}\n")
+ out = subprocess.run(["node", str(script)],
+ capture_output=True, text=True, timeout=60)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+def test_a_sign_in_runs_argon2_exactly_once(tmp_path):
+ """The budget is the 650 ms already on the sign-in path. Two handles over
+ one run is the whole point of `deriveBundleKeys`."""
+ out = _run(tmp_path, """
+ argonCalls = 0;
+ const keys = await deriveBundleKeys('passphrase', 'someone');
+ console.log(JSON.stringify({
+ calls: argonCalls,
+ aes: keys.aes.algorithm.name,
+ hkdf: keys.hkdf.algorithm.name,
+ }));
+ """)
+ assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost"
+ assert out["aes"] == "AES-GCM"
+ assert out["hkdf"] == "HKDF"
+
+
+def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path):
+ """Both places that build a `session.bundleKey` go through this, so
+ `v2hkdf` cannot be the field one sign-in path forgot."""
+ out = _run(tmp_path, """
+ argonCalls = 0;
+ const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone');
+ console.log(JSON.stringify({
+ calls: argonCalls,
+ keys: Object.keys(fields).sort(),
+ v2: fields.v2.algorithm.name,
+ v2hkdf: fields.v2hkdf.algorithm.name,
+ }));
+ """)
+ assert out["calls"] == 1
+ assert out["keys"] == ["v2", "v2hkdf"]
+ assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF"
+
+
+def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path):
+ """`deriveEncryptionKey` still returns exactly what it always did — every
+ identity bundle already written is opened with it."""
+ out = _run(tmp_path, """
+ const legacy = await deriveEncryptionKey('p', 'someone');
+ const paired = (await deriveBundleKeys('p', 'someone')).aes;
+ const data = new TextEncoder().encode('a keypair bundle');
+ const iv = new Uint8Array(12);
+ const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data);
+ const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct);
+ console.log(JSON.stringify({
+ same: new TextDecoder().decode(back) === 'a keypair bundle',
+ extractable: legacy.extractable,
+ }));
+ """)
+ assert out["same"], "the paired AES handle is not the same key as before"
+ assert out["extractable"] is False
+
+
+def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path):
+ """Derived under its own `info`, so what opens a playlist opens nothing
+ else — and cannot be produced from the AES handle at all."""
+ out = _run(tmp_path, """
+ const { aes, hkdf } = await deriveBundleKeys('p', 'someone');
+ const playlistKey = await crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode('meshbay:playlists:v1') },
+ hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+
+ const iv = new Uint8Array(12);
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks'));
+
+ // The bundle key must not open what the playlist key sealed.
+ let bundleOpens = true;
+ try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); }
+ catch { bundleOpens = false; }
+
+ // And nothing can be derived from the AES handle, which is why the HKDF
+ // one has to be a second import rather than a derivation.
+ let derivable = true;
+ try {
+ await crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new Uint8Array(0) },
+ aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
+ } catch { derivable = false; }
+
+ console.log(JSON.stringify({ bundleOpens, derivable }));
+ """)
+ assert out["bundleOpens"] is False, (
+ "the playlist key is the bundle key — purpose separation is gone")
+ assert out["derivable"] is False, (
+ "if the AES handle were derivable the second import would be needless; "
+ "it is not, which is exactly why deriveBundleKeys imports twice")
+
+
+def test_a_different_info_gives_a_different_key(tmp_path):
+ """What makes it a *purpose*-separated subkey rather than a rename."""
+ out = _run(tmp_path, """
+ const { hkdf } = await deriveBundleKeys('p', 'someone');
+ const mk = (info) => crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode(info) },
+ hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ const a = await mk('meshbay:playlists:v1');
+ const b = await mk('meshbay:something-else:v1');
+ const iv = new Uint8Array(12);
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, a, new TextEncoder().encode('x'));
+ let opens = true;
+ try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); }
+ catch { opens = false; }
+ console.log(JSON.stringify({ opens }));
+ """)
+ assert out["opens"] is False
+
+
+def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path):
+ """The whole point, and the reason the nonce must be random rather than a
+ counter: two devices derive the *same* key, so a counter would repeat."""
+ out = _run(tmp_path, """
+ const mk = async () => {
+ const { hkdf } = await deriveBundleKeys('same passphrase', 'someone');
+ return crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode('meshbay:playlists:v1') },
+ hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ };
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening'));
+ const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct);
+ console.log(JSON.stringify({ text: new TextDecoder().decode(back) }));
+ """)
+ assert out["text"] == "Evening"
+
+
+def test_a_different_account_derives_a_different_key(tmp_path):
+ """The salt is domain-separated per user; this is what that buys."""
+ out = _run(tmp_path, """
+ const mk = async (user) => {
+ const { hkdf } = await deriveBundleKeys('p', user);
+ return crypto.subtle.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode('meshbay:playlists:v1') },
+ hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ };
+ const iv = new Uint8Array(12);
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x'));
+ let opens = true;
+ try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); }
+ catch { opens = false; }
+ console.log(JSON.stringify({ opens }));
+ """)
+ assert out["opens"] is False
diff --git a/packages/meshbay-hub/tests/test_playlist_merge.py b/packages/meshbay-hub/tests/test_playlist_merge.py
new file mode 100644
index 0000000..2c7b612
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_playlist_merge.py
@@ -0,0 +1,398 @@
+"""
+Reconciling N copies of a playlist when nodes are ON and OFF.
+
+This is the one part of the playlist design that can be properly tested, so
+`playlist-merge.js` is kept free of imports and the whole module is executed
+here — a copy of the merge rules in a test would keep agreeing with the
+original right up until one of them changed.
+
+The stated fear is well founded for a single blob under last-writer-wins: node
+A is off while an edit is made, node B is off while the next one is, and one
+edit disappears with nothing to show for it. Four rules remove it, and each one
+has its own case below. The tombstone rule has two, because dropping it does
+not break anything that looks broken: a node rehomed after three weeks quietly
+resurrects every deleted playlist, and it reads as a sync working correctly
+right up until it doesn't.
+
+See docs/playlists.md §5, §6.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+SRC = STATIC / "playlist-merge.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not SRC.exists(),
+ reason="node or the SPA sources are not available")
+
+IMPORT = re.compile(r"^\s*import\b", re.M)
+# The export list spans several lines here; source-merge.js's fits on one.
+EXPORT = re.compile(r"^export \{[^}]*\};?\s*$", re.M | re.S)
+
+
+@pytest.fixture(scope="module")
+def module_source():
+ text = SRC.read_text()
+ assert not IMPORT.search(text), (
+ "playlist-merge.js has gained an import. It is executed standalone "
+ "here, and the merge is untested from the moment it cannot be — keep "
+ "the module free of imports, or this test needs a bundler")
+ stripped, n = EXPORT.subn("", text)
+ assert n == 1, (
+ "playlist-merge.js no longer ends in a single export statement — the "
+ "test can no longer strip it to run the module")
+ return stripped
+
+
+def _run(tmp_path, module_source, body):
+ script = tmp_path / "case.js"
+ script.write_text(f"{module_source}\n{body}\n")
+ out = subprocess.run(
+ ["node", str(script)], capture_output=True, text=True, timeout=30)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+def _eval(tmp_path, module_source, expr):
+ return _run(tmp_path, module_source,
+ f"console.log(JSON.stringify({expr}));")
+
+
+def _entry(name, rev, device, *, body_rev=0, count=0, deleted=False):
+ return {"name": name, "rev": rev, "device": device,
+ "body_rev": body_rev, "count": count, "deleted": deleted,
+ # Carried for display and never read by the merge. Deliberately
+ # set backwards from `rev` in several cases below: a merge that
+ # reads the clock gives the wrong answer for all of them.
+ "updated_at": 1_000_000 - rev}
+
+
+def _manifest(**playlists):
+ return {"v": 1, "playlists": playlists}
+
+
+def _merge(tmp_path, src, a, b):
+ return _eval(tmp_path, src,
+ f"mergeManifests({json.dumps(a)}, {json.dumps(b)})")
+
+
+# ── the unit is one playlist, not the collection ─────────────────────────────
+
+def test_two_playlists_edited_on_two_devices_both_survive(tmp_path, module_source):
+ """The overwhelmingly common case for one person with two devices, and the
+ whole reason the blob is a map rather than a list."""
+ a = _manifest(evening=_entry("Evening", 4, "dev-a"),
+ drive=_entry("Drive", 1, "dev-a"))
+ b = _manifest(evening=_entry("Evening", 1, "dev-b"),
+ drive=_entry("Drive", 7, "dev-b"))
+ out = _merge(tmp_path, module_source, a, b)["playlists"]
+ assert out["evening"]["rev"] == 4 and out["evening"]["device"] == "dev-a"
+ assert out["drive"]["rev"] == 7 and out["drive"]["device"] == "dev-b"
+
+
+def test_a_playlist_only_one_side_has_is_kept(tmp_path, module_source):
+ """A node that has never seen a playlist must not delete it by omission —
+ which is the same rule as the tombstone one, from the other end."""
+ a = _manifest(evening=_entry("Evening", 2, "dev-a"))
+ b = _manifest(drive=_entry("Drive", 1, "dev-b"))
+ out = _merge(tmp_path, module_source, a, b)["playlists"]
+ assert set(out) == {"evening", "drive"}
+
+
+# ── revisions, never the wall clock ──────────────────────────────────────────
+
+def test_the_higher_revision_wins_even_with_an_older_timestamp(
+ tmp_path, module_source):
+ """`updated_at` is set backwards from `rev` throughout this file. A merge
+ that reads the clock fails here, and clocks across devices are exactly as
+ trustworthy as this test assumes."""
+ a = _manifest(x=_entry("new name", 9, "dev-a"))
+ b = _manifest(x=_entry("old name", 2, "dev-b"))
+ out = _merge(tmp_path, module_source, a, b)["playlists"]["x"]
+ assert out["name"] == "new name"
+ assert a["playlists"]["x"]["updated_at"] < b["playlists"]["x"]["updated_at"], (
+ "the fixture must actually have the older clock on the winning side")
+
+
+def test_a_revision_tie_is_broken_the_same_way_on_every_device(
+ tmp_path, module_source):
+ """Two devices, no conversation between them, one answer. Merged in both
+ orders because a tie-break that depends on argument order is not one."""
+ a = _manifest(x=_entry("from A", 5, "dev-a"))
+ b = _manifest(x=_entry("from B", 5, "dev-b"))
+ forward = _merge(tmp_path, module_source, a, b)["playlists"]["x"]
+ backward = _merge(tmp_path, module_source, b, a)["playlists"]["x"]
+ assert forward == backward
+ assert forward["device"] == "dev-a"
+
+
+# ── a deletion is a tombstone, never an absence ──────────────────────────────
+
+def test_a_node_rehomed_after_three_weeks_does_not_resurrect_a_deletion(
+ tmp_path, module_source):
+ """The single most likely defect in the whole design.
+
+ The client deleted "Evening" (rev 5, tombstoned). A node that went offline
+ at rev 4 comes back still holding it, alive. Absence on that node must not
+ win, and the tombstone must not be dropped just because the other side has
+ a live entry.
+ """
+ local = _manifest(evening=_entry("Evening", 5, "dev-a", deleted=True))
+ stale_node = _manifest(evening=_entry("Evening", 4, "dev-a"))
+ out = _merge(tmp_path, module_source, local, stale_node)["playlists"]
+ assert out["evening"]["deleted"] is True, "the deleted playlist came back"
+
+
+def test_a_deletion_loses_to_a_later_edit(tmp_path, module_source):
+ """A tombstone is not special-cased into always winning: it is one more
+ revision. Someone who deletes a playlist and then, from another device that
+ had not seen the deletion, renames it at a higher revision, gets the
+ rename — which is last-writer-wins doing exactly what it says."""
+ deleted = _manifest(x=_entry("X", 5, "dev-a", deleted=True))
+ later = _manifest(x=_entry("X renamed", 6, "dev-b"))
+ out = _merge(tmp_path, module_source, deleted, later)["playlists"]["x"]
+ assert out["deleted"] is False and out["name"] == "X renamed"
+
+
+def test_a_tombstone_is_not_shown_to_the_reader(tmp_path, module_source):
+ m = _manifest(gone=_entry("Gone", 3, "dev-a", deleted=True),
+ kept=_entry("Kept", 1, "dev-a"))
+ live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})")
+ assert [p["id"] for p in live] == ["kept"]
+
+
+# ── the two counters ─────────────────────────────────────────────────────────
+
+def test_a_rename_and_a_track_added_elsewhere_both_survive(
+ tmp_path, module_source):
+ """Why there are two counters rather than one.
+
+ Device A renames the playlist; device B adds a track to it. Neither edit
+ touches the other, but with a single revision counter both write n+1 and
+ one of them is lost. `rev` carries the name, `body_rev` is a watermark for
+ the tracks, and they move independently.
+ """
+ renamed = _manifest(x=_entry("New name", 8, "dev-a", body_rev=3, count=10))
+ tracked = _manifest(x=_entry("Old name", 7, "dev-b", body_rev=4, count=11))
+ out = _merge(tmp_path, module_source, renamed, tracked)["playlists"]["x"]
+ assert out["name"] == "New name", "the rename was lost"
+ assert out["body_rev"] == 4 and out["count"] == 11, "the added track was lost"
+
+
+def test_the_body_watermark_never_goes_backwards(tmp_path, module_source):
+ """A stale node reporting body_rev 2 against a local 9 must not lower it —
+ the local copy is one of the inputs, and that is what stops a node that
+ serves an old copy from being able to undo anything."""
+ local = _manifest(x=_entry("X", 4, "dev-a", body_rev=9, count=40))
+ stale = _manifest(x=_entry("X", 4, "dev-a", body_rev=2, count=5))
+ out = _merge(tmp_path, module_source, local, stale)["playlists"]["x"]
+ assert out["body_rev"] == 9 and out["count"] == 40
+
+
+# ── the client is the authority ──────────────────────────────────────────────
+
+def test_the_offline_node_dance_loses_nothing(tmp_path, module_source):
+ """The scenario the fear is actually about, played out.
+
+ Device 1 renames Evening while node B is off; device 2 renames Drive while
+ node A is off. Each node holds one of the two edits. The client folds its
+ own copy together with both and keeps both edits — which it can only do
+ because its own copy is one of the inputs.
+ """
+ body = """
+ const base = {v:1, playlists: {
+ evening: {name:'Evening', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false},
+ drive: {name:'Drive', rev:1, device:'dev-a', body_rev:1, count:2, deleted:false}}};
+ const nodeA = JSON.parse(JSON.stringify(base));
+ nodeA.playlists.evening = {...nodeA.playlists.evening, name:'Soirée', rev:2, device:'dev-1'};
+ const nodeB = JSON.parse(JSON.stringify(base));
+ nodeB.playlists.drive = {...nodeB.playlists.drive, name:'Route', rev:2, device:'dev-2'};
+ const merged = mergeAllManifests([base, nodeA, nodeB]);
+ console.log(JSON.stringify({
+ evening: merged.playlists.evening.name,
+ drive: merged.playlists.drive.name}));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out == {"evening": "Soirée", "drive": "Route"}
+
+
+def test_folding_in_any_order_gives_the_same_answer(tmp_path, module_source):
+ """Nodes answer in whatever order they answer in; the merged state cannot
+ depend on that."""
+ body = """
+ const c = [
+ {v:1, playlists:{x:{name:'a', rev:1, device:'d1', body_rev:1, count:1, deleted:false}}},
+ {v:1, playlists:{x:{name:'b', rev:3, device:'d2', body_rev:5, count:9, deleted:false}}},
+ {v:1, playlists:{x:{name:'c', rev:2, device:'d3', body_rev:2, count:4, deleted:false}}},
+ ];
+ const one = mergeAllManifests(c);
+ const two = mergeAllManifests([c[2], c[0], c[1]]);
+ const three = mergeAllManifests([c[1], c[2], c[0]]);
+ console.log(JSON.stringify([one, two, three]));
+ """
+ one, two, three = _run(tmp_path, module_source, body)
+ assert one == two == three
+ assert one["playlists"]["x"]["name"] == "b"
+
+
+# ── bodies ───────────────────────────────────────────────────────────────────
+
+def test_a_body_merge_takes_the_higher_revision(tmp_path, module_source):
+ a = {"v": 1, "id": "x", "rev": 4, "device": "d1", "tracks": [{"id": "t1"}]}
+ b = {"v": 1, "id": "x", "rev": 6, "device": "d2",
+ "tracks": [{"id": "t1"}, {"id": "t2"}]}
+ out = _eval(tmp_path, module_source,
+ f"mergeBodies({json.dumps(a)}, {json.dumps(b)})")
+ assert out["rev"] == 6 and len(out["tracks"]) == 2
+
+
+def test_a_body_a_node_has_never_seen_is_not_an_absence(tmp_path, module_source):
+ a = {"v": 1, "id": "x", "rev": 3, "device": "d1", "tracks": [{"id": "t1"}]}
+ assert _eval(tmp_path, module_source,
+ f"mergeBodies({json.dumps(a)}, null)")["rev"] == 3
+ assert _eval(tmp_path, module_source,
+ f"mergeBodies(null, {json.dumps(a)})")["rev"] == 3
+
+
+# ── what is stored, and what must not be ─────────────────────────────────────
+
+def test_a_stored_track_carries_the_fields_the_player_cannot_work_without(
+ tmp_path, module_source):
+ """`s` and `n` are the two the first draft of the design left out. The
+ player computes its chunk count from the size and reads the name both for
+ the MIME type and to decide whether the file needs converting first, so
+ without them a playlist entry cannot be downloaded at all."""
+ entry = {"id": "abc", "name": "03 - A Track.flac", "size": 41238711,
+ "path": "Artist/Album", "type": "audio", "duration": 214,
+ "display_title": "A Track", "artist": "Artist", "album": "Album",
+ "track_no": 3, "hash_version": 2}
+ stored = _eval(tmp_path, module_source,
+ f"toStored({json.dumps(entry)}, 'g1')")
+ assert stored["s"] == 41238711
+ assert stored["n"] == "03 - A Track.flac"
+ assert stored["g"] == "g1" and stored["hv"] == 2
+
+
+def test_live_objects_are_never_stored(tmp_path, module_source):
+ """The entries the views hand over carry a transport and a CryptoKey,
+ attached by the Search page's merge. Storing one is at best unserialisable
+ and at worst a dead connection read back a week later."""
+ body = """
+ const entry = { id:'abc', name:'x.flac', size:1, path:'p', type:'audio',
+ groupId:'g9', _tRef:{live:'transport'}, _gRef:{key:true},
+ _origPath:'elsewhere', _sources:[{groupId:'g9'}] };
+ const stored = toStored(entry, 'g1');
+ console.log(JSON.stringify({ keys: Object.keys(stored).sort(),
+ g: stored.g, json: JSON.stringify(stored) }));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out["keys"] == sorted(["id", "g", "hv", "n", "s", "p", "t", "a", "b", "d", "tn"])
+ assert out["g"] == "g9", "the entry's own group must win over the caller's"
+ for leak in ("_tRef", "_gRef", "_origPath", "_sources", "transport"):
+ assert leak not in out["json"]
+
+
+def test_a_stored_track_round_trips_to_what_the_player_consumes(
+ tmp_path, module_source):
+ body = """
+ const entry = { id:'abc', name:'03 - A Track.flac', size:99, path:'A/B',
+ type:'audio', duration:214, display_title:'A Track',
+ artist:'Artist', album:'Album', track_no:3,
+ hash_version:1, groupId:'g1' };
+ console.log(JSON.stringify(fromStored(toStored(entry, 'g1'))));
+ """
+ out = _run(tmp_path, module_source, body)
+ for field in ("id", "name", "size", "path", "duration", "display_title",
+ "artist", "album", "track_no", "hash_version"):
+ assert out[field] == {**{"display_title": "A Track"},
+ **{"id": "abc", "name": "03 - A Track.flac",
+ "size": 99, "path": "A/B", "duration": 214,
+ "artist": "Artist", "album": "Album",
+ "track_no": 3, "hash_version": 1}}[field]
+ assert out["groupId"] == "g1"
+
+
+# ── repair ───────────────────────────────────────────────────────────────────
+
+def test_a_moved_file_is_found_by_its_hash_and_its_path_rewritten(
+ tmp_path, module_source):
+ """Content addressing survives a move."""
+ body = """
+ const index = indexTracks([
+ {id:'t1', type:'audio', path:'New/Place', name:'a.flac'}]);
+ const { tracks, repaired } = repairTracks(
+ [{id:'t1', g:'g1', p:'Old/Place', n:'a.flac'}], 'g1', index);
+ console.log(JSON.stringify({tracks, repaired}));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out["repaired"] == 1
+ assert out["tracks"][0]["p"] == "New/Place"
+
+
+def test_a_re_encoded_file_is_found_by_its_path_and_its_hash_rewritten(
+ tmp_path, module_source):
+ """A path survives a re-encode. Either field can repair the other, which is
+ the whole reason both are stored."""
+ body = """
+ const index = indexTracks([
+ {id:'t1-new', type:'audio', path:'A/B', name:'a.flac'}]);
+ const { tracks, repaired } = repairTracks(
+ [{id:'t1-old', g:'g1', p:'A/B', n:'a.flac'}], 'g1', index);
+ console.log(JSON.stringify({tracks, repaired}));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out["repaired"] == 1
+ assert out["tracks"][0]["id"] == "t1-new"
+
+
+def test_entries_from_other_groups_are_left_alone(tmp_path, module_source):
+ """"Not in the index I happen to be holding" is not evidence that a track
+ is gone — it is evidence about a different group."""
+ body = """
+ const index = indexTracks([{id:'x', type:'audio', path:'A', name:'a.flac'}]);
+ const { tracks, repaired } = repairTracks(
+ [{id:'t1', g:'g2', p:'A', n:'a.flac'}], 'g1', index);
+ console.log(JSON.stringify({tracks, repaired}));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out["repaired"] == 0
+ assert out["tracks"][0]["id"] == "t1"
+
+
+def test_an_entry_that_is_simply_gone_is_left_as_it_is(tmp_path, module_source):
+ """Greyed at play time, not deleted from the playlist: a file that is
+ missing today may be a disk that is unplugged today."""
+ body = """
+ const index = indexTracks([{id:'other', type:'audio', path:'Z', name:'z.flac'}]);
+ const { tracks, repaired } = repairTracks(
+ [{id:'t1', g:'g1', p:'A', n:'a.flac'}], 'g1', index);
+ console.log(JSON.stringify({tracks, repaired}));
+ """
+ out = _run(tmp_path, module_source, body)
+ assert out["repaired"] == 0 and out["tracks"][0]["id"] == "t1"
+
+
+# ── favourites ───────────────────────────────────────────────────────────────
+
+def test_favourites_is_first_whatever_it_is_called(tmp_path, module_source):
+ """The menus promise it first, and the reserved id is what is stored — the
+ localised name is never written, or an account that switches language
+ grows a second favourites list."""
+ m = _manifest(zzz=_entry("Zzz", 1, "d"),
+ aaa=_entry("Aaa", 1, "d"),
+ favorites=_entry("Favoris", 1, "d"))
+ live = _eval(tmp_path, module_source, f"livePlaylists({json.dumps(m)})")
+ assert [p["id"] for p in live] == ["favorites", "aaa", "zzz"]
+
+
+def test_the_reserved_id_is_a_constant_not_a_literal(tmp_path, module_source):
+ assert _eval(tmp_path, module_source, "FAVORITES_ID") == "favorites"
+ assert _eval(tmp_path, module_source, "bodyKind('favorites')") == "playlist:favorites"
+ assert _eval(tmp_path, module_source, "MANIFEST_KIND") == "playlists"