-
Notifications
You must be signed in to change notification settings - Fork 0
Promote test → preview (2026-09-13): permission-matrix live sync, parallel Stripe catalog #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d90bfe6
feat(page): link the app stylesheet when the UI build emits one
luongtrieuvy202 f77b6d9
Merge branch 'perf/link-app-stylesheet' into test
luongtrieuvy202 ad1eb85
Revert "feat(page): link the app stylesheet when the UI build emits o…
luongtrieuvy202 a6b54ce
perf(payment): fetch the catalog's Stripe prices in parallel
EddyOne81 f9eca98
fix(hub): role changes and removals reach every open permission matri…
tranh0anghuan 6200991
Merge remote-tracking branch 'origin/preview' into promote/bm-srv-0914
EddyOne81 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| // hub.set_privilege and hub.delete_contributor tell the WHOLE hub. | ||
| // | ||
| // Before this, both pushed only to the member being changed: set_privilege | ||
| // sent { privilege, hub_id, area } to that member's sockets, and | ||
| // delete_contributor sent media.remove + hub.member_removed to the removed | ||
| // member's. Every other admin with the permission matrix open kept the old row | ||
| // until they reopened the panel. | ||
| // | ||
| // Each now ends with ONE hub.members_changed broadcast to the hub's sockets, | ||
| // after the per-member work, so the matrices refetch a committed state. The | ||
| // existing per-member pushes are unchanged — the changed member's own windows | ||
| // still rely on them. | ||
| // | ||
| // Run: node --test test/members-changed-push.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 HubPrivate = require("../service/private/hub"); | ||
|
|
||
| // hub.js and notify-member-joined.js both destructure this same RedisStore | ||
| // object, so patching the method reaches every send without a Redis. | ||
| const sent = []; | ||
| RedisStore.sendData = async (payload, dest) => { | ||
| sent.push({ payload, dest }); | ||
| }; | ||
|
|
||
| const HUB_SOCKETS = [{ socket_id: "s-admin-a" }, { socket_id: "s-admin-b" }]; | ||
| const HUB = { | ||
| [Attr.id]: "hub-1", | ||
| [Attr.hub_id]: "hub-1", | ||
| [Attr.area]: "private", | ||
| [Attr.db_name]: "hub_db", | ||
| [Attr.profile]: { name: "Design team" }, | ||
| [Attr.settings]: {}, | ||
| }; | ||
|
|
||
| function fakeService({ users, privilege }) { | ||
| const procs = []; | ||
| return { | ||
| procs, | ||
| uid: "admin-a", | ||
| input: { | ||
| need: (k) => (k === Attr.users ? users : undefined), | ||
| use: (k) => (k === Attr.privilege ? privilege : undefined), | ||
| get: () => undefined, | ||
| }, | ||
| hub: { get: (k) => HUB[k] }, | ||
| db: { | ||
| await_proc: async (name, ...args) => { | ||
| procs.push(["db", name, ...args]); | ||
| return name === "mfs_home" ? { chat_upload_id: "chat-up" } : {}; | ||
| }, | ||
| }, | ||
| yp: { | ||
| await_proc: async (name, ...args) => { | ||
| procs.push(["yp", name, ...args]); | ||
| if (name === "user_sockets") return [{ socket_id: `s-${args[0]}` }]; | ||
| if (name === "entity_sockets") return HUB_SOCKETS; | ||
| if (name === "get_entity") return { db_name: `db_${args[0]}` }; | ||
| return []; | ||
| }, | ||
| }, | ||
| payload: (data, options) => ({ data, options }), | ||
| output: { data: () => {}, list: () => {} }, | ||
| warn: () => {}, | ||
| granted_node: () => ({}), | ||
| _actor_name: () => "Admin A", | ||
| _unassign_tasks: async () => {}, | ||
| _broadcast_task_unassign: async () => {}, | ||
| _trackWorkspaceMembers: async () => {}, | ||
| _members_by_type: async () => [], | ||
| }; | ||
| } | ||
|
|
||
| const broadcasts = () => | ||
| sent.filter((s) => s.payload.options?.service === "hub.members_changed"); | ||
|
|
||
| test("set_privilege broadcasts one hub.members_changed to the hub, last", async () => { | ||
| sent.length = 0; | ||
| const svc = fakeService({ users: ["u1", "u2"], privilege: 15 }); | ||
| await HubPrivate.prototype.set_privilege.call(svc); | ||
|
|
||
| const b = broadcasts(); | ||
| assert.equal(b.length, 1, "one broadcast for the whole request"); | ||
| assert.deepEqual(b[0].payload.data, { | ||
| hub_id: "hub-1", | ||
| change: "privilege", | ||
| users: ["u1", "u2"], | ||
| }); | ||
| assert.deepEqual(b[0].dest, HUB_SOCKETS); | ||
| assert.equal(sent.at(-1), b[0], | ||
| "sent after every per-member write, so a refetch reads committed rows"); | ||
|
|
||
| // The changed members' own live-privilege pushes are still there. | ||
| const own = sent.filter((s) => s !== b[0]); | ||
| assert.deepEqual(own.map((s) => s.dest), [[{ socket_id: "s-u1" }], [{ socket_id: "s-u2" }]]); | ||
| assert.ok(own.every((s) => s.payload.data.privilege === 15)); | ||
| }); | ||
|
|
||
| test("delete_contributor broadcasts one hub.members_changed for the removed members", async () => { | ||
| sent.length = 0; | ||
| // The acting admin in the list is skipped by delete_contributor itself and | ||
| // must not be reported as removed. | ||
| const svc = fakeService({ users: ["u1", "admin-a", "u2"] }); | ||
| await HubPrivate.prototype.delete_contributor.call(svc); | ||
|
|
||
| const b = broadcasts(); | ||
| assert.equal(b.length, 1); | ||
| assert.deepEqual(b[0].payload.data, { | ||
| hub_id: "hub-1", | ||
| change: "removed", | ||
| users: ["u1", "u2"], | ||
| }); | ||
| assert.deepEqual(b[0].dest, HUB_SOCKETS); | ||
|
|
||
| // The removed members' own notices are unchanged. | ||
| const removedNotices = sent.filter( | ||
| (s) => s.payload.options?.service === "hub.member_removed", | ||
| ); | ||
| assert.deepEqual(removedNotices.map((s) => s.dest), [[{ socket_id: "s-u1" }], [{ socket_id: "s-u2" }]]); | ||
| }); | ||
|
|
||
| test("delete_contributor with only the admin themself broadcasts nothing", async () => { | ||
| sent.length = 0; | ||
| const svc = fakeService({ users: ["admin-a"] }); | ||
| await HubPrivate.prototype.delete_contributor.call(svc); | ||
| assert.equal(broadcasts().length, 0, "no membership changed, so no matrix needs a refetch"); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // The "someone's membership changed" broadcast behind every open permission | ||
| // matrix. | ||
| // | ||
| // THE REPORT: an admin changes a member's role (or removes them) and every | ||
| // OTHER admin with the matrix open keeps seeing the old row until they reopen | ||
| // the panel. hub.set_privilege and hub.delete_contributor only ever pushed to | ||
| // the member being changed, and the panels only refetch on hub.member_joined — | ||
| // so nothing reached the people looking at the list. | ||
| // | ||
| // notifyMembersChanged is the missing push. These lock what it sends, to whom, | ||
| // and that it can never fail the write it follows. | ||
| // | ||
| // Run: node --test test/notify-members-changed.test.js | ||
| const assert = require("node:assert/strict"); | ||
| const test = require("node:test"); | ||
|
|
||
| // Stub the two things the module reaches for before requiring it — the same | ||
| // shape revenue-live.test.js uses. toArray lives under `utils` here because | ||
| // that is where notify-member-joined.js reads it from. | ||
| const sent = []; | ||
| require.cache[require.resolve("@drumee/server-essentials")] = { | ||
| exports: { | ||
| RedisStore: { | ||
| sendData: async (payload, dest) => { | ||
| sent.push({ payload, dest }); | ||
| }, | ||
| }, | ||
| utils: { | ||
| toArray: (v) => (Array.isArray(v) ? v : v == null ? [] : [v]), | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| const { notifyMembersChanged } = require("../service/lib/notify-member-joined"); | ||
|
|
||
| const SOCKETS = [ | ||
| { socket_id: "s-admin-a", uid: "admin-a" }, | ||
| { socket_id: "s-admin-b", uid: "admin-b" }, | ||
| ]; | ||
|
|
||
| function ctx({ rows = SOCKETS, fail = false } = {}) { | ||
| const calls = []; | ||
| const warnings = []; | ||
| return { | ||
| calls, | ||
| warnings, | ||
| yp: { | ||
| await_proc: async (name, arg) => { | ||
| calls.push([name, arg]); | ||
| if (fail) throw new Error("db down"); | ||
| return rows; | ||
| }, | ||
| }, | ||
| payload: (data, options) => ({ data, options }), | ||
| warn: (...args) => warnings.push(args), | ||
| }; | ||
| } | ||
|
|
||
| test("sends ONE hub.members_changed push to every online member of the hub", async () => { | ||
| sent.length = 0; | ||
| const svc = ctx(); | ||
| await notifyMembersChanged(svc, "hub-1", { | ||
| change: "privilege", | ||
| users: ["u1", "u2"], | ||
| }); | ||
|
|
||
| assert.deepEqual(svc.calls, [["entity_sockets", "hub-1"]], | ||
| "the audience is the hub's sockets, not the changed member's own"); | ||
| assert.equal(sent.length, 1, "one push per request, not one per user"); | ||
| assert.equal(sent[0].payload.options.service, "hub.members_changed"); | ||
| assert.deepEqual(sent[0].payload.data, { | ||
| hub_id: "hub-1", | ||
| change: "privilege", | ||
| users: ["u1", "u2"], | ||
| }); | ||
| assert.deepEqual(sent[0].dest, SOCKETS); | ||
| }); | ||
|
|
||
| test("a single user id is sent as a one-item list", async () => { | ||
| sent.length = 0; | ||
| await notifyMembersChanged(ctx(), "hub-1", { change: "removed", users: "u9" }); | ||
| assert.deepEqual(sent[0].payload.data.users, ["u9"]); | ||
| }); | ||
|
|
||
| test("nobody online: nothing is sent", async () => { | ||
| sent.length = 0; | ||
| await notifyMembersChanged(ctx({ rows: [] }), "hub-1", { | ||
| change: "removed", | ||
| users: ["u1"], | ||
| }); | ||
| assert.equal(sent.length, 0); | ||
| }); | ||
|
|
||
| test("no hub id: does nothing at all", async () => { | ||
| sent.length = 0; | ||
| const svc = ctx(); | ||
| await notifyMembersChanged(svc, null, { change: "privilege", users: ["u1"] }); | ||
| assert.equal(svc.calls.length, 0); | ||
| assert.equal(sent.length, 0); | ||
| }); | ||
|
|
||
| test("never throws — the role change or removal has already committed", async () => { | ||
| sent.length = 0; | ||
| const svc = ctx({ fail: true }); | ||
| await assert.doesNotReject( | ||
| notifyMembersChanged(svc, "hub-1", { change: "privilege", users: ["u1"] }), | ||
| ); | ||
| assert.equal(sent.length, 0); | ||
| assert.equal(svc.warnings.length, 1, "the failure is logged, not swallowed silently"); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When callers use the ACL-exposed
hub.set_member_privilegeendpoint—for example, to update one member with an expiry—the adjacent implementation writespermission_grantand returns the refreshed list without invokingnotifyMembersChanged; onlyset_privilegereaches this new call. Those updates therefore still leave every other open permission matrix stale, so the single-member privilege writer should emit the same broadcast.Useful? React with 👍 / 👎.