@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,150 @@
1
- import{getP2PStore as e}from"@kubun/store-p2p";import{bindHubToGroup as t,createHub as r,deleteHub as s,getHub as a,listHubs as u,listHubsByGroup as i,unbindHubFromGroup as l,updateHub as n}from"../hub/manager.js";import{toISO as o}from"./types.js";function d(e){return{id:e.id,label:e.label,url:e.url,serverDID:e.serverDID,createdAt:e.createdAt.toISOString(),updatedAt:null==e.updatedAt?null:e.updatedAt.toISOString()}}export function createHubContext(c,p){return{list:async()=>(await u({stores:p.stores})).map(d),get:async e=>{let t=await a({stores:p.stores,id:e});return null==t?null:d(t)},create:async e=>d(await r({stores:p.stores,url:e.url,label:e.label,serverDID:e.serverDID})),update:async e=>d(await n({stores:p.stores,id:e.id,url:e.url,label:e.label,serverDID:e.serverDID})),delete:async(e,t)=>{let r=await s({stores:p.stores,id:e,force:t});return{success:r.success,boundGroupIDs:r.boundGroupIDs}},bind:async({hubID:e,groupID:r})=>{let s=await t({stores:p.stores,hubID:e,groupID:r});if(s){let t=await a({stores:p.stores,id:e});null!=t&&await p.emitter.emit("hubBound",{groupID:r,hubID:e,hubURL:t.url,serverDID:t.serverDID})}return s},unbind:async({hubID:e,groupID:t})=>{let r=await i({stores:p.stores,groupID:t}),s=r.find(t=>t.id===e)?.url??null,a=await l({stores:p.stores,hubID:e,groupID:t});return a&&null!=s&&await p.emitter.emit("hubUnbound",{groupID:t,hubID:e,hubURL:s}),a},listByGroup:async e=>(await i({stores:p.stores,groupID:e})).map(d),listGroups:async t=>{let r=await e(p.stores);return(await r.listGroupsByHubID(t)).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:o(e.created_at)}))}}}
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ import { bindHubToGroup, createHub, deleteHub, getHub, listHubs, listHubsByGroup, unbindHubFromGroup, updateHub } from '../hub/manager.js';
3
+ import { toISO } from './types.js';
4
+ function toHubData(hub) {
5
+ return {
6
+ id: hub.id,
7
+ label: hub.label,
8
+ url: hub.url,
9
+ serverDID: hub.serverDID,
10
+ createdAt: hub.createdAt.toISOString(),
11
+ updatedAt: hub.updatedAt == null ? null : hub.updatedAt.toISOString()
12
+ };
13
+ }
14
+ export function createHubContext(_ctx, deps) {
15
+ return {
16
+ list: async ()=>{
17
+ const hubs = await listHubs({
18
+ stores: deps.stores
19
+ });
20
+ return hubs.map(toHubData);
21
+ },
22
+ get: async (id)=>{
23
+ const hub = await getHub({
24
+ stores: deps.stores,
25
+ id
26
+ });
27
+ return hub == null ? null : toHubData(hub);
28
+ },
29
+ create: async (input)=>{
30
+ const hub = await createHub({
31
+ stores: deps.stores,
32
+ url: input.url,
33
+ label: input.label,
34
+ serverDID: input.serverDID
35
+ });
36
+ return toHubData(hub);
37
+ },
38
+ update: async (input)=>{
39
+ const hub = await updateHub({
40
+ stores: deps.stores,
41
+ id: input.id,
42
+ url: input.url,
43
+ label: input.label,
44
+ serverDID: input.serverDID
45
+ });
46
+ return toHubData(hub);
47
+ },
48
+ delete: async (id, force)=>{
49
+ const result = await deleteHub({
50
+ stores: deps.stores,
51
+ id,
52
+ force
53
+ });
54
+ return {
55
+ success: result.success,
56
+ boundGroupIDs: result.boundGroupIDs
57
+ };
58
+ },
59
+ bind: async ({ hubID, groupID })=>{
60
+ const inserted = await bindHubToGroup({
61
+ stores: deps.stores,
62
+ hubID,
63
+ groupID
64
+ });
65
+ if (inserted) {
66
+ const hub = await getHub({
67
+ stores: deps.stores,
68
+ id: hubID
69
+ });
70
+ if (hub != null) {
71
+ const hubURL = hub.url;
72
+ const serverDID = hub.serverDID;
73
+ // Defer past the outermost commit: the `hubBound` listener reads the P2P
74
+ // store on the main connection (deadlocks single-conn SQLite / uncommitted on PG).
75
+ deps.stores.onCommit(()=>{
76
+ deps.emitter.emit('hubBound', {
77
+ groupID,
78
+ hubID,
79
+ hubURL,
80
+ serverDID
81
+ }).catch((error)=>{
82
+ deps.logger.error('hubBound emit failed', {
83
+ groupID,
84
+ hubID,
85
+ error
86
+ });
87
+ });
88
+ });
89
+ }
90
+ }
91
+ return inserted;
92
+ },
93
+ unbind: async ({ hubID, groupID })=>{
94
+ // Capture URL from the (group, hub) binding row itself so a concurrent
95
+ // `deleteHub({ force: true })` racing this unbind can't strand us with
96
+ // a `removed === true` but no URL to emit. If the binding does not
97
+ // exist for this group/hub pair, `unbindHubFromGroup` returns false
98
+ // and no event fires.
99
+ const boundHubs = await listHubsByGroup({
100
+ stores: deps.stores,
101
+ groupID
102
+ });
103
+ const hubURL = boundHubs.find((h)=>h.id === hubID)?.url ?? null;
104
+ const removed = await unbindHubFromGroup({
105
+ stores: deps.stores,
106
+ hubID,
107
+ groupID
108
+ });
109
+ if (removed && hubURL != null) {
110
+ const boundURL = hubURL;
111
+ // Defer past the outermost commit: the `hubUnbound` listener reads the P2P
112
+ // store on the main connection (deadlocks single-conn SQLite / uncommitted on PG).
113
+ deps.stores.onCommit(()=>{
114
+ deps.emitter.emit('hubUnbound', {
115
+ groupID,
116
+ hubID,
117
+ hubURL: boundURL
118
+ }).catch((error)=>{
119
+ deps.logger.error('hubUnbound emit failed', {
120
+ groupID,
121
+ hubID,
122
+ error
123
+ });
124
+ });
125
+ });
126
+ }
127
+ return removed;
128
+ },
129
+ listByGroup: async (groupID)=>{
130
+ const hubs = await listHubsByGroup({
131
+ stores: deps.stores,
132
+ groupID
133
+ });
134
+ return hubs.map(toHubData);
135
+ },
136
+ listGroups: async (hubID)=>{
137
+ const store = await getP2PStore(deps.stores);
138
+ const rows = await store.listGroupsByHubID(hubID);
139
+ return rows.map((row)=>({
140
+ id: row.id,
141
+ name: row.name,
142
+ description: row.description,
143
+ createdBy: row.created_by,
144
+ createdAt: toISO(row.created_at)
145
+ }));
146
+ },
147
+ getGroupHealth: (groupID)=>deps.monitor.getHealth(groupID),
148
+ rejoinGroup: (groupID)=>deps.rejoinGroup(groupID)
149
+ };
150
+ }
@@ -1 +1,80 @@
1
- import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as r}from"../groups/invite-payload.js";import{applyInviteGrants as i,applyInviteRevocations as o,finalizeJoinedGroup as a}from"../groups/join-utils.js";import{checkPeerAccess as s}from"./types.js";export function createJoinContext(g,n){return{prepareRequest:async()=>{s(g.viewerDID,n.identity.id,n.autoAcceptPeers);let t=n.identity,i=await e(t);return n.pendingJoinRequests.set(g.viewerDID,{publicPackage:i.publicPackage,privatePackage:i.privatePackage,did:t.id}),{joinRequest:r({did:t.id,publicPackage:i.publicPackage})}},complete:async e=>{s(g.viewerDID,n.identity.id,n.autoAcceptPeers);let r=n.pendingJoinRequests.get(g.viewerDID);if(null==r)throw Error("No pending join request found — call prepareJoinRequest first");let p=n.identity,u=t(e),c={publicPackage:r.publicPackage,privatePackage:r.privatePackage,ownerDID:p.id};await n.groupManager.joinGroup({stores:n.stores,identity:p,groupID:u.groupID,groupName:u.groupName,hubs:u.suggestedHubs,invite:u.invite,keyPackageBundle:c,welcomeMessage:u.welcomeMessage,ratchetTree:u.ratchetTree}),await i({stores:n.stores,selfDID:p.id,groupID:u.groupID,grants:u.grants,hlc:n.hlc,emitter:n.emitter,logger:n.logger}),await o({stores:n.stores,selfDID:p.id,groupID:u.groupID,revocations:u.revocations,hlc:n.hlc,emitter:n.emitter,logger:n.logger});let l=await a({stores:n.stores,emitter:n.emitter,invite:u});return n.pendingJoinRequests.delete(g.viewerDID),l}}}
1
+ import { createKeyPackageBundle } from '@kumiai/mls';
2
+ import { groupAnchorCapabilities } from '../groups/group-anchor.js';
3
+ import { decodeInvitePayload, encodeJoinRequest } from '../groups/invite-payload.js';
4
+ import { applyInviteGrants, applyInviteRevocations, finalizeJoinedGroup } from '../groups/join-utils.js';
5
+ import { checkPeerAccess } from './types.js';
6
+ export function createJoinContext(ctx, deps) {
7
+ return {
8
+ prepareRequest: async ()=>{
9
+ checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
10
+ const ownIdentity = deps.identity;
11
+ // The invitee leaf must advertise the anchor extension capability or
12
+ // commitInvite rejects the added leaf when joining an anchored group.
13
+ const bundle = await createKeyPackageBundle(ownIdentity, {
14
+ capabilities: groupAnchorCapabilities()
15
+ });
16
+ deps.pendingJoinRequests.set(ctx.viewerDID, {
17
+ publicPackage: bundle.publicPackage,
18
+ privatePackage: bundle.privatePackage,
19
+ did: ownIdentity.id
20
+ });
21
+ const encoded = encodeJoinRequest({
22
+ did: ownIdentity.id,
23
+ publicPackage: bundle.publicPackage
24
+ });
25
+ return {
26
+ joinRequest: encoded
27
+ };
28
+ },
29
+ complete: async (invitePayloadStr)=>{
30
+ checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
31
+ const stored = deps.pendingJoinRequests.get(ctx.viewerDID);
32
+ if (stored == null) {
33
+ throw new Error('No pending join request found — call prepareJoinRequest first');
34
+ }
35
+ const ownIdentity = deps.identity;
36
+ const invite = decodeInvitePayload(invitePayloadStr);
37
+ const keyPackageBundle = {
38
+ publicPackage: stored.publicPackage,
39
+ privatePackage: stored.privatePackage,
40
+ ownerDID: ownIdentity.id
41
+ };
42
+ await deps.groupManager.joinGroup({
43
+ stores: deps.stores,
44
+ identity: ownIdentity,
45
+ groupID: invite.groupID,
46
+ groupName: invite.groupName,
47
+ hubs: invite.suggestedHubs,
48
+ invite: invite.invite,
49
+ keyPackageBundle,
50
+ welcomeMessage: invite.welcomeMessage,
51
+ ratchetTree: invite.ratchetTree
52
+ });
53
+ await applyInviteGrants({
54
+ stores: deps.stores,
55
+ selfDID: ownIdentity.id,
56
+ groupID: invite.groupID,
57
+ grants: invite.grants,
58
+ hlc: deps.hlc,
59
+ emitter: deps.emitter,
60
+ logger: deps.logger
61
+ });
62
+ await applyInviteRevocations({
63
+ stores: deps.stores,
64
+ selfDID: ownIdentity.id,
65
+ groupID: invite.groupID,
66
+ revocations: invite.revocations,
67
+ hlc: deps.hlc,
68
+ emitter: deps.emitter,
69
+ logger: deps.logger
70
+ });
71
+ const result = await finalizeJoinedGroup({
72
+ stores: deps.stores,
73
+ emitter: deps.emitter,
74
+ invite
75
+ });
76
+ deps.pendingJoinRequests.delete(ctx.viewerDID);
77
+ return result;
78
+ }
79
+ };
80
+ }
@@ -1 +1,19 @@
1
- export function createSyncContext(e,s){return{addPeer:e=>s.syncManager.addPeer({config:e,stores:s.stores}),removePeer:e=>s.syncManager.removePeer({peerDID:e,stores:s.stores}),listPeers:()=>s.syncManager.listPeers({stores:s.stores}),getStatus:e=>s.syncManager.getStatus({peerDID:e,stores:s.stores})}}
1
+ export function createSyncContext(_ctx, deps) {
2
+ return {
3
+ addPeer: (config)=>deps.syncManager.addPeer({
4
+ config,
5
+ stores: deps.stores
6
+ }),
7
+ removePeer: (peerDID)=>deps.syncManager.removePeer({
8
+ peerDID,
9
+ stores: deps.stores
10
+ }),
11
+ listPeers: ()=>deps.syncManager.listPeers({
12
+ stores: deps.stores
13
+ }),
14
+ getStatus: (peerDID)=>deps.syncManager.getStatus({
15
+ peerDID,
16
+ stores: deps.stores
17
+ })
18
+ };
19
+ }
@@ -1,15 +1,17 @@
1
- import type { KeyPackageBundle } from '@enkaku/group';
2
- import type { Runtime } from '@enkaku/runtime';
3
- import type { OwnIdentity } from '@enkaku/token';
1
+ import type { OwnIdentity } from '@kokuin/token';
4
2
  import type { StoreProvider } from '@kubun/db';
5
3
  import type { Adapter } from '@kubun/db-adapter';
6
4
  import type { HLC } from '@kubun/hlc';
7
5
  import type { Logger } from '@kubun/logger';
6
+ import type { KeyPackageBundle } from '@kumiai/mls';
7
+ import type { Runtime } from '@sozai/runtime';
8
8
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
9
9
  import type { P2PEventEmitter } from '../groups/events.js';
10
10
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
11
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
11
12
  import type { GroupManager } from '../groups/manager.js';
12
13
  import type { SyncManager } from '../sync/sync-manager.js';
14
+ import type { RejoinResult } from '../types.js';
13
15
  export type PendingJoinRequest = {
14
16
  publicPackage: KeyPackageBundle['publicPackage'];
15
17
  privatePackage: KeyPackageBundle['privatePackage'];
@@ -48,6 +50,13 @@ export type ContextDeps = {
48
50
  * runs after the mutation transaction commits.
49
51
  */
50
52
  scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
53
+ /** Shared group-health monitor; source for the `groupHealth` query. */
54
+ monitor: GroupHealthMonitor;
55
+ /**
56
+ * Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup`;
57
+ * a no-op `recovery-failed` under `DISABLED_HUB`.
58
+ */
59
+ rejoinGroup: (groupID: string) => Promise<RejoinResult>;
51
60
  logger: Logger;
52
61
  };
53
62
  export declare function toISO(timestamp: unknown): string;
@@ -1 +1,28 @@
1
- export function toISO(e){return"number"==typeof e?new Date(1e3*e).toISOString():e instanceof Date?e.toISOString():String(e)}export function normalizeCredential(e){return"string"==typeof e?e:JSON.stringify(e)}export function checkPeerAccess(e,t,n){if(null!=n){if(!n.includes(e))throw Error("Peer not in autoAcceptPeers list")}else if(e!==t)throw Error("Only the engine identity can perform this operation")}
1
+ export function toISO(timestamp) {
2
+ if (typeof timestamp === 'number') {
3
+ return new Date(timestamp * 1000).toISOString();
4
+ }
5
+ if (timestamp instanceof Date) {
6
+ return timestamp.toISOString();
7
+ }
8
+ return String(timestamp);
9
+ }
10
+ /**
11
+ * Normalize a credential value from the DB.
12
+ * Kysely's ParseJSONResultsPlugin may have already parsed the JSON string
13
+ * into an object. SerializedMLSGroupState expects a JSON string.
14
+ */ export function normalizeCredential(value) {
15
+ if (typeof value === 'string') return value;
16
+ return JSON.stringify(value);
17
+ }
18
+ export function checkPeerAccess(viewerDID, identityID, autoAcceptPeers) {
19
+ if (autoAcceptPeers != null) {
20
+ if (!autoAcceptPeers.includes(viewerDID)) {
21
+ throw new Error('Peer not in autoAcceptPeers list');
22
+ }
23
+ } else {
24
+ if (viewerDID !== identityID) {
25
+ throw new Error('Only the engine identity can perform this operation');
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,130 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { P2PStoreAPI } from '@kubun/store-p2p';
3
+ import type { GroupAnchor } from './group-anchor.js';
4
+ import { type VerifiedLedgerEntry } from './ledger-entry.js';
5
+ import { type FoldInput, type LedgerReducer } from './ledger-fold.js';
6
+ /** Ledger entry `type` for an admin-role grant or revocation. */
7
+ export declare const ADMIN_ROLE_ENTRY_TYPE = "group.role.admin";
8
+ /**
9
+ * Projection key for the admin-roster watermark. One projection per entry type
10
+ * in this design, so the key is the entry type itself.
11
+ */
12
+ export declare const ADMIN_ROSTER_PROJECTION = "group.role.admin";
13
+ /**
14
+ * The claim carried by an admin-role entry. `subject` (on the entry) is the
15
+ * member DID the claim is about; this value says whether that member becomes an
16
+ * admin or loses the role.
17
+ */
18
+ export type AdminRoleValue = 'admin' | 'revoked';
19
+ /** Folded admin set: the normalized DIDs currently holding the admin role. */
20
+ export type AdminRosterState = {
21
+ admins: Set<string>;
22
+ };
23
+ /**
24
+ * Self-referential admin roster. Authority is rooted at the genesis anchor (the
25
+ * creator is the epoch-0 admin) and grows only through admins-so-far: every
26
+ * grant or revocation must be issued by a DID already in the set accumulated
27
+ * from strictly-earlier entries. That state-so-far check is what makes rotation
28
+ * sound — an admin can revoke the very creator that granted them without
29
+ * retroactively voiding their own earlier grants.
30
+ */
31
+ export declare const adminRosterReducer: LedgerReducer<AdminRoleValue, AdminRosterState>;
32
+ /**
33
+ * Fold the admin-role ledger into the current admin set. With `atHLC`, only
34
+ * entries at or before that HLC are folded, yielding the exact was-admin-at-HLC
35
+ * answer the current-only role column cannot express. The string `<=` compare on
36
+ * the serialized HLC is the total order (zero-padded, lexicographic), matching
37
+ * the membership-removal LWW elsewhere in the store.
38
+ */
39
+ export declare function foldAdminRoster(entries: Array<FoldInput<AdminRoleValue>>, anchor: GroupAnchor, atHLC?: string): AdminRosterState;
40
+ export type ProjectAdminRosterParams = {
41
+ store: P2PStoreAPI;
42
+ groupID: string;
43
+ anchor: GroupAnchor;
44
+ /** The group's admin-role ledger entries, in any order. */
45
+ entries: Array<FoldInput<AdminRoleValue>>;
46
+ /** Current active members whose role overlay should be reconciled. */
47
+ memberDIDs: Array<string>;
48
+ };
49
+ /**
50
+ * Project the folded admin set onto the store's `role` column. Membership stays
51
+ * MLS-native — this only overlays a role on members that already exist: each
52
+ * current member is set to `admin` when the fold places them in the set and
53
+ * `member` otherwise. `setGroupMemberRole` is update-only, so a DID in the
54
+ * admin set with no membership row is silently skipped.
55
+ */
56
+ export declare function projectAdminRoster(params: ProjectAdminRosterParams): Promise<void>;
57
+ /**
58
+ * Load the group's admin-role ledger entries and re-verify each `signed_token`
59
+ * into a fold input. The stored token is re-verified rather than trusting the
60
+ * decoded `value` column: the ledger is the source of truth and must stay
61
+ * self-verifying, so a stored token that no longer verifies is dropped and the
62
+ * rest survive. Returned in store order; the fold sorts by total order. Results
63
+ * are memoized per group on the stored id set (see {@link verifiedAdminEntryCache}).
64
+ */
65
+ export declare function loadVerifiedAdminEntries(store: P2PStoreAPI, groupID: string, logger?: Logger): Promise<Array<FoldInput<AdminRoleValue>>>;
66
+ /**
67
+ * Re-project the admin overlay from the stored ledger. The active member set
68
+ * bounds the overlay — `projectAdminRoster` only touches rows that already
69
+ * exist, so an admin DID with no membership is silently skipped.
70
+ */
71
+ export type ProjectAdminRosterFromStoreParams = {
72
+ store: P2PStoreAPI;
73
+ groupID: string;
74
+ anchor: GroupAnchor;
75
+ logger?: Logger;
76
+ };
77
+ export declare function projectAdminRosterFromStore(params: ProjectAdminRosterFromStoreParams): Promise<void>;
78
+ /**
79
+ * Was `did` a group admin at `atHLC`? Authority is folded from the
80
+ * anchor-seeded ledger evaluated at the operation's HLC (rotation-aware:
81
+ * was-admin-at-HLC, not is-admin-now), conjoined with membership at that HLC.
82
+ * Folding from the genesis anchor — not the local `role` column — is what makes
83
+ * a receiver agree with the author's peer: the creator is the epoch-0 admin on
84
+ * every peer regardless of how its membership row's role was written.
85
+ *
86
+ * Admin implies membership: an MLS-removed member loses admin regardless of the
87
+ * ledger, so membership-at-HLC is checked first and short-circuits.
88
+ */
89
+ export type IsLedgerAdminAtHLCParams = {
90
+ store: P2PStoreAPI;
91
+ groupID: string;
92
+ anchor: GroupAnchor;
93
+ did: string;
94
+ atHLC: string;
95
+ logger?: Logger;
96
+ };
97
+ export declare function isLedgerAdminAtHLC(params: IsLedgerAdminAtHLCParams): Promise<boolean>;
98
+ /**
99
+ * Authoritatively rebuild the admin overlay from the whole ledger and reset the
100
+ * watermark to the newest folded entry. Used on a first projection, an
101
+ * out-of-order arrival, or drift repair — anywhere the cached projection can no
102
+ * longer be trusted as a fold-through-watermark. Seeds the admin set from the
103
+ * genesis anchor (the creator is the epoch-0 admin), so it never relies on a
104
+ * pre-existing `role` value in the store.
105
+ */
106
+ export type ReprojectAdminRosterParams = {
107
+ store: P2PStoreAPI;
108
+ groupID: string;
109
+ anchor: GroupAnchor;
110
+ logger?: Logger;
111
+ };
112
+ export declare function reprojectAdminRoster(params: ReprojectAdminRosterParams): Promise<void>;
113
+ /**
114
+ * Apply a single just-appended in-order entry onto the current projection
115
+ * without re-folding the whole ledger. The caller guarantees the entry is
116
+ * strictly newer than the watermark, so the projection already equals the
117
+ * fold-so-far: the current admins are exactly the members projected with role
118
+ * `admin`. Authority is read from that projection — only a current admin may
119
+ * grant or revoke — so an entry from a non-admin issuer is dropped (never
120
+ * thrown). `setGroupMemberRole` is update-only, so a grant for a DID with no
121
+ * membership row is silently skipped; admin implies membership and membership
122
+ * stays MLS-native.
123
+ */
124
+ export type ApplyAdminEntryIncrementalParams = {
125
+ store: P2PStoreAPI;
126
+ groupID: string;
127
+ verified: VerifiedLedgerEntry<AdminRoleValue>;
128
+ logger?: Logger;
129
+ };
130
+ export declare function applyAdminEntryIncremental(params: ApplyAdminEntryIncrementalParams): Promise<void>;
@@ -0,0 +1,184 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { verifyLedgerEntry } from './ledger-entry.js';
3
+ import { foldLedger } from './ledger-fold.js';
4
+ /** Ledger entry `type` for an admin-role grant or revocation. */ export const ADMIN_ROLE_ENTRY_TYPE = 'group.role.admin';
5
+ /**
6
+ * Projection key for the admin-roster watermark. One projection per entry type
7
+ * in this design, so the key is the entry type itself.
8
+ */ export const ADMIN_ROSTER_PROJECTION = ADMIN_ROLE_ENTRY_TYPE;
9
+ /**
10
+ * Self-referential admin roster. Authority is rooted at the genesis anchor (the
11
+ * creator is the epoch-0 admin) and grows only through admins-so-far: every
12
+ * grant or revocation must be issued by a DID already in the set accumulated
13
+ * from strictly-earlier entries. That state-so-far check is what makes rotation
14
+ * sound — an admin can revoke the very creator that granted them without
15
+ * retroactively voiding their own earlier grants.
16
+ */ export const adminRosterReducer = {
17
+ type: ADMIN_ROLE_ENTRY_TYPE,
18
+ seed: (anchor)=>({
19
+ admins: new Set([
20
+ normalizeDID(anchor.creatorDID)
21
+ ])
22
+ }),
23
+ verifyAuthority: (verified, state)=>state.admins.has(verified.issuer),
24
+ apply: (verified, state)=>{
25
+ const admins = new Set(state.admins);
26
+ const subject = normalizeDID(verified.entry.subject);
27
+ if (verified.entry.value === 'admin') {
28
+ admins.add(subject);
29
+ } else if (verified.entry.value === 'revoked') {
30
+ admins.delete(subject);
31
+ } else {
32
+ // Unknown claim value: leave the set untouched rather than guess intent.
33
+ return state;
34
+ }
35
+ return {
36
+ admins
37
+ };
38
+ }
39
+ };
40
+ /**
41
+ * Fold the admin-role ledger into the current admin set. With `atHLC`, only
42
+ * entries at or before that HLC are folded, yielding the exact was-admin-at-HLC
43
+ * answer the current-only role column cannot express. The string `<=` compare on
44
+ * the serialized HLC is the total order (zero-padded, lexicographic), matching
45
+ * the membership-removal LWW elsewhere in the store.
46
+ */ export function foldAdminRoster(entries, anchor, atHLC) {
47
+ const scoped = atHLC == null ? entries : entries.filter(({ verified })=>verified.entry.hlc <= atHLC);
48
+ return foldLedger(scoped, anchor, adminRosterReducer);
49
+ }
50
+ /**
51
+ * Project the folded admin set onto the store's `role` column. Membership stays
52
+ * MLS-native — this only overlays a role on members that already exist: each
53
+ * current member is set to `admin` when the fold places them in the set and
54
+ * `member` otherwise. `setGroupMemberRole` is update-only, so a DID in the
55
+ * admin set with no membership row is silently skipped.
56
+ */ export async function projectAdminRoster(params) {
57
+ const { store, groupID, anchor, entries, memberDIDs } = params;
58
+ const { admins } = foldAdminRoster(entries, anchor);
59
+ for (const memberDID of memberDIDs){
60
+ const role = admins.has(normalizeDID(memberDID)) ? 'admin' : 'member';
61
+ await store.setGroupMemberRole(groupID, memberDID, role);
62
+ }
63
+ }
64
+ /**
65
+ * Memoized verified admin entries, one slot per group keyed by the exact set of
66
+ * stored entry ids. Verifying every stored token is the dominant cost of an
67
+ * authority check, and the gate folds on every control op, so re-verifying an
68
+ * unchanged ledger each time is pure waste. Reading the id set stays cheap (an
69
+ * indexed scan); signatures are re-verified only when the set changes. The key
70
+ * is derived purely from content-addressed entry ids — never the `role` column —
71
+ * so the cache can never reintroduce the column-trust bypass: a forged or
72
+ * altered entry produces a different id set, a different key, and a full
73
+ * re-verification. One slot per group bounds memory and self-invalidates, since
74
+ * the ledger is append-only and every append changes the id set.
75
+ *
76
+ * Cross-instance safety: this Map is process-global, shared across store
77
+ * instances and keyed only by group id. Two instances using the same group id
78
+ * with different ledgers stay correct because differing ledgers yield differing
79
+ * keys (entry ids are content digests); the only shared key is the empty-ledger
80
+ * `''`, whose cached value is `[]` — exactly what either empty ledger produces.
81
+ * Group growth is bounded: only device-side peers fold rosters (the blind relay
82
+ * hub never calls this path), so the distinct-group set per process is small and
83
+ * needs no eviction.
84
+ */ const verifiedAdminEntryCache = new Map();
85
+ function adminEntryCacheKey(entryIDs) {
86
+ return entryIDs.join('\n');
87
+ }
88
+ /**
89
+ * Load the group's admin-role ledger entries and re-verify each `signed_token`
90
+ * into a fold input. The stored token is re-verified rather than trusting the
91
+ * decoded `value` column: the ledger is the source of truth and must stay
92
+ * self-verifying, so a stored token that no longer verifies is dropped and the
93
+ * rest survive. Returned in store order; the fold sorts by total order. Results
94
+ * are memoized per group on the stored id set (see {@link verifiedAdminEntryCache}).
95
+ */ export async function loadVerifiedAdminEntries(store, groupID, logger) {
96
+ const rows = await store.getLedgerEntriesByType(groupID, ADMIN_ROLE_ENTRY_TYPE);
97
+ const key = adminEntryCacheKey(rows.map((row)=>row.entry_id));
98
+ const cached = verifiedAdminEntryCache.get(groupID);
99
+ if (cached != null && cached.key === key) {
100
+ return cached.entries;
101
+ }
102
+ const entries = [];
103
+ for (const row of rows){
104
+ const verified = await verifyLedgerEntry(row.signed_token);
105
+ if (verified == null) {
106
+ logger?.warn('admin roster: dropped stored entry whose token no longer verifies', {
107
+ groupID,
108
+ entryID: row.entry_id
109
+ });
110
+ continue;
111
+ }
112
+ entries.push({
113
+ verified,
114
+ entryID: row.entry_id
115
+ });
116
+ }
117
+ verifiedAdminEntryCache.set(groupID, {
118
+ key,
119
+ entries
120
+ });
121
+ return entries;
122
+ }
123
+ export async function projectAdminRosterFromStore(params) {
124
+ const { store, groupID, anchor, logger } = params;
125
+ const entries = await loadVerifiedAdminEntries(store, groupID, logger);
126
+ const memberDIDs = (await store.listGroupMembers(groupID)).map((member)=>member.member_did);
127
+ await projectAdminRoster({
128
+ store,
129
+ groupID,
130
+ anchor,
131
+ entries,
132
+ memberDIDs
133
+ });
134
+ }
135
+ export async function isLedgerAdminAtHLC(params) {
136
+ const { store, groupID, anchor, did, atHLC, logger } = params;
137
+ if (!await store.isGroupMemberAtHLC(groupID, did, atHLC)) return false;
138
+ const entries = await loadVerifiedAdminEntries(store, groupID, logger);
139
+ const { admins } = foldAdminRoster(entries, anchor, atHLC);
140
+ return admins.has(normalizeDID(did));
141
+ }
142
+ export async function reprojectAdminRoster(params) {
143
+ const { store, groupID, anchor, logger } = params;
144
+ const entries = await loadVerifiedAdminEntries(store, groupID, logger);
145
+ const memberDIDs = (await store.listGroupMembers(groupID)).map((member)=>member.member_did);
146
+ await projectAdminRoster({
147
+ store,
148
+ groupID,
149
+ anchor,
150
+ entries,
151
+ memberDIDs
152
+ });
153
+ // Watermark through the newest folded entry: the projection now equals the
154
+ // fold of every verified entry, so a later in-order arrival (strictly newer
155
+ // HLC) can be applied incrementally instead of forcing a full reproject.
156
+ let maxHLC = null;
157
+ for (const { verified } of entries){
158
+ if (maxHLC == null || verified.entry.hlc > maxHLC) {
159
+ maxHLC = verified.entry.hlc;
160
+ }
161
+ }
162
+ if (maxHLC != null) {
163
+ await store.setProjectionWatermark(groupID, ADMIN_ROSTER_PROJECTION, maxHLC);
164
+ }
165
+ }
166
+ export async function applyAdminEntryIncremental(params) {
167
+ const { store, groupID, verified, logger } = params;
168
+ const members = await store.listGroupMembers(groupID);
169
+ const currentAdmins = new Set(members.filter((m)=>m.role === 'admin').map((m)=>normalizeDID(m.member_did)));
170
+ if (!currentAdmins.has(verified.issuer)) {
171
+ logger?.warn('admin roster: dropped incremental entry from a non-admin issuer', {
172
+ groupID,
173
+ issuer: verified.issuer
174
+ });
175
+ return;
176
+ }
177
+ const subject = normalizeDID(verified.entry.subject);
178
+ if (verified.entry.value === 'admin') {
179
+ await store.setGroupMemberRole(groupID, subject, 'admin');
180
+ } else if (verified.entry.value === 'revoked') {
181
+ await store.setGroupMemberRole(groupID, subject, 'member');
182
+ }
183
+ // Unknown value: no-op, mirroring the reducer's leave-untouched policy.
184
+ }
@@ -1 +1,6 @@
1
- export function serializeBroadcast(e){return new TextEncoder().encode(JSON.stringify(e))}export function deserializeBroadcast(e){return JSON.parse(new TextDecoder().decode(e))}
1
+ export function serializeBroadcast(message) {
2
+ return new TextEncoder().encode(JSON.stringify(message));
3
+ }
4
+ export function deserializeBroadcast(bytes) {
5
+ return JSON.parse(new TextDecoder().decode(bytes));
6
+ }