@kubun/plugin-p2p 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,8 @@ import { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeIn
14
14
  import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
15
15
  import { applyInviteControlState, applyInviteGrants, applyInviteRevocations, collectInviteSeeds, finalizeJoinedGroup } from '../groups/join-utils.js';
16
16
  import { signLedgerEntry } from '../groups/ledger.js';
17
- import { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, requireGroupAdmin } from './require-admin.js';
17
+ import { roleFromRoster } from '../groups/roster-projection.js';
18
+ import { assertRosterAdmin, GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, notGroupAdminError, requireGroupAdmin } from './require-admin.js';
18
19
  import { checkPeerAccess, toCircleData, toISO } from './types.js';
19
20
  /** Project a stored catalog row into the GraphQL `PeerCatalog` shape. */ function toCatalogData(row) {
20
21
  return {
@@ -257,6 +258,36 @@ export function createGroupContext(ctx, deps) {
257
258
  action
258
259
  });
259
260
  },
261
+ // The self-authority gate the resolver runs ahead of its rollback wrapper for
262
+ // the control-mint ops (remove, set role), reading the FOLDED ROSTER instead
263
+ // of the projected `role` column. The actor is often a freshly promoted admin
264
+ // whose projection flaps admin->member under a concurrent invite-accept
265
+ // mirror; gating on the column would refuse them their own op the instant the
266
+ // mirror lags. The in-handler gate reads the roster the same way, so the two
267
+ // gates agree. See `assertRosterAdmin`.
268
+ requireAdminByRoster: async (groupID, action)=>{
269
+ const store = await getP2PStore();
270
+ const selfDID = deps.identity.id;
271
+ // Membership is checked from the store FIRST, before the handle is loaded.
272
+ // For an unknown or locally departed group `readHandle` throws a plain "no
273
+ // MLS state" error, which would escape as a bare message and strip the
274
+ // KB14/NOT_GROUP_ADMIN pair a client matches on — and a caller with no
275
+ // membership row is no admin regardless of what any roster says. Refusing
276
+ // here keeps the coded reason and never touches a handle that isn't there.
277
+ if (await store.getGroupMember(groupID, selfDID) == null) {
278
+ throw notGroupAdminError(groupID, action);
279
+ }
280
+ const roster = await deps.registry.readHandle(groupID, (handle)=>handle.roster, {
281
+ stores: deps.stores
282
+ });
283
+ await assertRosterAdmin({
284
+ store,
285
+ roster,
286
+ groupID,
287
+ did: selfDID,
288
+ action
289
+ });
290
+ },
260
291
  requireCircleAdmin: async (circleID, action)=>{
261
292
  const store = await getP2PStore();
262
293
  // Tombstoned circles count: deleting an already-deleted circle is a no-op
@@ -293,7 +324,16 @@ export function createGroupContext(ctx, deps) {
293
324
  // `mutateGraph` this handler already runs inside that transaction, so the
294
325
  // registry's restore/persist must share its connection — a separate
295
326
  // connection blocks forever on the outer write lock (single-connection SQLite).
296
- const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
327
+ // Read the anchor AND the target's authoritative role from the same handle
328
+ // hold. The target's admin status must come from the folded roster — the
329
+ // same authority the commit policy enforces — not the projected `role`
330
+ // column, which can transiently flap under concurrent catch-up and would
331
+ // make a genuine demotion settle a silent no-op.
332
+ const { anchor, targetRole, roster } = await deps.registry.readHandle(groupID, async (handle)=>({
333
+ anchor: readGroupAnchor(handle),
334
+ targetRole: roleFromRoster(handle.roster, memberDID),
335
+ roster: handle.roster
336
+ }), {
297
337
  stores: deps.stores
298
338
  });
299
339
  if (anchor == null) {
@@ -306,8 +346,15 @@ export function createGroupContext(ctx, deps) {
306
346
  // here lets the caller fail loudly instead of silently emitting a no-op.
307
347
  // The GraphQL resolver runs this same gate before its rollback wrapper so
308
348
  // the refusal keeps its code; repeating it here covers direct callers.
309
- await requireGroupAdmin({
349
+ //
350
+ // Read from the FOLDED ROSTER, not the projected `role` column: this is the
351
+ // freshly promoted admin's own authority, and the column flaps admin->member
352
+ // under a concurrent invite-accept mirror, which would refuse them their own
353
+ // control op the instant the mirror lags. The roster moves only on an
354
+ // enacted commit, so it never carries that flap.
355
+ await assertRosterAdmin({
310
356
  store,
357
+ roster,
311
358
  groupID,
312
359
  did: selfDID,
313
360
  action: 'set member roles'
@@ -318,7 +365,7 @@ export function createGroupContext(ctx, deps) {
318
365
  throw new Error('cannot grant admin to a non-member');
319
366
  }
320
367
  } else {
321
- if (!await store.isGroupAdmin(groupID, memberDID)) {
368
+ if (targetRole !== 'admin') {
322
369
  // Demoting a DID that is not currently an admin is a no-op; don't mint
323
370
  // a dead ledger entry + broadcast for it. The caller still gets a real,
324
371
  // resolvable request — one that is already settled, because the state
@@ -337,14 +384,15 @@ export function createGroupContext(ctx, deps) {
337
384
  // `rejected` settle the caller has to wait for, and a group with no admin
338
385
  // can never grant, revoke or remove again, so this is fail-closed.
339
386
  //
340
- // The survivor set comes from the same projected rows the authority check
341
- // above reads, exactly as `requestRemoveMember` counts its own: a grant
342
- // reaches the ledger when it is signed but the projection only when its
343
- // commit is enacted, so counting ledger admins would let the only admin
344
- // who can still act be demoted in favour of one who cannot yet.
345
- const members = await store.listGroupMembers(groupID);
346
- const survivors = members.filter((member)=>member.role === 'admin' && normalizeDID(member.member_did) !== normalizeDID(memberDID));
347
- if (survivors.length === 0) {
387
+ // The survivor set comes from the folded roster read above — the same
388
+ // authority the commit policy enforces not the projected `role` column.
389
+ // The column flaps admin->member under a concurrent invite-accept mirror,
390
+ // so counting it can miss a co-admin whose projection has lagged and
391
+ // spuriously refuse a demotion the roster plainly allows. The roster holds
392
+ // only enacted admins, so it never over-counts a signed-but-unlanded grant
393
+ // either. The target is admin in this branch, so it counts here; a count of
394
+ // one means it is the sole admin and no survivor remains.
395
+ if (adminCount(roster) <= 1) {
348
396
  throw new GraphQLError('cannot demote: this is the only admin — promote another member first', {
349
397
  extensions: {
350
398
  code: GROUP_CONTROL_DENIED,
@@ -1070,7 +1118,10 @@ export function createGroupContext(ctx, deps) {
1070
1118
  // `mutateGraph` this handler already runs inside that transaction, so the
1071
1119
  // registry's restore/persist must share its connection — a separate
1072
1120
  // connection blocks forever on the outer write lock (single-connection SQLite).
1073
- const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
1121
+ const { anchor, roster } = await deps.registry.readHandle(groupID, async (handle)=>({
1122
+ anchor: readGroupAnchor(handle),
1123
+ roster: handle.roster
1124
+ }), {
1074
1125
  stores: deps.stores
1075
1126
  });
1076
1127
  if (anchor == null) {
@@ -1084,31 +1135,35 @@ export function createGroupContext(ctx, deps) {
1084
1135
  // full-transaction rollback and forwards the message to the caller. The
1085
1136
  // resolver runs the same gate ahead of that wrap so the refusal keeps its
1086
1137
  // code; repeating it here covers direct callers.
1087
- await requireGroupAdmin({
1138
+ //
1139
+ // Read from the FOLDED ROSTER, not the projected `role` column: a freshly
1140
+ // promoted co-admin evicting a peer is exactly the actor whose projection
1141
+ // flaps admin->member under a concurrent invite-accept mirror, which would
1142
+ // refuse them their own eviction the instant the mirror lags.
1143
+ await assertRosterAdmin({
1088
1144
  store,
1145
+ roster,
1089
1146
  groupID,
1090
1147
  did: selfDID,
1091
1148
  action: 'remove a member'
1092
1149
  });
1093
- // Resolve whether the removed DID is an admin from the roster as it stands
1094
- // NOW: this is the only point at which they are still a member, and the
1095
- // answer decides whether the commit must carry a demotion at all.
1096
- const removedIsAdmin = await store.isGroupAdmin(groupID, memberDID);
1150
+ // Resolve whether the removed DID is an admin from the FOLDED ROSTER as it
1151
+ // stands now the same authority the commit policy enforces, not the
1152
+ // projected `role` column. The column flaps admin->member under a concurrent
1153
+ // invite-accept mirror; reading it here could report the removed sole admin
1154
+ // as a plain member and skip the last-admin guard entirely, letting the
1155
+ // group be left with no admin at all.
1156
+ const removedIsAdmin = roleFromRoster(roster, memberDID) === 'admin';
1097
1157
  // Refuse to remove the last admin: check what survives once the removed DID
1098
1158
  // is dropped. A group with no admins can never grant, revoke, or remove
1099
1159
  // again, so this is fail-closed.
1100
1160
  //
1101
- // The survivor set is read from the same projected rows the authority check
1102
- // reads, never from the ledger. A grant reaches the ledger the moment it is
1103
- // signed but reaches the projection only when its commit is enacted, so the
1104
- // ledger holds admins who cannot yet act. Counting those as survivors would
1105
- // permit removing the only admin who can — and if the pending grant's commit
1106
- // never lands, the group is left with no effective admin at all.
1161
+ // Counted from the roster for the same reason: it holds only enacted admins,
1162
+ // so it never counts a signed-but-unlanded grant as a survivor, and never
1163
+ // misses a co-admin whose projection has flapped. The removed DID is admin
1164
+ // here, so it counts; a count of one means it is the sole admin.
1107
1165
  if (removedIsAdmin) {
1108
- const members = await store.listGroupMembers(groupID);
1109
- const removedDID = normalizeDID(memberDID);
1110
- const survivors = members.filter((member)=>member.role === 'admin' && normalizeDID(member.member_did) !== removedDID);
1111
- if (survivors.length === 0) {
1166
+ if (adminCount(roster) <= 1) {
1112
1167
  // The same refusal `leave` and `requestSetMemberRole` raise, carrying
1113
1168
  // the same pair: one rule, three paths, and an app matching on the
1114
1169
  // code has to reach it from all three.
@@ -1121,30 +1176,6 @@ export function createGroupContext(ctx, deps) {
1121
1176
  });
1122
1177
  }
1123
1178
  }
1124
- // Revoke the ex-admin's control authority on the commit that evicts them.
1125
- // Only admins fold authoritatively, so only an admin needs revoking — a
1126
- // non-admin removal carries no ledger entry at all.
1127
- //
1128
- // Minted BEFORE the removal so it can ride that very commit: a receiver
1129
- // refuses a Remove whose target is still an admin in the roster the commit's
1130
- // entries fold to, so an admin can only be evicted by a commit that also
1131
- // demotes them.
1132
- //
1133
- // The token is NOT appended here. On the lane the accepted commit's
1134
- // `onAccepted` is its writer, and only that path knows the group took this
1135
- // attempt: a row written here would survive a lane that never lands,
1136
- // demoting the target in THIS device's admin-roster fold while every
1137
- // co-member still folds them as an admin.
1138
- let revokeToken = null;
1139
- if (removedIsAdmin) {
1140
- revokeToken = await signLedgerEntry(deps.identity, {
1141
- type: ADMIN_ROLE_ENTRY_TYPE,
1142
- subject: memberDID,
1143
- value: 'member',
1144
- groupID,
1145
- ord: hlc
1146
- });
1147
- }
1148
1179
  // The request is enqueued before the lane is armed so a crash can never
1149
1180
  // leave a commit driving toward a request that does not exist.
1150
1181
  const requestID = deps.runtime.getRandomID();
@@ -1158,12 +1189,17 @@ export function createGroupContext(ctx, deps) {
1158
1189
  enqueuedAt
1159
1190
  });
1160
1191
  });
1161
- // The commit is the ONLY delivery of the demotion: the token rides the
1162
- // commit's own frame as a sealed body, so every co-member resolves it,
1192
+ // The commit is the ONLY delivery of the demotion: the build mints the
1193
+ // ex-admin's demotion against the folded roster per attempt and rides it on
1194
+ // the commit's own frame as a sealed body, so every co-member resolves it,
1163
1195
  // folds it and emits from that frame. A separate `ledger:entry` broadcast
1164
1196
  // would race the commit and hand co-members a demotion the group has not
1165
1197
  // yet accepted, so none is sent.
1166
1198
  //
1199
+ // Whether the target is an admin is NOT decided here: the request-time
1200
+ // projection flaps under a concurrent invite-accept mirror, so the mint is
1201
+ // taken inside the build against the authoritative folded roster.
1202
+ //
1167
1203
  // The tombstone and the announcement move with it: they happen in the
1168
1204
  // lane's `onAccepted`, so nothing here observes a removal the group never
1169
1205
  // took. Driving is deferred to the outermost commit — awaiting the lane
@@ -1172,13 +1208,9 @@ export function createGroupContext(ctx, deps) {
1172
1208
  const build = deps.groupManager.buildRemoveCommit({
1173
1209
  groupID,
1174
1210
  memberDID,
1211
+ identity: deps.identity,
1175
1212
  hlc,
1176
- requestID,
1177
- ...revokeToken != null ? {
1178
- ledgerEntries: [
1179
- revokeToken
1180
- ]
1181
- } : {}
1213
+ requestID
1182
1214
  });
1183
1215
  deps.stores.onCommit(()=>{
1184
1216
  void deps.commitToGroup(groupID, build).catch((error)=>{
@@ -1,4 +1,6 @@
1
1
  import type { P2PStoreAPI } from '@kubun/store-p2p';
2
+ import type { RosterState } from '@kumiai/mls';
3
+ import { GraphQLError } from 'graphql';
2
4
  /**
3
5
  * `extensions.code` carried by every group-authority refusal, on the local
4
6
  * GraphQL path and on the wire alike. The specific refusal travels beside it as
@@ -54,4 +56,41 @@ export type RequireGroupAdminParams = {
54
56
  * silently discarded write into a loud, catchable failure — a stale gate is a
55
57
  * stale button, never a hole.
56
58
  */
59
+ /**
60
+ * The one refusal every admin gate raises, so a client matches the same
61
+ * `code`/`reason` pair whichever gate declined it — and whichever layer catches
62
+ * it: the resolver runs a gate ahead of the rollback wrapper precisely because
63
+ * this coded error must survive it. A gate that instead let a lower-level throw
64
+ * (e.g. a missing MLS handle) escape would strip the pair to a bare message.
65
+ */
66
+ export declare function notGroupAdminError(groupID: string, action: string): GraphQLError;
57
67
  export declare function requireGroupAdmin(params: RequireGroupAdminParams): Promise<void>;
68
+ export type AssertRosterAdminParams = {
69
+ store: P2PStoreAPI;
70
+ /** The folded roster the commit policy enforces — `handle.roster`. */
71
+ roster: RosterState;
72
+ groupID: string;
73
+ /** The DID whose authority is being checked — the caller, not the target. */
74
+ did: string;
75
+ /** Phrase completing "only a group admin can ..." in the refusal message. */
76
+ action: string;
77
+ };
78
+ /**
79
+ * Like `requireGroupAdmin`, but the admin half of the check reads the FOLDED
80
+ * ROSTER instead of the projected `role` column.
81
+ *
82
+ * The projected column flaps admin->member under a concurrent invite-accept
83
+ * mirror overwrite, so a freshly promoted admin can be refused its own control
84
+ * op the instant the mirror lags — a spurious, fail-closed NOT_GROUP_ADMIN.
85
+ * The roster is the authority the commit policy itself enforces and moves only
86
+ * on an enacted commit, so it never carries that transient flap.
87
+ *
88
+ * The membership half is unchanged and still reads the row: the flap moves the
89
+ * role column, never the row's existence, so a lagged projection cannot hide a
90
+ * member. Reading the roster ALONE would drop this half — the roster is
91
+ * DID-keyed and can hold a role for a DID with no MLS leaf — so a DID with no
92
+ * (or a tombstoned) membership row is refused even when the roster names it an
93
+ * admin. Callers pass their own DID, which always holds a leaf when they hold
94
+ * the handle, so this half only ever fires on a misuse.
95
+ */
96
+ export declare function assertRosterAdmin(params: AssertRosterAdminParams): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { GraphQLError } from 'graphql';
2
+ import { roleFromRoster } from '../groups/roster-projection.js';
2
3
  /**
3
4
  * `extensions.code` carried by every group-authority refusal, on the local
4
5
  * GraphQL path and on the wire alike. The specific refusal travels beside it as
@@ -42,12 +43,14 @@ import { GraphQLError } from 'graphql';
42
43
  * fold, which drops a non-admin's entry regardless. Refusing here turns a
43
44
  * silently discarded write into a loud, catchable failure — a stale gate is a
44
45
  * stale button, never a hole.
45
- */ export async function requireGroupAdmin(params) {
46
- const { store, groupID, did, action } = params;
47
- if (await store.isGroupAdmin(groupID, did)) {
48
- return;
49
- }
50
- throw new GraphQLError(`not authorized: only a group admin can ${action}`, {
46
+ */ /**
47
+ * The one refusal every admin gate raises, so a client matches the same
48
+ * `code`/`reason` pair whichever gate declined it — and whichever layer catches
49
+ * it: the resolver runs a gate ahead of the rollback wrapper precisely because
50
+ * this coded error must survive it. A gate that instead let a lower-level throw
51
+ * (e.g. a missing MLS handle) escape would strip the pair to a bare message.
52
+ */ export function notGroupAdminError(groupID, action) {
53
+ return new GraphQLError(`not authorized: only a group admin can ${action}`, {
51
54
  extensions: {
52
55
  code: GROUP_CONTROL_DENIED,
53
56
  reason: NOT_GROUP_ADMIN,
@@ -55,3 +58,35 @@ import { GraphQLError } from 'graphql';
55
58
  }
56
59
  });
57
60
  }
61
+ export async function requireGroupAdmin(params) {
62
+ const { store, groupID, did, action } = params;
63
+ if (await store.isGroupAdmin(groupID, did)) {
64
+ return;
65
+ }
66
+ throw notGroupAdminError(groupID, action);
67
+ }
68
+ /**
69
+ * Like `requireGroupAdmin`, but the admin half of the check reads the FOLDED
70
+ * ROSTER instead of the projected `role` column.
71
+ *
72
+ * The projected column flaps admin->member under a concurrent invite-accept
73
+ * mirror overwrite, so a freshly promoted admin can be refused its own control
74
+ * op the instant the mirror lags — a spurious, fail-closed NOT_GROUP_ADMIN.
75
+ * The roster is the authority the commit policy itself enforces and moves only
76
+ * on an enacted commit, so it never carries that transient flap.
77
+ *
78
+ * The membership half is unchanged and still reads the row: the flap moves the
79
+ * role column, never the row's existence, so a lagged projection cannot hide a
80
+ * member. Reading the roster ALONE would drop this half — the roster is
81
+ * DID-keyed and can hold a role for a DID with no MLS leaf — so a DID with no
82
+ * (or a tombstoned) membership row is refused even when the roster names it an
83
+ * admin. Callers pass their own DID, which always holds a leaf when they hold
84
+ * the handle, so this half only ever fires on a misuse.
85
+ */ export async function assertRosterAdmin(params) {
86
+ const { store, roster, groupID, did, action } = params;
87
+ const member = await store.getGroupMember(groupID, did);
88
+ if (member != null && roleFromRoster(roster, did) === 'admin') {
89
+ return;
90
+ }
91
+ throw notGroupAdminError(groupID, action);
92
+ }
@@ -188,9 +188,17 @@ export type BuildRemoveCommitParams = {
188
188
  groupID: string;
189
189
  memberDID: string;
190
190
  /**
191
- * Signed control-ledger tokens this removal enacts. Removing an admin means
192
- * riding their demotion entry on the same commit: a receiver refuses a Remove
193
- * whose target is still an admin in the roster the commit folds to.
191
+ * The identity that signs the demotion entry riding this removal. Removing an
192
+ * admin means demoting them on the same commit, and the demotion is minted
193
+ * INSIDE the per-attempt build against the folded roster never at request
194
+ * time against the projected role column, which flaps under a concurrent
195
+ * invite-accept mirror and would mint no demotion for an admin the fold still
196
+ * names, leaving the receiver to refuse the eviction.
197
+ */
198
+ identity: OwnIdentity;
199
+ /**
200
+ * Extra signed control-ledger tokens this removal must also enact, beyond the
201
+ * admin demotion the build mints for itself. Empty for a plain eviction.
194
202
  */
195
203
  ledgerEntries?: Array<string>;
196
204
  /**
@@ -5,6 +5,7 @@ import { commitInvite, commitLedgerEntries, controlCapabilities, createGroup, cr
5
5
  import { toB64 } from '@sozai/codec';
6
6
  import { toISO } from '../context/types.js';
7
7
  import { bindHubToGroup, upsertHub } from '../hub/manager.js';
8
+ import { ADMIN_ROLE_ENTRY_TYPE } from './admin-roster.js';
8
9
  import { reprojectGroupSettings } from './circle-projection.js';
9
10
  import { GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
10
11
  import { serializeCommitJournalBlob } from './commit-adoption.js';
@@ -29,6 +30,22 @@ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
29
30
  * left alone: the commit attempt fails a few lines later with the library's own
30
31
  * precise reason.
31
32
  */ async function assertRemovalDemotes(handle, memberDID, tokens) {
33
+ if (await roleAfterFolding(handle, memberDID, tokens) === 'admin') {
34
+ throw new Error(`Cannot remove ${memberDID}: still an admin in the roster this commit folds to. A demotion entry must ride the same commit.`);
35
+ }
36
+ }
37
+ /**
38
+ * The role `memberDID` holds in the roster this commit would fold to: the
39
+ * handle's current roster with `tokens` applied. `undefined` when the fold
40
+ * rejects outright — the commit attempt then fails a few lines later with the
41
+ * library's own precise reason, so this check leaves it alone.
42
+ *
43
+ * Both the demotion mint in {@link GroupManager.buildRemoveCommit} and
44
+ * {@link assertRemovalDemotes} key on this: the mint rides a demotion when it
45
+ * returns `'admin'`, and the assert then confirms the completed token set clears
46
+ * it. A caller that already supplied the demotion drives it to non-admin here, so
47
+ * no second demotion is minted.
48
+ */ async function roleAfterFolding(handle, memberDID, tokens) {
32
49
  const inputs = [];
33
50
  for (const token of tokens){
34
51
  const verified = await verifyLedgerEntry(token);
@@ -42,11 +59,9 @@ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
42
59
  }
43
60
  const folded = foldEnvelope(handle.roster, handle.registry, inputs, handle.groupID);
44
61
  if (!folded.ok) {
45
- return;
46
- }
47
- if (folded.roster.roles.get(normalizeDID(memberDID)) === 'admin') {
48
- throw new Error(`Cannot remove ${memberDID}: still an admin in the roster this commit folds to. A demotion entry must ride the same commit.`);
62
+ return undefined;
49
63
  }
64
+ return folded.roster.roles.get(normalizeDID(memberDID));
50
65
  }
51
66
  export class GroupManager {
52
67
  #deviceID;
@@ -668,12 +683,33 @@ export class GroupManager {
668
683
  * the intent lives in the MLS Remove proposal, which does not survive the
669
684
  * process.
670
685
  */ buildRemoveCommit(params) {
671
- const tokens = params.ledgerEntries ?? [];
672
686
  return ()=>this.#registry.readHandle(params.groupID, async (handle)=>{
673
687
  const leafIndex = handle.findMemberLeafIndex(params.memberDID);
674
688
  if (leafIndex == null) {
675
689
  throw new Error(`Member ${params.memberDID} not found in MLS group`);
676
690
  }
691
+ // Fresh copy per attempt: a rebase re-runs this closure, and the demotion
692
+ // it mints below is stamped against THIS attempt's folded roster.
693
+ const tokens = [
694
+ ...params.ledgerEntries ?? []
695
+ ];
696
+ // Mint the admin demotion here, keyed on the FOLDED roster — the only
697
+ // authority on who holds admin. The request-time projection flaps under a
698
+ // concurrent invite-accept mirror, so a decision taken there would ship no
699
+ // demotion for an admin the fold still names and the receiver would refuse
700
+ // the eviction. `hlc` orders the demotion with the tombstone riding the
701
+ // same commit. A caller that already supplied the demotion in
702
+ // `ledgerEntries` drives the folded role to non-admin, so none is added
703
+ // twice.
704
+ if (await roleAfterFolding(handle, params.memberDID, tokens) === 'admin') {
705
+ tokens.push(await signLedgerEntry(params.identity, {
706
+ type: ADMIN_ROLE_ENTRY_TYPE,
707
+ subject: params.memberDID,
708
+ value: 'member',
709
+ groupID: params.groupID,
710
+ ord: params.hlc
711
+ }));
712
+ }
677
713
  await assertRemovalDemotes(handle, params.memberDID, tokens);
678
714
  const { commitMessage, newGroup } = await removeMember(handle, leafIndex, tokens);
679
715
  return {
package/lib/schema.js CHANGED
@@ -1252,7 +1252,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
1252
1252
  // must roll it back: a queued request no lane settles is `pending` forever.
1253
1253
  // Returns `pending`, not awaited: the commit lane runs on the outermost
1254
1254
  // commit, after this transaction closes.
1255
- return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'remove a member'), async ()=>{
1255
+ return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'remove a member'), async ()=>{
1256
1256
  const request = await requireP2P(context).group.requestRemoveMember(input);
1257
1257
  return {
1258
1258
  request: toControlRequestSDL(request)
@@ -1266,7 +1266,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
1266
1266
  // Returns `pending`, not awaited: the commit lane runs on the outermost
1267
1267
  // commit, after this transaction closes.
1268
1268
  //
1269
- return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.requireAdmin(input.groupID, 'set member roles'), async ()=>{
1269
+ return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'set member roles'), async ()=>{
1270
1270
  const request = await requireP2P(context).group.requestSetMemberRole(input);
1271
1271
  return {
1272
1272
  request: toControlRequestSDL(request)
package/lib/types.d.ts CHANGED
@@ -198,6 +198,15 @@ export type GroupRequestContext = {
198
198
  * re-raises and strips extensions — that ordering is what keeps the code visible.
199
199
  */
200
200
  requireAdmin: (groupID: string, action: string) => Promise<void>;
201
+ /**
202
+ * `requireAdmin` for the control-mint ops (remove a member, set a member role),
203
+ * reading admin authority from the FOLDED ROSTER instead of the projected
204
+ * `role` column. The column flaps admin->member under a concurrent invite-accept
205
+ * mirror, so a freshly promoted admin would be spuriously refused NOT_GROUP_ADMIN
206
+ * on its own op; the roster carries no such flap. Same code/reason as
207
+ * `requireAdmin`, so a caller matching on the pair is unaffected.
208
+ */
209
+ requireAdminByRoster: (groupID: string, action: string) => Promise<void>;
201
210
  /**
202
211
  * `requireAdmin` for an operation that only knows a circle: resolves the
203
212
  * circle's group and gates on that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -72,7 +72,7 @@
72
72
  "@kubun/db-better-sqlite": "^0.17.0",
73
73
  "@kubun/db-node-sqlite": "^0.17.0",
74
74
  "@kubun/db-postgres": "^0.17.0",
75
- "@kubun/hub": "^0.17.0",
75
+ "@kubun/hub": "^0.17.3",
76
76
  "@kubun/plugin-blob": "^0.17.0",
77
77
  "@kubun/plugin-connector": "^0.17.1",
78
78
  "@kubun/plugin-service-server": "^0.17.0",