@kubun/plugin-p2p 0.10.1 → 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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +3 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +105 -91
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +5 -1
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +61 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +20 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +0 -6
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +3 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/context/hub.js
CHANGED
|
@@ -1 +1,150 @@
|
|
|
1
|
-
import{getP2PStore
|
|
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
|
+
}
|
package/lib/context/join.js
CHANGED
|
@@ -1 +1,80 @@
|
|
|
1
|
-
import{createKeyPackageBundle
|
|
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
|
+
}
|
package/lib/context/sync.js
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
-
export function createSyncContext(
|
|
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
|
+
}
|
package/lib/context/types.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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';
|
package/lib/context/types.js
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
-
export function toISO(
|
|
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(
|
|
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
|
+
}
|