Skip to content

Commit 73008ee

Browse files
Ramanclaude
andcommitted
feat(channel-ms): owner succession on leave, admin role changes
An owner may now leave: the longest-standing admin, else the longest-standing member (ties by user_id), becomes owner and is re-announced with ChannelMemberAdded{role: owner}; the last member leaving hard-deletes the channel. New PATCH /v3.0/channels/{id}/ members/{user_id} {role: admin|member} for owners and admins (403 for plain members, self, or the owner as target; 404 for non-members), through the usual dedup/prefix/permission/seq pipeline, fanned out as ChannelMemberAdded{role}. Proto: additive `string role = 4` on ChannelMemberAdded. 149 unit tests; conformance 24/24 on both stacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oYMrPP3kZZvmSFk4kJr1Z
1 parent 436228a commit 73008ee

6 files changed

Lines changed: 340 additions & 3 deletions

File tree

‎proto/v3-server-event-payload.proto‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ message ChannelMemberAdded {
171171

172172
// When the server applied the add, ms since epoch.
173173
uint64 added_at_ms = 3;
174+
175+
// The role every `members` entry holds after this event: `owner`,
176+
// `admin` or `member` (DECISIONS row 80). Role changes and owner
177+
// succession re-announce the affected member here rather than adding an
178+
// event type; recipients upsert the role.
179+
string role = 4;
174180
}
175181

176182
// Emitted on REST `remove_member` to remaining members + the removed

‎services/channel-ms/channel-ms.js‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ function memberIds(channel) {
7070
return channel.members.map((m) => m.user_id);
7171
}
7272

73+
/**
74+
* DECISIONS row 9 — who inherits a channel when the owner leaves: the
75+
* longest-standing admin, else the longest-standing remaining member. Ties on
76+
* `joinedAt` break lexically so every replica picks the same heir.
77+
*/
78+
function successor(members) {
79+
const admins = members.filter((m) => m.role === 'admin');
80+
return [...(admins.length ? admins : members)]
81+
.sort((a, b) => a.joinedAt - b.joinedAt || a.user_id.localeCompare(b.user_id))[0];
82+
}
83+
7384
/** The shape a channel takes in a REST response. */
7485
function channelView(channel) {
7586
return {
@@ -175,6 +186,20 @@ class ChannelMs extends HttpServiceBase {
175186
}
176187
});
177188

189+
this.addRoute('/{channelId}/members/{userId}', 'PATCH', this.setMemberRole.bind(this), {
190+
validate: {
191+
headers: schemas.authHeaders,
192+
params: Joi.object({
193+
channelId: Joi.string().required(),
194+
userId: USER_ID.required()
195+
}),
196+
payload: Joi.object({
197+
...OP_FIELDS,
198+
role: Joi.string().valid('admin', 'member').required()
199+
})
200+
}
201+
});
202+
178203
this.addInternalRoute('/{channelId}', 'GET', this.getChannelInfo.bind(this), {
179204
validate: { params: channelParam }
180205
});
@@ -339,6 +364,7 @@ class ChannelMs extends HttpServiceBase {
339364
await this.fanout('CHANNEL_MEMBER_ADDED', channel, user, [...existing, ...added], {
340365
channelId,
341366
members: added,
367+
role: 'member',
342368
addedAtMs: Date.now()
343369
});
344370
}
@@ -371,6 +397,42 @@ class ChannelMs extends HttpServiceBase {
371397
return this.accept(res, user, opId, 200, { member_count: channel.members.length - 1 });
372398
}
373399

400+
/**
401+
* DECISIONS row 80 — owner and admins promote a member to admin or demote an
402+
* admin back. The owner's role and the caller's own are off limits here: the
403+
* owner hands over by leaving (row 9).
404+
*/
405+
async setMemberRole(req, res) {
406+
const user = extractInfoFromRequest(req);
407+
const { channelId, userId } = req.params;
408+
const { op_id: opId, resource_seq: seq, role } = req.payload;
409+
410+
const replay = await this.replayed(res, user, opId);
411+
if (replay) return replay;
412+
413+
const channel = await this.db.getChannelInfo(channelId);
414+
const denied = this.requireRole(channel, user, ['owner', 'admin']);
415+
if (denied) return this.reject(res, user, opId, denied.status, denied.code, denied.message);
416+
417+
const current = roleOf(channel, userId);
418+
if (!current) {
419+
return this.reject(res, user, opId, 404, 'not_found', 'not a member of this channel');
420+
}
421+
if (userId === user || current === 'owner') {
422+
return this.reject(
423+
res, user, opId, 403, 'forbidden', 'the owner role and your own cannot be changed'
424+
);
425+
}
426+
427+
if (!(await this.inOrder(user, channelId, seq))) {
428+
return this.reject(res, user, opId, 400, 'out_of_order', 'resource_seq skipped');
429+
}
430+
431+
await this.db.setMemberRole(channelId, userId, role);
432+
await this.announceRole(channel, user, userId, role, memberIds(channel));
433+
return this.accept(res, user, opId, 200, { user_id: userId, role });
434+
}
435+
374436
async editChannel(req, res) {
375437
const user = extractInfoFromRequest(req);
376438
const { channelId } = req.params;
@@ -487,15 +549,48 @@ class ChannelMs extends HttpServiceBase {
487549
return null;
488550
}
489551

490-
/** Tombstone a member and tell the channel (remaining members + the removed one). */
552+
/**
553+
* Tombstone a member and tell the channel (remaining members + the removed
554+
* one). DECISIONS row 9: the last member out takes the channel with them,
555+
* and a leaving owner hands the channel to `successor`.
556+
*/
491557
async leave(channel, actor, userId) {
492558
const removedAtMs = Date.now();
493559
await this.db.removeMember(channel.channelId, userId, removedAtMs);
560+
const remaining = channel.members.filter((m) => m.user_id !== userId);
561+
if (!remaining.length) {
562+
await this.db.deleteChannel(channel.channelId);
563+
await this.fanout('CHANNEL_DELETED', channel, actor, [userId], {
564+
channelId: channel.channelId,
565+
deletedAtMs: removedAtMs
566+
});
567+
return;
568+
}
494569
await this.fanout('CHANNEL_MEMBER_REMOVED', channel, actor, memberIds(channel), {
495570
channelId: channel.channelId,
496571
member: userId,
497572
removedAtMs
498573
});
574+
if (roleOf(channel, userId) === 'owner') {
575+
const heir = successor(remaining);
576+
await this.db.setMemberRole(channel.channelId, heir.user_id, 'owner');
577+
await this.announceRole(
578+
channel, actor, heir.user_id, 'owner', remaining.map((m) => m.user_id)
579+
);
580+
}
581+
}
582+
583+
/**
584+
* DECISIONS row 80 — a role change or a succession re-announces the affected
585+
* member as `ChannelMemberAdded{role}`; clients upsert the role.
586+
*/
587+
async announceRole(channel, actor, userId, role, recipients) {
588+
await this.fanout('CHANNEL_MEMBER_ADDED', channel, actor, recipients, {
589+
channelId: channel.channelId,
590+
members: [userId],
591+
role,
592+
addedAtMs: Date.now()
593+
});
499594
}
500595

501596
/**

‎services/channel-ms/database/channel-db.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ class IChannelDB {
9797
throw new Error('Method not implemented');
9898
}
9999

100+
/**
101+
* @abstract
102+
* Set one member's role (DECISIONS row 80 promote/demote, row 9 succession).
103+
* @param {string} channelId
104+
* @param {string} userId
105+
* @param {'owner'|'admin'|'member'} role
106+
*/
107+
async setMemberRole(channelId, userId, role) {
108+
throw new Error('Method not implemented');
109+
}
110+
100111
/**
101112
* @abstract
102113
* @param {string} channelId
@@ -109,7 +120,7 @@ class IChannelDB {
109120

110121
/**
111122
* @abstract
112-
* Hard delete, owner only (DECISIONS row 9 — no ownership transfer in 3.0).
123+
* Hard delete: the owner's DELETE, or the last member leaving (row 9).
113124
* @param {string} channelId
114125
*/
115126
async deleteChannel(channelId) {

‎services/channel-ms/database/mongo-channel-db.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ class MongoChannelDB extends IChannelDB {
103103
);
104104
}
105105

106+
async setMemberRole(channelId, userId, role) {
107+
// `owner` is denormalised on the doc, so succession has to move it too.
108+
const update = { 'members.$.role': role };
109+
if (role === 'owner') update.owner = userId;
110+
await this.#collection.updateOne({ channelId, 'members.user_id': userId }, { $set: update });
111+
}
112+
106113
async updateChannel(channelId, updates) {
107114
const channel = await this.#collection.findOneAndUpdate(
108115
{ channelId },

‎services/channel-ms/test/channel-ms.test.js‎

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const test = require('node:test');
22
const assert = require('node:assert');
3-
const { startChannelMs, op, headers } = require('./helper');
3+
const { startChannelMs, decodeServerEvent, mintOpId, op, headers } = require('./helper');
44

55
const ALICE = 'a3f2e8c5d';
66
const BOB = 'b1c2d3e4f';
@@ -309,3 +309,149 @@ test('REST and WS share one sequence space per (user, resource)', async () => {
309309
assert.strictEqual(response.statusCode, 200);
310310
assert.strictEqual(Number(await memCache.get(`seq:${ALICE}:${DM}`)), 41);
311311
});
312+
313+
// ---- roles, owner leave and succession (DECISIONS rows 9 / 80) ------------
314+
315+
const ROLES = '01efabcd-7000-8000-8abc-000000000100';
316+
const HANDOVER = '01efabcd-7000-8000-8abc-000000000101';
317+
const SENIORITY = '01efabcd-7000-8000-8abc-000000000102';
318+
const LAST = '01efabcd-7000-8000-8abc-000000000103';
319+
const STRANGER = 'e5f6a7b8c';
320+
321+
/** `op` with a unique op_id, so same-seq writes on other channels cannot collide. */
322+
let nonce = 0;
323+
function uop(userId, seq, extra = {}) {
324+
nonce += 1;
325+
return { ...op(userId, seq, extra), op_id: mintOpId(userId, { counter: seq, rand: nonce }) };
326+
}
327+
328+
const newGroup = (channelId, creator, members) => harness.inject({
329+
method: 'POST',
330+
url: '/',
331+
headers: headers(creator),
332+
payload: uop(creator, 1, { channel_id: channelId, kind: 'group', name: 'Roles', members })
333+
});
334+
335+
const setRole = (channelId, actor, target, role, seq) => harness.inject({
336+
method: 'PATCH',
337+
url: `/${channelId}/members/${target}`,
338+
headers: headers(actor),
339+
payload: uop(actor, seq, { role })
340+
});
341+
342+
const leaveChannel = (channelId, actor, seq) => harness.inject({
343+
method: 'DELETE',
344+
url: `/${channelId}/members/${actor}`,
345+
headers: headers(actor),
346+
payload: uop(actor, seq)
347+
});
348+
349+
const roles = async (channelId) => {
350+
const info = await harness.inject({ method: 'GET', url: `/_internal/${channelId}` });
351+
return Object.fromEntries(body(info).members.map((m) => [m.user_id, m.role]));
352+
};
353+
354+
test('the owner promotes a member to admin and re-announces them', async () => {
355+
await newGroup(ROLES, ALICE, [ALICE, BOB, CARL, DAVE]);
356+
const response = await setRole(ROLES, ALICE, BOB, 'admin', 2);
357+
assert.strictEqual(response.statusCode, 200);
358+
assert.deepStrictEqual(body(response), { user_id: BOB, role: 'admin' });
359+
360+
const { recipients, body: event } = harness.lastEvent();
361+
assert.strictEqual(event.type, 'CHANNEL_MEMBER_ADDED');
362+
assert.deepStrictEqual(event.memberAdded.members, [BOB]);
363+
assert.strictEqual(event.memberAdded.role, 'admin');
364+
assert.deepStrictEqual(recipients.sort(), [ALICE, BOB, CARL, DAVE].sort());
365+
assert.strictEqual((await roles(ROLES))[BOB], 'admin');
366+
});
367+
368+
test('an admin may promote another member', async () => {
369+
const response = await setRole(ROLES, BOB, CARL, 'admin', 1);
370+
assert.strictEqual(response.statusCode, 200);
371+
assert.strictEqual((await roles(ROLES))[CARL], 'admin');
372+
});
373+
374+
test('a plain member cannot change roles', async () => {
375+
const response = await setRole(ROLES, DAVE, CARL, 'member', 1);
376+
assert.strictEqual(response.statusCode, 403);
377+
assert.strictEqual(body(response).error.code, 'forbidden');
378+
});
379+
380+
test('nobody demotes themselves, and the owner role cannot be changed', async () => {
381+
const self = await setRole(ROLES, BOB, BOB, 'member', 2);
382+
assert.strictEqual(self.statusCode, 403);
383+
const owner = await setRole(ROLES, BOB, ALICE, 'member', 2);
384+
assert.strictEqual(owner.statusCode, 403);
385+
assert.strictEqual((await roles(ROLES))[ALICE], 'owner');
386+
});
387+
388+
test('a role change on a non member is not_found', async () => {
389+
const response = await setRole(ROLES, ALICE, STRANGER, 'admin', 3);
390+
assert.strictEqual(response.statusCode, 404);
391+
assert.strictEqual(body(response).error.code, 'not_found');
392+
});
393+
394+
test('a replayed role PATCH returns the stored outcome without fanning out', async () => {
395+
const payload = uop(ALICE, 3, { role: 'admin' });
396+
const request = {
397+
method: 'PATCH', url: `/${ROLES}/members/${DAVE}`, headers: headers(ALICE), payload
398+
};
399+
const first = await harness.inject(request);
400+
const before = harness.published().length;
401+
const second = await harness.inject(request);
402+
assert.strictEqual(first.statusCode, 200);
403+
assert.deepStrictEqual(body(second), body(first));
404+
assert.strictEqual(harness.published().length, before, 'replay must not fan out again');
405+
});
406+
407+
test('an owner who leaves hands the channel to the longest-standing admin', async () => {
408+
await newGroup(HANDOVER, ALICE, [ALICE, BOB, CARL]);
409+
await setRole(HANDOVER, ALICE, CARL, 'admin', 2);
410+
411+
const before = harness.published().length;
412+
const response = await leaveChannel(HANDOVER, ALICE, 3);
413+
assert.strictEqual(response.statusCode, 200);
414+
assert.strictEqual(body(response).member_count, 2);
415+
416+
const fanned = harness.published().slice(before);
417+
assert.strictEqual(fanned.length, 2, 'removal + succession');
418+
const [removed, promoted] = fanned;
419+
assert.strictEqual(decodeServerEvent(removed).memberRemoved.member, ALICE);
420+
assert.deepStrictEqual(decodeServerEvent(promoted).memberAdded.members, [CARL]);
421+
assert.strictEqual(decodeServerEvent(promoted).memberAdded.role, 'owner');
422+
assert.deepStrictEqual(promoted.recipients.sort(), [BOB, CARL].sort());
423+
assert.deepStrictEqual(await roles(HANDOVER), { [BOB]: 'member', [CARL]: 'owner' });
424+
});
425+
426+
test('with no admin the longest-standing member inherits', async () => {
427+
await newGroup(SENIORITY, ALICE, [ALICE, DAVE]);
428+
await harness.inject({
429+
method: 'POST',
430+
url: `/${SENIORITY}/members`,
431+
headers: headers(ALICE),
432+
payload: uop(ALICE, 2, { members: [BOB] })
433+
});
434+
435+
const response = await leaveChannel(SENIORITY, ALICE, 3);
436+
assert.strictEqual(response.statusCode, 200);
437+
// DAVE joined at create, BOB later: seniority beats the lexical tiebreak
438+
assert.deepStrictEqual(await roles(SENIORITY), { [DAVE]: 'owner', [BOB]: 'member' });
439+
assert.strictEqual(harness.lastEvent().body.memberAdded.role, 'owner');
440+
});
441+
442+
test('the last member out hard deletes the channel', async () => {
443+
await newGroup(LAST, ALICE, [ALICE, BOB]);
444+
const bobLeaves = await leaveChannel(LAST, BOB, 1);
445+
assert.strictEqual(bobLeaves.statusCode, 200);
446+
447+
const response = await leaveChannel(LAST, ALICE, 2);
448+
assert.strictEqual(response.statusCode, 200);
449+
assert.strictEqual(body(response).member_count, 0);
450+
451+
const { recipients, body: event } = harness.lastEvent();
452+
assert.strictEqual(event.type, 'CHANNEL_DELETED');
453+
assert.deepStrictEqual(recipients, [ALICE]);
454+
455+
const info = await harness.inject({ method: 'GET', url: `/_internal/${LAST}` });
456+
assert.strictEqual(info.statusCode, 404);
457+
});

0 commit comments

Comments
 (0)