@kubun/plugin-p2p 0.17.1 → 0.17.3
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/peer.js +12 -5
- package/lib/context/require-admin.d.ts +39 -0
- package/lib/context/require-admin.js +41 -6
- package/lib/groups/credential-readiness.d.ts +15 -0
- package/lib/groups/credential-readiness.js +35 -7
- package/lib/groups/manager.d.ts +11 -3
- package/lib/groups/manager.js +41 -5
- package/lib/schema.js +46 -19
- package/lib/types.d.ts +55 -7
- package/package.json +5 -5
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)=>{
|
package/lib/context/peer.js
CHANGED
|
@@ -1389,14 +1389,14 @@ export function createPeerContext(ctx, deps) {
|
|
|
1389
1389
|
},
|
|
1390
1390
|
beginProvisioningExpectation: async (params)=>{
|
|
1391
1391
|
const store = await getCredentialStore(deps.stores);
|
|
1392
|
-
const
|
|
1392
|
+
const attemptID = params.attemptID ?? deps.runtime.getRandomID();
|
|
1393
1393
|
await store.beginProvisioningAttempt({
|
|
1394
1394
|
ownerDID: normalizeDID(params.ownerDID),
|
|
1395
1395
|
grantorDID: normalizeDID(params.grantorDID),
|
|
1396
|
-
|
|
1396
|
+
attemptID
|
|
1397
1397
|
});
|
|
1398
1398
|
return {
|
|
1399
|
-
|
|
1399
|
+
attemptID
|
|
1400
1400
|
};
|
|
1401
1401
|
},
|
|
1402
1402
|
recordProvisioningExpectation: async (params)=>{
|
|
@@ -1422,7 +1422,7 @@ export function createPeerContext(ctx, deps) {
|
|
|
1422
1422
|
return await txStore.recordProvisioningAttempt({
|
|
1423
1423
|
ownerDID: owner,
|
|
1424
1424
|
grantorDID: grantor,
|
|
1425
|
-
|
|
1425
|
+
attemptID: params.attemptID,
|
|
1426
1426
|
epoch: params.epoch,
|
|
1427
1427
|
heldEpoch
|
|
1428
1428
|
});
|
|
@@ -1433,7 +1433,14 @@ export function createPeerContext(ctx, deps) {
|
|
|
1433
1433
|
await store.abandonProvisioningAttempt({
|
|
1434
1434
|
ownerDID: normalizeDID(params.ownerDID),
|
|
1435
1435
|
grantorDID: normalizeDID(params.grantorDID),
|
|
1436
|
-
|
|
1436
|
+
attemptID: params.attemptID
|
|
1437
|
+
});
|
|
1438
|
+
},
|
|
1439
|
+
abandonProvisioningExpectations: async (params)=>{
|
|
1440
|
+
const store = await getCredentialStore(deps.stores);
|
|
1441
|
+
await store.abandonProvisioningAttempts({
|
|
1442
|
+
ownerDID: normalizeDID(params.ownerDID),
|
|
1443
|
+
grantorDID: normalizeDID(params.grantorDID)
|
|
1437
1444
|
});
|
|
1438
1445
|
},
|
|
1439
1446
|
provisioningHeldEpoch: async (params)=>{
|
|
@@ -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
|
+
}
|
|
@@ -7,10 +7,25 @@ import { type DelegatedWrappingCheckDeps } from './grantor-authority.js';
|
|
|
7
7
|
* decrypt-materialization; `reason` explains a `false` verdict (absent on
|
|
8
8
|
* `complete`).
|
|
9
9
|
*/
|
|
10
|
+
export type CredentialProvisioningGrantorStatus = {
|
|
11
|
+
grantorDID: string;
|
|
12
|
+
/** Manifest at/above floor, proof verifies now, and every key materialized. */
|
|
13
|
+
satisfied: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The held delegation proof verifies right now. `false` only when the
|
|
16
|
+
* administer-chain check on a HELD manifest returns false (revoked/expired
|
|
17
|
+
* OR transiently unvalidatable). An un-pulled/below-floor manifest and a
|
|
18
|
+
* self-grantor leave it `true` — no held proof has failed.
|
|
19
|
+
*/
|
|
20
|
+
authorityValid: boolean;
|
|
21
|
+
/** This grantor's keys not yet materialized here. */
|
|
22
|
+
missing: Array<string>;
|
|
23
|
+
};
|
|
10
24
|
export type CredentialProvisioningReadiness = {
|
|
11
25
|
complete: boolean;
|
|
12
26
|
missing: Array<string>;
|
|
13
27
|
reason?: 'not-initiated' | 'pending' | 'incomplete';
|
|
28
|
+
grantors: Array<CredentialProvisioningGrantorStatus>;
|
|
14
29
|
};
|
|
15
30
|
/**
|
|
16
31
|
* Whether one expected key is materialized on this device AT the manifest's
|
|
@@ -76,7 +76,8 @@ import { checkAdministerChain } from './grantor-authority.js';
|
|
|
76
76
|
return {
|
|
77
77
|
complete: false,
|
|
78
78
|
missing: [],
|
|
79
|
-
reason: 'pending'
|
|
79
|
+
reason: 'pending',
|
|
80
|
+
grantors: []
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
// Rule 2: nothing has ever been asked for — never a vacuous complete.
|
|
@@ -84,7 +85,8 @@ import { checkAdministerChain } from './grantor-authority.js';
|
|
|
84
85
|
return {
|
|
85
86
|
complete: false,
|
|
86
87
|
missing: [],
|
|
87
|
-
reason: 'not-initiated'
|
|
88
|
+
reason: 'not-initiated',
|
|
89
|
+
grantors: []
|
|
88
90
|
};
|
|
89
91
|
}
|
|
90
92
|
const manifests = await store.listManifests({
|
|
@@ -96,13 +98,21 @@ import { checkAdministerChain } from './grantor-authority.js';
|
|
|
96
98
|
manifest
|
|
97
99
|
]));
|
|
98
100
|
const missing = [];
|
|
101
|
+
const grantors = [];
|
|
99
102
|
let allSatisfied = true;
|
|
100
103
|
for (const expectation of expectations){
|
|
101
104
|
const grantor = normalizeDID(expectation.grantorDID);
|
|
102
105
|
const manifest = byGrantor.get(grantor);
|
|
103
106
|
// No held manifest at/above the floor: this expectation names no known
|
|
104
|
-
// target keys to report as `missing` — it is simply unsatisfied.
|
|
107
|
+
// target keys to report as `missing` — it is simply unsatisfied. No held
|
|
108
|
+
// proof has failed here, so `authorityValid` stays true.
|
|
105
109
|
if (manifest == null || manifest.record.epoch < expectation.epochFloor) {
|
|
110
|
+
grantors.push({
|
|
111
|
+
grantorDID: grantor,
|
|
112
|
+
satisfied: false,
|
|
113
|
+
authorityValid: true,
|
|
114
|
+
missing: []
|
|
115
|
+
});
|
|
106
116
|
allSatisfied = false;
|
|
107
117
|
continue;
|
|
108
118
|
}
|
|
@@ -114,9 +124,16 @@ import { checkAdministerChain } from './grantor-authority.js';
|
|
|
114
124
|
});
|
|
115
125
|
if (!authorized) {
|
|
116
126
|
// Expired/revoked/unwired proof: fail-closed means unmet, not thrown.
|
|
127
|
+
grantors.push({
|
|
128
|
+
grantorDID: grantor,
|
|
129
|
+
satisfied: false,
|
|
130
|
+
authorityValid: false,
|
|
131
|
+
missing: []
|
|
132
|
+
});
|
|
117
133
|
allSatisfied = false;
|
|
118
134
|
continue;
|
|
119
135
|
}
|
|
136
|
+
const grantorMissing = [];
|
|
120
137
|
for (const key of manifest.record.keys){
|
|
121
138
|
let materialized = false;
|
|
122
139
|
try {
|
|
@@ -134,21 +151,32 @@ import { checkAdministerChain } from './grantor-authority.js';
|
|
|
134
151
|
materialized = false;
|
|
135
152
|
}
|
|
136
153
|
if (!materialized) {
|
|
137
|
-
|
|
138
|
-
allSatisfied = false;
|
|
154
|
+
grantorMissing.push(key.keyID);
|
|
139
155
|
}
|
|
140
156
|
}
|
|
157
|
+
if (grantorMissing.length > 0) {
|
|
158
|
+
missing.push(...grantorMissing);
|
|
159
|
+
allSatisfied = false;
|
|
160
|
+
}
|
|
161
|
+
grantors.push({
|
|
162
|
+
grantorDID: grantor,
|
|
163
|
+
satisfied: grantorMissing.length === 0,
|
|
164
|
+
authorityValid: true,
|
|
165
|
+
missing: grantorMissing
|
|
166
|
+
});
|
|
141
167
|
}
|
|
142
168
|
if (allSatisfied && missing.length === 0) {
|
|
143
169
|
return {
|
|
144
170
|
complete: true,
|
|
145
|
-
missing: []
|
|
171
|
+
missing: [],
|
|
172
|
+
grantors
|
|
146
173
|
};
|
|
147
174
|
}
|
|
148
175
|
return {
|
|
149
176
|
complete: false,
|
|
150
177
|
missing,
|
|
151
|
-
reason: 'incomplete'
|
|
178
|
+
reason: 'incomplete',
|
|
179
|
+
grantors
|
|
152
180
|
};
|
|
153
181
|
}
|
|
154
182
|
/**
|
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
|
@@ -680,6 +680,17 @@ type GrantDeviceCredentialsPayload {
|
|
|
680
680
|
epoch: Int!
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
+
"""One grantor's provisioning verdict within a credentialProvisioningStatus."""
|
|
684
|
+
type CredentialProvisioningGrantorStatus {
|
|
685
|
+
grantorDID: DID!
|
|
686
|
+
"""Manifest at/above floor, proof verifies now, every key materialized."""
|
|
687
|
+
satisfied: Boolean!
|
|
688
|
+
"""False only when a held manifest's administer chain fails now (revoked/expired or transiently unvalidatable); un-pulled or self-grantor stays true."""
|
|
689
|
+
authorityValid: Boolean!
|
|
690
|
+
"""This grantor's keyIDs not yet materialized here."""
|
|
691
|
+
missing: [ID!]!
|
|
692
|
+
}
|
|
693
|
+
|
|
683
694
|
"""
|
|
684
695
|
Readiness of this device's credential provisioning for one owner. A key counts
|
|
685
696
|
materialized only when its wrapping decrypt-verifies (or, for a zero-entry key,
|
|
@@ -695,14 +706,13 @@ type CredentialProvisioningStatus {
|
|
|
695
706
|
(asked for, not yet fully materialized/proven). Absent when complete.
|
|
696
707
|
"""
|
|
697
708
|
reason: String
|
|
709
|
+
"""Per-grantor breakdown; empty on pending/not-initiated."""
|
|
710
|
+
grantors: [CredentialProvisioningGrantorStatus!]!
|
|
698
711
|
}
|
|
699
712
|
|
|
700
713
|
type BeginProvisioningExpectationPayload {
|
|
701
|
-
"""
|
|
702
|
-
|
|
703
|
-
abandonProvisioningExpectation to close this attempt.
|
|
704
|
-
"""
|
|
705
|
-
attemptId: ID!
|
|
714
|
+
"""The effective attempt id (caller-supplied or minted); pass to record/abandon to close it."""
|
|
715
|
+
attemptID: ID!
|
|
706
716
|
}
|
|
707
717
|
|
|
708
718
|
enum RecordProvisioningExpectationStatus {
|
|
@@ -745,25 +755,32 @@ extend type Mutation {
|
|
|
745
755
|
grantDeviceCredentials(groupID: ID!, recipientDID: DID!, ownerDID: DID!, minEpoch: Int): GrantDeviceCredentialsPayload!
|
|
746
756
|
"""
|
|
747
757
|
Arm this device to converge a credential grant for ownerDID from grantorDID:
|
|
748
|
-
write the provisioning-expectation row
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
758
|
+
write the provisioning-expectation row the credential-reconcile pull fetches
|
|
759
|
+
against. Call before asking the grantor to grant, then recordProvisioningExpectation
|
|
760
|
+
once the grant returns its epoch. Optional attemptID: persist it before calling
|
|
761
|
+
for crash-recovery; omit to mint server-side. Idempotent only within the
|
|
762
|
+
outstanding window (a replay after record/abandon re-pins 'pending').
|
|
753
763
|
"""
|
|
754
|
-
beginProvisioningExpectation(ownerDID: DID!, grantorDID: DID
|
|
764
|
+
beginProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptID: ID): BeginProvisioningExpectationPayload!
|
|
755
765
|
"""
|
|
756
766
|
Bind the provisioning floor for an in-flight attempt to the epoch its grant was
|
|
757
767
|
signed under. Returns RECORDED when the floor binds, or RETRY with the held
|
|
758
768
|
epoch when a higher manifest already landed (re-grant with minEpoch, then record
|
|
759
769
|
the new attempt).
|
|
760
770
|
"""
|
|
761
|
-
recordProvisioningExpectation(ownerDID: DID!, grantorDID: DID!,
|
|
771
|
+
recordProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptID: ID!, epoch: Int!): RecordProvisioningExpectationPayload!
|
|
762
772
|
"""
|
|
763
|
-
Stop tracking one provisioning attempt. Removes only this
|
|
773
|
+
Stop tracking one provisioning attempt. Removes only this attemptID; never binds
|
|
764
774
|
or lowers a floor. Returns true.
|
|
765
775
|
"""
|
|
766
|
-
abandonProvisioningExpectation(ownerDID: DID!, grantorDID: DID!,
|
|
776
|
+
abandonProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptID: ID!): Boolean!
|
|
777
|
+
"""
|
|
778
|
+
Clear EVERY outstanding provisioning attempt for a (owner, grantor) scope —
|
|
779
|
+
the recovery path when an attempt id was lost. Never binds or lowers a floor.
|
|
780
|
+
Exclusive recovery: also cancels any other in-flight attempt, so ensure none
|
|
781
|
+
is live. Returns true.
|
|
782
|
+
"""
|
|
783
|
+
abandonProvisioningExpectations(ownerDID: DID!, grantorDID: DID!): Boolean!
|
|
767
784
|
joinPeerGroup(peerDID: ID!, groupID: ID!): JoinPeerGroupPayload!
|
|
768
785
|
"""
|
|
769
786
|
Apply one circle's desired sync end state. A null argument leaves that dimension
|
|
@@ -1099,14 +1116,17 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1099
1116
|
beginProvisioningExpectation: async (_source, args, context)=>{
|
|
1100
1117
|
return await requireP2P(context).peer.beginProvisioningExpectation({
|
|
1101
1118
|
ownerDID: args.ownerDID,
|
|
1102
|
-
grantorDID: args.grantorDID
|
|
1119
|
+
grantorDID: args.grantorDID,
|
|
1120
|
+
...args.attemptID == null ? {} : {
|
|
1121
|
+
attemptID: args.attemptID
|
|
1122
|
+
}
|
|
1103
1123
|
});
|
|
1104
1124
|
},
|
|
1105
1125
|
recordProvisioningExpectation: async (_source, args, context)=>{
|
|
1106
1126
|
const result = await requireP2P(context).peer.recordProvisioningExpectation({
|
|
1107
1127
|
ownerDID: args.ownerDID,
|
|
1108
1128
|
grantorDID: args.grantorDID,
|
|
1109
|
-
|
|
1129
|
+
attemptID: args.attemptID,
|
|
1110
1130
|
epoch: args.epoch
|
|
1111
1131
|
});
|
|
1112
1132
|
return {
|
|
@@ -1118,7 +1138,14 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1118
1138
|
await requireP2P(context).peer.abandonProvisioningExpectation({
|
|
1119
1139
|
ownerDID: args.ownerDID,
|
|
1120
1140
|
grantorDID: args.grantorDID,
|
|
1121
|
-
|
|
1141
|
+
attemptID: args.attemptID
|
|
1142
|
+
});
|
|
1143
|
+
return true;
|
|
1144
|
+
},
|
|
1145
|
+
abandonProvisioningExpectations: async (_source, args, context)=>{
|
|
1146
|
+
await requireP2P(context).peer.abandonProvisioningExpectations({
|
|
1147
|
+
ownerDID: args.ownerDID,
|
|
1148
|
+
grantorDID: args.grantorDID
|
|
1122
1149
|
});
|
|
1123
1150
|
return true;
|
|
1124
1151
|
},
|
|
@@ -1252,7 +1279,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1252
1279
|
// must roll it back: a queued request no lane settles is `pending` forever.
|
|
1253
1280
|
// Returns `pending`, not awaited: the commit lane runs on the outermost
|
|
1254
1281
|
// commit, after this transaction closes.
|
|
1255
|
-
return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.
|
|
1282
|
+
return await gatedTransaction('requestRemoveGroupMember', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'remove a member'), async ()=>{
|
|
1256
1283
|
const request = await requireP2P(context).group.requestRemoveMember(input);
|
|
1257
1284
|
return {
|
|
1258
1285
|
request: toControlRequestSDL(request)
|
|
@@ -1266,7 +1293,7 @@ export function createP2PSchemaExtension(emitter, syncManager, logger = getKubun
|
|
|
1266
1293
|
// Returns `pending`, not awaited: the commit lane runs on the outermost
|
|
1267
1294
|
// commit, after this transaction closes.
|
|
1268
1295
|
//
|
|
1269
|
-
return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.
|
|
1296
|
+
return await gatedTransaction('requestSetMemberRole', ()=>requireP2P(context).group.requireAdminByRoster(input.groupID, 'set member roles'), async ()=>{
|
|
1270
1297
|
const request = await requireP2P(context).group.requestSetMemberRole(input);
|
|
1271
1298
|
return {
|
|
1272
1299
|
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.
|
|
@@ -630,6 +639,13 @@ export type CredentialProvisioningStatusParams = {
|
|
|
630
639
|
*/
|
|
631
640
|
ownerDID: string;
|
|
632
641
|
};
|
|
642
|
+
export type CredentialProvisioningGrantorStatus = {
|
|
643
|
+
grantorDID: string;
|
|
644
|
+
satisfied: boolean;
|
|
645
|
+
/** Held proof verifies now; `false` only when a held manifest's proof fails. */
|
|
646
|
+
authorityValid: boolean;
|
|
647
|
+
missing: Array<string>;
|
|
648
|
+
};
|
|
633
649
|
export type CredentialProvisioningStatusData = {
|
|
634
650
|
/** True iff every durable expectation is satisfied — `missing` is empty. */
|
|
635
651
|
complete: boolean;
|
|
@@ -644,22 +660,41 @@ export type CredentialProvisioningStatusData = {
|
|
|
644
660
|
* `'incomplete'` (recorded but not yet satisfied). Absent when `complete`.
|
|
645
661
|
*/
|
|
646
662
|
reason?: 'not-initiated' | 'pending' | 'incomplete';
|
|
663
|
+
/**
|
|
664
|
+
* Per-grantor breakdown, populated on the post-checks path (empty on
|
|
665
|
+
* `pending`/`not-initiated`). Lets a driver tell an un-pulled manifest
|
|
666
|
+
* (`authorityValid:true`, keep pulling) from a held proof that fails to
|
|
667
|
+
* verify (`authorityValid:false`, back off and await a fresh grant).
|
|
668
|
+
*/
|
|
669
|
+
grantors: Array<CredentialProvisioningGrantorStatus>;
|
|
647
670
|
};
|
|
648
671
|
export type BeginProvisioningExpectationParams = {
|
|
649
672
|
/** The credential owner this expectation tracks provisioning for. */
|
|
650
673
|
ownerDID: string;
|
|
651
674
|
/** The grantor this device expects a manifest from. */
|
|
652
675
|
grantorDID: string;
|
|
676
|
+
/**
|
|
677
|
+
* Optional caller-supplied attempt id. Supply and persist it BEFORE calling
|
|
678
|
+
* to make begin crash-recoverable — after a crash the caller still knows the
|
|
679
|
+
* id to abandon. Idempotent only within the outstanding window: a replay
|
|
680
|
+
* after record/abandon re-creates the attempt and re-pins `pending`. Omit to
|
|
681
|
+
* have the id minted server-side.
|
|
682
|
+
*/
|
|
683
|
+
attemptID?: string;
|
|
653
684
|
};
|
|
654
685
|
export type BeginProvisioningExpectationData = {
|
|
655
|
-
/**
|
|
656
|
-
|
|
686
|
+
/**
|
|
687
|
+
* The effective attempt id for this attempt: the caller-supplied
|
|
688
|
+
* `attemptID` when one was given, else a freshly minted one. Pass it to
|
|
689
|
+
* `recordProvisioningExpectation`/`abandonProvisioningExpectation`.
|
|
690
|
+
*/
|
|
691
|
+
attemptID: string;
|
|
657
692
|
};
|
|
658
693
|
export type RecordProvisioningExpectationParams = {
|
|
659
694
|
ownerDID: string;
|
|
660
695
|
grantorDID: string;
|
|
661
696
|
/** The id `beginProvisioningExpectation` minted for this attempt. */
|
|
662
|
-
|
|
697
|
+
attemptID: string;
|
|
663
698
|
/** The epoch this attempt's grant call was allocated and signed under. */
|
|
664
699
|
epoch: number;
|
|
665
700
|
};
|
|
@@ -674,7 +709,11 @@ export type AbandonProvisioningExpectationParams = {
|
|
|
674
709
|
ownerDID: string;
|
|
675
710
|
grantorDID: string;
|
|
676
711
|
/** The id to stop tracking. Removes ONLY this id; never binds or lowers a floor. */
|
|
677
|
-
|
|
712
|
+
attemptID: string;
|
|
713
|
+
};
|
|
714
|
+
export type AbandonProvisioningExpectationsParams = {
|
|
715
|
+
ownerDID: string;
|
|
716
|
+
grantorDID: string;
|
|
678
717
|
};
|
|
679
718
|
export type ProvisioningHeldEpochParams = {
|
|
680
719
|
ownerDID: string;
|
|
@@ -858,7 +897,7 @@ export type PeerRequestContext = {
|
|
|
858
897
|
credentialProvisioningStatus: (params: CredentialProvisioningStatusParams) => Promise<CredentialProvisioningStatusData>;
|
|
859
898
|
/**
|
|
860
899
|
* Begin tracking a provisioning attempt for `(ownerDID, grantorDID)`: mints
|
|
861
|
-
* an `
|
|
900
|
+
* an `attemptID` and forces `credentialProvisioningStatus` to `pending`
|
|
862
901
|
* until recorded/abandoned. Call BEFORE asking the grantor to grant, to
|
|
863
902
|
* close the epoch-allocation race window.
|
|
864
903
|
*/
|
|
@@ -869,14 +908,23 @@ export type PeerRequestContext = {
|
|
|
869
908
|
* attempt's epoch still exceeds it, the floor advances (`'recorded'`);
|
|
870
909
|
* otherwise a higher manifest already arrived and the attempt stays
|
|
871
910
|
* outstanding (`'retry'`, `minEpoch`) — retry the grant and call again with
|
|
872
|
-
* the same `
|
|
911
|
+
* the same `attemptID`.
|
|
873
912
|
*/
|
|
874
913
|
recordProvisioningExpectation: (params: RecordProvisioningExpectationParams) => Promise<RecordProvisioningExpectationData>;
|
|
875
914
|
/**
|
|
876
915
|
* Stop tracking a dead/abandoned provisioning attempt: removes only this
|
|
877
|
-
* `
|
|
916
|
+
* `attemptID` from the outstanding set, never touches the epoch floor.
|
|
878
917
|
*/
|
|
879
918
|
abandonProvisioningExpectation: (params: AbandonProvisioningExpectationParams) => Promise<void>;
|
|
919
|
+
/**
|
|
920
|
+
* Clear EVERY outstanding provisioning attempt for a (owner, grantor) scope
|
|
921
|
+
* without an id — the recovery path for an orphaned attempt whose id was
|
|
922
|
+
* lost. Never touches the epoch floor. Exclusive-recovery contract: also
|
|
923
|
+
* cancels any other live coordinator's attempt for the scope, so the caller
|
|
924
|
+
* must ensure none is in flight; when the id is known, prefer
|
|
925
|
+
* `abandonProvisioningExpectation`.
|
|
926
|
+
*/
|
|
927
|
+
abandonProvisioningExpectations: (params: AbandonProvisioningExpectationsParams) => Promise<void>;
|
|
880
928
|
/**
|
|
881
929
|
* This device's currently-held epoch for `(ownerDID, grantorDID)` — max
|
|
882
930
|
* epoch among held manifests from that grantor, or `0`. Feeds `minEpoch`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/plugin-p2p",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.3",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@kubun/db": "^0.17.0",
|
|
28
28
|
"@kubun/db-adapter": "^0.17.0",
|
|
29
29
|
"@kubun/engine": "^0.17.0",
|
|
30
|
-
"@kubun/graphql": "^0.17.
|
|
30
|
+
"@kubun/graphql": "^0.17.1",
|
|
31
31
|
"@kubun/hlc": "^0.17.0",
|
|
32
32
|
"@kubun/http-util": "^0.17.0",
|
|
33
33
|
"@kubun/id": "^0.17.0",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@kubun/protocol": "^0.17.0",
|
|
43
43
|
"@kubun/store-blob": "^0.17.0",
|
|
44
44
|
"@kubun/store-controller": "^0.17.0",
|
|
45
|
-
"@kubun/store-credential": "^0.17.
|
|
45
|
+
"@kubun/store-credential": "^0.17.1",
|
|
46
46
|
"@kubun/store-delegation": "^0.17.0",
|
|
47
47
|
"@kubun/store-graph": "^0.17.0",
|
|
48
48
|
"@kubun/store-p2p": "^0.17.0",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"@sozai/runtime": "^0.1.0",
|
|
63
63
|
"@sozai/schema": "^0.1.2",
|
|
64
64
|
"@sozai/stream": "^0.2.0",
|
|
65
|
-
"graphql": "^
|
|
65
|
+
"graphql": "^17.0.2",
|
|
66
66
|
"kysely": "^0.29.5",
|
|
67
67
|
"ts-mls": "2.0.0-rc.13"
|
|
68
68
|
},
|
|
@@ -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.
|
|
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",
|