@kubun/plugin-p2p 0.17.0 → 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.
- package/lib/context/group.js +92 -60
- package/lib/context/require-admin.d.ts +39 -0
- package/lib/context/require-admin.js +41 -6
- package/lib/groups/manager.d.ts +11 -3
- package/lib/groups/manager.js +41 -5
- package/lib/schema.js +81 -2
- package/lib/types.d.ts +9 -0
- package/package.json +3 -3
package/lib/context/group.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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)=>
|
|
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
|
-
|
|
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
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
1096
|
-
|
|
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
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
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
|
-
|
|
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
|
|
1162
|
-
//
|
|
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
|
-
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
package/lib/groups/manager.d.ts
CHANGED
|
@@ -188,9 +188,17 @@ export type BuildRemoveCommitParams = {
|
|
|
188
188
|
groupID: string;
|
|
189
189
|
memberDID: string;
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
/**
|
package/lib/groups/manager.js
CHANGED
|
@@ -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
|
@@ -114,6 +114,11 @@ const CONTROL_REQUEST_KIND_TO_SDL = {
|
|
|
114
114
|
invite: 'INVITE',
|
|
115
115
|
remove: 'REMOVE'
|
|
116
116
|
};
|
|
117
|
+
// Internal lowercase record-provisioning outcome -> SDL enum name.
|
|
118
|
+
const RECORD_PROVISIONING_STATUS_TO_SDL = {
|
|
119
|
+
recorded: 'RECORDED',
|
|
120
|
+
retry: 'RETRY'
|
|
121
|
+
};
|
|
117
122
|
/** Project a control request into its SDL shape, mapping both enums to uppercase. */ function toControlRequestSDL(request) {
|
|
118
123
|
return {
|
|
119
124
|
...request,
|
|
@@ -692,6 +697,33 @@ type CredentialProvisioningStatus {
|
|
|
692
697
|
reason: String
|
|
693
698
|
}
|
|
694
699
|
|
|
700
|
+
type BeginProvisioningExpectationPayload {
|
|
701
|
+
"""
|
|
702
|
+
Freshly minted attempt id; pass it to recordProvisioningExpectation /
|
|
703
|
+
abandonProvisioningExpectation to close this attempt.
|
|
704
|
+
"""
|
|
705
|
+
attemptId: ID!
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
enum RecordProvisioningExpectationStatus {
|
|
709
|
+
"""The attempt's epoch bound the provisioning floor for this owner/grantor."""
|
|
710
|
+
RECORDED
|
|
711
|
+
"""
|
|
712
|
+
A higher manifest already landed; the floor was not lowered. Re-run the grant
|
|
713
|
+
with minEpoch, then record the new attempt.
|
|
714
|
+
"""
|
|
715
|
+
RETRY
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
type RecordProvisioningExpectationPayload {
|
|
719
|
+
status: RecordProvisioningExpectationStatus!
|
|
720
|
+
"""
|
|
721
|
+
Present only on RETRY: the recipient's now-held epoch. Re-run the grant with
|
|
722
|
+
this as the new minEpoch.
|
|
723
|
+
"""
|
|
724
|
+
minEpoch: Int
|
|
725
|
+
}
|
|
726
|
+
|
|
695
727
|
extend type Mutation {
|
|
696
728
|
connectPeer(url: String!): ConnectPeerPayload!
|
|
697
729
|
sharePeerGroup(peerDID: ID!, groupID: ID, name: String, send: ShareInput, receive: ShareReceiveInput): SharePeerGroupPayload!
|
|
@@ -711,6 +743,27 @@ extend type Mutation {
|
|
|
711
743
|
reprovision attempt always supersedes a manifest it already holds. Defaults to 0.
|
|
712
744
|
"""
|
|
713
745
|
grantDeviceCredentials(groupID: ID!, recipientDID: DID!, ownerDID: DID!, minEpoch: Int): GrantDeviceCredentialsPayload!
|
|
746
|
+
"""
|
|
747
|
+
Arm this device to converge a credential grant for ownerDID from grantorDID:
|
|
748
|
+
write the provisioning-expectation row that lets the credential-reconcile PULL
|
|
749
|
+
fetch that owner's buckets (a delegate-signed grant converges only via the
|
|
750
|
+
pull, never the live apply). Call before asking the grantor to grant, then
|
|
751
|
+
recordProvisioningExpectation once the grant returns its epoch. Mirrors the
|
|
752
|
+
credentialProvisioningStatus query's recipient-local surface.
|
|
753
|
+
"""
|
|
754
|
+
beginProvisioningExpectation(ownerDID: DID!, grantorDID: DID!): BeginProvisioningExpectationPayload!
|
|
755
|
+
"""
|
|
756
|
+
Bind the provisioning floor for an in-flight attempt to the epoch its grant was
|
|
757
|
+
signed under. Returns RECORDED when the floor binds, or RETRY with the held
|
|
758
|
+
epoch when a higher manifest already landed (re-grant with minEpoch, then record
|
|
759
|
+
the new attempt).
|
|
760
|
+
"""
|
|
761
|
+
recordProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptId: ID!, epoch: Int!): RecordProvisioningExpectationPayload!
|
|
762
|
+
"""
|
|
763
|
+
Stop tracking one provisioning attempt. Removes only this attemptId; never binds
|
|
764
|
+
or lowers a floor. Returns true.
|
|
765
|
+
"""
|
|
766
|
+
abandonProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptId: ID!): Boolean!
|
|
714
767
|
joinPeerGroup(peerDID: ID!, groupID: ID!): JoinPeerGroupPayload!
|
|
715
768
|
"""
|
|
716
769
|
Apply one circle's desired sync end state. A null argument leaves that dimension
|
|
@@ -1043,6 +1096,32 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1043
1096
|
epoch
|
|
1044
1097
|
};
|
|
1045
1098
|
},
|
|
1099
|
+
beginProvisioningExpectation: async (_source, args, context)=>{
|
|
1100
|
+
return await requireP2P(context).peer.beginProvisioningExpectation({
|
|
1101
|
+
ownerDID: args.ownerDID,
|
|
1102
|
+
grantorDID: args.grantorDID
|
|
1103
|
+
});
|
|
1104
|
+
},
|
|
1105
|
+
recordProvisioningExpectation: async (_source, args, context)=>{
|
|
1106
|
+
const result = await requireP2P(context).peer.recordProvisioningExpectation({
|
|
1107
|
+
ownerDID: args.ownerDID,
|
|
1108
|
+
grantorDID: args.grantorDID,
|
|
1109
|
+
attemptId: args.attemptId,
|
|
1110
|
+
epoch: args.epoch
|
|
1111
|
+
});
|
|
1112
|
+
return {
|
|
1113
|
+
status: RECORD_PROVISIONING_STATUS_TO_SDL[result.status],
|
|
1114
|
+
minEpoch: result.status === 'retry' ? result.minEpoch : null
|
|
1115
|
+
};
|
|
1116
|
+
},
|
|
1117
|
+
abandonProvisioningExpectation: async (_source, args, context)=>{
|
|
1118
|
+
await requireP2P(context).peer.abandonProvisioningExpectation({
|
|
1119
|
+
ownerDID: args.ownerDID,
|
|
1120
|
+
grantorDID: args.grantorDID,
|
|
1121
|
+
attemptId: args.attemptId
|
|
1122
|
+
});
|
|
1123
|
+
return true;
|
|
1124
|
+
},
|
|
1046
1125
|
joinPeerGroup: async (_source, args, context)=>{
|
|
1047
1126
|
return await requireP2P(context).peer.joinPeerGroup({
|
|
1048
1127
|
peerDID: args.peerDID,
|
|
@@ -1173,7 +1252,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1173
1252
|
// must roll it back: a queued request no lane settles is `pending` forever.
|
|
1174
1253
|
// Returns `pending`, not awaited: the commit lane runs on the outermost
|
|
1175
1254
|
// commit, after this transaction closes.
|
|
1176
|
-
return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.
|
|
1255
|
+
return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'remove a member'), async ()=>{
|
|
1177
1256
|
const request = await requireP2P(context).group.requestRemoveMember(input);
|
|
1178
1257
|
return {
|
|
1179
1258
|
request: toControlRequestSDL(request)
|
|
@@ -1187,7 +1266,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1187
1266
|
// Returns `pending`, not awaited: the commit lane runs on the outermost
|
|
1188
1267
|
// commit, after this transaction closes.
|
|
1189
1268
|
//
|
|
1190
|
-
return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.
|
|
1269
|
+
return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'set member roles'), async ()=>{
|
|
1191
1270
|
const request = await requireP2P(context).group.requestSetMemberRole(input);
|
|
1192
1271
|
return {
|
|
1193
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.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -72,9 +72,9 @@
|
|
|
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.
|
|
75
|
+
"@kubun/hub": "^0.17.3",
|
|
76
76
|
"@kubun/plugin-blob": "^0.17.0",
|
|
77
|
-
"@kubun/plugin-connector": "^0.17.
|
|
77
|
+
"@kubun/plugin-connector": "^0.17.1",
|
|
78
78
|
"@kubun/plugin-service-server": "^0.17.0",
|
|
79
79
|
"@kubun/plugin-workflow": "^0.17.0",
|
|
80
80
|
"@kubun/service-graph-api": "^0.17.0",
|