@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.
Files changed (62) hide show
  1. package/lib/context/group.js +3 -0
  2. package/lib/context/join.js +3 -0
  3. package/lib/context/peer.js +7 -2
  4. package/lib/context/sync.js +63 -5
  5. package/lib/context/types.d.ts +14 -1
  6. package/lib/context/types.js +12 -0
  7. package/lib/groups/access-default-apply.d.ts +42 -0
  8. package/lib/groups/access-default-apply.js +75 -0
  9. package/lib/groups/broadcast-codec.d.ts +1 -1
  10. package/lib/groups/broadcast-message.d.ts +240 -0
  11. package/lib/groups/broadcast-message.js +1 -0
  12. package/lib/groups/broadcast.d.ts +19 -266
  13. package/lib/groups/broadcast.js +30 -204
  14. package/lib/groups/credential-apply.d.ts +82 -0
  15. package/lib/groups/credential-apply.js +410 -0
  16. package/lib/groups/credential-grant-token.d.ts +42 -0
  17. package/lib/groups/credential-grant-token.js +51 -0
  18. package/lib/groups/credential-grant.d.ts +98 -0
  19. package/lib/groups/credential-grant.js +165 -0
  20. package/lib/groups/group-handlers.js +25 -2
  21. package/lib/groups/group-mls.d.ts +5 -0
  22. package/lib/groups/group-mls.js +4 -1
  23. package/lib/groups/group-peer-manager.d.ts +7 -1
  24. package/lib/groups/group-peer-manager.js +44 -1
  25. package/lib/groups/group-protocols.d.ts +227 -0
  26. package/lib/groups/group-protocols.js +146 -0
  27. package/lib/groups/join-utils.d.ts +5 -0
  28. package/lib/groups/join-utils.js +5 -1
  29. package/lib/groups/ledger-adopt.d.ts +84 -0
  30. package/lib/groups/ledger-adopt.js +142 -0
  31. package/lib/groups/ledger-commit-fold.d.ts +3 -1
  32. package/lib/groups/ledger-commit-fold.js +3 -0
  33. package/lib/groups/ledger-ingest.d.ts +23 -1
  34. package/lib/groups/ledger-ingest.js +30 -1
  35. package/lib/groups/manager.d.ts +5 -0
  36. package/lib/groups/manager.js +6 -1
  37. package/lib/groups/peer-presence.d.ts +9 -2
  38. package/lib/groups/peer-presence.js +14 -2
  39. package/lib/groups/peer-selection.d.ts +9 -0
  40. package/lib/groups/peer-selection.js +10 -0
  41. package/lib/hub/wiring.d.ts +6 -1
  42. package/lib/hub/wiring.js +2 -1
  43. package/lib/index.d.ts +5 -1
  44. package/lib/index.js +58 -6
  45. package/lib/peer/blob-fetch.d.ts +45 -0
  46. package/lib/peer/blob-fetch.js +89 -0
  47. package/lib/peer/blob-handlers.d.ts +11 -0
  48. package/lib/peer/blob-handlers.js +123 -0
  49. package/lib/peer/controller-fetch.d.ts +6 -0
  50. package/lib/peer/controller-fetch.js +59 -0
  51. package/lib/peer/controller-handlers.d.ts +10 -0
  52. package/lib/peer/controller-handlers.js +28 -0
  53. package/lib/protocol.d.ts +279 -0
  54. package/lib/protocol.js +358 -0
  55. package/lib/sync/access-default-sender.d.ts +1 -1
  56. package/lib/sync/broadcast-queue.d.ts +1 -1
  57. package/lib/sync/forwarder.d.ts +1 -1
  58. package/lib/sync/handlers.js +98 -1
  59. package/lib/sync/sync-manager.d.ts +10 -1
  60. package/lib/sync/sync-manager.js +24 -2
  61. package/lib/types.d.ts +18 -0
  62. 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. */
@@ -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 { type LedgerAffected } from './ledger-ingest.js';
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.hlc.receive(HLC.parse(ord));
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,
@@ -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
@@ -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: profile.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: profile.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