@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.
@@ -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)=>{
@@ -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 attemptId = crypto.randomUUID();
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
- attemptId
1396
+ attemptID
1397
1397
  });
1398
1398
  return {
1399
- attemptId
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
- attemptId: params.attemptId,
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
- attemptId: params.attemptId
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
- */ 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
+ }
@@ -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
- missing.push(key.keyID);
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
  /**
@@ -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
@@ -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
- Freshly minted attempt id; pass it to recordProvisioningExpectation /
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 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.
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!): BeginProvisioningExpectationPayload!
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!, attemptId: ID!, epoch: Int!): RecordProvisioningExpectationPayload!
771
+ recordProvisioningExpectation(ownerDID: DID!, grantorDID: DID!, attemptID: ID!, epoch: Int!): RecordProvisioningExpectationPayload!
762
772
  """
763
- Stop tracking one provisioning attempt. Removes only this attemptId; never binds
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!, attemptId: ID!): Boolean!
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
- attemptId: args.attemptId,
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
- attemptId: args.attemptId
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.requireAdmin(input.groupID, 'remove a member'), async ()=>{
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.requireAdmin(input.groupID, 'set member roles'), async ()=>{
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
- /** Freshly minted attempt id; pass it to `recordProvisioningExpectation`/`abandonProvisioningExpectation`. */
656
- attemptId: string;
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
- attemptId: string;
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
- attemptId: string;
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 `attemptId` and forces `credentialProvisioningStatus` to `pending`
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 `attemptId`.
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
- * `attemptId` from the outstanding set, never touches the epoch floor.
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.1",
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.0",
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.0",
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": "^16.14.2",
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.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",