@kubun/plugin-p2p 0.11.0 → 0.12.0

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.
Files changed (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +56 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,19 +1,51 @@
1
1
  import { createCapability, createRevocationRecord } from '@kokuin/capability';
2
2
  import { normalizeDID, stringifyToken } from '@kokuin/token';
3
3
  import { HLC } from '@kubun/hlc';
4
- import { getDelegationStore as resolveDelegationStore } from '@kubun/store-delegation';
4
+ import { MAX_CAP_TTL_SECONDS, getDelegationStore as resolveDelegationStore } from '@kubun/store-delegation';
5
5
  import { getGraphStore as resolveGraphStore } from '@kubun/store-graph';
6
6
  import { getP2PStore as resolveP2PStore } from '@kubun/store-p2p';
7
+ import { adminCount, readGroupAnchor } from '@kumiai/mls';
7
8
  import { GraphQLError } from 'graphql';
8
- import { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster, isLedgerAdminAtHLC, loadVerifiedAdminEntries, reprojectAdminRoster } from '../groups/admin-roster.js';
9
- import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from '../groups/circle-projection.js';
9
+ import { ADMIN_ROLE_ENTRY_TYPE } from '../groups/admin-roster.js';
10
+ import { signCatalogSet } from '../groups/catalog-token.js';
10
11
  import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, encodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from '../groups/circle-reducers.js';
11
- import { readGroupAnchor } from '../groups/group-anchor.js';
12
+ import { awaitControlRequestSettled, settleControlRequest, toControlRequestData } from '../groups/control-request.js';
12
13
  import { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeInvitePayload } from '../groups/invite-payload.js';
13
- import { applyInviteGrants, applyInviteRevocations, finalizeJoinedGroup } from '../groups/join-utils.js';
14
- import { ledgerEntryDigest, signLedgerEntry } from '../groups/ledger-entry.js';
15
- import { checkPeerAccess, toISO } from './types.js';
16
- /** Default write-grant lifetime in seconds (30 days). */ const DEFAULT_GRANT_EXPIRES_IN = 2_592_000;
14
+ import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
15
+ import { applyInviteControlState, applyInviteGrants, applyInviteRevocations, collectInviteSeeds, finalizeJoinedGroup } from '../groups/join-utils.js';
16
+ import { signLedgerEntry } from '../groups/ledger.js';
17
+ import { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, requireGroupAdmin } from './require-admin.js';
18
+ import { checkPeerAccess, toCircleData, toISO } from './types.js';
19
+ /** Project a stored catalog row into the GraphQL `PeerCatalog` shape. */ function toCatalogData(row) {
20
+ return {
21
+ id: row.id,
22
+ ownerDID: row.owner_did,
23
+ name: row.name,
24
+ description: row.description,
25
+ filterCriteria: JSON.stringify(row.filter_criteria),
26
+ active: row.active === 1,
27
+ sourceGroupID: row.source_group_id,
28
+ sourceCircleID: row.source_circle_id,
29
+ createdAt: toISO(row.created_at)
30
+ };
31
+ }
32
+ /**
33
+ * Default write-grant lifetime in seconds (30 days).
34
+ *
35
+ * Sits exactly at `MAX_CAP_TTL_SECONDS`. Raising it above that ceiling mints
36
+ * silently — the mint guard below tests the `expiresIn` argument, not the
37
+ * effective lifetime, so a raised default never reaches it. Every receiver then
38
+ * drops the grant, which is a harder failure to trace than a throw.
39
+ */ const DEFAULT_GRANT_EXPIRES_IN = 2_592_000;
40
+ /**
41
+ * How long an invite waits for its Add commit to land before refusing.
42
+ *
43
+ * Sized for the commit lane, not for a local write: the Add goes to the group's
44
+ * commit log and can be rebased by a concurrent commit before it is accepted, so
45
+ * the bound has to cover several attempts plus their round-trips. It is a
46
+ * refusal deadline, not a latency target — an invite that waits this long has
47
+ * hit a real fault, and the caller can retry.
48
+ */ const INVITE_COMMIT_TIMEOUT = 30_000;
17
49
  export function createGroupContext(ctx, deps) {
18
50
  let p2pStorePromise = null;
19
51
  let delegationStorePromise = null;
@@ -21,6 +53,142 @@ export function createGroupContext(ctx, deps) {
21
53
  const getP2PStore = ()=>p2pStorePromise ??= resolveP2PStore(deps.stores);
22
54
  const getDelegationStore = ()=>delegationStorePromise ??= resolveDelegationStore(deps.stores);
23
55
  const getGraphStore = ()=>graphStorePromise ??= resolveGraphStore(deps.stores);
56
+ /**
57
+ * Mint the identity of a new circle.
58
+ *
59
+ * The ONE place a circle id comes from, so the source can be changed without
60
+ * hunting call sites: the id is handed to the caller before the commit
61
+ * carrying the definition lands, and every later step names the circle by it.
62
+ */ const mintCircleID = ()=>deps.runtime.getRandomID();
63
+ /**
64
+ * Mint an already-settled `no_op` request: the state the caller asked for is
65
+ * the state that already holds, so there is no entry to sign, no commit to
66
+ * drive and nothing to broadcast. The caller still gets a real, resolvable
67
+ * request — a terminal status IS the outcome, and "nothing to do" is an
68
+ * outcome an app must be able to read back.
69
+ *
70
+ * The enqueue and the settle share one transaction so a crash can never leave
71
+ * a request that reports `pending` for work that will never run.
72
+ */ const settleNoOpRequest = async (groupID)=>{
73
+ const requestID = deps.runtime.getRandomID();
74
+ const request = await deps.stores.withTransaction(async (tx)=>{
75
+ const txStore = await resolveP2PStore(tx);
76
+ const at = new Date().toISOString();
77
+ await txStore.enqueueControlRequest({
78
+ id: requestID,
79
+ groupID,
80
+ kind: 'ledger',
81
+ enqueuedAt: at
82
+ });
83
+ await txStore.settleControlRequest({
84
+ id: requestID,
85
+ outcome: 'no_op',
86
+ settledAt: at
87
+ });
88
+ const row = await txStore.getControlRequest(requestID);
89
+ if (row == null) {
90
+ throw new Error('Failed to read the control request after settling it');
91
+ }
92
+ return toControlRequestData(row);
93
+ });
94
+ // Announce only once the row is durable: a settle emitted from inside the
95
+ // transaction would tell subscribers about a row a rollback erases.
96
+ deps.stores.onCommit(()=>{
97
+ deps.emitter.emit('controlRequestSettled', request).catch((error)=>{
98
+ deps.logger.error('controlRequestSettled emit failed', {
99
+ groupID,
100
+ error
101
+ });
102
+ });
103
+ });
104
+ return request;
105
+ };
106
+ /**
107
+ * Report a commit lane's refusal, from inside the lane.
108
+ *
109
+ * Takes no store provider, on purpose. Every caller runs inside a
110
+ * `stores.onCommit` callback, and those fire AFTER the mutation's transaction
111
+ * has committed — under `mutateGraph` `deps.stores` IS that transaction, so a
112
+ * settle written through it waits on a transaction that is already over and
113
+ * never lands. Silently: `settleControlRequest` swallows everything, so the
114
+ * row stays `pending`, `controlRequestSettled` never fires, and every waiter
115
+ * times out instead of learning the outcome. `deps.deviceStores` exists for
116
+ * exactly this, and a helper that closes over it is what stops the next
117
+ * deferred settle reaching for the wrong provider.
118
+ */ const settleRejectedFromLane = (requestID, error)=>{
119
+ void settleControlRequest({
120
+ stores: deps.deviceStores,
121
+ requestID,
122
+ outcome: 'rejected',
123
+ error: error instanceof Error ? error.message : String(error),
124
+ emitter: deps.emitter,
125
+ logger: deps.logger
126
+ });
127
+ };
128
+ /**
129
+ * Queue the request that will report a signed entry's outcome, then drive the
130
+ * MLS commit that enacts it.
131
+ *
132
+ * Nothing is projected, broadcast or emitted here, and there is nowhere to write
133
+ * the entry: an entry enters the group's ledger by being ENACTED. This device
134
+ * then folds its own committed entry through the same `ingestVerifiedLedgerEntry`
135
+ * a receiver runs, from the same signed token.
136
+ */ const enqueueLedgerEntryRequest = async (params)=>{
137
+ const { groupID, token } = params;
138
+ const requestID = deps.runtime.getRandomID();
139
+ const enqueuedAt = new Date().toISOString();
140
+ await deps.stores.withTransaction(async (tx)=>{
141
+ const txStore = await resolveP2PStore(tx);
142
+ await txStore.enqueueControlRequest({
143
+ id: requestID,
144
+ groupID,
145
+ kind: 'ledger',
146
+ enqueuedAt
147
+ });
148
+ });
149
+ // The commit is the ONLY delivery: the token rides the commit's own frame as
150
+ // a sealed body, so every co-member resolves it, folds it and emits from
151
+ // that frame. A separate `ledger:entry` broadcast would race the commit and
152
+ // hand co-members an entry the group has not yet accepted, so none is sent.
153
+ //
154
+ // It is driven AFTER the request row is durable, and decoupled from this
155
+ // resolver's write transaction — the local MLS handle advances only when the
156
+ // hub accepts — so it is deferred to the outermost commit. Deferring is what
157
+ // guarantees the request exists to be settled by the time the lane can
158
+ // settle it.
159
+ const build = deps.groupManager.buildEnactLedgerCommit({
160
+ groupID,
161
+ tokens: [
162
+ token
163
+ ],
164
+ requestID
165
+ });
166
+ deps.stores.onCommit(()=>{
167
+ void deps.commitToGroup(groupID, build).catch((error)=>{
168
+ deps.logger.error('enact-ledger commit lane failed', {
169
+ groupID,
170
+ error
171
+ });
172
+ // A lane throw is the group refusing the entry or the deadline
173
+ // expiring — the request is over and the app must be told. The local
174
+ // admin gate is NOT this: it throws before anything is enqueued, so a
175
+ // refused caller has no row to settle.
176
+ settleRejectedFromLane(requestID, error);
177
+ });
178
+ });
179
+ // Built from the values just written rather than read back: this handler
180
+ // runs inside `mutateGraph`'s write transaction, so the enqueued row is not
181
+ // yet visible on any other connection.
182
+ return {
183
+ id: requestID,
184
+ groupID,
185
+ kind: 'ledger',
186
+ status: 'pending',
187
+ error: null,
188
+ enqueuedAt,
189
+ settledAt: null
190
+ };
191
+ };
24
192
  return {
25
193
  create: async (input)=>{
26
194
  const result = await deps.groupManager.createGroup({
@@ -80,29 +248,46 @@ export function createGroupContext(ctx, deps) {
80
248
  createdAt: toISO(row.created_at)
81
249
  };
82
250
  },
83
- addMember: async (groupID, memberDID, role)=>{
251
+ requireAdmin: async (groupID, action)=>{
84
252
  const store = await getP2PStore();
85
- await store.addGroupMember({
86
- group_id: groupID,
87
- member_did: memberDID,
88
- role,
89
- hlc: HLC.serialize(deps.hlc.now())
90
- });
91
- const row = await store.getGroupMember(groupID, memberDID);
92
- if (row == null) throw new Error('Failed to add group member');
93
- const memberData = {
94
- groupID: row.group_id,
95
- memberDID: row.member_did,
96
- role: row.role,
97
- createdAt: toISO(row.created_at)
98
- };
99
- await deps.emitter.emit('groupMemberJoined', {
100
- ...memberData,
101
- groupID: memberData.groupID
253
+ await requireGroupAdmin({
254
+ store,
255
+ groupID,
256
+ did: deps.identity.id,
257
+ action
258
+ });
259
+ },
260
+ requireCircleAdmin: async (circleID, action)=>{
261
+ const store = await getP2PStore();
262
+ // Tombstoned circles count: deleting an already-deleted circle is a no-op
263
+ // the gate still has to run ahead of, and skipping them here would send
264
+ // that path back through a context-method gate whose extensions the
265
+ // rollback wrapper strips.
266
+ const circle = await store.getCircle(circleID, {
267
+ includeRemoved: true
102
268
  });
103
- return memberData;
269
+ if (circle == null) {
270
+ // A gate that returns for an unknown id passes every caller it cannot
271
+ // place in a group. Safe today only because each op re-reads the circle
272
+ // and refuses, so the gate would be the only thing between a stranger and
273
+ // an operation if one ever stopped.
274
+ throw new Error(`Failed to ${action}: circle not found`);
275
+ }
276
+ await requireGroupAdmin({
277
+ store,
278
+ groupID: circle.group_id,
279
+ did: deps.identity.id,
280
+ action
281
+ });
282
+ },
283
+ isAdmin: async (groupID, did)=>{
284
+ const store = await getP2PStore();
285
+ // The same call `requireGroupAdmin` makes, deliberately: one membership
286
+ // row is the single answer to "may this DID act as an admin", so what a
287
+ // read surface reports and what the gate enforces cannot drift apart.
288
+ return await store.isGroupAdmin(groupID, did);
104
289
  },
105
- setMemberRole: async ({ groupID, memberDID, role })=>{
290
+ requestSetMemberRole: async ({ groupID, memberDID, role })=>{
106
291
  const store = await getP2PStore();
107
292
  // Read the anchor through the engine transaction's stores: under
108
293
  // `mutateGraph` this handler already runs inside that transaction, so the
@@ -119,99 +304,115 @@ export function createGroupContext(ctx, deps) {
119
304
  // Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
120
305
  // authority — a non-admin's entry is dropped at fold time — but checking
121
306
  // here lets the caller fail loudly instead of silently emitting a no-op.
122
- if (!await isLedgerAdminAtHLC({
307
+ // The GraphQL resolver runs this same gate before its rollback wrapper so
308
+ // the refusal keeps its code; repeating it here covers direct callers.
309
+ await requireGroupAdmin({
123
310
  store,
124
311
  groupID,
125
- anchor,
126
312
  did: selfDID,
127
- atHLC: hlc,
128
- logger: deps.logger
129
- })) {
130
- throw new Error('not authorized: only a group admin can set member roles');
131
- }
313
+ action: 'set member roles'
314
+ });
132
315
  if (role === 'admin') {
133
316
  // Admin implies membership: a grant must target an existing member.
134
317
  if (!await store.isGroupMemberAtHLC(groupID, memberDID, hlc)) {
135
318
  throw new Error('cannot grant admin to a non-member');
136
319
  }
137
- } else if (!await isLedgerAdminAtHLC({
138
- store,
139
- groupID,
140
- anchor,
141
- did: memberDID,
142
- atHLC: hlc,
143
- logger: deps.logger
144
- })) {
145
- // Demoting a DID that is not currently an admin is a no-op; don't mint a
146
- // dead ledger entry + broadcast for it.
147
- const existing = await store.getGroupMember(groupID, memberDID);
148
- if (existing == null) {
149
- throw new Error('cannot change role: not a group member');
320
+ } else {
321
+ if (!await store.isGroupAdmin(groupID, memberDID)) {
322
+ // Demoting a DID that is not currently an admin is a no-op; don't mint
323
+ // a dead ledger entry + broadcast for it. The caller still gets a real,
324
+ // resolvable request — one that is already settled, because the state
325
+ // it asked for is the state that already holds. No lane runs, so
326
+ // nothing here may touch the commit path.
327
+ const existing = await store.getGroupMember(groupID, memberDID);
328
+ if (existing == null) {
329
+ throw new Error('cannot change role: not a group member');
330
+ }
331
+ return await settleNoOpRequest(groupID);
332
+ }
333
+ // Refuse the last admin's demotion where the caller can see it, the same
334
+ // guard `leave` and `requestRemoveMember` apply to their own paths. The
335
+ // group would refuse it anyway — the fold drops an entry that empties the
336
+ // admin set — but that refusal arrives asynchronously, on the lane, as a
337
+ // `rejected` settle the caller has to wait for, and a group with no admin
338
+ // can never grant, revoke or remove again, so this is fail-closed.
339
+ //
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) {
348
+ throw new GraphQLError('cannot demote: this is the only admin — promote another member first', {
349
+ extensions: {
350
+ code: GROUP_CONTROL_DENIED,
351
+ reason: LAST_GROUP_ADMIN,
352
+ groupID
353
+ }
354
+ });
150
355
  }
151
- return {
152
- groupID: existing.group_id,
153
- memberDID: existing.member_did,
154
- role: existing.role,
155
- createdAt: toISO(existing.created_at)
156
- };
157
356
  }
158
- const value = role === 'admin' ? 'admin' : 'revoked';
357
+ const value = role === 'admin' ? 'admin' : 'member';
159
358
  const token = await signLedgerEntry(deps.identity, {
160
359
  type: ADMIN_ROLE_ENTRY_TYPE,
161
360
  subject: memberDID,
162
361
  value,
163
- hlc
362
+ groupID,
363
+ ord: hlc
164
364
  });
165
- const entryID = ledgerEntryDigest(token);
166
- // Append the durable entry and reproject the admin overlay atomically, so a
167
- // crash can never leave the durable ledger and the projected `role` column
168
- // diverged. Reads inside the transaction use the transaction-bound store.
169
- // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
170
- const memberData = await deps.stores.withTransaction(async (tx)=>{
365
+ // The `role` column and `groupMemberRoleChanged` are both written by the
366
+ // roster mirror when the commit lands, so what comes back is the PENDING
367
+ // request, not a member row.
368
+ const requestID = deps.runtime.getRandomID();
369
+ const enqueuedAt = new Date().toISOString();
370
+ await deps.stores.withTransaction(async (tx)=>{
171
371
  const txStore = await resolveP2PStore(tx);
172
- await txStore.appendLedgerEntry({
173
- group_id: groupID,
174
- entry_id: entryID,
175
- type: ADMIN_ROLE_ENTRY_TYPE,
176
- subject: memberDID,
177
- value,
178
- hlc,
179
- issuer_did: selfDID,
180
- signed_token: token
181
- });
182
- await reprojectAdminRoster({
183
- store: txStore,
372
+ await txStore.enqueueControlRequest({
373
+ id: requestID,
184
374
  groupID,
185
- anchor,
186
- logger: deps.logger
375
+ kind: 'ledger',
376
+ enqueuedAt
187
377
  });
188
- const row = await txStore.getGroupMember(groupID, memberDID);
189
- if (row == null) throw new Error('Failed to read group member after role change');
190
- return {
191
- groupID: row.group_id,
192
- memberDID: row.member_did,
193
- role: row.role,
194
- createdAt: toISO(row.created_at)
195
- };
196
378
  });
197
- // Record-before-send: defer the fan-out (so co-members fold the same entry
198
- // against their own anchor) and the local emit to the OUTERMOST commit. Under
199
- // `mutateGraph` this handler runs inside the engine's write transaction, so
200
- // the inner `withTransaction` above nests inline and does NOT commit on its
201
- // own the outer transaction commits later. Registering via `onCommit` keeps
202
- // a rolled-back role change from ever being broadcast or observed locally.
203
- deps.stores.onCommit(()=>{
204
- deps.scheduleBroadcast(groupID, {
205
- type: 'ledger:entry',
206
- groupID,
379
+ // The commit is the ONLY delivery of the role change: without it a newly
380
+ // promoted admin is an admin nowhere and every commit they author is refused.
381
+ // Driven AFTER the request row is durable and deferred to the outermost
382
+ // commit, because the local MLS handle advances only when the hub accepts. A
383
+ // missing commit hub is logged on the lane's promise, not fatal.
384
+ const build = deps.groupManager.buildEnactLedgerCommit({
385
+ groupID,
386
+ tokens: [
207
387
  token
208
- });
209
- void deps.emitter.emit('groupMemberRoleChanged', {
210
- ...memberData,
211
- groupID: memberData.groupID
388
+ ],
389
+ requestID
390
+ });
391
+ deps.stores.onCommit(()=>{
392
+ void deps.commitToGroup(groupID, build).catch((error)=>{
393
+ deps.logger.error('enact-ledger commit lane failed', {
394
+ groupID,
395
+ error
396
+ });
397
+ // A lane throw is the group refusing the entry or the deadline
398
+ // expiring — the request is over and the app must be told. The local
399
+ // admin gate is NOT this: it throws above, before anything is
400
+ // enqueued, so a refused caller has no row to settle.
401
+ settleRejectedFromLane(requestID, error);
212
402
  });
213
403
  });
214
- return memberData;
404
+ // Built from the values just written rather than read back: this handler
405
+ // runs inside `mutateGraph`'s write transaction, so the enqueued row is
406
+ // not yet visible on any other connection.
407
+ return {
408
+ id: requestID,
409
+ groupID,
410
+ kind: 'ledger',
411
+ status: 'pending',
412
+ error: null,
413
+ enqueuedAt,
414
+ settledAt: null
415
+ };
215
416
  },
216
417
  listMembers: async (groupID)=>{
217
418
  const store = await getP2PStore();
@@ -223,8 +424,19 @@ export function createGroupContext(ctx, deps) {
223
424
  createdAt: toISO(row.created_at)
224
425
  }));
225
426
  },
226
- createCircle: async (p)=>{
227
- const groupID = p.groupID;
427
+ requestCreateCircle: async (input)=>{
428
+ const groupID = input.groupID;
429
+ const store = await getP2PStore();
430
+ // Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
431
+ // authority — a non-admin's entry is dropped at fold time — but checking
432
+ // here lets the caller fail loudly instead of receiving a request that
433
+ // settles `committed` for a circle no peer ever projects.
434
+ await requireGroupAdmin({
435
+ store,
436
+ groupID,
437
+ did: deps.identity.id,
438
+ action: 'create a circle'
439
+ });
228
440
  // Read the anchor through the engine transaction's stores: under
229
441
  // `mutateGraph` this handler already runs inside that transaction, so the
230
442
  // registry's restore/persist must share its connection — a separate
@@ -235,87 +447,65 @@ export function createGroupContext(ctx, deps) {
235
447
  if (anchor == null) {
236
448
  throw new Error('cannot create circle: group has no genesis anchor');
237
449
  }
238
- const circleID = deps.runtime.getRandomID();
450
+ // The caller is handed the id back before the commit lands, because every
451
+ // step that follows a create — membership, catalogs, the invite seeds —
452
+ // names the circle by id. An internal caller may supply a DERIVED id so
453
+ // its own retry converges on this circle; see `CreateCircleInput`.
454
+ const circleID = input.circleID ?? mintCircleID();
239
455
  const hlc = HLC.serialize(deps.hlc.now());
240
456
  const value = {
241
- name: p.name,
242
- description: p.description ?? '',
457
+ name: input.name,
458
+ description: input.description ?? '',
243
459
  catalogIDs: []
244
460
  };
245
461
  const token = await signLedgerEntry(deps.identity, {
246
462
  type: CIRCLE_DEF_ENTRY_TYPE,
247
463
  subject: circleID,
248
464
  value,
249
- hlc
250
- });
251
- const entryID = ledgerEntryDigest(token);
252
- // Append the signed entry and reproject the circle-definition overlay
253
- // atomically so a crash can never leave the durable ledger and the
254
- // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
255
- // the raw self DID is passed.
256
- const circleData = await deps.stores.withTransaction(async (tx)=>{
257
- const txStore = await resolveP2PStore(tx);
258
- await txStore.appendLedgerEntry({
259
- group_id: groupID,
260
- entry_id: entryID,
261
- type: CIRCLE_DEF_ENTRY_TYPE,
262
- subject: circleID,
263
- value,
264
- hlc,
265
- issuer_did: deps.identity.id,
266
- signed_token: token
267
- });
268
- await reprojectCircleDefs({
269
- store: txStore,
270
- groupID,
271
- anchor,
272
- logger: deps.logger
273
- });
274
- const row = await txStore.getCircle(circleID);
275
- if (row == null) throw new Error('Failed to read circle after create');
276
- return {
277
- id: row.id,
278
- groupID: row.group_id,
279
- name: row.name,
280
- description: row.description,
281
- createdAt: toISO(row.created_at)
282
- };
465
+ groupID,
466
+ ord: hlc
283
467
  });
284
- // Record-before-send: defer the fan-out (so co-members fold the same entry
285
- // against their own anchor) and the local emit to the OUTERMOST commit. Under
286
- // `mutateGraph` this handler runs inside the engine's write transaction, so
287
- // the inner `withTransaction` above nests inline and does NOT commit on its
288
- // own — the outer transaction commits later. Registering via `onCommit` keeps
289
- // a rolled-back creation from ever being broadcast or observed locally.
290
- deps.stores.onCommit(()=>{
291
- deps.scheduleBroadcast(groupID, {
292
- type: 'ledger:entry',
293
- groupID,
294
- token
295
- });
296
- void deps.emitter.emit('circleCreated', {
297
- ...circleData,
298
- groupID: circleData.groupID
299
- });
468
+ // No no-op branch: the subject is freshly minted, so there is no prior
469
+ // state for the requested one to already equal.
470
+ const request = await enqueueLedgerEntryRequest({
471
+ groupID,
472
+ token
300
473
  });
301
- return circleData;
474
+ return {
475
+ request,
476
+ circleID
477
+ };
302
478
  },
303
479
  listCircles: async (groupID)=>{
304
480
  const store = await getP2PStore();
305
481
  const rows = await store.listCirclesByGroup(groupID);
306
- return rows.map((row)=>({
307
- id: row.id,
308
- groupID: row.group_id,
309
- name: row.name,
310
- description: row.description,
311
- createdAt: toISO(row.created_at)
312
- }));
482
+ return rows.map(toCircleData);
313
483
  },
314
- addCircleMember: async (circleID, memberDID, role)=>{
484
+ requestAddCircleMember: async ({ circleID, memberDID, role })=>{
315
485
  const store = await getP2PStore();
316
486
  const circleRow = await store.getCircle(circleID);
317
487
  if (circleRow == null) throw new Error('Failed to add circle member: circle not found');
318
488
  const groupID = circleRow.group_id;
489
+ // Defense-in-depth + fail-loud UX, as in the other circle producers: an
490
+ // ungated non-admin's entry would ride a commit the group accepts, so the
491
+ // request would settle `committed` while every peer's fold drops it.
492
+ //
493
+ // It runs BEFORE the no-op check: answering a non-admin `no_op` would both
494
+ // grant them an outcome they have no authority to ask for and leak whether
495
+ // the membership they named already held.
496
+ await requireGroupAdmin({
497
+ store,
498
+ groupID,
499
+ did: deps.identity.id,
500
+ action: 'add a circle member'
501
+ });
502
+ // No-op when the DID is already an active member at exactly this role: the
503
+ // entry would fold to the state that already holds. A tombstoned row is NOT
504
+ // a no-op — re-adding it is a revival, and only a fresh entry performs one.
505
+ const existingMember = await store.getCircleMember(circleID, memberDID);
506
+ if (existingMember != null && existingMember.removed_at_hlc == null && existingMember.role === role) {
507
+ return await settleNoOpRequest(groupID);
508
+ }
319
509
  // Read the anchor through the engine transaction's stores: under
320
510
  // `mutateGraph` this handler already runs inside that transaction, so the
321
511
  // registry's restore/persist must share its connection — a separate
@@ -335,124 +525,122 @@ export function createGroupContext(ctx, deps) {
335
525
  type: CIRCLE_MEMBER_ENTRY_TYPE,
336
526
  subject,
337
527
  value,
338
- hlc
339
- });
340
- const entryID = ledgerEntryDigest(token);
341
- // Append the signed entry and reproject the circle-member overlay
342
- // atomically so a crash can never leave the durable ledger and the
343
- // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
344
- // the raw self DID is passed.
345
- const circleMemberData = await deps.stores.withTransaction(async (tx)=>{
346
- const txStore = await resolveP2PStore(tx);
347
- await txStore.appendLedgerEntry({
348
- group_id: groupID,
349
- entry_id: entryID,
350
- type: CIRCLE_MEMBER_ENTRY_TYPE,
351
- subject,
352
- value,
353
- hlc,
354
- issuer_did: deps.identity.id,
355
- signed_token: token
356
- });
357
- await reprojectCircleMembers({
358
- store: txStore,
359
- groupID,
360
- anchor,
361
- logger: deps.logger
362
- });
363
- const row = await txStore.getCircleMember(circleID, memberDID);
364
- if (row == null) throw new Error('Failed to read circle member after add');
365
- return {
366
- circleID: row.circle_id,
367
- memberDID: row.member_did,
368
- role: row.role,
369
- createdAt: toISO(row.created_at)
370
- };
528
+ groupID,
529
+ ord: hlc
371
530
  });
372
- // Record-before-send: defer the fan-out (so co-members fold the same entry
373
- // against their own anchor) and the local emit to the OUTERMOST commit. Under
374
- // `mutateGraph` this handler runs inside the engine's write transaction, so
375
- // the inner `withTransaction` above nests inline and does NOT commit on its
376
- // own — the outer transaction commits later. Registering via `onCommit` keeps
377
- // a rolled-back addition from ever being broadcast or observed locally.
378
- deps.stores.onCommit(()=>{
379
- deps.scheduleBroadcast(groupID, {
380
- type: 'ledger:entry',
381
- groupID,
382
- token
383
- });
384
- void deps.emitter.emit('circleMemberAdded', {
385
- ...circleMemberData,
386
- circleID: circleMemberData.circleID
387
- });
531
+ return await enqueueLedgerEntryRequest({
532
+ groupID,
533
+ token
388
534
  });
389
- return circleMemberData;
390
535
  },
391
536
  createCatalog: async (p)=>{
392
537
  const graphStore = await getGraphStore();
393
- const id = deps.runtime.getRandomID();
538
+ const id = p.catalogID ?? deps.runtime.getRandomID();
394
539
  const hlcStr = HLC.serialize(deps.hlc.now());
540
+ const filterCriteria = JSON.parse(p.filterCriteria);
541
+ // Sign a self-contained `catalog:set` token at creation so the catalog can
542
+ // be forwarded (invite seed / broadcast) with creator-bound provenance a
543
+ // receiver re-verifies. The signed record carries every authoritative field.
544
+ const signedToken = await signCatalogSet(deps.identity, {
545
+ id,
546
+ ownerDID: ctx.viewerDID,
547
+ name: p.name,
548
+ description: p.description ?? '',
549
+ filterCriteria,
550
+ hlc: hlcStr
551
+ });
395
552
  await graphStore.createCatalog({
396
553
  id,
397
554
  owner_did: ctx.viewerDID,
398
555
  name: p.name,
399
556
  description: p.description ?? '',
400
- filter_criteria: JSON.parse(p.filterCriteria),
401
- hlc: hlcStr
557
+ filter_criteria: filterCriteria,
558
+ hlc: hlcStr,
559
+ signed_token: signedToken
402
560
  });
403
561
  const row = await graphStore.getCatalog(id);
404
562
  if (row == null) throw new Error('Failed to create catalog');
405
- return {
406
- id: row.id,
407
- ownerDID: row.owner_did,
408
- name: row.name,
409
- description: row.description,
410
- filterCriteria: JSON.stringify(row.filter_criteria),
411
- createdAt: toISO(row.created_at)
412
- };
563
+ return toCatalogData(row);
413
564
  },
414
- listCatalogs: async (ownerDID)=>{
565
+ listStoredCatalogs: async (filter)=>{
415
566
  const graphStore = await getGraphStore();
416
- if (ownerDID != null) {
417
- const rows = await graphStore.listCatalogs(ownerDID);
418
- return rows.map((row)=>({
419
- id: row.id,
420
- ownerDID: row.owner_did,
421
- name: row.name,
422
- description: row.description,
423
- filterCriteria: JSON.stringify(row.filter_criteria),
424
- createdAt: toISO(row.created_at)
425
- }));
426
- }
427
- // listCatalogs requires ownerDID -- for unfiltered, list groups then catalogs
567
+ const rows = await graphStore.listStoredCatalogs(filter);
568
+ return rows.map(toCatalogData);
569
+ },
570
+ listControlRequests: async (groupID)=>{
428
571
  const store = await getP2PStore();
429
- const groups = await store.listGroups();
430
- const ownerDIDs = new Set(groups.map((s)=>s.created_by));
431
- const allCatalogs = await Promise.all(Array.from(ownerDIDs).map((did)=>graphStore.listCatalogs(did)));
432
- return allCatalogs.flat().map((row)=>({
433
- id: row.id,
434
- ownerDID: row.owner_did,
435
- name: row.name,
436
- description: row.description,
437
- filterCriteria: JSON.stringify(row.filter_criteria),
438
- createdAt: toISO(row.created_at)
439
- }));
572
+ const rows = await store.getControlRequests(groupID);
573
+ return rows.map(toControlRequestData);
440
574
  },
441
- invite: async (groupID, joinRequest, grants, revocations)=>{
442
- checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
575
+ getControlRequest: async (id)=>{
443
576
  const store = await getP2PStore();
444
- const ownIdentity = deps.identity;
445
- const joinReq = decodeJoinRequest(joinRequest);
446
- const result = await deps.groupManager.inviteToGroup({
577
+ const row = await store.getControlRequest(id);
578
+ return row == null ? null : toControlRequestData(row);
579
+ },
580
+ ackControlRequest: async (id)=>{
581
+ const store = await getP2PStore();
582
+ await store.ackControlRequest(id);
583
+ },
584
+ awaitSettle: async (id, timeoutMs)=>await awaitControlRequestSettled({
447
585
  stores: deps.stores,
586
+ emitter: deps.emitter,
587
+ requestID: id,
588
+ timeoutMs
589
+ }),
590
+ activateCatalogSync: async (catalogID)=>{
591
+ const graphStore = await getGraphStore();
592
+ if (await graphStore.getCatalog(catalogID) == null) {
593
+ throw new Error(`Catalog ${catalogID} not found`);
594
+ }
595
+ await graphStore.setCatalogActive(catalogID, true);
596
+ const row = await graphStore.getCatalog(catalogID);
597
+ if (row == null) throw new Error(`Catalog ${catalogID} not found`);
598
+ return toCatalogData(row);
599
+ },
600
+ deactivateCatalogSync: async (catalogID)=>{
601
+ const graphStore = await getGraphStore();
602
+ if (await graphStore.getCatalog(catalogID) == null) {
603
+ throw new Error(`Catalog ${catalogID} not found`);
604
+ }
605
+ await graphStore.setCatalogActive(catalogID, false);
606
+ const row = await graphStore.getCatalog(catalogID);
607
+ if (row == null) throw new Error(`Catalog ${catalogID} not found`);
608
+ return toCatalogData(row);
609
+ },
610
+ requestInvite: async (input)=>{
611
+ const { groupID, joinRequest, grants, revocations } = input;
612
+ checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
613
+ const store = await getP2PStore();
614
+ // Inviting adds a member to the MLS group — an admin action. kumiai already
615
+ // gates the committer at BUILD time (`createInvite` throws "the inviter must
616
+ // be an admin in the group roster"), so without this gate a non-admin's
617
+ // invite still fails — but only after enqueuing a request and reaching the
618
+ // lane, which then settles `rejected` on the build throw. This gate makes it
619
+ // fail fast and clean (`NOT_GROUP_ADMIN` before any row is written), and it
620
+ // is the local half of the defense-in-depth the receive side completes: the
621
+ // one path kumiai's build gate cannot catch is an inviter admin at build who
622
+ // is demoted before co-members fold the commit, which the receive policy
623
+ // refuses (`CommitRejectedError`, group-mls.ts).
624
+ await requireGroupAdmin({
625
+ store,
448
626
  groupID,
449
- identity: ownIdentity,
450
- recipientDID: joinReq.did,
451
- recipientKeyPackage: joinReq.publicPackage,
452
- permission: 'member'
627
+ did: deps.identity.id,
628
+ action: 'invite a member to the group'
453
629
  });
454
- // Look up group name for the invite payload
630
+ const ownIdentity = deps.identity;
631
+ const joinReq = decodeJoinRequest(joinRequest);
632
+ // The recipient comes from the KeyPackage's own credential, never from the
633
+ // request's `did` field. The two halves go to different subsystems — the
634
+ // KeyPackage becomes an MLS leaf, this DID becomes a membership row and a
635
+ // role entry — so a forged pairing names one identity in the ratchet and
636
+ // another in the projections, and nothing downstream reconciles them: a
637
+ // co-member folds the row from the roster diff while the inviter writes
638
+ // the one it was handed. Resolving here makes every caller safe rather
639
+ // than each having to remember.
640
+ const recipientDID = resolveJoinRequestDID(joinRequest);
455
641
  const groupRow = await store.getGroup(groupID);
642
+ if (groupRow == null) throw new Error('Failed to invite member: group not found');
643
+ const groupName = groupRow.name;
456
644
  const boundHubs = await store.listHubsByGroupID(groupID);
457
645
  const suggestedHubs = boundHubs.map((hub)=>hub.server_did == null ? {
458
646
  url: hub.url
@@ -460,34 +648,121 @@ export function createGroupContext(ctx, deps) {
460
648
  url: hub.url,
461
649
  serverDID: hub.server_did
462
650
  });
463
- const encoded = encodeInvitePayload({
651
+ // Seed the joiner with the group's control state so it is current the
652
+ // instant it joins — the ledger (circles/members/admin roster), the
653
+ // inviter's own model access-defaults, and the group's catalogs — without
654
+ // waiting for a broadcast or a catch-up round-trip.
655
+ //
656
+ // Collected ONCE, here, rather than per attempt inside the encoder: they
657
+ // are the caller's snapshot of the group's control state, and nothing in
658
+ // them turns on which attempt the group accepts. Re-collecting per rebase
659
+ // would only make the payload vary between attempts for no gain.
660
+ const seeds = await collectInviteSeeds({
661
+ stores: deps.stores,
662
+ identity: ownIdentity,
663
+ ownerDID: ctx.viewerDID,
464
664
  groupID,
465
- groupName: groupRow?.name ?? '',
466
- suggestedHubs,
467
- invite: result.invite,
468
- welcomeMessage: result.welcomeMessage,
469
- ratchetTree: result.ratchetTree,
470
- ...grants != null && grants.length > 0 ? {
471
- grants
472
- } : {},
473
- ...revocations != null && revocations.length > 0 ? {
474
- revocations
475
- } : {}
665
+ logger: deps.logger
666
+ });
667
+ const requestID = deps.runtime.getRandomID();
668
+ const enqueuedAt = new Date().toISOString();
669
+ await deps.stores.withTransaction(async (tx)=>{
670
+ const txStore = await resolveP2PStore(tx);
671
+ await txStore.enqueueControlRequest({
672
+ id: requestID,
673
+ groupID,
674
+ kind: 'invite',
675
+ enqueuedAt
676
+ });
476
677
  });
477
- await deps.emitter.emit('groupMemberJoined', {
678
+ const build = deps.groupManager.buildInviteCommit({
478
679
  groupID,
479
- memberDID: joinReq.did,
480
- role: 'member',
481
- createdAt: new Date().toISOString()
482
- });
483
- // Fan out the add-commit to existing members so their MLS ratchet +
484
- // local roster learn about the new member (RFC 9750 §6.1). The joiner
485
- // itself receives the Welcome out-of-band via the invite payload above.
486
- deps.stores.onCommit(()=>deps.scheduleSendCommit(groupID, result.commitMessage));
680
+ identity: ownIdentity,
681
+ recipientDID,
682
+ recipientKeyPackage: joinReq.publicPackage,
683
+ permission: 'member',
684
+ requestID,
685
+ encodeInvitePayload: async (material)=>encodeInvitePayload({
686
+ groupID,
687
+ groupName,
688
+ suggestedHubs,
689
+ invite: material.invite,
690
+ welcomeMessage: material.welcomeMessage,
691
+ ratchetTree: material.ratchetTree,
692
+ ...grants != null && grants.length > 0 ? {
693
+ grants
694
+ } : {},
695
+ ...revocations != null && revocations.length > 0 ? {
696
+ revocations
697
+ } : {},
698
+ ...seeds.accessDefaults.length > 0 ? {
699
+ accessDefaults: seeds.accessDefaults
700
+ } : {},
701
+ ...seeds.catalogs.length > 0 ? {
702
+ catalogs: seeds.catalogs
703
+ } : {}
704
+ })
705
+ });
706
+ deps.stores.onCommit(()=>{
707
+ void deps.commitToGroup(groupID, build).catch((error)=>{
708
+ deps.logger.error('invite commit lane failed', {
709
+ groupID,
710
+ error
711
+ });
712
+ settleRejectedFromLane(requestID, error);
713
+ });
714
+ });
715
+ let settled;
716
+ try {
717
+ settled = await awaitControlRequestSettled({
718
+ stores: deps.stores,
719
+ emitter: deps.emitter,
720
+ requestID,
721
+ timeoutMs: deps.inviteCommitTimeoutMs ?? INVITE_COMMIT_TIMEOUT
722
+ });
723
+ } catch (waitError) {
724
+ // The wait elapsed without a settle. A PARKED Add commit (the inviter's
725
+ // hub is offline) leaves the request `pending` and the commit still in
726
+ // flight — it may yet land and add the member — so this is NOT a failure
727
+ // to report. Read the authoritative row: only a still-`pending` request
728
+ // returns the truthful pending shape below; anything else fell through
729
+ // the wait's own settle/timeout race and is handled by the status branch.
730
+ const parkedRow = await store.getControlRequest(requestID);
731
+ if (parkedRow == null) {
732
+ throw waitError;
733
+ }
734
+ settled = toControlRequestData(parkedRow);
735
+ }
736
+ if (settled.status === 'pending') {
737
+ // Truthful pending result — NOT a throw: the caller learns the invite has
738
+ // not landed yet rather than being told it failed. `request` carries that
739
+ // status back. No payload rides a pending result: the commit that would
740
+ // produce it has not been accepted, so there is nothing to hand over yet.
741
+ return {
742
+ request: settled,
743
+ invitePayload: null,
744
+ groupID,
745
+ groupName
746
+ };
747
+ }
748
+ if (settled.status !== 'committed') {
749
+ // A definitively-failed invite (`rejected`/`lost`/`no_op`) SHOULD report
750
+ // failure — a settled-negative is a real error, not a pending outcome.
751
+ throw new Error(`group/invite: the add commit did not land (${settled.status}${settled.error == null ? '' : ` — ${settled.error}`})`);
752
+ }
753
+ // Read the payload from the settled row rather than from the callback that
754
+ // fired alongside it: this is the one route both the live path and a
755
+ // replayed adopt write to, so a caller answered after a restart is
756
+ // answered identically.
757
+ const invitePayload = await store.getControlRequestResult(requestID);
758
+ if (invitePayload == null) {
759
+ throw new Error('group/invite: the add commit landed without an invite payload');
760
+ }
487
761
  return {
488
- invitePayload: encoded,
762
+ request: settled,
763
+ invitePayload,
489
764
  groupID,
490
- groupName: groupRow?.name ?? ''
765
+ groupName
491
766
  };
492
767
  },
493
768
  join: async (invitePayloadStr, joinRequestPayloadStr)=>{
@@ -529,6 +804,25 @@ export function createGroupContext(ctx, deps) {
529
804
  emitter: deps.emitter,
530
805
  logger: deps.logger
531
806
  });
807
+ // Fold the invite-carried control state (ledger, access-defaults,
808
+ // catalogs) now the MLS handle exists, so the genesis anchor is readable
809
+ // and the joiner projects circles/members at join. Reads the anchor
810
+ // through the engine transaction's stores so the registry shares its
811
+ // connection (a separate connection deadlocks single-connection SQLite).
812
+ await applyInviteControlState({
813
+ stores: deps.stores,
814
+ groupID: invite.groupID,
815
+ accessDefaults: invite.accessDefaults,
816
+ catalogs: invite.catalogs,
817
+ hlc: deps.hlc,
818
+ getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
819
+ stores: deps.stores
820
+ }),
821
+ getGroupLedger: (gid)=>deps.registry.readHandle(gid, (handle)=>handle.ledger, {
822
+ stores: deps.stores
823
+ }),
824
+ logger: deps.logger
825
+ });
532
826
  return await finalizeJoinedGroup({
533
827
  stores: deps.stores,
534
828
  emitter: deps.emitter,
@@ -538,135 +832,228 @@ export function createGroupContext(ctx, deps) {
538
832
  },
539
833
  leave: async (groupID)=>{
540
834
  // One HLC orders BOTH the MLS self-removal tombstone and the admin-role
541
- // self-revocation appended below. Sharing a single stamp is what lets a
835
+ // self-revocation enacted below. Sharing a single stamp is what lets a
542
836
  // receiver's deterministic fold keep this leaver's control entries signed
543
837
  // at or before departure and drop everything they sign strictly later.
544
838
  const hlc = HLC.serialize(deps.hlc.now());
545
839
  const store = await getP2PStore();
546
840
  const groupRow = await store.getGroup(groupID);
547
841
  const ownIdentity = deps.identity;
548
- // Read the anchor while the MLS handle is still live `leaveGroup` tears
549
- // it down. Under `mutateGraph` this handler already runs inside the engine
550
- // transaction, so the registry's restore/persist must share its connection
551
- // (a separate connection blocks on the outer write lock under SQLite).
552
- const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
842
+ // Anchor and roster in one lock: both come from the live handle, which
843
+ // `leaveGroup` tears down. Under `mutateGraph` this handler already runs
844
+ // inside the engine transaction, so the registry's restore/persist must
845
+ // share its connection (a separate connection blocks on the outer write
846
+ // lock under SQLite).
847
+ const { anchor, roster } = await deps.registry.readHandle(groupID, async (handle)=>({
848
+ anchor: readGroupAnchor(handle),
849
+ roster: handle.roster
850
+ }), {
553
851
  stores: deps.stores
554
852
  });
555
- // Resolve self-admin BEFORE the self-tombstone: once the tombstone writes
556
- // `removed_at_hlc == hlc`, membership-at-HLC (which requires
557
- // `removed_at_hlc > hlc`) would read false and wrongly report the leaver
558
- // as a non-admin, suppressing the revocation.
559
- const selfIsAdmin = anchor != null && await isLedgerAdminAtHLC({
560
- store,
561
- groupID,
562
- anchor,
563
- did: ownIdentity.id,
564
- atHLC: hlc,
565
- logger: deps.logger
566
- });
567
- // Revoke the leaver's own control authority atomically with the tombstone.
568
- // Only admins fold authoritatively, so only an admin needs revoking — a
569
- // non-admin leave appends nothing and broadcasts no ledger entry.
853
+ // Authority is read from the HANDLE's roster, not the membership row's
854
+ // `role`, because the demotion below has to be one the group will enact:
855
+ // `commitLedgerEntries` judges the roster its own ledger folds to and
856
+ // refuses an entry that would empty the admin set. Reading the mirrored
857
+ // column instead lets this producer mint an entry the commit then refuses,
858
+ // and a lane failure is logged rather than returned — so the caller would
859
+ // watch a leave succeed and nothing happen.
860
+ const selfIsAdmin = anchor != null && roster.roles.get(normalizeDID(ownIdentity.id)) === 'admin';
861
+ // Revoke the leaver's own control authority as it goes. Only admins fold
862
+ // authoritatively, so only an admin needs revoking — a non-admin leave
863
+ // enacts no ledger entry and drives no commit.
570
864
  let revokeToken = null;
571
- if (selfIsAdmin && anchor != null) {
572
- const value = 'revoked';
573
- const token = await signLedgerEntry(deps.identity, {
574
- type: ADMIN_ROLE_ENTRY_TYPE,
575
- subject: ownIdentity.id,
576
- value,
577
- hlc
578
- });
579
- const entryID = ledgerEntryDigest(token);
580
- // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
581
- await store.appendLedgerEntry({
582
- group_id: groupID,
583
- entry_id: entryID,
865
+ // A group the leaver is alone in has nobody to revoke to and no state to
866
+ // converge: leaving it is deleting it. Nothing is enacted, so a solo owner
867
+ // is never trapped in their own group by the guard below.
868
+ // From the ROSTER, like the authority above it and for the same reason: a
869
+ // projection that lags reports a group this device is alone in, which skips
870
+ // the last-admin guard entirely and lets the only admin walk out of a group
871
+ // that still has members.
872
+ const hasCoMembers = [
873
+ ...roster.roles.keys()
874
+ ].some((did)=>normalizeDID(did) !== normalizeDID(ownIdentity.id));
875
+ if (selfIsAdmin && anchor != null && hasCoMembers) {
876
+ // Refuse the last admin's departure where the caller can see it. The
877
+ // group would refuse it anyway — one admin means this demotion empties
878
+ // the admin set — but that refusal arrives on the lane, asynchronously,
879
+ // as a log line. The app's move is to promote a successor first.
880
+ if (adminCount(roster) <= 1) {
881
+ throw new GraphQLError('cannot leave: you are the only admin — promote another member first', {
882
+ extensions: {
883
+ code: GROUP_CONTROL_DENIED,
884
+ reason: LAST_GROUP_ADMIN,
885
+ groupID
886
+ }
887
+ });
888
+ }
889
+ revokeToken = await signLedgerEntry(deps.identity, {
584
890
  type: ADMIN_ROLE_ENTRY_TYPE,
585
891
  subject: ownIdentity.id,
586
- value,
587
- hlc,
588
- issuer_did: ownIdentity.id,
589
- signed_token: token
590
- });
591
- await reprojectAdminRoster({
592
- store,
892
+ value: 'member',
593
893
  groupID,
594
- anchor,
595
- logger: deps.logger
894
+ ord: hlc
596
895
  });
597
- revokeToken = token;
598
896
  }
599
- // Both broadcasts go out BEFORE MLS teardown — once `leaveGroup` deletes
600
- // the local MLS handle we can no longer encrypt to the group. They are
601
- // awaited (not deferred to `onCommit`) so the encrypt happens against the
602
- // still-live handle; deferring would fail with the handle gone. A pre-commit
603
- // send that a later rollback orphans is benign here: it is an authentic
604
- // self-signed revoke converging early, monotonic toward less privilege for
605
- // a DID that itself invoked leave — symmetric to the leaveRequest below.
606
- // Best-effort: a failed notify must not block the leave itself.
607
- try {
608
- await deps.broadcastNow(groupID, {
609
- type: 'group:leaveRequest',
897
+ const leftData = groupRow != null ? {
898
+ id: groupRow.id,
899
+ name: groupRow.name,
900
+ description: groupRow.description,
901
+ createdBy: groupRow.created_by,
902
+ createdAt: toISO(groupRow.created_at),
903
+ groupID
904
+ } : null;
905
+ // Announce the departure, then tear the MLS state down. The order is
906
+ // load-bearing: the announcement is encrypted to the group, and teardown
907
+ // deletes the key it encrypts under. Advisory and best-effort — a failed
908
+ // notify must not block the leave itself.
909
+ const announceAndTearDown = async ()=>{
910
+ try {
911
+ await deps.broadcastNow(groupID, {
912
+ type: 'group:leaveRequest',
913
+ groupID,
914
+ memberDID: ownIdentity.id,
915
+ hlc
916
+ });
917
+ } catch (error) {
918
+ deps.logger.warn('leave-request broadcast failed', {
919
+ groupID,
920
+ error
921
+ });
922
+ }
923
+ // `deviceStores`, never `stores`: this runs after the commit lane has
924
+ // resolved, so under `mutateGraph` the request's transaction is long
925
+ // over and a write through it would never land — silently, since
926
+ // nothing throws and the lane's `catch` sees no error. The departed
927
+ // admin would keep its MLS state, and so the group's key.
928
+ await deps.groupManager.leaveGroup({
929
+ stores: deps.deviceStores,
610
930
  groupID,
611
- memberDID: ownIdentity.id,
931
+ identity: ownIdentity,
612
932
  hlc
613
933
  });
614
- } catch (error) {
615
- deps.logger.warn('leave-request broadcast failed', {
616
- groupID,
617
- error
618
- });
619
- }
620
- if (revokeToken != null) {
934
+ };
935
+ // The same order with the local rows already written: announce first, then
936
+ // drop the key that the announcement was encrypted under.
937
+ const announceThenDropKey = async ()=>{
621
938
  try {
622
939
  await deps.broadcastNow(groupID, {
623
- type: 'ledger:entry',
940
+ type: 'group:leaveRequest',
624
941
  groupID,
625
- token: revokeToken
942
+ memberDID: ownIdentity.id,
943
+ hlc
626
944
  });
627
945
  } catch (error) {
628
- deps.logger.warn('self-revoke broadcast failed', {
946
+ deps.logger.warn('leave-request broadcast failed', {
629
947
  groupID,
630
948
  error
631
949
  });
632
950
  }
951
+ await deps.groupManager.tearDownGroupMLS(deps.deviceStores, groupID);
952
+ };
953
+ const emitGroupLeft = ()=>{
954
+ if (leftData == null) return;
955
+ deps.emitter.emit('groupLeft', leftData).catch((error)=>{
956
+ deps.logger.error('groupLeft emit failed', {
957
+ groupID,
958
+ error
959
+ });
960
+ });
961
+ };
962
+ if (revokeToken == null) {
963
+ // Nothing to enact, so nothing to confirm — but still nothing that may
964
+ // be AWAITED here. The announcement publishes on the group's lane, which
965
+ // encrypts under the live handle, and this handler runs inside the
966
+ // engine's write transaction: the lane's own reads take a second
967
+ // connection and block on the outer write lock, so awaiting it hangs the
968
+ // mutation for good on a hub-bound group. Deferred to the outermost
969
+ // commit for exactly the reason the self-revoke path below is.
970
+ //
971
+ // The LOCAL record of the departure is written here rather than out
972
+ // there, because it must be true the moment this returns: a screen that
973
+ // navigates away on success would otherwise still be told this device
974
+ // belongs to the group it just left.
975
+ await deps.groupManager.markGroupLeft({
976
+ stores: deps.stores,
977
+ groupID,
978
+ identity: ownIdentity,
979
+ hlc
980
+ });
981
+ deps.stores.onCommit(()=>{
982
+ // A tick past the commit hook, not just past the transaction: KubunDB
983
+ // fires a non-transactional `onCommit` SYNCHRONOUSLY, so a store write
984
+ // made from inside it still lands while the outer write is finalizing
985
+ // and blocks on the connection it is holding.
986
+ setTimeout(()=>{
987
+ void announceThenDropKey().then(emitGroupLeft).catch((error)=>{
988
+ deps.logger.error('leave teardown failed', {
989
+ groupID,
990
+ error
991
+ });
992
+ });
993
+ }, 0);
994
+ });
995
+ return {
996
+ groupID
997
+ };
633
998
  }
634
- await deps.groupManager.leaveGroup({
635
- stores: deps.stores,
999
+ // The commit is the ONLY delivery of the self-demotion, and the leave waits
1000
+ // on it. A revocation that reached co-members by an ephemeral broadcast
1001
+ // alone was held by whoever happened to be listening and by no one else,
1002
+ // and it never entered the group's authenticated ledger at all — so a peer
1003
+ // that rebuilt from that ledger folded the departed admin back to admin.
1004
+ //
1005
+ // Local state therefore moves only once the group has taken the demotion:
1006
+ // teardown runs on the lane's success, and the entry lands in this device's
1007
+ // own store through the same fold every co-member runs. A lane that never
1008
+ // lands leaves the leaver in the group, still an admin — the state the
1009
+ // group agrees on — rather than a member of nothing who is an admin
1010
+ // everywhere.
1011
+ const requestID = deps.runtime.getRandomID();
1012
+ const enqueuedAt = new Date().toISOString();
1013
+ await deps.stores.withTransaction(async (tx)=>{
1014
+ const txStore = await resolveP2PStore(tx);
1015
+ await txStore.enqueueControlRequest({
1016
+ id: requestID,
1017
+ groupID,
1018
+ kind: 'ledger',
1019
+ enqueuedAt
1020
+ });
1021
+ });
1022
+ const build = deps.groupManager.buildEnactLedgerCommit({
636
1023
  groupID,
637
- identity: ownIdentity,
638
- hlc
1024
+ tokens: [
1025
+ revokeToken
1026
+ ],
1027
+ requestID
639
1028
  });
640
- if (groupRow != null) {
641
- const leftData = {
642
- id: groupRow.id,
643
- name: groupRow.name,
644
- description: groupRow.description,
645
- createdBy: groupRow.created_by,
646
- createdAt: toISO(groupRow.created_at),
647
- groupID
648
- };
649
- // Defer past the outermost commit: the `groupLeft` listener reads the P2P
650
- // store on the main connection (deadlocks single-conn SQLite / uncommitted on PG).
651
- deps.stores.onCommit(()=>{
652
- deps.emitter.emit('groupLeft', leftData).catch((error)=>{
653
- deps.logger.error('groupLeft emit failed', {
654
- groupID,
655
- error
656
- });
1029
+ // Deferred to the outermost commit for the reason every other lane is:
1030
+ // awaiting it inside this write transaction deadlocks the single
1031
+ // connection the lane's own reads need.
1032
+ deps.stores.onCommit(()=>{
1033
+ void deps.commitToGroup(groupID, build).then(async ()=>{
1034
+ await announceAndTearDown();
1035
+ // Already past the outermost commit here, so the emit needs no
1036
+ // further deferral: the listener's reads run on their own connection.
1037
+ emitGroupLeft();
1038
+ }).catch((error)=>{
1039
+ deps.logger.error('leave self-revoke commit lane failed', {
1040
+ groupID,
1041
+ error
657
1042
  });
1043
+ settleRejectedFromLane(requestID, error);
658
1044
  });
659
- }
1045
+ });
660
1046
  return {
661
1047
  groupID
662
1048
  };
663
1049
  },
664
- removeMember: async (groupID, memberDID)=>{
665
- // One HLC orders BOTH the MLS-roster tombstone and the admin-role
666
- // revocation appended below. Sharing a single stamp is what makes the
667
- // revocation drop exactly the ex-admin's control entries signed after the
668
- // removal: the deterministic fold keeps entries at or before this HLC and
669
- // drops everything the ex-admin signs strictly later.
1050
+ requestRemoveMember: async (input)=>{
1051
+ const { groupID, memberDID } = input;
1052
+ // One HLC orders BOTH the MLS-roster tombstone the accepted commit writes
1053
+ // and the admin-role revocation signed below to ride it. Sharing a single
1054
+ // stamp is what makes the revocation drop exactly the ex-admin's control
1055
+ // entries signed after the removal: the deterministic fold keeps entries
1056
+ // at or before this HLC and drops everything signed strictly later.
670
1057
  const hlc = HLC.serialize(deps.hlc.now());
671
1058
  // Read the anchor through the engine transaction's stores: under
672
1059
  // `mutateGraph` this handler already runs inside that transaction, so the
@@ -680,110 +1067,177 @@ export function createGroupContext(ctx, deps) {
680
1067
  }
681
1068
  const store = await getP2PStore();
682
1069
  const selfDID = deps.identity.id;
683
- // Only a current admin may remove a member. This gate runs BEFORE the
684
- // roster tombstone so a non-admin attempt rolls back without touching the
685
- // MLS roster; the schema-layer `failTransactionFatal` wrap turns the throw
686
- // into a full-transaction rollback and forwards the message to the caller.
687
- if (!await isLedgerAdminAtHLC({
1070
+ // Only a current admin may remove a member. This gate runs before anything
1071
+ // is signed or enqueued, so a non-admin attempt leaves no request behind;
1072
+ // the schema-layer `failTransactionFatal` wrap turns the throw into a
1073
+ // full-transaction rollback and forwards the message to the caller. The
1074
+ // resolver runs the same gate ahead of that wrap so the refusal keeps its
1075
+ // code; repeating it here covers direct callers.
1076
+ await requireGroupAdmin({
688
1077
  store,
689
1078
  groupID,
690
- anchor,
691
1079
  did: selfDID,
692
- atHLC: hlc,
693
- logger: deps.logger
694
- })) {
695
- throw new Error('only a group admin can remove a member');
696
- }
697
- // Resolve whether the removed DID is an admin BEFORE the tombstone. Once
698
- // the roster tombstone writes `removed_at_hlc == hlc`, membership-at-HLC
699
- // (which requires `removed_at_hlc > hlc`) would read false and wrongly
700
- // report a removed admin as a non-admin, suppressing the revocation.
701
- const removedIsAdmin = await isLedgerAdminAtHLC({
702
- store,
703
- groupID,
704
- anchor,
705
- did: memberDID,
706
- atHLC: hlc,
707
- logger: deps.logger
1080
+ action: 'remove a member'
708
1081
  });
709
- // Refuse to remove the last admin: fold the admin set at this HLC and check
710
- // what survives once the removed DID is dropped. A group with no admins can
711
- // never grant, revoke, or remove again, so this is fail-closed.
1082
+ // Resolve whether the removed DID is an admin from the roster as it stands
1083
+ // NOW: this is the only point at which they are still a member, and the
1084
+ // answer decides whether the commit must carry a demotion at all.
1085
+ const removedIsAdmin = await store.isGroupAdmin(groupID, memberDID);
1086
+ // Refuse to remove the last admin: check what survives once the removed DID
1087
+ // is dropped. A group with no admins can never grant, revoke, or remove
1088
+ // again, so this is fail-closed.
1089
+ //
1090
+ // The survivor set is read from the same projected rows the authority check
1091
+ // reads, never from the ledger. A grant reaches the ledger the moment it is
1092
+ // signed but reaches the projection only when its commit is enacted, so the
1093
+ // ledger holds admins who cannot yet act. Counting those as survivors would
1094
+ // permit removing the only admin who can — and if the pending grant's commit
1095
+ // never lands, the group is left with no effective admin at all.
712
1096
  if (removedIsAdmin) {
713
- const entries = await loadVerifiedAdminEntries(store, groupID, deps.logger);
714
- const admins = foldAdminRoster(entries, anchor, hlc).admins;
715
- const survivors = new Set(admins);
716
- survivors.delete(normalizeDID(memberDID));
717
- if (survivors.size === 0) {
718
- throw new Error('cannot remove the last admin');
1097
+ const members = await store.listGroupMembers(groupID);
1098
+ const removedDID = normalizeDID(memberDID);
1099
+ const survivors = members.filter((member)=>member.role === 'admin' && normalizeDID(member.member_did) !== removedDID);
1100
+ if (survivors.length === 0) {
1101
+ // The same refusal `leave` and `requestSetMemberRole` raise, carrying
1102
+ // the same pair: one rule, three paths, and an app matching on the
1103
+ // code has to reach it from all three.
1104
+ throw new GraphQLError('cannot remove the last admin — promote another member first', {
1105
+ extensions: {
1106
+ code: GROUP_CONTROL_DENIED,
1107
+ reason: LAST_GROUP_ADMIN,
1108
+ groupID
1109
+ }
1110
+ });
719
1111
  }
720
1112
  }
721
- // MLS-roster delta + projected tombstone, stamped with the shared HLC.
722
- const { commitMessage } = await deps.groupManager.removeGroupMember({
723
- stores: deps.stores,
724
- groupID,
725
- memberDID,
726
- hlc
727
- });
728
- // Revoke the ex-admin's control authority atomically with the tombstone.
1113
+ // Revoke the ex-admin's control authority on the commit that evicts them.
729
1114
  // Only admins fold authoritatively, so only an admin needs revoking — a
730
- // non-admin removal appends nothing and broadcasts no ledger entry.
1115
+ // non-admin removal carries no ledger entry at all.
1116
+ //
1117
+ // Minted BEFORE the removal so it can ride that very commit: a receiver
1118
+ // refuses a Remove whose target is still an admin in the roster the commit's
1119
+ // entries fold to, so an admin can only be evicted by a commit that also
1120
+ // demotes them.
1121
+ //
1122
+ // The token is NOT appended here. On the lane the accepted commit's
1123
+ // `onAccepted` is its writer, and only that path knows the group took this
1124
+ // attempt: a row written here would survive a lane that never lands,
1125
+ // demoting the target in THIS device's admin-roster fold while every
1126
+ // co-member still folds them as an admin.
731
1127
  let revokeToken = null;
732
1128
  if (removedIsAdmin) {
733
- const value = 'revoked';
734
- const token = await signLedgerEntry(deps.identity, {
1129
+ revokeToken = await signLedgerEntry(deps.identity, {
735
1130
  type: ADMIN_ROLE_ENTRY_TYPE,
736
1131
  subject: memberDID,
737
- value,
738
- hlc
739
- });
740
- const entryID = ledgerEntryDigest(token);
741
- // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
742
- await store.appendLedgerEntry({
743
- group_id: groupID,
744
- entry_id: entryID,
745
- type: ADMIN_ROLE_ENTRY_TYPE,
746
- subject: memberDID,
747
- value,
748
- hlc,
749
- issuer_did: selfDID,
750
- signed_token: token
751
- });
752
- await reprojectAdminRoster({
753
- store,
1132
+ value: 'member',
754
1133
  groupID,
755
- anchor,
756
- logger: deps.logger
1134
+ ord: hlc
757
1135
  });
758
- revokeToken = token;
759
1136
  }
760
- await deps.emitter.emit('groupMemberLeft', {
1137
+ // The request is enqueued before the lane is armed so a crash can never
1138
+ // leave a commit driving toward a request that does not exist.
1139
+ const requestID = deps.runtime.getRandomID();
1140
+ const enqueuedAt = new Date().toISOString();
1141
+ await deps.stores.withTransaction(async (tx)=>{
1142
+ const txStore = await resolveP2PStore(tx);
1143
+ await txStore.enqueueControlRequest({
1144
+ id: requestID,
1145
+ groupID,
1146
+ kind: 'remove',
1147
+ enqueuedAt
1148
+ });
1149
+ });
1150
+ // The commit is the ONLY delivery of the demotion: the token rides the
1151
+ // commit's own frame as a sealed body, so every co-member resolves it,
1152
+ // folds it and emits from that frame. A separate `ledger:entry` broadcast
1153
+ // would race the commit and hand co-members a demotion the group has not
1154
+ // yet accepted, so none is sent.
1155
+ //
1156
+ // The tombstone and the announcement move with it: they happen in the
1157
+ // lane's `onAccepted`, so nothing here observes a removal the group never
1158
+ // took. Driving is deferred to the outermost commit — awaiting the lane
1159
+ // from inside this write transaction would deadlock the single connection
1160
+ // the lane's own reads need.
1161
+ const build = deps.groupManager.buildRemoveCommit({
761
1162
  groupID,
762
1163
  memberDID,
763
- role: '',
764
- createdAt: new Date().toISOString()
765
- });
766
- // Record-before-send: defer the MLS commit fan-out (so existing members
767
- // advance their MLS epoch and tombstone the removed member) and the revoke
768
- // broadcast (so co-members fold the same entry against their own anchor) to
769
- // the OUTERMOST commit. Registering via `onCommit` keeps a rolled-back
770
- // removal from ever being sent or observed.
1164
+ hlc,
1165
+ requestID,
1166
+ ...revokeToken != null ? {
1167
+ ledgerEntries: [
1168
+ revokeToken
1169
+ ]
1170
+ } : {}
1171
+ });
771
1172
  deps.stores.onCommit(()=>{
772
- deps.scheduleSendCommit(groupID, commitMessage);
773
- if (revokeToken != null) {
774
- deps.scheduleBroadcast(groupID, {
775
- type: 'ledger:entry',
1173
+ void deps.commitToGroup(groupID, build).catch((error)=>{
1174
+ deps.logger.error('remove-member commit lane failed', {
776
1175
  groupID,
777
- token: revokeToken
1176
+ error
778
1177
  });
779
- }
1178
+ // A lane throw is the group refusing the removal or the deadline
1179
+ // expiring — the request is over and the app must be told. The local
1180
+ // admin gate is NOT this: it throws above, before anything is
1181
+ // enqueued, so a refused caller has no row to settle.
1182
+ settleRejectedFromLane(requestID, error);
1183
+ });
780
1184
  });
1185
+ // Built from the values just written rather than read back: this handler
1186
+ // runs inside `mutateGraph`'s write transaction, so the enqueued row is
1187
+ // not yet visible on any other connection.
781
1188
  return {
782
- groupID
1189
+ id: requestID,
1190
+ groupID,
1191
+ kind: 'remove',
1192
+ status: 'pending',
1193
+ error: null,
1194
+ enqueuedAt,
1195
+ settledAt: null
783
1196
  };
784
1197
  },
785
- update: async (input)=>{
1198
+ requestUpdate: async (input)=>{
786
1199
  const groupID = input.groupID;
1200
+ const store = await getP2PStore();
1201
+ // Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
1202
+ // authority — a non-admin's entry is dropped at fold time — but checking
1203
+ // here lets the caller fail loudly instead of receiving a request that
1204
+ // settles `committed` for a change the fold silently discarded.
1205
+ //
1206
+ // It runs BEFORE the no-op check: answering a non-admin `no_op` would
1207
+ // both grant them an outcome they have no authority to ask for and leak
1208
+ // whether the state they named already held.
1209
+ await requireGroupAdmin({
1210
+ store,
1211
+ groupID,
1212
+ did: deps.identity.id,
1213
+ action: 'update a group'
1214
+ });
1215
+ // `group.settings` folds per field: carry ONLY the fields the caller set.
1216
+ // An explicit empty string (or empty list) clears the field; an omitted
1217
+ // field is left out of the value entirely so the prior value survives.
1218
+ const value = {};
1219
+ if (input.name !== undefined) value.name = input.name;
1220
+ if (input.description !== undefined) value.description = input.description;
1221
+ if (input.openCircleIDs !== undefined) value.openCircleIDs = input.openCircleIDs;
1222
+ // The commit-hub designation carries only its hub identity (URL + optional
1223
+ // server DID), never a local hub-row id: it is the shared identity every
1224
+ // device folds and matches against its own bound hubs. An absent server DID
1225
+ // is left off entirely rather than carried as null.
1226
+ if (input.commitHub !== undefined) {
1227
+ value.commitHub = input.commitHub.serverDID != null ? {
1228
+ url: input.commitHub.url,
1229
+ serverDID: input.commitHub.serverDID
1230
+ } : {
1231
+ url: input.commitHub.url
1232
+ };
1233
+ }
1234
+ // No-op when the caller set no field: every `UpdateGroupInput` field but
1235
+ // `groupID` is optional, so an input carrying only `groupID` asks for
1236
+ // nothing. Minting an empty-value `group.settings` entry would drive a
1237
+ // commit that folds to the state that already holds.
1238
+ if (Object.keys(value).length === 0) {
1239
+ return await settleNoOpRequest(groupID);
1240
+ }
787
1241
  // Read the anchor through the engine transaction's stores: under
788
1242
  // `mutateGraph` this handler already runs inside that transaction, so the
789
1243
  // registry's restore/persist must share its connection — a separate
@@ -794,93 +1248,42 @@ export function createGroupContext(ctx, deps) {
794
1248
  if (anchor == null) {
795
1249
  throw new Error('cannot update group: group has no genesis anchor');
796
1250
  }
797
- // No-op when the caller set neither field: `UpdateGroupInput` makes both
798
- // `name` and `description` optional, so an input carrying only `groupID`
799
- // would otherwise mint an empty-value `group.settings` entry + broadcast
800
- // that folds to the same projected state. Return the current group row
801
- // without appending an entry, broadcasting, or emitting.
802
- if (input.name === undefined && input.description === undefined) {
803
- const store = await getP2PStore();
804
- const row = await store.getGroup(groupID);
805
- if (row == null) throw new Error(`Group ${groupID} not found`);
806
- return {
807
- id: row.id,
808
- name: row.name,
809
- description: row.description,
810
- createdBy: row.created_by,
811
- createdAt: toISO(row.created_at)
812
- };
813
- }
814
1251
  const hlc = HLC.serialize(deps.hlc.now());
815
- // `group.settings` folds per field: carry ONLY the fields the caller set.
816
- // An explicit empty string clears the field; an omitted field is left out
817
- // of the value entirely so the prior value survives.
818
- const value = {};
819
- if (input.name !== undefined) value.name = input.name;
820
- if (input.description !== undefined) value.description = input.description;
821
1252
  const token = await signLedgerEntry(deps.identity, {
822
1253
  type: GROUP_SETTINGS_ENTRY_TYPE,
823
1254
  subject: groupID,
824
1255
  value,
825
- hlc
826
- });
827
- const entryID = ledgerEntryDigest(token);
828
- // Append the signed entry and reproject the group-settings overlay
829
- // atomically so a crash can never leave the durable ledger and the
830
- // projected group row diverged. `appendLedgerEntry` normalizes
831
- // `issuer_did`, so the raw self DID is passed.
832
- const updatedGroupData = await deps.stores.withTransaction(async (tx)=>{
833
- const txStore = await resolveP2PStore(tx);
834
- await txStore.appendLedgerEntry({
835
- group_id: groupID,
836
- entry_id: entryID,
837
- type: GROUP_SETTINGS_ENTRY_TYPE,
838
- subject: groupID,
839
- value,
840
- hlc,
841
- issuer_did: deps.identity.id,
842
- signed_token: token
843
- });
844
- await reprojectGroupSettings({
845
- store: txStore,
846
- groupID,
847
- anchor,
848
- logger: deps.logger
849
- });
850
- const row = await txStore.getGroup(groupID);
851
- if (row == null) throw new Error(`Group ${groupID} not found`);
852
- return {
853
- id: row.id,
854
- name: row.name,
855
- description: row.description,
856
- createdBy: row.created_by,
857
- createdAt: toISO(row.created_at)
858
- };
1256
+ groupID,
1257
+ ord: hlc
859
1258
  });
860
- // Record-before-send: defer the fan-out (so co-members fold the same entry
861
- // against their own anchor) and the local emit to the OUTERMOST commit. Under
862
- // `mutateGraph` this handler runs inside the engine's write transaction, so
863
- // the inner `withTransaction` above nests inline and does NOT commit on its
864
- // own — the outer transaction commits later. Registering via `onCommit` keeps
865
- // a rolled-back update from ever being broadcast or observed locally.
866
- deps.stores.onCommit(()=>{
867
- deps.scheduleBroadcast(groupID, {
868
- type: 'ledger:entry',
869
- groupID,
870
- token
871
- });
872
- void deps.emitter.emit('groupDataChanged', {
873
- ...updatedGroupData,
874
- groupID
875
- });
1259
+ return await enqueueLedgerEntryRequest({
1260
+ groupID,
1261
+ token
876
1262
  });
877
- return updatedGroupData;
878
1263
  },
879
- updateCircle: async (input)=>{
1264
+ requestUpdateCircle: async (input)=>{
880
1265
  const store = await getP2PStore();
881
1266
  const existing = await store.getCircle(input.circleID);
882
1267
  if (existing == null) throw new Error(`Circle ${input.circleID} not found`);
883
1268
  const groupID = existing.group_id;
1269
+ // Defense-in-depth + fail-loud UX, and it runs BEFORE the no-op check so a
1270
+ // caller with no authority is refused rather than told `no_op`, which would
1271
+ // leak whether the definition they named already held.
1272
+ await requireGroupAdmin({
1273
+ store,
1274
+ groupID,
1275
+ did: deps.identity.id,
1276
+ action: 'update a circle'
1277
+ });
1278
+ // No-op when the caller set no field: every `UpdateCircleInput` field but
1279
+ // `circleID` is optional, so an input carrying only `circleID` asks for
1280
+ // nothing, and the merge below would re-mint the definition that already
1281
+ // holds. Naming a field whose value is unchanged is NOT this case — it
1282
+ // still mints an entry, and the fold that lands it decides from the value
1283
+ // diff whether anything moved.
1284
+ if (input.name === undefined && input.description === undefined && input.catalogIDs === undefined) {
1285
+ return await settleNoOpRequest(groupID);
1286
+ }
884
1287
  // Read the anchor through the engine transaction's stores: under
885
1288
  // `mutateGraph` this handler already runs inside that transaction, so the
886
1289
  // registry's restore/persist must share its connection — a separate
@@ -904,116 +1307,110 @@ export function createGroupContext(ctx, deps) {
904
1307
  type: CIRCLE_DEF_ENTRY_TYPE,
905
1308
  subject: input.circleID,
906
1309
  value,
907
- hlc
908
- });
909
- const entryID = ledgerEntryDigest(token);
910
- const updatedCircleData = await deps.stores.withTransaction(async (tx)=>{
911
- const txStore = await resolveP2PStore(tx);
912
- await txStore.appendLedgerEntry({
913
- group_id: groupID,
914
- entry_id: entryID,
915
- type: CIRCLE_DEF_ENTRY_TYPE,
916
- subject: input.circleID,
917
- value,
918
- hlc,
919
- issuer_did: deps.identity.id,
920
- signed_token: token
921
- });
922
- await reprojectCircleDefs({
923
- store: txStore,
924
- groupID,
925
- anchor,
926
- logger: deps.logger
927
- });
928
- const row = await txStore.getCircle(input.circleID);
929
- if (row == null) throw new Error('Failed to read circle after update');
930
- return {
931
- id: row.id,
932
- groupID: row.group_id,
933
- name: row.name,
934
- description: row.description,
935
- createdAt: toISO(row.created_at)
936
- };
1310
+ groupID,
1311
+ ord: hlc
937
1312
  });
938
- // Record-before-send: defer the fan-out (so co-members fold the same entry
939
- // against their own anchor) and the local emit to the OUTERMOST commit. Under
940
- // `mutateGraph` this handler runs inside the engine's write transaction, so
941
- // the inner `withTransaction` above nests inline and does NOT commit on its
942
- // own the outer transaction commits later. Registering via `onCommit` keeps
943
- // a rolled-back update from ever being broadcast or observed locally. Broadcast
944
- // first, then emit, to match the order the other producers use.
945
- deps.stores.onCommit(()=>{
946
- deps.scheduleBroadcast(groupID, {
947
- type: 'ledger:entry',
948
- groupID,
949
- token
950
- });
951
- if (input.catalogIDs != null) {
952
- void deps.emitter.emit('circleCatalogsChanged', {
953
- ...updatedCircleData,
954
- circleID: input.circleID
955
- });
956
- } else {
957
- void deps.emitter.emit('circleDataChanged', {
958
- ...updatedCircleData,
959
- circleID: input.circleID
960
- });
961
- }
1313
+ // Which event this becomes is NOT decided here. The fold that lands the
1314
+ // entry compares the projected catalog list before and after and emits
1315
+ // `circleCatalogsChanged` only when it actually moved. That is the only
1316
+ // predicate every peer can evaluate a remote peer never sees this input —
1317
+ // so naming `catalogIDs` with the list the circle already carries emits
1318
+ // `circleDataChanged`, not `circleCatalogsChanged`.
1319
+ return await enqueueLedgerEntryRequest({
1320
+ groupID,
1321
+ token
962
1322
  });
963
- return updatedCircleData;
964
1323
  },
965
1324
  updateCatalog: async (p)=>{
966
1325
  const graphStore = await getGraphStore();
1326
+ // A catalog is owned by its creator (its `owner_did`, bound and signed at
1327
+ // creation). A device holds foreign-owned catalogs from invite seeds and
1328
+ // discovery, so key alone is not authority to change one — verify ownership
1329
+ // before touching the row.
1330
+ const owned = await graphStore.getCatalog(p.catalogID);
1331
+ if (owned == null) throw new Error(`Catalog ${p.catalogID} not found`);
1332
+ if (owned.owner_did !== ctx.viewerDID) {
1333
+ throw new Error(`Catalog ${p.catalogID} is not owned by this device`);
1334
+ }
967
1335
  const hlcStr = HLC.serialize(deps.hlc.now());
968
- const update = {
1336
+ const name = p.name ?? owned.name;
1337
+ const filterCriteria = p.filterCriteria != null ? JSON.parse(p.filterCriteria) : owned.filter_criteria;
1338
+ // Re-sign the whole record, exactly as creation does. The stored token is
1339
+ // forwarded VERBATIM in an invite seed and matched field-by-field against
1340
+ // the plaintext row it travels with, so a token left describing the record
1341
+ // before this edit does not merely go stale — it makes the catalog
1342
+ // unshareable, silently, on the receiving side. `hlc` alone moving is
1343
+ // enough to break that match, so every update re-signs, not only one that
1344
+ // changes the criteria.
1345
+ const signedToken = await signCatalogSet(deps.identity, {
1346
+ id: p.catalogID,
1347
+ ownerDID: ctx.viewerDID,
1348
+ name,
1349
+ description: owned.description,
1350
+ filterCriteria,
969
1351
  hlc: hlcStr
970
- };
971
- if (p.name != null) update.name = p.name;
972
- if (p.filterCriteria != null) {
973
- update.filter_criteria = JSON.parse(p.filterCriteria);
974
- }
975
- await graphStore.updateCatalog(p.catalogID, update);
1352
+ });
1353
+ await graphStore.updateCatalog(p.catalogID, {
1354
+ name,
1355
+ description: owned.description,
1356
+ filter_criteria: filterCriteria,
1357
+ hlc: hlcStr,
1358
+ signed_token: signedToken
1359
+ });
976
1360
  const row = await graphStore.getCatalog(p.catalogID);
977
1361
  if (row == null) throw new Error(`Catalog ${p.catalogID} not found`);
978
- return {
979
- id: row.id,
980
- ownerDID: row.owner_did,
981
- name: row.name,
982
- description: row.description,
983
- filterCriteria: JSON.stringify(row.filter_criteria),
984
- createdAt: toISO(row.created_at)
985
- };
1362
+ return toCatalogData(row);
986
1363
  },
987
1364
  deleteCatalog: async (catalogID)=>{
988
1365
  const graphStore = await getGraphStore();
1366
+ // Owner-scoped, as `updateCatalog`: a device holding a foreign-owned catalog
1367
+ // is not authorized to delete it — verify ownership before the row goes.
1368
+ const owned = await graphStore.getCatalog(catalogID);
1369
+ if (owned == null) throw new Error(`Catalog ${catalogID} not found`);
1370
+ if (owned.owner_did !== ctx.viewerDID) {
1371
+ throw new Error(`Catalog ${catalogID} is not owned by this device`);
1372
+ }
989
1373
  await graphStore.deleteCatalog(catalogID);
990
1374
  return {
991
1375
  deletedID: catalogID
992
1376
  };
993
1377
  },
994
- deleteCircle: async (circleID)=>{
1378
+ requestDeleteCircle: async ({ circleID })=>{
995
1379
  const store = await getP2PStore();
996
1380
  const existing = await store.getCircle(circleID, {
997
1381
  includeRemoved: true
998
1382
  });
999
- // No-op when the circle is absent (never created) or already tombstoned:
1000
- // minting a dead `removed` entry + broadcast would only churn the ledger
1001
- // and converge to the same projected state.
1002
- if (existing == null || existing.removed_at_hlc != null) {
1003
- return {
1004
- deletedID: circleID
1005
- };
1006
- }
1383
+ if (existing == null) throw new Error(`Circle ${circleID} not found`);
1007
1384
  const groupID = existing.group_id;
1385
+ // Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
1386
+ // authority — a non-admin's entry is dropped at fold time — but checking
1387
+ // here lets the caller fail loudly instead of receiving a request that
1388
+ // settles `committed` for a change the fold silently discarded.
1389
+ await requireGroupAdmin({
1390
+ store,
1391
+ groupID,
1392
+ did: deps.identity.id,
1393
+ action: 'delete a circle'
1394
+ });
1395
+ // No-op when the circle is already tombstoned: minting a dead `removed`
1396
+ // entry would only churn the ledger and converge to the same projected
1397
+ // state. The gate runs first, so a non-admin is refused either way.
1398
+ if (existing.removed_at_hlc != null) {
1399
+ return await settleNoOpRequest(groupID);
1400
+ }
1401
+ // Read the anchor through the engine transaction's stores: under
1402
+ // `mutateGraph` this handler already runs inside that transaction, so the
1403
+ // registry's restore/persist must share its connection — a separate
1404
+ // connection blocks forever on the outer write lock (single-connection SQLite).
1008
1405
  const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
1009
1406
  stores: deps.stores
1010
1407
  });
1011
1408
  if (anchor == null) {
1012
1409
  throw new Error('cannot delete circle: group has no genesis anchor');
1013
1410
  }
1014
- // One HLC stamps both the durable tombstone entry and the broadcast token,
1015
- // so the locally projected `removed_at_hlc` and the HLC every co-member
1016
- // folds are identical store and wire can never diverge.
1411
+ // One HLC stamps the durable tombstone entry, the broadcast token and the
1412
+ // commit body, so the HLC every co-member folds and the one this device
1413
+ // folds back off its own commit are identical.
1017
1414
  const hlc = HLC.serialize(deps.hlc.now());
1018
1415
  const value = {
1019
1416
  removed: true
@@ -1022,75 +1419,34 @@ export function createGroupContext(ctx, deps) {
1022
1419
  type: CIRCLE_DEF_ENTRY_TYPE,
1023
1420
  subject: circleID,
1024
1421
  value,
1025
- hlc
1026
- });
1027
- const entryID = ledgerEntryDigest(token);
1028
- await deps.stores.withTransaction(async (tx)=>{
1029
- const txStore = await resolveP2PStore(tx);
1030
- await txStore.appendLedgerEntry({
1031
- group_id: groupID,
1032
- entry_id: entryID,
1033
- type: CIRCLE_DEF_ENTRY_TYPE,
1034
- subject: circleID,
1035
- value,
1036
- hlc,
1037
- issuer_did: deps.identity.id,
1038
- signed_token: token
1039
- });
1040
- await reprojectCircleDefs({
1041
- store: txStore,
1042
- groupID,
1043
- anchor,
1044
- logger: deps.logger
1045
- });
1046
- const row = await txStore.getCircle(circleID, {
1047
- includeRemoved: true
1048
- });
1049
- if (row == null || row.removed_at_hlc == null) {
1050
- throw new Error('Failed to tombstone circle after delete');
1051
- }
1422
+ groupID,
1423
+ ord: hlc
1052
1424
  });
1053
- // Record-before-send: defer the fan-out (so co-members fold the same
1054
- // tombstone against their own anchor) and the local emit to the OUTERMOST
1055
- // commit. Under `mutateGraph` this handler runs inside the engine's write
1056
- // transaction, so the inner `withTransaction` above nests inline and does
1057
- // NOT commit on its own — the outer transaction commits later. Registering
1058
- // via `onCommit` keeps a rolled-back deletion from ever being broadcast or
1059
- // observed locally.
1060
- deps.stores.onCommit(()=>{
1061
- deps.scheduleBroadcast(groupID, {
1062
- type: 'ledger:entry',
1063
- groupID,
1064
- token
1065
- });
1066
- void deps.emitter.emit('circleDeleted', {
1067
- id: existing.id,
1068
- groupID: existing.group_id,
1069
- name: existing.name,
1070
- description: existing.description,
1071
- createdAt: toISO(existing.created_at)
1072
- });
1425
+ return await enqueueLedgerEntryRequest({
1426
+ groupID,
1427
+ token
1073
1428
  });
1074
- return {
1075
- deletedID: circleID
1076
- };
1077
1429
  },
1078
- removeCircleMember: async (circleID, memberDID)=>{
1430
+ requestRemoveCircleMember: async ({ circleID, memberDID })=>{
1079
1431
  const store = await getP2PStore();
1432
+ const circleRow = await store.getCircle(circleID);
1433
+ if (circleRow == null) throw new Error('Failed to remove circle member: circle not found');
1434
+ const groupID = circleRow.group_id;
1435
+ // Defense-in-depth + fail-loud UX, as in `requestDeleteCircle`: without it
1436
+ // a non-admin's entry rides a commit the hub accepts, so the request would
1437
+ // settle `committed` while every peer's fold drops the entry.
1438
+ await requireGroupAdmin({
1439
+ store,
1440
+ groupID,
1441
+ did: deps.identity.id,
1442
+ action: 'remove a circle member'
1443
+ });
1080
1444
  const existing = await store.getCircleMember(circleID, memberDID);
1081
1445
  // No-op when the target is not an active member: a never-added DID
1082
- // (absent row) or an already-tombstoned one. Minting a dead `removed`
1083
- // entry + broadcast for it would only churn the ledger and converge to
1084
- // the same projected state, so return the requested pair untouched.
1446
+ // (absent row) or an already-tombstoned one.
1085
1447
  if (existing == null || existing.removed_at_hlc != null) {
1086
- return {
1087
- circleID,
1088
- memberDID
1089
- };
1448
+ return await settleNoOpRequest(groupID);
1090
1449
  }
1091
- const circleRow = await store.getCircle(circleID);
1092
- if (circleRow == null) throw new Error('Failed to remove circle member: circle not found');
1093
- const groupID = circleRow.group_id;
1094
1450
  // Read the anchor through the engine transaction's stores: under
1095
1451
  // `mutateGraph` this handler already runs inside that transaction, so the
1096
1452
  // registry's restore/persist must share its connection — a separate
@@ -1101,9 +1457,6 @@ export function createGroupContext(ctx, deps) {
1101
1457
  if (anchor == null) {
1102
1458
  throw new Error('cannot remove circle member: group has no genesis anchor');
1103
1459
  }
1104
- // One HLC stamps both the durable tombstone entry and the broadcast token,
1105
- // so the locally projected `removed_at_hlc` and the HLC every co-member
1106
- // folds are identical — store and wire can never diverge.
1107
1460
  const hlc = HLC.serialize(deps.hlc.now());
1108
1461
  const subject = encodeCircleMemberSubject(circleID, memberDID);
1109
1462
  const value = {
@@ -1113,62 +1466,25 @@ export function createGroupContext(ctx, deps) {
1113
1466
  type: CIRCLE_MEMBER_ENTRY_TYPE,
1114
1467
  subject,
1115
1468
  value,
1116
- hlc
1117
- });
1118
- const entryID = ledgerEntryDigest(token);
1119
- // Append the signed tombstone and reproject the circle-member overlay
1120
- // atomically so a crash can never leave the durable ledger and the
1121
- // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
1122
- // the raw self DID is passed.
1123
- await deps.stores.withTransaction(async (tx)=>{
1124
- const txStore = await resolveP2PStore(tx);
1125
- await txStore.appendLedgerEntry({
1126
- group_id: groupID,
1127
- entry_id: entryID,
1128
- type: CIRCLE_MEMBER_ENTRY_TYPE,
1129
- subject,
1130
- value,
1131
- hlc,
1132
- issuer_did: deps.identity.id,
1133
- signed_token: token
1134
- });
1135
- await reprojectCircleMembers({
1136
- store: txStore,
1137
- groupID,
1138
- anchor,
1139
- logger: deps.logger
1140
- });
1141
- const row = await txStore.getCircleMember(circleID, memberDID);
1142
- if (row == null || row.removed_at_hlc == null) {
1143
- throw new Error('Failed to tombstone circle member after remove');
1144
- }
1469
+ groupID,
1470
+ ord: hlc
1145
1471
  });
1146
- // Record-before-send: defer the fan-out (so co-members fold the same
1147
- // tombstone against their own anchor) and the local emit to the OUTERMOST
1148
- // commit. Under `mutateGraph` this handler runs inside the engine's write
1149
- // transaction, so the inner `withTransaction` above nests inline and does
1150
- // NOT commit on its own — the outer transaction commits later. Registering
1151
- // via `onCommit` keeps a rolled-back removal from ever being broadcast or
1152
- // observed locally.
1153
- deps.stores.onCommit(()=>{
1154
- deps.scheduleBroadcast(groupID, {
1155
- type: 'ledger:entry',
1156
- groupID,
1157
- token
1158
- });
1159
- void deps.emitter.emit('circleMemberRemoved', {
1160
- circleID,
1161
- memberDID,
1162
- role: existing.role,
1163
- createdAt: toISO(existing.created_at)
1164
- });
1472
+ return await enqueueLedgerEntryRequest({
1473
+ groupID,
1474
+ token
1165
1475
  });
1166
- return {
1167
- circleID,
1168
- memberDID
1169
- };
1170
1476
  },
1171
1477
  grantWriteCapability: async ({ to, res, groupID, expiresIn })=>{
1478
+ // Refused rather than clamped: silently shortening a capability the caller
1479
+ // asked for produces a grant that stops working on a date it was never
1480
+ // told about.
1481
+ if (expiresIn != null && expiresIn > MAX_CAP_TTL_SECONDS) {
1482
+ throw new GraphQLError(`expiresIn ${expiresIn} exceeds the maximum capability lifetime of ${MAX_CAP_TTL_SECONDS} seconds`, {
1483
+ extensions: {
1484
+ code: 'GRANT_TTL_TOO_LONG'
1485
+ }
1486
+ });
1487
+ }
1172
1488
  const [store, delegationStore] = await Promise.all([
1173
1489
  getP2PStore(),
1174
1490
  getDelegationStore()
@@ -1240,24 +1556,33 @@ export function createGroupContext(ctx, deps) {
1240
1556
  getP2PStore(),
1241
1557
  getDelegationStore()
1242
1558
  ]);
1243
- const cap = await delegationStore.getDelegationTokenByJTI(jti);
1559
+ // Scoped to the viewer: a co-member can mint a capability reusing a `jti`
1560
+ // it saw broadcast, so an unscoped read can hand back someone else's row
1561
+ // and deny the issuer its own revocation.
1562
+ const cap = await delegationStore.getDelegationTokenByGrantorJTI({
1563
+ jti,
1564
+ grantor: ctx.viewerDID
1565
+ });
1244
1566
  if (cap == null) {
1245
- // Multi-device-grantor case (the issuing device is not the one running
1246
- // this resolver) is not yet supported; surface the missing-local-row
1247
- // condition explicitly so callers can distinguish it from authz.
1567
+ // Both branches deny, but callers act on them differently: rows under
1568
+ // this `jti` from other grantors mean the viewer is not the issuer,
1569
+ // while none at all means the capability is unknown here — the
1570
+ // multi-device-grantor case (the issuing device is not the one running
1571
+ // this resolver) is not yet supported.
1572
+ const others = await delegationStore.listDelegationTokensByJTI(jti);
1573
+ if (others.length > 0) {
1574
+ throw new GraphQLError(`Only the capability issuer can revoke jti ${jti}`, {
1575
+ extensions: {
1576
+ code: 'REVOCATION_UNAUTHORIZED'
1577
+ }
1578
+ });
1579
+ }
1248
1580
  throw new GraphQLError(`No capability found locally for jti ${jti}`, {
1249
1581
  extensions: {
1250
1582
  code: 'REVOCATION_CAP_UNKNOWN'
1251
1583
  }
1252
1584
  });
1253
1585
  }
1254
- if (cap.grantor !== ctx.viewerDID) {
1255
- throw new GraphQLError(`Only the capability issuer can revoke jti ${jti}`, {
1256
- extensions: {
1257
- code: 'REVOCATION_UNAUTHORIZED'
1258
- }
1259
- });
1260
- }
1261
1586
  // Resolve the group the cap was granted in from the p2p edge; it targets
1262
1587
  // the revoke broadcast and enriches the emitted event. A missing edge
1263
1588
  // means the group is unknown locally — the broadcast then cannot be
@@ -1270,20 +1595,22 @@ export function createGroupContext(ctx, deps) {
1270
1595
  }
1271
1596
  });
1272
1597
  }
1598
+ // `createRevocationRecord` signs the claims itself, so the record is
1599
+ // already the signed token the store persists and the broadcast carries.
1273
1600
  const record = await createRevocationRecord(deps.identity, jti);
1274
- const signed = await deps.identity.signToken(record);
1275
- const token = stringifyToken(signed);
1601
+ const revokedAt = record.payload.iat;
1602
+ const token = stringifyToken(record);
1276
1603
  const hlc = HLC.serialize(deps.hlc.now());
1277
1604
  // Self-issued revocations are verified by construction (the revoker is
1278
1605
  // the cap's issuer, checked above); seed `verified_at` with the
1279
- // record's iat so the hot-path `isRevoked` gate fires immediately
1280
- // without a separate verification round-trip.
1606
+ // record's iat so the revocation gate fires immediately without a
1607
+ // separate verification round-trip.
1281
1608
  const changed = await delegationStore.addRevocation({
1282
1609
  jti,
1283
1610
  revoker_did: deps.identity.id,
1284
- revoked_iat: record.iat,
1611
+ revoked_iat: revokedAt,
1285
1612
  revocation_token: token,
1286
- verified_at: record.iat,
1613
+ verified_at: revokedAt,
1287
1614
  cap_exp: cap.exp,
1288
1615
  hlc
1289
1616
  });
@@ -1298,8 +1625,8 @@ export function createGroupContext(ctx, deps) {
1298
1625
  grantor: cap.grantor,
1299
1626
  audience: cap.audience,
1300
1627
  revokerDID: deps.identity.id,
1301
- revokedAt: record.iat,
1302
- verifiedAt: record.iat,
1628
+ revokedAt: revokedAt,
1629
+ verifiedAt: revokedAt,
1303
1630
  capExp: cap.exp,
1304
1631
  groupID
1305
1632
  });