@kubun/plugin-p2p 0.12.0 → 0.13.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/group.js +3 -0
- package/lib/context/join.js +3 -0
- package/lib/context/peer.js +7 -2
- package/lib/context/sync.js +63 -5
- package/lib/context/types.d.ts +14 -1
- package/lib/context/types.js +12 -0
- package/lib/groups/access-default-apply.d.ts +42 -0
- package/lib/groups/access-default-apply.js +75 -0
- package/lib/groups/broadcast-codec.d.ts +1 -1
- package/lib/groups/broadcast-message.d.ts +240 -0
- package/lib/groups/broadcast-message.js +1 -0
- package/lib/groups/broadcast.d.ts +19 -266
- package/lib/groups/broadcast.js +30 -204
- package/lib/groups/credential-apply.d.ts +82 -0
- package/lib/groups/credential-apply.js +410 -0
- package/lib/groups/credential-grant-token.d.ts +42 -0
- package/lib/groups/credential-grant-token.js +51 -0
- package/lib/groups/credential-grant.d.ts +98 -0
- package/lib/groups/credential-grant.js +165 -0
- package/lib/groups/group-handlers.js +25 -2
- package/lib/groups/group-mls.d.ts +5 -0
- package/lib/groups/group-mls.js +4 -1
- package/lib/groups/group-peer-manager.d.ts +7 -1
- package/lib/groups/group-peer-manager.js +44 -1
- package/lib/groups/group-protocols.d.ts +227 -0
- package/lib/groups/group-protocols.js +146 -0
- package/lib/groups/join-utils.d.ts +5 -0
- package/lib/groups/join-utils.js +5 -1
- package/lib/groups/ledger-adopt.d.ts +84 -0
- package/lib/groups/ledger-adopt.js +142 -0
- package/lib/groups/ledger-commit-fold.d.ts +3 -1
- package/lib/groups/ledger-commit-fold.js +3 -0
- package/lib/groups/ledger-ingest.d.ts +23 -1
- package/lib/groups/ledger-ingest.js +30 -1
- package/lib/groups/manager.d.ts +5 -0
- package/lib/groups/manager.js +6 -1
- package/lib/groups/peer-presence.d.ts +9 -2
- package/lib/groups/peer-presence.js +14 -2
- package/lib/groups/peer-selection.d.ts +9 -0
- package/lib/groups/peer-selection.js +10 -0
- package/lib/hub/wiring.d.ts +6 -1
- package/lib/hub/wiring.js +2 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +58 -6
- package/lib/peer/blob-fetch.d.ts +45 -0
- package/lib/peer/blob-fetch.js +89 -0
- package/lib/peer/blob-handlers.d.ts +11 -0
- package/lib/peer/blob-handlers.js +123 -0
- package/lib/peer/controller-fetch.d.ts +6 -0
- package/lib/peer/controller-fetch.js +59 -0
- package/lib/peer/controller-handlers.d.ts +10 -0
- package/lib/peer/controller-handlers.js +28 -0
- package/lib/protocol.d.ts +279 -0
- package/lib/protocol.js +358 -0
- package/lib/sync/access-default-sender.d.ts +1 -1
- package/lib/sync/broadcast-queue.d.ts +1 -1
- package/lib/sync/forwarder.d.ts +1 -1
- package/lib/sync/handlers.js +98 -1
- package/lib/sync/sync-manager.d.ts +10 -1
- package/lib/sync/sync-manager.js +24 -2
- package/lib/types.d.ts +18 -0
- package/package.json +55 -43
package/lib/context/group.js
CHANGED
|
@@ -815,6 +815,9 @@ export function createGroupContext(ctx, deps) {
|
|
|
815
815
|
accessDefaults: invite.accessDefaults,
|
|
816
816
|
catalogs: invite.catalogs,
|
|
817
817
|
hlc: deps.hlc,
|
|
818
|
+
...deps.maxDriftMS != null ? {
|
|
819
|
+
maxDriftMS: deps.maxDriftMS
|
|
820
|
+
} : {},
|
|
818
821
|
getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
|
|
819
822
|
stores: deps.stores
|
|
820
823
|
}),
|
package/lib/context/join.js
CHANGED
|
@@ -94,6 +94,9 @@ export function createJoinContext(ctx, deps) {
|
|
|
94
94
|
accessDefaults: invite.accessDefaults,
|
|
95
95
|
catalogs: invite.catalogs,
|
|
96
96
|
hlc: deps.hlc,
|
|
97
|
+
...deps.maxDriftMS != null ? {
|
|
98
|
+
maxDriftMS: deps.maxDriftMS
|
|
99
|
+
} : {},
|
|
97
100
|
getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
|
|
98
101
|
stores: deps.stores
|
|
99
102
|
}),
|
package/lib/context/peer.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Client } from '@enkaku/client';
|
|
2
2
|
import { ClientTransport } from '@enkaku/http-fetch';
|
|
3
3
|
import { normalizeDID } from '@kokuin/token';
|
|
4
|
+
import { createDeviceAuthority } from '@kubun/credential';
|
|
4
5
|
import { getGraphStore } from '@kubun/store-graph';
|
|
5
6
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
6
7
|
import { readGroupAnchor } from '@kumiai/mls';
|
|
7
8
|
import { blake3 } from '@noble/hashes/blake3.js';
|
|
8
9
|
import { GraphQLError } from 'graphql';
|
|
10
|
+
import { applyAccessDefaultSetFrame } from '../groups/access-default-apply.js';
|
|
9
11
|
import { signAccessDefaultSet } from '../groups/access-default-token.js';
|
|
10
|
-
import { applyAccessDefaultSetFrame } from '../groups/broadcast.js';
|
|
11
12
|
import { foldGroupSettings } from '../groups/circle-projection.js';
|
|
12
13
|
import { awaitControlRequestSettled } from '../groups/control-request.js';
|
|
13
14
|
import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
|
|
@@ -238,8 +239,12 @@ function isPeerInfoResponse(value) {
|
|
|
238
239
|
if (normalizeDID(credentialDID) !== normalizeDID(callerDID)) {
|
|
239
240
|
throw new Error('group/invite: join request DID does not match the authenticated caller');
|
|
240
241
|
}
|
|
242
|
+
// Out-of-band peer-handler context: it acts as the local device, so a
|
|
243
|
+
// self-scoped device authority is the correct credential authority (the group
|
|
244
|
+
// ops here never consult it).
|
|
241
245
|
const groupCtx = createGroupContext({
|
|
242
|
-
viewerDID: deps.identity.id
|
|
246
|
+
viewerDID: deps.identity.id,
|
|
247
|
+
credentialAuthority: createDeviceAuthority(deps.identity.id)
|
|
243
248
|
}, deps);
|
|
244
249
|
// A caller already in the MLS roster is a joiner that lost its local group
|
|
245
250
|
// state — re-adding it directly would fail (MLS rejects an Add for a current
|
package/lib/context/sync.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
|
|
1
2
|
import { getGraphStore } from '@kubun/store-graph';
|
|
2
3
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
3
|
-
import {
|
|
4
|
+
import { applyCredentialReconcile } from '../groups/credential-apply.js';
|
|
5
|
+
import { answersCredentialSync, rankSyncPeers } from '../groups/peer-selection.js';
|
|
4
6
|
import { resolveCatalogSyncScopes } from '../sync/catalog-scope.js';
|
|
5
7
|
const NO_OP = {
|
|
6
8
|
messagesReceived: 0,
|
|
@@ -75,13 +77,56 @@ export function createSyncContext(_ctx, deps) {
|
|
|
75
77
|
* Route only. Which documents a session serves is decided per document at the
|
|
76
78
|
* far end, so preferring a tunnel changes who can be dialled and nothing about
|
|
77
79
|
* what comes back.
|
|
78
|
-
*/ const
|
|
80
|
+
*/ const resolveAnnouncement = async (groupID, peerDID)=>{
|
|
79
81
|
const p2pStore = await getP2PStore(deps.stores);
|
|
80
|
-
|
|
82
|
+
return await p2pStore.getPeerAnnouncement({
|
|
81
83
|
groupID,
|
|
82
84
|
peerDID
|
|
83
85
|
});
|
|
84
|
-
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* The credential subject ids this device holds, to ride a doc sync as the
|
|
89
|
+
* catch-up's `held` — or `undefined` to skip the lane entirely. Gated twice: the
|
|
90
|
+
* peer must advertise `credential-sync` (its opt-out is the absence of the
|
|
91
|
+
* capability), and this device must have a credential store to apply into.
|
|
92
|
+
*/ const credentialHeld = async (announcement)=>{
|
|
93
|
+
if (announcement == null || !answersCredentialSync(announcement) || !deps.stores.hasStore(CREDENTIAL_STORE)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
return await (await getCredentialStore(deps.stores)).listHeldSubjects();
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Materialise the credential catch-up the doc session pulled back. Independent
|
|
100
|
+
* of the doc lane: a failure here never fails the catch-up, so it is logged and
|
|
101
|
+
* swallowed. The server's scoping is not trusted — `applyCredentialReconcile`
|
|
102
|
+
* re-verifies every row.
|
|
103
|
+
*/ const applyReconcile = async (peerDID, reconcile)=>{
|
|
104
|
+
if (reconcile == null) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const store = await getCredentialStore(deps.stores);
|
|
109
|
+
const result = await applyCredentialReconcile({
|
|
110
|
+
store,
|
|
111
|
+
selfDID: deps.identity.id,
|
|
112
|
+
bundles: reconcile.bundles,
|
|
113
|
+
tombstones: reconcile.tombstones,
|
|
114
|
+
hlc: deps.hlc,
|
|
115
|
+
...deps.maxDriftMS == null ? {} : {
|
|
116
|
+
maxDriftMS: deps.maxDriftMS
|
|
117
|
+
},
|
|
118
|
+
logger: deps.logger
|
|
119
|
+
});
|
|
120
|
+
deps.logger.debug('credential reconcile piggybacked on catch-up', {
|
|
121
|
+
peerDID,
|
|
122
|
+
...result
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
deps.logger.warn('credential reconcile apply failed; doc catch-up unaffected', {
|
|
126
|
+
peerDID,
|
|
127
|
+
error
|
|
128
|
+
});
|
|
129
|
+
}
|
|
85
130
|
};
|
|
86
131
|
// Bidirectional: each device pushes the docs it owns (its own owner-scope
|
|
87
132
|
// authorizes) and pulls what it may. Reaching the peer needs no prior
|
|
@@ -95,7 +140,12 @@ export function createSyncContext(_ctx, deps) {
|
|
|
95
140
|
if (scopes.length === 0) {
|
|
96
141
|
return NO_OP;
|
|
97
142
|
}
|
|
98
|
-
const
|
|
143
|
+
const announcement = await resolveAnnouncement(groupID, peerDID);
|
|
144
|
+
const endpoint = announcement == null ? undefined : `tunnel://${groupID}/${peerDID}`;
|
|
145
|
+
// Decided before the sync so it can ride the same session: the credential
|
|
146
|
+
// lane must reuse the doc session, since a second dial to the same peer hangs
|
|
147
|
+
// on a responder still locked to the first.
|
|
148
|
+
const held = await credentialHeld(announcement);
|
|
99
149
|
const result = await deps.syncManager.merkleSyncWithPeer({
|
|
100
150
|
peerDID,
|
|
101
151
|
scopes,
|
|
@@ -104,8 +154,16 @@ export function createSyncContext(_ctx, deps) {
|
|
|
104
154
|
direction: 'both',
|
|
105
155
|
...endpoint == null ? {} : {
|
|
106
156
|
endpoint
|
|
157
|
+
},
|
|
158
|
+
...held == null ? {} : {
|
|
159
|
+
reconcileCredentialsHeld: held
|
|
107
160
|
}
|
|
108
161
|
});
|
|
162
|
+
// Materialise whatever the session pulled back. Independent of the doc lane:
|
|
163
|
+
// its failure is swallowed inside `applyReconcile`.
|
|
164
|
+
if (held != null) {
|
|
165
|
+
await applyReconcile(peerDID, result.credentialReconcile);
|
|
166
|
+
}
|
|
109
167
|
return {
|
|
110
168
|
messagesReceived: result.messagesReceived,
|
|
111
169
|
messagesSent: result.messagesSent,
|
package/lib/context/types.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { Logger } from '@kubun/logger';
|
|
|
6
6
|
import type { Circle } from '@kubun/store-p2p';
|
|
7
7
|
import type { LaneResult, PendingCommit } from '@kumiai/rpc';
|
|
8
8
|
import type { Runtime } from '@sozai/runtime';
|
|
9
|
-
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
9
|
+
import type { GroupBroadcastMessage } from '../groups/broadcast-message.js';
|
|
10
10
|
import type { P2PEventEmitter } from '../groups/events.js';
|
|
11
11
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
12
12
|
import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
|
|
@@ -34,6 +34,12 @@ export type ContextDeps = {
|
|
|
34
34
|
deviceStores: StoreProvider;
|
|
35
35
|
adapter: Adapter;
|
|
36
36
|
hlc: HLC;
|
|
37
|
+
/**
|
|
38
|
+
* How far into the future a peer's stamp may sit before this device refuses to
|
|
39
|
+
* merge it into {@link hlc}. The engine's bound, carried so the control-ledger
|
|
40
|
+
* adoption a join performs uses the configured value rather than the default.
|
|
41
|
+
*/
|
|
42
|
+
maxDriftMS?: number;
|
|
37
43
|
emitter: P2PEventEmitter;
|
|
38
44
|
runtime: Runtime;
|
|
39
45
|
autoAcceptPeers?: Array<string>;
|
|
@@ -102,4 +108,11 @@ export declare function toCircleData(row: Circle): CircleData;
|
|
|
102
108
|
* into an object. SerializedMLSGroupState expects a JSON string.
|
|
103
109
|
*/
|
|
104
110
|
export declare function normalizeCredential(value: unknown): string;
|
|
111
|
+
/**
|
|
112
|
+
* The DID a signed peer-procedure payload was issued by, having checked the
|
|
113
|
+
* caller is not forging one: a subject must equal its issuer, or the caller is
|
|
114
|
+
* claiming an identity it did not sign as. Shared by every direct-request serve
|
|
115
|
+
* handler (blob, controller) that gates on the caller.
|
|
116
|
+
*/
|
|
117
|
+
export declare function verifiedCaller(payload: unknown): string;
|
|
105
118
|
export declare function checkPeerAccess(viewerDID: string, identityID: string, autoAcceptPeers?: Array<string>): void;
|
package/lib/context/types.js
CHANGED
|
@@ -29,6 +29,18 @@ export function toISO(timestamp) {
|
|
|
29
29
|
if (typeof value === 'string') return value;
|
|
30
30
|
return JSON.stringify(value);
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* The DID a signed peer-procedure payload was issued by, having checked the
|
|
34
|
+
* caller is not forging one: a subject must equal its issuer, or the caller is
|
|
35
|
+
* claiming an identity it did not sign as. Shared by every direct-request serve
|
|
36
|
+
* handler (blob, controller) that gates on the caller.
|
|
37
|
+
*/ export function verifiedCaller(payload) {
|
|
38
|
+
const signed = payload;
|
|
39
|
+
if (signed.sub != null && signed.sub !== signed.iss) {
|
|
40
|
+
throw new Error('Peer access denied: signed payload subject does not match issuer');
|
|
41
|
+
}
|
|
42
|
+
return signed.iss;
|
|
43
|
+
}
|
|
32
44
|
export function checkPeerAccess(viewerDID, identityID, autoAcceptPeers) {
|
|
33
45
|
// The device operator is always allowed to run its own peer ops. The
|
|
34
46
|
// allow-list gates OTHER DIDs (remote callers of the dance handlers); it must
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
3
|
+
import { type AccessDefaultRule } from './access-default-token.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sender-bound, LWW apply of a signed `access-default:set` frame. Shared by the
|
|
6
|
+
* live broadcast path and the invite-seeding path so both authenticate through
|
|
7
|
+
* the same token verification. The authoritative fields come from the verified
|
|
8
|
+
* token; the plaintext `ownerDID` is used only for the sender-bound equality
|
|
9
|
+
* check. Returns whether a row was written.
|
|
10
|
+
*/
|
|
11
|
+
export type AccessDefaultSetFrame = {
|
|
12
|
+
ownerDID: string;
|
|
13
|
+
modelID: string;
|
|
14
|
+
permissionType: 'read' | 'write';
|
|
15
|
+
rule: AccessDefaultRule;
|
|
16
|
+
hlc: string;
|
|
17
|
+
auth: string;
|
|
18
|
+
};
|
|
19
|
+
export type ApplyAccessDefaultSetFrameParams = {
|
|
20
|
+
graphStore: GraphStoreAPI;
|
|
21
|
+
frame: AccessDefaultSetFrame;
|
|
22
|
+
logger?: Logger;
|
|
23
|
+
/** Scopes the log lines to the group the frame arrived on, when it arrived on one. */
|
|
24
|
+
groupID?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function applyAccessDefaultSetFrame(params: ApplyAccessDefaultSetFrameParams): Promise<boolean>;
|
|
27
|
+
/**
|
|
28
|
+
* Apply one signed access-default set token, with no surrounding frame.
|
|
29
|
+
*
|
|
30
|
+
* What a catch-up reply carries: a token and nothing else, so there is no
|
|
31
|
+
* plaintext mirror to cross-check and none is needed — the frame's mirror
|
|
32
|
+
* fields exist for a receiver rebuilding what was signed, and the authoritative
|
|
33
|
+
* values were always the claim's. Every other rule is the live path's: the owner
|
|
34
|
+
* is the authenticated issuer, and the write happens only if strictly newer than
|
|
35
|
+
* what is stored.
|
|
36
|
+
*/
|
|
37
|
+
export declare function applyAccessDefaultSetToken(params: {
|
|
38
|
+
graphStore: GraphStoreAPI;
|
|
39
|
+
token: string;
|
|
40
|
+
logger?: Logger;
|
|
41
|
+
groupID: string;
|
|
42
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { verifyAccessDefault } from './access-default-token.js';
|
|
3
|
+
export async function applyAccessDefaultSetFrame(params) {
|
|
4
|
+
const { graphStore, frame, logger, groupID } = params;
|
|
5
|
+
// Authenticate via the signed token: a forged or unsigned token verifies to
|
|
6
|
+
// null and is dropped. Every authoritative field comes from the verified
|
|
7
|
+
// payload, never the surrounding plaintext frame.
|
|
8
|
+
const verified = await verifyAccessDefault(frame.auth);
|
|
9
|
+
if (verified == null || !isSetClaim(verified)) {
|
|
10
|
+
logger?.warn('access-default:set dropped: token verification failed', {
|
|
11
|
+
groupID
|
|
12
|
+
});
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
// Sender-bound: a member states only its OWN sharing policy. The row owner is
|
|
16
|
+
// the authenticated issuer; a frame claiming a foreign owner is dropped. The
|
|
17
|
+
// issuer is already normalized, so the plaintext owner must be too — an
|
|
18
|
+
// equivalent DID in another form is the same principal.
|
|
19
|
+
if (normalizeDID(frame.ownerDID) !== verified.issuer) {
|
|
20
|
+
logger?.warn('access-default:set dropped: owner is not the authenticated author', {
|
|
21
|
+
groupID
|
|
22
|
+
});
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return await applyVerifiedAccessDefaultSet(graphStore, verified);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Apply one signed access-default set token, with no surrounding frame.
|
|
29
|
+
*
|
|
30
|
+
* What a catch-up reply carries: a token and nothing else, so there is no
|
|
31
|
+
* plaintext mirror to cross-check and none is needed — the frame's mirror
|
|
32
|
+
* fields exist for a receiver rebuilding what was signed, and the authoritative
|
|
33
|
+
* values were always the claim's. Every other rule is the live path's: the owner
|
|
34
|
+
* is the authenticated issuer, and the write happens only if strictly newer than
|
|
35
|
+
* what is stored.
|
|
36
|
+
*/ export async function applyAccessDefaultSetToken(params) {
|
|
37
|
+
const verified = await verifyAccessDefault(params.token);
|
|
38
|
+
if (verified == null || !isSetClaim(verified)) {
|
|
39
|
+
params.logger?.warn('access-default catch-up entry dropped: verification failed', {
|
|
40
|
+
groupID: params.groupID
|
|
41
|
+
});
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return await applyVerifiedAccessDefaultSet(params.graphStore, verified);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A verified access-default that states a rule rather than withdrawing one.
|
|
48
|
+
* Written as a guard because the union discriminates on a NESTED field, which a
|
|
49
|
+
* `verified.claim.op !== 'set'` check does not narrow the parent by.
|
|
50
|
+
*/ function isSetClaim(verified) {
|
|
51
|
+
return verified.claim.op === 'set';
|
|
52
|
+
}
|
|
53
|
+
async function applyVerifiedAccessDefaultSet(graphStore, verified) {
|
|
54
|
+
const claim = verified.claim;
|
|
55
|
+
// LWW: apply only when strictly newer than the stored rule's anchor. An
|
|
56
|
+
// unstamped stored rule (null hlc) is treated as oldest, so any stamped
|
|
57
|
+
// incoming rule wins.
|
|
58
|
+
const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, claim.permissionType);
|
|
59
|
+
if (storedHLC != null && claim.hlc <= storedHLC) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
await graphStore.setUserModelAccessDefault({
|
|
63
|
+
ownerDID: verified.issuer,
|
|
64
|
+
modelID: claim.modelID,
|
|
65
|
+
permissionType: claim.permissionType,
|
|
66
|
+
// Narrowed by `isRule` at verification, which refuses any level outside the
|
|
67
|
+
// three the read predicate is written for.
|
|
68
|
+
accessLevel: claim.rule.level,
|
|
69
|
+
allowedDIDs: claim.rule.allowedDIDs,
|
|
70
|
+
allowedCircles: claim.rule.allowedCircles,
|
|
71
|
+
allowedGroups: claim.rule.allowedGroups,
|
|
72
|
+
hlc: claim.hlc
|
|
73
|
+
});
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { GroupBroadcastMessage } from './broadcast.js';
|
|
1
|
+
import type { GroupBroadcastMessage } from './broadcast-message.js';
|
|
2
2
|
export declare function serializeBroadcast(message: GroupBroadcastMessage): Uint8Array;
|
|
3
3
|
export declare function deserializeBroadcast(bytes: Uint8Array): GroupBroadcastMessage;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { CatalogRecord } from '@kubun/protocol';
|
|
2
|
+
import type { CredentialFactorDescriptor } from '@kubun/store-credential';
|
|
3
|
+
import type { PeerAvailability, PeerCapability } from './group-protocols.js';
|
|
4
|
+
export type MutationApplyEntry = {
|
|
5
|
+
mutationJWT: string;
|
|
6
|
+
docID: string;
|
|
7
|
+
/** HLC string identifying this mutation's version. */
|
|
8
|
+
version: string;
|
|
9
|
+
modelID: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Optional signed token over a control operation. Its issuer (`iss`) is the
|
|
13
|
+
* authenticated author; receivers verify it and authorize that author as a
|
|
14
|
+
* group admin, dropping the operation when the token is absent or invalid. The
|
|
15
|
+
* signed payload is the whole message minus this field, so the signature covers
|
|
16
|
+
* every operation field, not just the author.
|
|
17
|
+
*/
|
|
18
|
+
export type ControlAuth = string;
|
|
19
|
+
/**
|
|
20
|
+
* The one wrapping a grant addresses, as it rides the wire.
|
|
21
|
+
*
|
|
22
|
+
* Binary fields are base64url rather than `Uint8Array`: every lane payload is
|
|
23
|
+
* JSON-schema'd and JSON-encoded, so bytes not encoded here would not survive
|
|
24
|
+
* the trip. `factors` is already JSON-safe — a descriptor holds base64 public
|
|
25
|
+
* material, never raw bytes and never a secret.
|
|
26
|
+
*/
|
|
27
|
+
export type CredentialKeyGrantWrapping = {
|
|
28
|
+
wrappingID: string;
|
|
29
|
+
/** ORDERED, and covered by the wrapping's AAD — a reordered copy will not open. */
|
|
30
|
+
factors: Array<CredentialFactorDescriptor>;
|
|
31
|
+
iv: string;
|
|
32
|
+
wrappedKey: string;
|
|
33
|
+
/**
|
|
34
|
+
* Un-normalized, as the stored column is: this is a key-resolution input, and
|
|
35
|
+
* a `did:peer:4` short form carries no document. Compare it only through
|
|
36
|
+
* `normalizeDID`.
|
|
37
|
+
*/
|
|
38
|
+
recipientDID: string;
|
|
39
|
+
/**
|
|
40
|
+
* The granter's signed op for this row, verbatim. The receiver stores these
|
|
41
|
+
* bytes rather than signing its own: the merkle leaf hashes the op, so a
|
|
42
|
+
* receiver that minted one would compute a different leaf for the same
|
|
43
|
+
* logical row and the two devices would never converge.
|
|
44
|
+
*/
|
|
45
|
+
op: string;
|
|
46
|
+
};
|
|
47
|
+
/** One entry ciphertext, copied verbatim into a grant. */
|
|
48
|
+
export type CredentialKeyGrantEntry = {
|
|
49
|
+
entryID: string;
|
|
50
|
+
iv: string;
|
|
51
|
+
ciphertext: string;
|
|
52
|
+
hlc: string;
|
|
53
|
+
/** The granter's signed op — see {@link CredentialKeyGrantWrapping.op}. */
|
|
54
|
+
op: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The row bytes of one credential key addressed to one recipient, minus the
|
|
58
|
+
* grant's single-frame `auth` envelope.
|
|
59
|
+
*
|
|
60
|
+
* The shape a `credential:key-grant` carries under `type`/`auth`, reused as the
|
|
61
|
+
* catch-up unit of the credential reconcile lane: the serving device already
|
|
62
|
+
* holds the recipient's row bytes, so a missed grant is pulled as this same
|
|
63
|
+
* bundle. One shape, one apply, for push and pull. Each row still
|
|
64
|
+
* self-authenticates through its own `op`, so no frame-level signature is needed
|
|
65
|
+
* — the reconcile response is not one signed frame.
|
|
66
|
+
*/
|
|
67
|
+
export type CredentialKeyBundle = {
|
|
68
|
+
keyID: string;
|
|
69
|
+
keyVersion: number;
|
|
70
|
+
suite: number;
|
|
71
|
+
/** A mirror the receiver ignores in favour of each op's verified issuer. */
|
|
72
|
+
ownerDID: string;
|
|
73
|
+
keyOp: string;
|
|
74
|
+
keyBranches: Array<string>;
|
|
75
|
+
wrapping: CredentialKeyGrantWrapping;
|
|
76
|
+
entries: Array<CredentialKeyGrantEntry>;
|
|
77
|
+
};
|
|
78
|
+
export type GroupBroadcastMessage = {
|
|
79
|
+
type: 'catalog:create';
|
|
80
|
+
catalog: CatalogRecord;
|
|
81
|
+
auth?: ControlAuth;
|
|
82
|
+
} | {
|
|
83
|
+
type: 'catalog:update';
|
|
84
|
+
catalogID: string;
|
|
85
|
+
update: {
|
|
86
|
+
name?: string;
|
|
87
|
+
description?: string;
|
|
88
|
+
filterCriteria?: CatalogRecord['filterCriteria'];
|
|
89
|
+
hlc: string;
|
|
90
|
+
};
|
|
91
|
+
auth?: ControlAuth;
|
|
92
|
+
} | {
|
|
93
|
+
type: 'catalog:delete';
|
|
94
|
+
catalogID: string;
|
|
95
|
+
hlc: string;
|
|
96
|
+
auth?: ControlAuth;
|
|
97
|
+
} | {
|
|
98
|
+
/**
|
|
99
|
+
* A member states its own model access-default so co-members can evaluate
|
|
100
|
+
* the owner's documents. `auth` is a signed token whose issuer is the
|
|
101
|
+
* owner; the receiver derives every authoritative field from that verified
|
|
102
|
+
* token and applies the rule ONLY when `ownerDID` equals the issuer (a
|
|
103
|
+
* member states its own policy, never another's). LWW by `hlc`.
|
|
104
|
+
*/
|
|
105
|
+
type: 'access-default:set';
|
|
106
|
+
modelID: string;
|
|
107
|
+
permissionType: 'read' | 'write';
|
|
108
|
+
rule: {
|
|
109
|
+
level: string;
|
|
110
|
+
allowedDIDs: Array<string> | null;
|
|
111
|
+
allowedCircles: Array<string> | null;
|
|
112
|
+
allowedGroups: Array<string> | null;
|
|
113
|
+
};
|
|
114
|
+
ownerDID: string;
|
|
115
|
+
hlc: string;
|
|
116
|
+
auth: ControlAuth;
|
|
117
|
+
} | {
|
|
118
|
+
/**
|
|
119
|
+
* A member removes its own model access-default(s). Same sender-bound and
|
|
120
|
+
* LWW rules as `access-default:set`: applied only when `ownerDID` equals
|
|
121
|
+
* the verified `auth` issuer and the removal `hlc` is newer than the
|
|
122
|
+
* stored rule's.
|
|
123
|
+
*/
|
|
124
|
+
type: 'access-default:remove';
|
|
125
|
+
modelID: string;
|
|
126
|
+
permissionTypes: Array<'read' | 'write'>;
|
|
127
|
+
ownerDID: string;
|
|
128
|
+
hlc: string;
|
|
129
|
+
auth: ControlAuth;
|
|
130
|
+
} | {
|
|
131
|
+
/**
|
|
132
|
+
* Distributes a minted `document/write` capability token to group
|
|
133
|
+
* co-members. The recipient verifies the token's signature and stores a
|
|
134
|
+
* held row only when the token's audience (`aud`) is the receiving
|
|
135
|
+
* device, so the engine's auto-attach can reuse it on later mutations.
|
|
136
|
+
*/
|
|
137
|
+
type: 'delegation:share';
|
|
138
|
+
/** Stringified capability JWT carrying the `document/write` grant. */
|
|
139
|
+
token: string;
|
|
140
|
+
/** Group whose broadcast channel carries this grant. */
|
|
141
|
+
groupID: string;
|
|
142
|
+
/** HLC stamped by the grantor; mirrors the grantor-side issued row. */
|
|
143
|
+
hlc: string;
|
|
144
|
+
} | {
|
|
145
|
+
/**
|
|
146
|
+
* Distributes a signed revocation record for a previously minted
|
|
147
|
+
* `document/write` capability. The recipient verifies the token's
|
|
148
|
+
* signature and stores the revocation; verification against the
|
|
149
|
+
* referenced cap's `iss` flips `verified_at` once the cap is known.
|
|
150
|
+
* Revocation is binary and reactive — once verified locally, any
|
|
151
|
+
* subsequent mutation under the revoked `jti` is denied regardless
|
|
152
|
+
* of `mutation.atTime`.
|
|
153
|
+
*/
|
|
154
|
+
type: 'delegation:revoke';
|
|
155
|
+
/** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
|
|
156
|
+
token: string;
|
|
157
|
+
/** Group whose broadcast channel carries this revocation. */
|
|
158
|
+
groupID: string;
|
|
159
|
+
/** HLC stamped by the revoker; mirrors the revoker-side stored row. */
|
|
160
|
+
hlc: string;
|
|
161
|
+
} | {
|
|
162
|
+
/**
|
|
163
|
+
* A member announces a voluntary leave. MLS forbids self-removal, so this
|
|
164
|
+
* is an advisory notification (not an authoritative removal): receivers
|
|
165
|
+
* surface a `groupLeaveRequested` event for an admin to act on by issuing
|
|
166
|
+
* a real `removeMember`. Deliberately does NOT tombstone — a forged
|
|
167
|
+
* leave-request must not be able to evict an arbitrary member.
|
|
168
|
+
*/
|
|
169
|
+
type: 'group:leaveRequest';
|
|
170
|
+
groupID: string;
|
|
171
|
+
memberDID: string;
|
|
172
|
+
hlc: string;
|
|
173
|
+
} | {
|
|
174
|
+
type: 'mutation:apply';
|
|
175
|
+
entries: Array<MutationApplyEntry>;
|
|
176
|
+
/**
|
|
177
|
+
* DID of the peer this broadcast arrived FROM — used for loopback drop.
|
|
178
|
+
* For a forwarded mutation that is the forwarding peer, not the author, so
|
|
179
|
+
* it is never an authorization input: each entry carries its own signed
|
|
180
|
+
* `mutationJWT`, verified independently.
|
|
181
|
+
*
|
|
182
|
+
* `undefined` when the frame opened at a leaf that could not be named. The
|
|
183
|
+
* entries still apply — see the loopback guard.
|
|
184
|
+
*/
|
|
185
|
+
senderPeerDID: string | undefined;
|
|
186
|
+
} | {
|
|
187
|
+
/**
|
|
188
|
+
* Hands one co-member what it needs to open a credential key: the key's
|
|
189
|
+
* public record, the single wrapping addressed to that member, and every
|
|
190
|
+
* entry ciphertext at that version. No plaintext, and no key material
|
|
191
|
+
* outside the wrapping.
|
|
192
|
+
*
|
|
193
|
+
* Group-wide, because confidentiality is content-level — only the
|
|
194
|
+
* addressed DID's agreement key opens the wrapping. A co-member that is
|
|
195
|
+
* not the recipient receives the frame and applies nothing, so what it
|
|
196
|
+
* retains is the frame, never a row.
|
|
197
|
+
*/
|
|
198
|
+
type: 'credential:key-grant';
|
|
199
|
+
keyID: string;
|
|
200
|
+
keyVersion: number;
|
|
201
|
+
suite: number;
|
|
202
|
+
/**
|
|
203
|
+
* Who administers the key, as the granter's own row records it. A MIRROR,
|
|
204
|
+
* not an input: the receiver writes `owner_did` from the verified `auth`
|
|
205
|
+
* issuer, so this field is what the frame claims and never what is stored.
|
|
206
|
+
*/
|
|
207
|
+
ownerDID: string;
|
|
208
|
+
/**
|
|
209
|
+
* The granter's signed op for the key row. Top-level because the key row
|
|
210
|
+
* itself is inline on this frame and has nowhere else to hang.
|
|
211
|
+
*/
|
|
212
|
+
keyOp: string;
|
|
213
|
+
/** The introducing key-op(s) ride inline so every branch label can be authenticated. */
|
|
214
|
+
keyBranches: Array<string>;
|
|
215
|
+
wrapping: CredentialKeyGrantWrapping;
|
|
216
|
+
entries: Array<CredentialKeyGrantEntry>;
|
|
217
|
+
/**
|
|
218
|
+
* Signs identifiers and digests, not the ciphertext — see
|
|
219
|
+
* {@link CredentialKeyGrantClaim}. The receiver recomputes both digests
|
|
220
|
+
* from the plaintext frame before writing anything.
|
|
221
|
+
*/
|
|
222
|
+
auth: ControlAuth;
|
|
223
|
+
} | {
|
|
224
|
+
/**
|
|
225
|
+
* A device advertises itself to its co-members. Everything here except
|
|
226
|
+
* `announcerDID` came off the wire; `announcerDID` did NOT — it is the
|
|
227
|
+
* MLS-authenticated sender the handler read from `message.payload.iss`,
|
|
228
|
+
* because the announce body carries no DID field at all.
|
|
229
|
+
*
|
|
230
|
+
* `undefined` when the frame opened at a leaf that could not be named, and
|
|
231
|
+
* the apply then writes nothing: a capability claim attributed to nobody is
|
|
232
|
+
* worse than no row.
|
|
233
|
+
*/
|
|
234
|
+
type: 'peer:announce';
|
|
235
|
+
label: string;
|
|
236
|
+
availability: PeerAvailability;
|
|
237
|
+
capabilities: Array<PeerCapability>;
|
|
238
|
+
hlc: string;
|
|
239
|
+
announcerDID: string | undefined;
|
|
240
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|