@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
|
@@ -249,6 +249,152 @@ import { defineGroupProtocol } from '@kumiai/rpc';
|
|
|
249
249
|
additionalProperties: false
|
|
250
250
|
}
|
|
251
251
|
},
|
|
252
|
+
'control/credentialKeyGrant': {
|
|
253
|
+
type: 'event',
|
|
254
|
+
// Retained, and this is the load-bearing half of the design. A grant is an
|
|
255
|
+
// explicit one-shot push with no background replication and no catch-up
|
|
256
|
+
// procedure behind it: ephemeral, a recipient that was offline at grant time
|
|
257
|
+
// silently never gets access, and the only repair is a human granting again.
|
|
258
|
+
// Retention is what the app lane actually delivers — a device away across a
|
|
259
|
+
// roster change still reads the epoch's app topic on its way past it — and it
|
|
260
|
+
// costs one log frame, since the depth bound counts frames and not bytes.
|
|
261
|
+
retain: 'log',
|
|
262
|
+
description: "Hand one co-member what it needs to open a credential key: the key's public record, the single wrapping addressed to that member, and every entry ciphertext at that version. Group-wide because confidentiality is content-level; only the addressed DID applies it. `auth` signs identifiers and content digests, and the receiver recomputes both from this frame.",
|
|
263
|
+
data: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
keyID: {
|
|
267
|
+
type: 'string'
|
|
268
|
+
},
|
|
269
|
+
keyVersion: {
|
|
270
|
+
type: 'number'
|
|
271
|
+
},
|
|
272
|
+
suite: {
|
|
273
|
+
type: 'number'
|
|
274
|
+
},
|
|
275
|
+
ownerDID: {
|
|
276
|
+
type: 'string',
|
|
277
|
+
description: 'Claimed owner. A mirror — the receiver stores the `auth` issuer instead.'
|
|
278
|
+
},
|
|
279
|
+
keyOp: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
description: "The granter's signed op for the key row, stored verbatim. The key row rides inline on this frame, so its op has nowhere else to hang."
|
|
282
|
+
},
|
|
283
|
+
keyBranches: {
|
|
284
|
+
type: 'array',
|
|
285
|
+
items: {
|
|
286
|
+
type: 'string'
|
|
287
|
+
},
|
|
288
|
+
description: "The proven key-op JWTs that introduced this key version's branches."
|
|
289
|
+
},
|
|
290
|
+
wrapping: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
properties: {
|
|
293
|
+
wrappingID: {
|
|
294
|
+
type: 'string'
|
|
295
|
+
},
|
|
296
|
+
factors: {
|
|
297
|
+
type: 'array',
|
|
298
|
+
items: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: {
|
|
301
|
+
kind: {
|
|
302
|
+
type: 'string'
|
|
303
|
+
},
|
|
304
|
+
params: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
description: 'Per-kind PUBLIC material — a salt or an ephemeral public key.',
|
|
307
|
+
additionalProperties: true
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
required: [
|
|
311
|
+
'kind',
|
|
312
|
+
'params'
|
|
313
|
+
],
|
|
314
|
+
additionalProperties: false
|
|
315
|
+
},
|
|
316
|
+
description: 'ORDERED, and bound into the wrapping AAD: a permuted copy will not open.'
|
|
317
|
+
},
|
|
318
|
+
iv: {
|
|
319
|
+
type: 'string',
|
|
320
|
+
description: 'base64url'
|
|
321
|
+
},
|
|
322
|
+
wrappedKey: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
description: 'base64url'
|
|
325
|
+
},
|
|
326
|
+
recipientDID: {
|
|
327
|
+
type: 'string',
|
|
328
|
+
description: 'Un-normalized: a key-resolution input, not an identity to compare.'
|
|
329
|
+
},
|
|
330
|
+
op: {
|
|
331
|
+
type: 'string',
|
|
332
|
+
description: "The granter's signed op for this row, stored verbatim — the receiver never mints its own."
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
required: [
|
|
336
|
+
'wrappingID',
|
|
337
|
+
'factors',
|
|
338
|
+
'iv',
|
|
339
|
+
'wrappedKey',
|
|
340
|
+
'recipientDID',
|
|
341
|
+
'op'
|
|
342
|
+
],
|
|
343
|
+
additionalProperties: false
|
|
344
|
+
},
|
|
345
|
+
entries: {
|
|
346
|
+
type: 'array',
|
|
347
|
+
items: {
|
|
348
|
+
type: 'object',
|
|
349
|
+
properties: {
|
|
350
|
+
entryID: {
|
|
351
|
+
type: 'string'
|
|
352
|
+
},
|
|
353
|
+
iv: {
|
|
354
|
+
type: 'string',
|
|
355
|
+
description: 'base64url'
|
|
356
|
+
},
|
|
357
|
+
ciphertext: {
|
|
358
|
+
type: 'string',
|
|
359
|
+
description: 'base64url'
|
|
360
|
+
},
|
|
361
|
+
hlc: {
|
|
362
|
+
type: 'string'
|
|
363
|
+
},
|
|
364
|
+
op: {
|
|
365
|
+
type: 'string',
|
|
366
|
+
description: "The granter's signed op for this row, stored verbatim."
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
required: [
|
|
370
|
+
'entryID',
|
|
371
|
+
'iv',
|
|
372
|
+
'ciphertext',
|
|
373
|
+
'hlc',
|
|
374
|
+
'op'
|
|
375
|
+
],
|
|
376
|
+
additionalProperties: false
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
auth: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
description: 'Signed credential key-grant token.'
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
required: [
|
|
385
|
+
'keyID',
|
|
386
|
+
'keyVersion',
|
|
387
|
+
'suite',
|
|
388
|
+
'ownerDID',
|
|
389
|
+
'keyOp',
|
|
390
|
+
'keyBranches',
|
|
391
|
+
'wrapping',
|
|
392
|
+
'entries',
|
|
393
|
+
'auth'
|
|
394
|
+
],
|
|
395
|
+
additionalProperties: false
|
|
396
|
+
}
|
|
397
|
+
},
|
|
252
398
|
'control/groupLeaveRequest': {
|
|
253
399
|
type: 'event',
|
|
254
400
|
retain: 'ephemeral',
|
|
@@ -116,6 +116,11 @@ export type ApplyInviteControlStateParams = {
|
|
|
116
116
|
catalogs?: Array<InviteCatalogSeed>;
|
|
117
117
|
/** Device-wide clock — the joined ledger's HLCs merge into it. */
|
|
118
118
|
hlc: HLC;
|
|
119
|
+
/**
|
|
120
|
+
* How far into the future an adopted entry's `ord` may sit before its clock
|
|
121
|
+
* merge is refused — see `LedgerIngestParams.maxDriftMS`.
|
|
122
|
+
*/
|
|
123
|
+
maxDriftMS?: number;
|
|
119
124
|
/** Resolves the group's genesis anchor so ledger entries can project at join. */
|
|
120
125
|
getGroupAnchor?: (groupID: string) => Promise<GroupAnchor | null>;
|
|
121
126
|
/** Resolves the group's control ledger, which every projection folds from. */
|
package/lib/groups/join-utils.js
CHANGED
|
@@ -5,9 +5,10 @@ import { getGraphStore } from '@kubun/store-graph';
|
|
|
5
5
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
6
6
|
import { canonicalStringify } from '@sozai/codec';
|
|
7
7
|
import { toISO } from '../context/types.js';
|
|
8
|
+
import { applyAccessDefaultSetFrame } from './access-default-apply.js';
|
|
8
9
|
import { signAccessDefaultSet } from './access-default-token.js';
|
|
9
|
-
import { adoptHandleLedgerState, applyAccessDefaultSetFrame } from './broadcast.js';
|
|
10
10
|
import { verifyCatalog } from './catalog-token.js';
|
|
11
|
+
import { adoptHandleLedgerState } from './ledger-adopt.js';
|
|
11
12
|
import { storeReceivedGrant } from './store-received-grant.js';
|
|
12
13
|
import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
13
14
|
/**
|
|
@@ -261,6 +262,9 @@ export async function finalizeJoinedGroup(params) {
|
|
|
261
262
|
await adoptHandleLedgerState({
|
|
262
263
|
p2pStore,
|
|
263
264
|
hlc,
|
|
265
|
+
...params.maxDriftMS != null ? {
|
|
266
|
+
maxDriftMS: params.maxDriftMS
|
|
267
|
+
} : {},
|
|
264
268
|
getGroupAnchor,
|
|
265
269
|
getGroupLedger,
|
|
266
270
|
// Only the projection rebuild is transactional. The adopt reads the
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
2
|
+
import type { RosterState } from '@kumiai/mls';
|
|
3
|
+
import { type LedgerIngestParams } from './ledger-ingest.js';
|
|
4
|
+
/** Summary of a whole-ledger adoption. */
|
|
5
|
+
type ApplyLedgerCatchupReplyResult = {
|
|
6
|
+
/** Size of the ledger that was adopted. */
|
|
7
|
+
applied: number;
|
|
8
|
+
/**
|
|
9
|
+
* Always `0` on this path, and structurally so: the install verifies the whole
|
|
10
|
+
* ledger against the authenticated head before folding a single entry, so a
|
|
11
|
+
* bad token rejects the batch rather than dropping an entry out of it. Carried
|
|
12
|
+
* because the catch-up summary reports it beside counts from paths that CAN
|
|
13
|
+
* drop per entry.
|
|
14
|
+
*/
|
|
15
|
+
rejected: number;
|
|
16
|
+
};
|
|
17
|
+
/** Inputs the ledger-fold helpers need — a strict subset of the receive params. */
|
|
18
|
+
export type ApplyLedgerEntriesParams = LedgerIngestParams & {
|
|
19
|
+
/**
|
|
20
|
+
* Run the projection rebuild atomically, given the store to write it through.
|
|
21
|
+
*
|
|
22
|
+
* A rebuild materializes several tables from one fold — circle rows, every
|
|
23
|
+
* circle's `open` flag, the group row, the commit-hub designation — and a crash
|
|
24
|
+
* between them leaves them disagreeing. A device with no flagged commit hub
|
|
25
|
+
* cannot commit at all, which reads as a hang rather than an error.
|
|
26
|
+
*
|
|
27
|
+
* A seam rather than a `StoreProvider` because the surrounding operations must
|
|
28
|
+
* NOT be in the same transaction: an install or a gather waits on the network,
|
|
29
|
+
* and a transaction spanning one holds kubun's single SQLite connection across
|
|
30
|
+
* it. So the caller decides what the transaction covers. Omitted, the rebuild
|
|
31
|
+
* runs on {@link LedgerIngestParams.p2pStore} unwrapped — correct when the
|
|
32
|
+
* caller already holds a transaction, and self-healing on the bootstrap paths,
|
|
33
|
+
* where the next reproject rebuilds from the same ledger anyway.
|
|
34
|
+
*
|
|
35
|
+
* Declared here rather than on {@link LedgerIngestParams} because only the
|
|
36
|
+
* batch folds below read it: `ingestVerifiedLedgerEntry` reprojects through
|
|
37
|
+
* `p2pStore` directly, so a caller passing this to the single-entry path would
|
|
38
|
+
* be promised an atomicity it does not get.
|
|
39
|
+
*/
|
|
40
|
+
withProjectionTransaction?: (run: (store: P2PStoreAPI) => Promise<void>) => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Adopt the control state a handle already holds: merge the HLCs its entries
|
|
44
|
+
* carry into this device's clock, then rebuild every projection from them.
|
|
45
|
+
*
|
|
46
|
+
* For a peer whose handle was filled by something other than a landed commit —
|
|
47
|
+
* a joiner, whose Welcome folded the group's whole ledger and checked it against
|
|
48
|
+
* the authenticated head before this device had any say. Those entries never
|
|
49
|
+
* passed through the receive path, so nothing else has observed their clocks or
|
|
50
|
+
* projected them.
|
|
51
|
+
*
|
|
52
|
+
* The merge keeps this device's later writes sorting after what the group did
|
|
53
|
+
* before it arrived. It decides no authority — that reads the enacted position —
|
|
54
|
+
* but the LWW comparisons that are not ledger folds (removal timestamps,
|
|
55
|
+
* access-default rules) still depend on a clock that has seen the group's.
|
|
56
|
+
*/
|
|
57
|
+
export declare function adoptHandleLedgerState(params: ApplyLedgerEntriesParams, groupID: string): Promise<void>;
|
|
58
|
+
/** Inputs {@link bootstrapGroupLedger} needs on top of the ingest's. */
|
|
59
|
+
export type BootstrapGroupLedgerParams = ApplyLedgerEntriesParams & {
|
|
60
|
+
/**
|
|
61
|
+
* Install a gathered WHOLE ledger into the group's handle, verified against
|
|
62
|
+
* the authenticated head before a single entry is folded, and answer with the
|
|
63
|
+
* roster the install left the handle holding. Throws when the list does not
|
|
64
|
+
* reproduce that head.
|
|
65
|
+
*
|
|
66
|
+
* The roster is returned rather than read back afterwards so it is the one the
|
|
67
|
+
* install itself produced, taken while the handle is still held: a second read
|
|
68
|
+
* could observe a handle another operation has since advanced.
|
|
69
|
+
*/
|
|
70
|
+
installGroupLedger: (groupID: string, tokens: Array<string>) => Promise<RosterState>;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Adopt a whole ledger gathered from one untrusted responder.
|
|
74
|
+
*
|
|
75
|
+
* Signatures do NOT cover omission or reordering — a responder can hand back
|
|
76
|
+
* genuine tokens with a demotion missing — so the list is installed against the
|
|
77
|
+
* authenticated head FIRST and rejected whole on mismatch, before anything is
|
|
78
|
+
* projected. Roles are reconciled against the roster the install folded, not the
|
|
79
|
+
* ledger, because nothing later revisits a stale `admin` row.
|
|
80
|
+
*
|
|
81
|
+
* Throws whatever the install throws — a rejected ledger changes nothing.
|
|
82
|
+
*/
|
|
83
|
+
export declare function bootstrapGroupLedger(params: BootstrapGroupLedgerParams, tokens: Array<string>, groupID: string): Promise<ApplyLedgerCatchupReplyResult>;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
|
|
2
|
+
import { entryOrd } from './ledger.js';
|
|
3
|
+
import { resolveGroupAnchor, usableLedgerOrd } from './ledger-ingest.js';
|
|
4
|
+
import { mirrorRosterRoles } from './roster-projection.js';
|
|
5
|
+
/**
|
|
6
|
+
* Adopt the control state a handle already holds: merge the HLCs its entries
|
|
7
|
+
* carry into this device's clock, then rebuild every projection from them.
|
|
8
|
+
*
|
|
9
|
+
* For a peer whose handle was filled by something other than a landed commit —
|
|
10
|
+
* a joiner, whose Welcome folded the group's whole ledger and checked it against
|
|
11
|
+
* the authenticated head before this device had any say. Those entries never
|
|
12
|
+
* passed through the receive path, so nothing else has observed their clocks or
|
|
13
|
+
* projected them.
|
|
14
|
+
*
|
|
15
|
+
* The merge keeps this device's later writes sorting after what the group did
|
|
16
|
+
* before it arrived. It decides no authority — that reads the enacted position —
|
|
17
|
+
* but the LWW comparisons that are not ledger folds (removal timestamps,
|
|
18
|
+
* access-default rules) still depend on a clock that has seen the group's.
|
|
19
|
+
*/ export async function adoptHandleLedgerState(params, groupID) {
|
|
20
|
+
const { hlc, logger } = params;
|
|
21
|
+
if (hlc != null && params.getGroupLedger != null) {
|
|
22
|
+
let ledger;
|
|
23
|
+
try {
|
|
24
|
+
ledger = await params.getGroupLedger(groupID);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// The reproject below reads the ledger through the same resolver and
|
|
27
|
+
// defers on its own when it cannot; losing the merge is not worth failing
|
|
28
|
+
// a join over.
|
|
29
|
+
logger?.warn('ledger clock merge skipped: ledger unreadable', {
|
|
30
|
+
groupID,
|
|
31
|
+
error
|
|
32
|
+
});
|
|
33
|
+
ledger = [];
|
|
34
|
+
}
|
|
35
|
+
for (const { verified } of ledger){
|
|
36
|
+
// An entry `@kumiai/mls` minted itself carries no `ord` — it orders by the
|
|
37
|
+
// epoch chain and never stamps kubun's clock. Nothing to merge.
|
|
38
|
+
const ord = entryOrd(verified.entry);
|
|
39
|
+
if (ord == null) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const observed = usableLedgerOrd(ord, params.maxDriftMS);
|
|
44
|
+
if (observed == null) {
|
|
45
|
+
// SKIP THE MERGE, never the entry — do not "fix" this into a
|
|
46
|
+
// rejection. A joiner's Welcome already checked this ledger against
|
|
47
|
+
// the authenticated head, and `ord` orders and authorizes nothing, so
|
|
48
|
+
// dropping the entry would lose group state over a field nobody reads.
|
|
49
|
+
logger?.warn('ledger entry carries an unusable HLC', {
|
|
50
|
+
groupID,
|
|
51
|
+
hlc: ord
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
hlc.receive(observed);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger?.warn('ledger entry HLC merge failed', {
|
|
58
|
+
groupID,
|
|
59
|
+
hlc: ord,
|
|
60
|
+
error
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await reprojectControlProjections(params, groupID);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Rebuild every control projection from the group's whole ledger.
|
|
69
|
+
*
|
|
70
|
+
* A batch may carry any mix of entry types, so all three rebuild unconditionally
|
|
71
|
+
* rather than the caller working out which were touched; reprojecting a type the
|
|
72
|
+
* batch didn't move is a no-op that re-confirms the current rows.
|
|
73
|
+
*/ async function reprojectControlProjections(params, groupID) {
|
|
74
|
+
const { p2pStore, logger } = params;
|
|
75
|
+
const resolution = await resolveGroupAnchor(params, groupID);
|
|
76
|
+
if (resolution.status !== 'anchored') {
|
|
77
|
+
logger?.debug('ledger batch applied; projection deferred', {
|
|
78
|
+
groupID,
|
|
79
|
+
reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const { anchor, ledger } = resolution;
|
|
84
|
+
// Sequential, and in this order: the circle rebuilds upsert each row's `open`
|
|
85
|
+
// from the circle fold, then the settings rebuild overwrites `open` from the
|
|
86
|
+
// authoritative open set. Running them concurrently, or settings first, lets
|
|
87
|
+
// the circle upsert win and delists nothing.
|
|
88
|
+
const rebuild = async (store)=>{
|
|
89
|
+
await reprojectCircleDefs({
|
|
90
|
+
store,
|
|
91
|
+
groupID,
|
|
92
|
+
anchor,
|
|
93
|
+
ledger,
|
|
94
|
+
logger
|
|
95
|
+
});
|
|
96
|
+
await reprojectCircleMembers({
|
|
97
|
+
store,
|
|
98
|
+
groupID,
|
|
99
|
+
anchor,
|
|
100
|
+
ledger,
|
|
101
|
+
logger
|
|
102
|
+
});
|
|
103
|
+
await reprojectGroupSettings({
|
|
104
|
+
store,
|
|
105
|
+
groupID,
|
|
106
|
+
anchor,
|
|
107
|
+
ledger,
|
|
108
|
+
logger
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
if (params.withProjectionTransaction != null) {
|
|
112
|
+
await params.withProjectionTransaction(rebuild);
|
|
113
|
+
} else {
|
|
114
|
+
await rebuild(p2pStore);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Adopt a whole ledger gathered from one untrusted responder.
|
|
119
|
+
*
|
|
120
|
+
* Signatures do NOT cover omission or reordering — a responder can hand back
|
|
121
|
+
* genuine tokens with a demotion missing — so the list is installed against the
|
|
122
|
+
* authenticated head FIRST and rejected whole on mismatch, before anything is
|
|
123
|
+
* projected. Roles are reconciled against the roster the install folded, not the
|
|
124
|
+
* ledger, because nothing later revisits a stale `admin` row.
|
|
125
|
+
*
|
|
126
|
+
* Throws whatever the install throws — a rejected ledger changes nothing.
|
|
127
|
+
*/ export async function bootstrapGroupLedger(params, tokens, groupID) {
|
|
128
|
+
const roster = await params.installGroupLedger(groupID, tokens);
|
|
129
|
+
await reprojectControlProjections(params, groupID);
|
|
130
|
+
const members = await params.p2pStore.listGroupMembers(groupID);
|
|
131
|
+
await mirrorRosterRoles({
|
|
132
|
+
store: params.p2pStore,
|
|
133
|
+
groupID,
|
|
134
|
+
roster,
|
|
135
|
+
memberDIDs: members.map((member)=>member.member_did),
|
|
136
|
+
members
|
|
137
|
+
});
|
|
138
|
+
return {
|
|
139
|
+
applied: tokens.length,
|
|
140
|
+
rejected: 0
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { HLC } from '@kubun/hlc';
|
|
|
2
2
|
import type { Logger } from '@kubun/logger';
|
|
3
3
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
4
|
import { type GroupHandle, type VerifiedLedgerEntry } from '@kumiai/mls';
|
|
5
|
-
import {
|
|
5
|
+
import type { LedgerAffected } from './ledger-ingest.js';
|
|
6
6
|
export type CommitFoldParams = {
|
|
7
7
|
/**
|
|
8
8
|
* The handle that ACCEPTED the commit — post-advance. Two things are read from
|
|
@@ -35,6 +35,8 @@ export type CommitFoldParams = {
|
|
|
35
35
|
p2pStore: P2PStoreAPI;
|
|
36
36
|
groupID: string;
|
|
37
37
|
hlc?: HLC;
|
|
38
|
+
/** See {@link LedgerIngestParams.maxDriftMS}. */
|
|
39
|
+
maxDriftMS?: number;
|
|
38
40
|
logger?: Logger;
|
|
39
41
|
};
|
|
40
42
|
/** A signed token plus its notarized entry, as the handle's ledger log holds it. */
|
|
@@ -141,6 +141,9 @@ import { ingestVerifiedLedgerEntry } from './ledger-ingest.js';
|
|
|
141
141
|
...params.hlc != null ? {
|
|
142
142
|
hlc: params.hlc
|
|
143
143
|
} : {},
|
|
144
|
+
...params.maxDriftMS != null ? {
|
|
145
|
+
maxDriftMS: params.maxDriftMS
|
|
146
|
+
} : {},
|
|
144
147
|
...anchor != null ? {
|
|
145
148
|
getGroupAnchor: async ()=>anchor
|
|
146
149
|
} : {},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HLC } from '@kubun/hlc';
|
|
1
|
+
import { HLC, type HLCTimestamp } from '@kubun/hlc';
|
|
2
2
|
import type { Logger } from '@kubun/logger';
|
|
3
3
|
import type { Circle, CircleMember, Group, P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
4
|
import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
|
|
@@ -83,9 +83,31 @@ export type LedgerIngestParams = {
|
|
|
83
83
|
* pass a closure over it rather than re-acquiring the lock.
|
|
84
84
|
*/
|
|
85
85
|
getGroupLedger?: (groupID: string) => Promise<ReadonlyArray<LedgerLogEntry>>;
|
|
86
|
+
/**
|
|
87
|
+
* How far into the future an entry's `ord` may sit before this device refuses
|
|
88
|
+
* to merge it. The engine's bound, threaded here rather than configured
|
|
89
|
+
* separately. Optional: absence falls back to {@link DEFAULT_MAX_DRIFT_MS},
|
|
90
|
+
* the engine's own default, so an unthreaded caller gets the default bound and
|
|
91
|
+
* never no bound.
|
|
92
|
+
*/
|
|
93
|
+
maxDriftMS?: number;
|
|
86
94
|
/** Optional logger — warns on per-entry apply failures. */
|
|
87
95
|
logger?: Logger;
|
|
88
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* The timestamp an entry's `ord` may be merged into this device's clock, or
|
|
99
|
+
* `null` when it may not.
|
|
100
|
+
*
|
|
101
|
+
* `ord` is signed but never format-checked, so a member may write anything into
|
|
102
|
+
* it: `HLC.parse` answers a malformed stamp with NaN fields rather than
|
|
103
|
+
* throwing, and a plausible-looking one may sit years ahead. Either drags the
|
|
104
|
+
* device's ONE clock — the graph mutation log's clock too — somewhere it can
|
|
105
|
+
* never catch up to.
|
|
106
|
+
*
|
|
107
|
+
* The past direction stays unbounded, as on every other lane: an offline write
|
|
108
|
+
* legitimately lands late.
|
|
109
|
+
*/
|
|
110
|
+
export declare function usableLedgerOrd(ord: string, maxDriftMS?: number): HLCTimestamp | null;
|
|
89
111
|
/**
|
|
90
112
|
* Resolve a group's anchor without ever throwing into the receive loop. A
|
|
91
113
|
* missing `getGroupAnchor` (callers that never project) is treated as
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
|
|
2
3
|
import { ADMIN_ROLE_ENTRY_TYPE } from './admin-roster.js';
|
|
3
4
|
import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from './circle-projection.js';
|
|
4
5
|
import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
|
|
5
6
|
import { entryOrd } from './ledger.js';
|
|
7
|
+
/**
|
|
8
|
+
* The timestamp an entry's `ord` may be merged into this device's clock, or
|
|
9
|
+
* `null` when it may not.
|
|
10
|
+
*
|
|
11
|
+
* `ord` is signed but never format-checked, so a member may write anything into
|
|
12
|
+
* it: `HLC.parse` answers a malformed stamp with NaN fields rather than
|
|
13
|
+
* throwing, and a plausible-looking one may sit years ahead. Either drags the
|
|
14
|
+
* device's ONE clock — the graph mutation log's clock too — somewhere it can
|
|
15
|
+
* never catch up to.
|
|
16
|
+
*
|
|
17
|
+
* The past direction stays unbounded, as on every other lane: an offline write
|
|
18
|
+
* legitimately lands late.
|
|
19
|
+
*/ export function usableLedgerOrd(ord, maxDriftMS = DEFAULT_MAX_DRIFT_MS) {
|
|
20
|
+
const observed = HLC.tryParse(ord);
|
|
21
|
+
return observed == null || observed.wallTime - Date.now() > maxDriftMS ? null : observed;
|
|
22
|
+
}
|
|
6
23
|
/**
|
|
7
24
|
* Resolve a group's anchor without ever throwing into the receive loop. A
|
|
8
25
|
* missing `getGroupAnchor` (callers that never project) is treated as
|
|
@@ -67,7 +84,19 @@ import { entryOrd } from './ledger.js';
|
|
|
67
84
|
const ord = entryOrd(verified.entry);
|
|
68
85
|
if (params.hlc != null && ord != null) {
|
|
69
86
|
try {
|
|
70
|
-
params.
|
|
87
|
+
const observed = usableLedgerOrd(ord, params.maxDriftMS);
|
|
88
|
+
if (observed == null) {
|
|
89
|
+
// SKIP THE MERGE, never the entry — do not "fix" this into a rejection.
|
|
90
|
+
// The commit chain enacted this entry before we ran, and `ord` orders
|
|
91
|
+
// and authorizes nothing, so refusing it would drop authority the group
|
|
92
|
+
// has already agreed on over a field nobody reads.
|
|
93
|
+
params.logger?.warn('ledger entry carries an unusable HLC', {
|
|
94
|
+
groupID,
|
|
95
|
+
hlc: ord
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
params.hlc.receive(observed);
|
|
99
|
+
}
|
|
71
100
|
} catch (error) {
|
|
72
101
|
params.logger?.warn('ledger entry HLC merge failed', {
|
|
73
102
|
groupID,
|
package/lib/groups/manager.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export type GroupManagerParams = {
|
|
|
30
30
|
* timestamps (the second would silently lose under last-writer-wins).
|
|
31
31
|
*/
|
|
32
32
|
hlc: HLC;
|
|
33
|
+
/**
|
|
34
|
+
* How far into the future a folded control entry's `ord` may sit before its
|
|
35
|
+
* clock merge is refused — see `LedgerIngestParams.maxDriftMS`.
|
|
36
|
+
*/
|
|
37
|
+
maxDriftMS?: number;
|
|
33
38
|
/**
|
|
34
39
|
* Device-wide store coordinator for work that runs DECOUPLED from any request
|
|
35
40
|
* transaction — a commit's `onAccepted` fires on the lane, long after the
|
package/lib/groups/manager.js
CHANGED
|
@@ -40,7 +40,7 @@ import { mirrorRosterRoles, roleFromRoster } from './roster-projection.js';
|
|
|
40
40
|
entryID: ledgerEntryDigest(token)
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
|
-
const folded = foldEnvelope(handle.roster, inputs, handle.groupID);
|
|
43
|
+
const folded = foldEnvelope(handle.roster, handle.registry, inputs, handle.groupID);
|
|
44
44
|
if (!folded.ok) {
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
@@ -52,6 +52,7 @@ export class GroupManager {
|
|
|
52
52
|
#deviceID;
|
|
53
53
|
#runtime;
|
|
54
54
|
#hlc;
|
|
55
|
+
#maxDriftMS;
|
|
55
56
|
#registry;
|
|
56
57
|
#stores;
|
|
57
58
|
#emitter;
|
|
@@ -60,6 +61,7 @@ export class GroupManager {
|
|
|
60
61
|
this.#deviceID = params.identity.id;
|
|
61
62
|
this.#runtime = params.runtime;
|
|
62
63
|
this.#hlc = params.hlc;
|
|
64
|
+
this.#maxDriftMS = params.maxDriftMS;
|
|
63
65
|
this.#registry = params.registry;
|
|
64
66
|
this.#stores = params.stores;
|
|
65
67
|
this.#emitter = params.emitter;
|
|
@@ -114,6 +116,9 @@ export class GroupManager {
|
|
|
114
116
|
p2pStore,
|
|
115
117
|
groupID,
|
|
116
118
|
hlc: this.#hlc,
|
|
119
|
+
...this.#maxDriftMS != null ? {
|
|
120
|
+
maxDriftMS: this.#maxDriftMS
|
|
121
|
+
} : {},
|
|
117
122
|
...this.#logger != null ? {
|
|
118
123
|
logger: this.#logger
|
|
119
124
|
} : {}
|
|
@@ -2,8 +2,8 @@ import type { StoreProvider } from '@kubun/db';
|
|
|
2
2
|
import { HLC } from '@kubun/hlc';
|
|
3
3
|
import type { Logger } from '@kubun/logger';
|
|
4
4
|
import type { GatheredPeer, LocalPeerAnnouncement, PeerGatherOptions, PresenceReason } from '../types.js';
|
|
5
|
-
import type { GroupBroadcastMessage } from './broadcast.js';
|
|
6
|
-
import type { PeerQueryParam } from './group-protocols.js';
|
|
5
|
+
import type { GroupBroadcastMessage } from './broadcast-message.js';
|
|
6
|
+
import type { PeerCapability, PeerQueryParam } from './group-protocols.js';
|
|
7
7
|
/** One `peer/query` reply as the gather surface hands it back. */
|
|
8
8
|
type RawReply = {
|
|
9
9
|
senderDID: string;
|
|
@@ -28,6 +28,13 @@ export type PeerPresenceParams = {
|
|
|
28
28
|
applyAnnouncement: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
29
29
|
/** Every group this device currently belongs to, for a device-wide announce. */
|
|
30
30
|
joinedGroupIDs: () => Array<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Capabilities the DEVICE answers on regardless of the host-declared profile,
|
|
33
|
+
* merged into every announce. Read at announce time, so a store registered
|
|
34
|
+
* after the profile was set still surfaces (e.g. `credential-sync` once a
|
|
35
|
+
* credential store exists). Empty by default.
|
|
36
|
+
*/
|
|
37
|
+
deviceCapabilities?: () => Array<PeerCapability>;
|
|
31
38
|
/**
|
|
32
39
|
* Called after this device declares (or redeclares) its profile — the moment
|
|
33
40
|
* it becomes visible, and so the moment a co-member may start dialling it.
|
|
@@ -2,6 +2,13 @@ import { normalizeDID } from '@kokuin/token';
|
|
|
2
2
|
import { HLC } from '@kubun/hlc';
|
|
3
3
|
import { getP2PStore } from '@kubun/store-p2p';
|
|
4
4
|
/** Coalescing window for the epoch lane. A catch-up walk applies its commits well inside it. */ const ANNOUNCE_COALESCE_MS = 50;
|
|
5
|
+
/** Host capabilities plus any device ones whose protocol the host did not already declare. */ function mergeCapabilities(host, device) {
|
|
6
|
+
const declared = new Set(host.map((capability)=>capability.protocol));
|
|
7
|
+
return [
|
|
8
|
+
...host,
|
|
9
|
+
...device.filter((capability)=>!declared.has(capability.protocol))
|
|
10
|
+
];
|
|
11
|
+
}
|
|
5
12
|
/**
|
|
6
13
|
* Pull an announcement out of an untyped gather reply. The gather surface hands
|
|
7
14
|
* back `unknown`, so a version-skewed or malicious responder must contribute
|
|
@@ -83,6 +90,11 @@ export function createPeerPresence(params) {
|
|
|
83
90
|
// local row has to carry the same value a co-member holds, or the two copies
|
|
84
91
|
// of one announcement disagree.
|
|
85
92
|
const announcedHLC = HLC.serialize(params.hlc.now());
|
|
93
|
+
// Merged at announce time, so a device capability the host never declared
|
|
94
|
+
// (and one that only became true after `setProfile`) still rides every frame
|
|
95
|
+
// and is read back by the `peer/query` reply below. Dedup by protocol keeps a
|
|
96
|
+
// host-declared entry authoritative.
|
|
97
|
+
const capabilities = mergeCapabilities(profile.capabilities, params.deviceCapabilities?.() ?? []);
|
|
86
98
|
// Seed this device's own row. The hub fans a publish to subscribers minus the
|
|
87
99
|
// sender, so nothing ever hands a device back its own announcement: without
|
|
88
100
|
// this write the projection is the one place that cannot see the device it
|
|
@@ -93,7 +105,7 @@ export function createPeerPresence(params) {
|
|
|
93
105
|
peerDID: selfDID,
|
|
94
106
|
label: profile.label,
|
|
95
107
|
availability: profile.availability,
|
|
96
|
-
capabilities
|
|
108
|
+
capabilities,
|
|
97
109
|
hlc: announcedHLC,
|
|
98
110
|
epoch: params.getGroupEpoch(groupID) ?? 0
|
|
99
111
|
});
|
|
@@ -101,7 +113,7 @@ export function createPeerPresence(params) {
|
|
|
101
113
|
type: 'peer:announce',
|
|
102
114
|
label: profile.label,
|
|
103
115
|
availability: profile.availability,
|
|
104
|
-
capabilities
|
|
116
|
+
capabilities,
|
|
105
117
|
hlc: announcedHLC,
|
|
106
118
|
announcerDID: selfDID
|
|
107
119
|
});
|
|
@@ -5,6 +5,15 @@ import type { PeerAnnouncement } from '@kubun/store-p2p';
|
|
|
5
5
|
*/
|
|
6
6
|
export declare const MERKLE_SYNC_PROTOCOL = "kubun/sync-merkle";
|
|
7
7
|
export declare const MERKLE_SYNC_VERSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* "I serve credential catch-up" — advertised by a device that registered a
|
|
10
|
+
* credential store, so a co-member knows to reconcile credentials over the same
|
|
11
|
+
* tunnel after a doc catch-up. Absent it, the credential lane is skipped: a
|
|
12
|
+
* device without a credential store opts out by construction.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CREDENTIAL_SYNC_PROTOCOL = "credential-sync";
|
|
15
|
+
export declare const CREDENTIAL_SYNC_VERSION = 1;
|
|
16
|
+
export declare function answersCredentialSync(row: Pick<PeerAnnouncement, 'capabilities'>): boolean;
|
|
8
17
|
export type SelectSyncPeerOptions = {
|
|
9
18
|
/**
|
|
10
19
|
* This device's own DID. Required, because the projection holds this device's
|