Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/js/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -1411,10 +1411,10 @@ class ChannelManager {
banMemberLevels(messageStreamId, address, levels) { return this.membership.banMemberLevels(messageStreamId, address, levels); }
/** A ban or removal whose key rotation has not gone out yet. */
isRotationOwed(messageStreamId) { return this.rotationRetry.isOwed(messageStreamId); }
/** Owed rotations of the gated channels this account owns, taken up once the client connects. */
/** Owed rotations of the gated channels, taken up once the client connects; the ones this account cannot pay are dropped. */
resumeOwedRotations() {
this.rotationRetry.resume([...this.channels.values()]
.filter(ch => ch.gate?.address && epochKeyManager.isOwnAdmin(ch))
.filter(ch => ch.gate?.address)
.map(ch => ch.messageStreamId));
}
unbanMemberLevels(messageStreamId, address) { return this.membership.unbanMemberLevels(messageStreamId, address); }
Expand Down
23 changes: 15 additions & 8 deletions src/js/channels/RotationRetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,23 @@ export class RotationRetry {
}

/**
* Rotate for the addresses now; on failure keep them owed and retry.
* Rotate for the addresses now; on failure keep them owed and retry. Only
* the owner announces epochs: anyone else's cut is left to the owner's
* next open, and a debt they took on would hold back their own sends.
* @returns {Promise<boolean>} true when the rotation went out now
*/
async rotateFor(messageStreamId, addresses) {
if (!this.host.stillOwned(messageStreamId)) return false;
this._update(messageStreamId, (owed) => [...owed, ...addresses.map(a => a.toLowerCase())]);
if (await this._attempt(messageStreamId)) return true;
this._ensureLoop(messageStreamId);
return false;
}

/** Take up what an earlier session left owed on these channels. */
/** Take up what an earlier session left owed on these channels, and drop what this account cannot pay. */
resume(messageStreamIds) {
for (const messageStreamId of messageStreamIds) {
if (!this.isOwed(messageStreamId)) continue;
if (!this.isOwed(messageStreamId) || this._dropUnpayable(messageStreamId)) continue;
this._attempt(messageStreamId).then((done) => {
if (!done) this._ensureLoop(messageStreamId);
});
Expand All @@ -87,10 +90,17 @@ export class RotationRetry {

/** Before the admin publishes: an owed rotation goes first, or the publish does not go. */
async settle(messageStreamId) {
if (!this.isOwed(messageStreamId)) return;
if (!this.isOwed(messageStreamId) || this._dropUnpayable(messageStreamId)) return;
if (!await this._attempt(messageStreamId)) throw new Error(OWED_ROTATION_MESSAGE);
}

/** A debt on a channel this account does not own can never be paid. */
_dropUnpayable(messageStreamId) {
if (this.host.stillOwned(messageStreamId)) return false;
this._update(messageStreamId, () => []);
return true;
}

_attempt(messageStreamId) {
const previous = this.attempts.get(messageStreamId) || Promise.resolve();
const next = previous.catch(() => {}).then(() => this._rotateOnce(messageStreamId));
Expand Down Expand Up @@ -127,10 +137,7 @@ export class RotationRetry {
try {
for (let round = 0; this.isOwed(messageStreamId); round++) {
await this.sleep(this.delaysMs[Math.min(round, this.delaysMs.length - 1)]);
if (!this.host.stillOwned(messageStreamId)) {
this._update(messageStreamId, () => []);
return;
}
if (this._dropUnpayable(messageStreamId)) return;
await this._attempt(messageStreamId);
}
} finally {
Expand Down
40 changes: 34 additions & 6 deletions src/js/ui/ChannelModalsUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { GasEstimator } from './GasEstimator.js';
import { authManager } from '../auth.js';
import { confirmDialog } from './ConfirmDialogUI.js';
import { streamrController } from '../streamr.js';
import { CONFIG } from '../config.js';
import { snapRetentionDays, retentionLabel } from '../utils/retention.js';
Expand Down Expand Up @@ -750,9 +751,15 @@ class ChannelModalsUI {
* @param {() => void} [options.onBanned] - runs once the ban went through
*/
showBanMemberModal(address, channel, { onBanned } = {}) {
const owner = !!channel && this.channelManager.isChannelOwner(channel.streamId);
if (!owner && this.channelManager.isCachedModerator?.(channel?.streamId)) {
this._hideAsModerator(address, channel, { onBanned });
return;
}
const gated = !!channel?.gate?.address;
const me = authManager.getAddress()?.toLowerCase();
const canClientBan = !!me && me === channel?.createdBy?.toLowerCase();
const canClientBan = owner;
// The gate's ban() is onlyOwner: offered to anyone else, it reverts.
const canProtocolBan = gated && owner;

const label = document.getElementById('ban-member-label');
if (label) label.textContent = `${address.slice(0, 6)}…${address.slice(-4)}`;
Expand Down Expand Up @@ -790,8 +797,8 @@ class ChannelModalsUI {
}
document.getElementById('ban-level-purge-row')?.classList.toggle('opacity-40', !canPurge);
if (protocol) {
protocol.checked = gated;
protocol.disabled = !gated;
protocol.checked = canProtocolBan;
protocol.disabled = !canProtocolBan;
}
if (clientDetail && !canClientBan) {
clientDetail.textContent = 'Only the channel creator can publish this.';
Expand All @@ -800,18 +807,20 @@ class ChannelModalsUI {
}
if (protocolDetail && !gated) {
protocolDetail.textContent = 'Only gated channels have a gate to ban on.';
} else if (protocolDetail && !owner) {
protocolDetail.textContent = 'Only the channel creator can cut access.';
} else if (protocolDetail) {
protocolDetail.textContent = 'Cuts their access on the gate and rotates the channel key. One transaction.';
}
document.getElementById('ban-level-client-row')?.classList.toggle('opacity-40', !canClientBan);
document.getElementById('ban-level-protocol-row')?.classList.toggle('opacity-40', !gated);
document.getElementById('ban-level-protocol-row')?.classList.toggle('opacity-40', !canProtocolBan);

const confirmBtn = document.getElementById('confirm-ban-member-btn');
if (confirmBtn) {
confirmBtn.onclick = async () => {
const levels = {
client: !!client?.checked && canClientBan,
protocol: !!protocol?.checked && gated
protocol: !!protocol?.checked && canProtocolBan
};
const erase = !!purge?.checked && canPurge && levels.client;
if (!levels.client && !levels.protocol) return;
Expand Down Expand Up @@ -854,6 +863,25 @@ class ChannelModalsUI {
this.deps.modalManager?.show('ban-member-modal');
}

/** A moderator's ban: a delta every client composes over the owner's state. It hides, with no on-chain half. */
async _hideAsModerator(address, channel, { onBanned } = {}) {
if (!await confirmDialog({
title: 'Hide their messages',
message: `Every message from ${address.slice(0, 10)}… is hidden from now on. Only the channel creator can cut their access.`,
confirmLabel: 'Hide'
})) return;
try {
const { epochKeyManager } = await import('../epochKeyManager.js');
await this.channelManager.publishModAction(
channel.streamId, 'ban', address, epochKeyManager.currentEpoch(channel.streamId));
this.showNotification('Member banned', 'success');
} catch (error) {
this.showNotification(error?.message || 'Failed to ban member', 'error');
return;
}
onBanned?.();
}

/**
* Entry screen for a gated channel the user cannot enter yet:
* reads the gate mode on-chain and shows the requirement, the user's
Expand Down
4 changes: 3 additions & 1 deletion src/js/ui/ChannelSettingsUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -2001,7 +2001,9 @@ class ChannelSettingsUI {
try {
showLoading('Removing member (on-chain transaction)...');
await channelManager.removeMember(currentChannel.streamId, address);
if (channelManager.isRotationOwed(currentChannel.streamId)) {
if (!channelManager.isChannelOwner(currentChannel.streamId)) {
showNotification('Member removed. The key rotates when the owner next opens the channel.', 'info', 5000);
} else if (channelManager.isRotationOwed(currentChannel.streamId)) {
showNotification('Member removed. The channel key rotates the next time the app connects.', 'warning', 5000);
} else {
showNotification('Member removed successfully!', 'success');
Expand Down
16 changes: 0 additions & 16 deletions src/js/ui/MessageContextMenuUI.js
Original file line number Diff line number Diff line change
Expand Up @@ -537,22 +537,6 @@ class MessageContextMenuUI {
case 'ban-user': {
const ch = channelManager?.getCurrentChannel?.();
if (!ch) break;
// A moderator's ban is a delta and has no on-chain half, so
// the two-level modal (which spends gas) is the owner's.
if (channelManager.isCachedModerator?.(ch.streamId)
&& !channelManager.getCachedDeletePermission?.(ch.streamId)?.canDelete) {
if (!await confirmDialog({ title: 'Hide their messages', message: `Every message from ${address.slice(0, 10)}… is hidden from now on.`, confirmLabel: 'Hide' })) break;
try {
const { epochKeyManager } = await import('../epochKeyManager.js');
await channelManager.publishModAction(
ch.streamId, 'ban', address,
epochKeyManager.currentEpoch(ch.streamId));
showNotification('Member banned', 'success');
} catch (err) {
showNotification(err?.message || 'Failed to ban member', 'error');
}
break;
}
const { channelModalsUI } = await import('./ChannelModalsUI.js');
channelModalsUI.showBanMemberModal(address, ch);
break;
Expand Down
51 changes: 48 additions & 3 deletions tests/unit/ChannelModalsUI.ban.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* The ban modal reports back once the ban went through, so the panel that
* opened it can show the member gone from the members list.
* opened it can show the member gone from the members list. The gate's ban is
* the owner's alone; a moderator hides by delta and never reaches the gate.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
Expand All @@ -10,6 +11,8 @@ vi.mock('../../src/js/logger.js', () => ({
}));
vi.mock('../../src/js/auth.js', () => ({ authManager: { getAddress: () => '0xowner' } }));
vi.mock('../../src/js/streamr.js', () => ({ streamrController: {} }));
vi.mock('../../src/js/ui/ConfirmDialogUI.js', () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock('../../src/js/epochKeyManager.js', () => ({ epochKeyManager: { currentEpoch: () => 3 } }));

const { ChannelModalsUI } = await import('../../src/js/ui/ChannelModalsUI.js');

Expand All @@ -19,28 +22,70 @@ const MEMBER = '0x03e2b466754f187f571ab48c69e3ab592e76d819';
describe('ban modal', () => {
let ui;
let channelManager;
let modalManager;

beforeEach(() => {
document.body.innerHTML = `
<span id="ban-member-label"></span>
<input type="checkbox" id="ban-level-client">
<input type="checkbox" id="ban-level-protocol">
<span id="ban-level-protocol-detail"></span>
<input type="checkbox" id="ban-level-purge">
<button id="confirm-ban-member-btn"></button>
`;
channelManager = {
banMemberLevels: vi.fn().mockResolvedValue(true),
isRotationOwed: vi.fn().mockReturnValue(false)
isRotationOwed: vi.fn().mockReturnValue(false),
isChannelOwner: vi.fn().mockReturnValue(true),
isCachedModerator: vi.fn().mockReturnValue(false),
publishModAction: vi.fn().mockResolvedValue(undefined)
};
modalManager = { show: vi.fn(), hide: vi.fn() };
ui = new ChannelModalsUI();
ui.setDependencies({
channelManager,
modalManager: { show: vi.fn(), hide: vi.fn() },
modalManager,
notificationUI: { showLoadingToast: vi.fn(), hideLoadingToast: vi.fn() },
showNotification: vi.fn()
});
});

it('offers the gate to the owner', () => {
ui.showBanMemberModal(MEMBER, CHANNEL);

const protocol = document.getElementById('ban-level-protocol');
expect(protocol.disabled).toBe(false);
expect(protocol.checked).toBe(true);
});

it('never offers the gate to anyone else, and says why', async () => {
channelManager.isChannelOwner.mockReturnValue(false);
ui.showBanMemberModal(MEMBER, CHANNEL);

const protocol = document.getElementById('ban-level-protocol');
expect(protocol.disabled).toBe(true);
expect(protocol.checked).toBe(false);
expect(document.getElementById('ban-level-protocol-detail').textContent)
.toBe('Only the channel creator can cut access.');

protocol.checked = true;
await document.getElementById('confirm-ban-member-btn').onclick();
expect(channelManager.banMemberLevels).not.toHaveBeenCalled();
});

it('lets a moderator hide by delta, with no transaction', async () => {
channelManager.isChannelOwner.mockReturnValue(false);
channelManager.isCachedModerator.mockReturnValue(true);
const onBanned = vi.fn();

ui.showBanMemberModal(MEMBER, CHANNEL, { onBanned });

await vi.waitFor(() => expect(onBanned).toHaveBeenCalledTimes(1));
expect(channelManager.publishModAction).toHaveBeenCalledWith(CHANNEL.streamId, 'ban', MEMBER, 3);
expect(channelManager.banMemberLevels).not.toHaveBeenCalled();
expect(modalManager.show).not.toHaveBeenCalled();
});

it('tells the opener once the ban went through', async () => {
const onBanned = vi.fn();
ui.showBanMemberModal(MEMBER, CHANNEL, { onBanned });
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/ChannelSettingsUI.removeMember.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* What removing a member says about the channel key. Only the owner rotates
* it: their own device does it now or owes it, and a moderator's removal is
* left to the owner's next open.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';

vi.mock('../../src/js/logger.js', () => ({
Logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}));
vi.mock('../../src/js/ui/ModalManager.js', () => ({ modalManager: { show: vi.fn(), hide: vi.fn() } }));
vi.mock('../../src/js/relayManager.js', () => ({ relayManager: {} }));
vi.mock('../../src/js/graph.js', () => ({ graphAPI: {} }));
vi.mock('../../src/js/identity.js', () => ({ identityManager: { getCachedENS: vi.fn(() => null) } }));
vi.mock('../../src/js/media.js', () => ({ mediaController: {} }));
vi.mock('../../src/js/channelImageManager.js', () => ({ channelImageManager: {} }));

const { channelSettingsUI } = await import('../../src/js/ui/ChannelSettingsUI.js');

const CHANNEL = { streamId: '0xowner/room-1' };
const MEMBER = '0x03e2b466754f187f571ab48c69e3ab592e76d819';

describe('removing a member', () => {
let channelManager;
let showNotification;

beforeEach(() => {
channelManager = {
getCurrentChannel: () => CHANNEL,
removeMember: vi.fn().mockResolvedValue(true),
isChannelOwner: vi.fn().mockReturnValue(true),
isRotationOwed: vi.fn().mockReturnValue(false)
};
showNotification = vi.fn();
channelSettingsUI.setDependencies({ channelManager, showLoading: vi.fn(), hideLoading: vi.fn(), showNotification });
vi.spyOn(channelSettingsUI, 'loadMembers').mockResolvedValue(undefined);
});

it('tells the owner it is done when the key rotated', async () => {
await channelSettingsUI.executeRemoveMember(MEMBER);
expect(showNotification).toHaveBeenCalledWith('Member removed successfully!', 'success');
});

it('tells the owner the rotation is still owed', async () => {
channelManager.isRotationOwed.mockReturnValue(true);
await channelSettingsUI.executeRemoveMember(MEMBER);
expect(showNotification).toHaveBeenCalledWith(
'Member removed. The channel key rotates the next time the app connects.', 'warning', 5000);
});

it('tells a moderator the owner rotates the key', async () => {
channelManager.isChannelOwner.mockReturnValue(false);
await channelSettingsUI.executeRemoveMember(MEMBER);
expect(showNotification).toHaveBeenCalledWith(
'Member removed. The key rotates when the owner next opens the channel.', 'info', 5000);
});
});
19 changes: 18 additions & 1 deletion tests/unit/channels.extended.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ vi.mock('../../src/js/epochKeyManager.js', () => ({
handleKeysMessage: vi.fn(),
forgetChannel: vi.fn().mockResolvedValue(undefined),
ensureChannelKeys: vi.fn().mockResolvedValue(undefined),
getWaitingInfo: vi.fn().mockReturnValue({ waiting: false })
getWaitingInfo: vi.fn().mockReturnValue({ waiting: false }),
isOwnAdmin: vi.fn().mockReturnValue(true)
}
}));

Expand Down Expand Up @@ -330,6 +331,22 @@ describe('ChannelManager Extended', () => {
expect(epochKeyManager.rotateEpoch).toHaveBeenCalled();
});

it("leaves a moderator's removal to the owner, with no rotation owed on this device", async () => {
const { gateManager } = await import('../../src/js/gate.js');
const { epochKeyManager } = await import('../../src/js/epochKeyManager.js');
epochKeyManager.rotateEpoch.mockClear();
epochKeyManager.isOwnAdmin.mockReturnValue(false);
try {
await channelManager.removeMember(streamId, '0xmember1');

expect(gateManager.revokeAllow).toHaveBeenCalledWith('0xgate', '0xmember1');
expect(epochKeyManager.rotateEpoch).not.toHaveBeenCalled();
expect(channelManager.isRotationOwed(streamId)).toBe(false);
} finally {
epochKeyManager.isOwnAdmin.mockReturnValue(true);
}
});

it('removes member from local list', async () => {
await channelManager.removeMember(streamId, '0xmember1');

Expand Down
Loading
Loading