From 18d50af415e5d9ce5c4578b0aa38e0f1450b2d54 Mon Sep 17 00:00:00 2001 From: Drumee Dev Date: Thu, 1 Oct 2026 19:46:28 -0700 Subject: [PATCH] fix(media): a hub admin's workspace rename reaches every member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media.rename on a hub only renamed the caller's desk row and pushed to the caller's sockets, so other members never saw the new name — and the dest it built for any recipient was read from the caller's db. When the caller is admin on the hub (user_permission & ADMIN), rename now calls yp.hub_rename_for_members, reads each member's node back from THEIR db, and pushes to entity_sockets(hub_id). Below admin it stays a personal rename. If the shared step fails, the caller's own rename still answers. Needs yp.hub_rename_for_members (schemas fix/workspace-rename-sync). Co-Authored-By: Claude Opus 5.5 (1M context) --- service/private/media.js | 60 +++++++++++- test/hub-rename-members.test.js | 157 ++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 test/hub-rename-members.test.js diff --git a/service/private/media.js b/service/private/media.js index 4fb7bfa8..fc2ef98d 100644 --- a/service/private/media.js +++ b/service/private/media.js @@ -2865,7 +2865,9 @@ class __private_media extends Media { let newItems = {}; let recipients; - /** Renaming hubname must not change other's name*/ + /** A hub row lives on the caller's desk: start with the caller's own + * sockets. A hub admin's rename widens this to every member in + * renameHubForMembers; anyone else renames only their own label. */ if (node[FILETYPE] == Attr.hub) { recipients = await this.yp.await_proc( "entity_sockets", @@ -2917,6 +2919,16 @@ class __private_media extends Media { await this.changelog_write({ src: old, dest: attr }); } } + if (node[FILETYPE] == Attr.hub) { + // The caller's own rename has already landed; a failure here must not + // turn it into an error, so it degrades to the personal rename. + try { + const members = await this.renameHubForMembers(nid, filename, oldItems, newItems); + if (members) recipients = members; + } catch (e) { + this.warn("media.rename: could not rename the workspace for its members", e); + } + } for (let r of toArray(recipients)) { let dest; if (newItems[r.uid] && newItems[r.uid].filename) { @@ -2961,6 +2973,52 @@ class __private_media extends Media { this.output.data(model); } + /** + * A hub admin's rename reaches EVERY member of the workspace. + * + * Each member's desk holds its own row for the workspace and the desk lists + * that row's name, so renaming only the caller's row (above) left everyone + * else on the old name. yp.hub_rename_for_members writes yp.hub.name and + * every member's row; each member's node is then read back from THEIR own + * db, because this.db is the caller's desk and answers the caller's name. + * + * Anyone below admin keeps the personal rename: only their own label moves. + * + * @returns the hub's member sockets to push to, or null for a personal rename + */ + async renameHubForMembers(hub_id, filename, oldItems, newItems) { + const hub = firstRow( + await this.yp.await_query("SELECT db_name FROM entity WHERE id=?", hub_id) + ); + if (!hub || !hub.db_name) return null; + const hubDb = hub.db_name; + const privilege = await this.yp.await_func( + `${hubDb}.user_permission`, this.uid, "*" + ); + if (!(Number(privilege) & Permission.ADMIN)) return null; + + const members = toArray( + await this.yp.await_proc("hub_rename_for_members", hub_id, filename) + ); + for (const m of members) { + if (!m || !m.uid || !m.db_name) continue; + // The caller's row was renamed and read back by the main path. + if (m.uid == this.uid && newItems[m.uid]) continue; + const dest = await this.yp.await_proc( + `${m.db_name}.mfs_access_node`, m.uid, hub_id + ); + if (!dest || !dest.filename) continue; + dest.hub_id = dest.actual_hub_id; + dest.privilege = dest.permission; + dest.home_id = dest.actual_home_id; + newItems[m.uid] = dest; + if (!oldItems[m.uid]) { + oldItems[m.uid] = { ...dest, filename: m.old_filename, fname: m.old_filename }; + } + } + return this.yp.await_proc("entity_sockets", hub_id); + } + /** * Not used */ diff --git a/test/hub-rename-members.test.js b/test/hub-rename-members.test.js new file mode 100644 index 00000000..2cf1e1b1 --- /dev/null +++ b/test/hub-rename-members.test.js @@ -0,0 +1,157 @@ +// media.rename on a WORKSPACE reaches every member when a hub admin does it. +// +// Each member's desk holds its own row for a workspace (media, category hub) +// and the desk lists that row's name. rename() only ever ran mfs_rename on the +// CALLER's desk and pushed to the CALLER's sockets, so every other member kept +// the old name — and the dest it would have pushed them was read from the +// caller's db anyway. +// +// A hub admin's rename now also calls yp.hub_rename_for_members, reads each +// member's node back from THEIR db, and pushes to the hub's sockets. Anyone +// below admin keeps the personal rename. +// +// Run: node --test test/hub-rename-members.test.js +const assert = require("node:assert/strict"); +const test = require("node:test"); + +global.myDrumee = { arch: "pod", useEmail: 0 }; +global.verbosity = 0; +global.debug = {}; + +const { RedisStore, Attr } = require("@drumee/server-essentials"); +const MediaPrivate = require("../service/private/media"); + +const sent = []; +RedisStore.sendData = async (payload, dest) => { + sent.push({ payload, dest }); +}; + +const HUB_ID = "hub-1"; +const CALLER = { uid: "u1", db: "db_u1" }; +const MEMBER = { uid: "u2", db: "db_u2" }; + +function node(uid, filename) { + return { + nid: HUB_ID, + id: HUB_ID, + filename, + filetype: Attr.hub, + actual_hub_id: HUB_ID, + actual_home_id: "home-1", + permission: 63, + owner: uid, + }; +} + +function fakeService({ privilege }) { + const calls = []; + // Per-desk names, as the two databases would hold them. + const names = { [CALLER.uid]: "Old", [MEMBER.uid]: "Old" }; + const svc = Object.create(MediaPrivate.prototype); + Object.assign(svc, { + calls, + uid: CALLER.uid, + __changelog: {}, + randomString: () => "tag", + source_granted: () => ({ node: { ...node(CALLER.uid, "Old"), hub_id: CALLER.uid } }), + input: { need: () => "New Name", get: () => undefined, use: () => undefined }, + exception: { user: (code) => calls.push(["exception", code]) }, + hub: { get: (k) => (k === Attr.id ? CALLER.uid : k === Attr.db_name ? CALLER.db : undefined) }, + changelog_write: async () => {}, + payload: (model) => ({ model }), + output: { data: (d) => (svc.result = d) }, + warn: () => {}, + db: { + // The CALLER's desk: whatever uid it is asked about, it answers its own row. + await_proc: async (name, ...args) => { + calls.push(["db", name, ...args]); + if (name === "mfs_rename") names[CALLER.uid] = args[1]; + if (name === "mfs_access_node") return node(args[0], names[CALLER.uid]); + return {}; + }, + }, + yp: { + await_query: async (sql, id) => { + calls.push(["yp.query", id]); + return [{ db_name: "hub_db" }]; + }, + await_func: async (name, ...args) => { + calls.push(["yp.func", name, ...args]); + return privilege; + }, + await_proc: async (name, ...args) => { + calls.push(["yp", name, ...args]); + if (name === "entity_sockets") { + return args[0] === HUB_ID + ? [{ socket_id: "s1", uid: CALLER.uid }, { socket_id: "s2", uid: MEMBER.uid }] + : [{ socket_id: "s1", uid: CALLER.uid }]; + } + if (name === "hub_rename_for_members") { + names[MEMBER.uid] = args[1]; + return [ + { uid: CALLER.uid, db_name: CALLER.db, old_filename: "New Name", filename: "New Name", changed: 0 }, + { uid: MEMBER.uid, db_name: MEMBER.db, old_filename: "Old", filename: "New Name", changed: 1 }, + ]; + } + if (name === `${MEMBER.db}.mfs_access_node`) return node(args[0], names[MEMBER.uid]); + return {}; + }, + }, + }); + return svc; +} + +test("hub admin: every member's row is renamed and every member is told", async () => { + sent.length = 0; + const svc = fakeService({ privilege: 63 }); + await svc.rename(); + + assert.ok(svc.calls.some((c) => c[0] === "yp.func" && c[1] === "hub_db.user_permission" && c[2] === CALLER.uid)); + assert.ok(svc.calls.some((c) => c[1] === "hub_rename_for_members" && c[2] === HUB_ID && c[3] === "New Name")); + + const toMember = sent.find((s) => s.dest.socket_id === "s2"); + assert.ok(toMember, "the other member gets a push"); + assert.equal(toMember.payload.model.args.dest.filename, "New Name"); + assert.equal(toMember.payload.model.args.src.filename, "Old"); + assert.equal(toMember.payload.model.filetype, Attr.hub); + // Read from the MEMBER's db, never the caller's. + assert.ok(svc.calls.some((c) => c[1] === `${MEMBER.db}.mfs_access_node` && c[2] === MEMBER.uid)); + + const toCaller = sent.find((s) => s.dest.socket_id === "s1"); + assert.equal(toCaller.payload.model.args.dest.filename, "New Name"); + assert.equal(svc.result.args.dest.filename, "New Name"); +}); + +test("member below admin: personal rename only, nobody else touched", async () => { + sent.length = 0; + const svc = fakeService({ privilege: 3 }); + await svc.rename(); + + assert.ok(!svc.calls.some((c) => c[1] === "hub_rename_for_members")); + assert.deepEqual(sent.map((s) => s.dest.socket_id), ["s1"]); + assert.equal(svc.result.args.dest.filename, "New Name"); +}); + +test("not a hub: the shared path never runs", async () => { + sent.length = 0; + const svc = fakeService({ privilege: 63 }); + const granted = svc.source_granted; + svc.source_granted = () => ({ node: { ...granted().node, filetype: Attr.folder } }); + await svc.rename(); + + assert.ok(!svc.calls.some((c) => c[0] === "yp.func" || c[1] === "hub_rename_for_members")); +}); + +test("shared path throws: the caller's rename still answers, personal push only", async () => { + sent.length = 0; + const svc = fakeService({ privilege: 63 }); + const proc = svc.yp.await_proc; + svc.yp.await_proc = async (name, ...args) => { + if (name === "hub_rename_for_members") throw new Error("boom"); + return proc(name, ...args); + }; + await svc.rename(); + + assert.equal(svc.result.args.dest.filename, "New Name"); + assert.deepEqual(sent.map((s) => s.dest.socket_id), ["s1"]); +});