@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
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
2
+ import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast-message.js';
3
3
  /**
4
4
  * Configuration for the per-target-group batch broadcast queue.
5
5
  *
@@ -3,7 +3,7 @@ import type { Logger } from '@kubun/logger';
3
3
  import type { DocumentNode } from '@kubun/protocol';
4
4
  import type { GraphStoreAPI } from '@kubun/store-graph';
5
5
  import type { P2PStoreAPI } from '@kubun/store-p2p';
6
- import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
6
+ import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast-message.js';
7
7
  export declare function rethrowIfProgrammerError(error: unknown): void;
8
8
  /**
9
9
  * Context passed to a {@link ForwardFilter} for each candidate forwarding scope.
@@ -1,6 +1,9 @@
1
+ import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
1
2
  import { createDelegationRevocationChecker, getDelegationStore } from '@kubun/store-delegation';
2
3
  import { getGraphStore } from '@kubun/store-graph';
3
4
  import { getP2PStore } from '@kubun/store-p2p';
5
+ import { verifiedCaller } from '../context/types.js';
6
+ import { readCredentialKeyBundle } from '../groups/credential-grant.js';
4
7
  import { wrapHandlers } from '../util/handler-error.js';
5
8
  import { authorizeScope, checkSyncDelegation } from './authorize.js';
6
9
  import { resolveCatalogSyncScopes } from './catalog-scope.js';
@@ -8,6 +11,15 @@ import { SyncAccessDeniedError } from './errors.js';
8
11
  import { createMerkleSyncChannelHandler } from './merkle-channel.js';
9
12
  import { negotiateDirection } from './peer-registry.js';
10
13
  export { checkSyncDelegation };
14
+ /** The stored op row as the reconcile lane ships it — the same leaf a grant carries. */ function toWireOp(row) {
15
+ return {
16
+ keyID: row.key_id,
17
+ authorDID: row.author_did,
18
+ hlc: row.hlc,
19
+ opJWT: row.op_jwt,
20
+ opHash: row.op_hash
21
+ };
22
+ }
11
23
  // ---- Handler factory ----
12
24
  /**
13
25
  * Create sync protocol handlers served by plugin-p2p's own Enkaku server.
@@ -195,6 +207,91 @@ export { checkSyncDelegation };
195
207
  defaultAccessLevel: params.defaultAccessLevel,
196
208
  getRevocationChecker,
197
209
  getCircleReadGrant
198
- })
210
+ }),
211
+ 'sync/reconcile-credentials': async (ctx)=>{
212
+ const callerDID = verifiedCaller(ctx.message.payload);
213
+ // The credential store is registered by `@kubun/plugin-credential`, not by
214
+ // this plugin, so a device without it has nothing to serve and answers
215
+ // empty rather than throwing (`getStore` on an unregistered name throws).
216
+ if (!db.hasStore(CREDENTIAL_STORE)) {
217
+ return {
218
+ bundles: [],
219
+ tombstones: []
220
+ };
221
+ }
222
+ const store = await getCredentialStore(db);
223
+ const held = new Map(ctx.param.held.map((subject)=>[
224
+ `${subject.subjectKind}\u0000${subject.subjectID}`,
225
+ subject.opHash
226
+ ]));
227
+ // Catch-up in two parts, both recipient-scoped to the caller's live
228
+ // wrappings at the current key version. An UNHELD wrapping is a missed
229
+ // grant: ship the whole key bundle. A HELD wrapping means the grant landed,
230
+ // so ship the same bundle carrying only the current-version entries the
231
+ // caller has NOT advertised — an entry added to a key after the grant then
232
+ // reaches an existing recipient (there is no live entry lane). The wrapping
233
+ // is the gate on both paths; a co-recipient's wrapping never travels.
234
+ const scoped = await store.listOpsForRequester(callerDID);
235
+ const wrappingOps = scoped.filter((op)=>op.subject_kind === 'wrapping' && op.deleted === 0);
236
+ const bundles = [];
237
+ for (const wrappingOp of wrappingOps){
238
+ const wrapping = await store.getWrapping(wrappingOp.subject_id);
239
+ if (wrapping?.recipient_did == null) {
240
+ continue;
241
+ }
242
+ const key = await store.getKey(wrappingOp.key_id);
243
+ // A superseded wrapping's key has moved on; the current-version wrapping
244
+ // is what a catch-up carries, and its own op routes here when unheld.
245
+ if (key == null || wrapping.key_version !== key.version) {
246
+ continue;
247
+ }
248
+ const wrappingHeld = held.get(`wrapping\u0000${wrappingOp.subject_id}`) === wrappingOp.op_hash;
249
+ const keyOp = scoped.find((op)=>op.subject_kind === 'key' && op.subject_id === wrappingOp.key_id);
250
+ const keyHeld = keyOp != null && held.get(`key\u0000${wrappingOp.key_id}`) === keyOp.op_hash;
251
+ try {
252
+ const bundle = await readCredentialKeyBundle(store, wrappingOp.key_id, wrapping.recipient_did);
253
+ if (wrappingHeld && keyHeld) {
254
+ // Held wrapping: the entry top-up. Only entries the caller lacks; a
255
+ // held key with nothing new to send is skipped, not shipped empty.
256
+ const missingEntries = bundle.entries.filter((entry)=>{
257
+ const entryOp = scoped.find((op)=>op.subject_kind === 'entry' && op.subject_id === entry.entryID);
258
+ return entryOp == null || held.get(`entry\u0000${entry.entryID}`) !== entryOp.op_hash;
259
+ });
260
+ if (missingEntries.length === 0) {
261
+ continue;
262
+ }
263
+ bundles.push({
264
+ ...bundle,
265
+ entries: missingEntries
266
+ });
267
+ } else {
268
+ bundles.push(bundle);
269
+ }
270
+ } catch (error) {
271
+ // A rotation between the scan and this read leaves a key momentarily
272
+ // unreadable; skip it rather than fail the whole reconcile.
273
+ logger.debug('sync/reconcile-credentials: key not readable, skipped', {
274
+ keyID: wrappingOp.key_id,
275
+ error
276
+ });
277
+ }
278
+ }
279
+ // GC: a revoked wrapping's tombstone names no recipient once its row is
280
+ // gone, so it reaches the caller only when the caller advertises the id.
281
+ const allOps = await store.listOps();
282
+ const tombstones = allOps.filter((op)=>op.deleted === 1 && held.has(`${op.subject_kind}\u0000${op.subject_id}`)).map((op)=>({
283
+ ...toWireOp(op),
284
+ subjectID: op.subject_id
285
+ }));
286
+ logger.debug('sync/reconcile-credentials served', {
287
+ callerDID,
288
+ bundles: bundles.length,
289
+ tombstones: tombstones.length
290
+ });
291
+ return {
292
+ bundles,
293
+ tombstones
294
+ };
295
+ }
199
296
  });
200
297
  }
@@ -3,7 +3,7 @@ import type { StoreProvider } from '@kubun/db';
3
3
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
4
  import type { Logger } from '@kubun/logger';
5
5
  import type { Runtime } from '@sozai/runtime';
6
- import type { SyncDirection } from '../protocol.js';
6
+ import type { SyncDirection, SyncReconcileCredentialsParam, SyncReconcileCredentialsResult } from '../protocol.js';
7
7
  import type { StoreUnreadableMode } from '../types.js';
8
8
  import { type PeerConfig, type PeerConfigWithID, PeerRegistry } from './peer-registry.js';
9
9
  import { type SyncScope, type SyncServerResolver } from './sync-client.js';
@@ -56,6 +56,14 @@ export type MerkleSyncParams = {
56
56
  * grants it nothing.
57
57
  */
58
58
  endpoint?: string;
59
+ /**
60
+ * The caller's live credential subject ids, present when a credential catch-up
61
+ * piggybacks this doc sync. When set, a `sync/reconcile-credentials` request
62
+ * rides the SAME session after the peer answers negotiate, so no second tunnel
63
+ * is dialled. Its result comes back as `credentialReconcile`; a failure there
64
+ * is swallowed — the credential lane must never fail the doc sync.
65
+ */
66
+ reconcileCredentialsHeld?: SyncReconcileCredentialsParam['held'];
59
67
  };
60
68
  export declare class SyncManager {
61
69
  #private;
@@ -97,6 +105,7 @@ export declare class SyncManager {
97
105
  messagesReceived: number;
98
106
  messagesSent: number;
99
107
  missingClusters?: Record<string, unknown>;
108
+ credentialReconcile?: SyncReconcileCredentialsResult;
100
109
  }>;
101
110
  getStatus(params: {
102
111
  peerDID?: string;
@@ -126,6 +126,26 @@ export class SyncManager {
126
126
  direction
127
127
  });
128
128
  const { acceptedScopes, excludedDocumentIDs, missingClusters, direction: agreedDirection } = await syncClient.negotiate(client, scopes, delegationTokens, knownModelIDs, direction);
129
+ // The peer answered, so the session is live: ride the credential catch-up
130
+ // on it now rather than dialling a second tunnel. A responder locks to one
131
+ // dialing session until it idles out, so a fresh dial to the same peer
132
+ // would hang — reuse is the only prompt path. A failure is swallowed: the
133
+ // doc sync must not fail for the credential lane.
134
+ let credentialReconcile;
135
+ if (params.reconcileCredentialsHeld != null) {
136
+ try {
137
+ credentialReconcile = await client.request('sync/reconcile-credentials', {
138
+ param: {
139
+ held: params.reconcileCredentialsHeld
140
+ }
141
+ });
142
+ } catch (error) {
143
+ this.#logger.warn('credential reconcile over sync session failed', {
144
+ peerDID,
145
+ error
146
+ });
147
+ }
148
+ }
129
149
  if (acceptedScopes.length === 0) {
130
150
  this.#logger.info('No scopes accepted by peer');
131
151
  this.#lastSyncByPeer.set(peerDID, Date.now());
@@ -140,7 +160,8 @@ export class SyncManager {
140
160
  divergentBuckets: 0,
141
161
  messagesReceived: 0,
142
162
  messagesSent: 0,
143
- missingClusters
163
+ missingClusters,
164
+ credentialReconcile
144
165
  };
145
166
  }
146
167
  // Step 1b: Deploy missing clusters before building merkle tree
@@ -221,7 +242,8 @@ export class SyncManager {
221
242
  divergentBuckets: syncResult.divergentBuckets,
222
243
  messagesReceived: syncResult.mutationJWTs.length,
223
244
  messagesSent: syncResult.mutationsSent,
224
- missingClusters
245
+ missingClusters,
246
+ credentialReconcile
225
247
  };
226
248
  } catch (error) {
227
249
  // DEBUG, and re-thrown: this layer cannot know whether the failure is a
package/lib/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
+ import type { SignedEvent } from '@kokuin/controller';
2
3
  import type { AccessLevel } from '@kubun/store-graph';
3
4
  import type { ControlRequestKind, ControlRequestOutcome } from '@kubun/store-p2p';
4
5
  import type { PeerAvailability, PeerCapability } from './groups/group-protocols.js';
@@ -1019,4 +1020,21 @@ export type SyncPluginAPI = {
1019
1020
  * this held connection.
1020
1021
  */
1021
1022
  getPeerConnection(peerDID: string): PeerConnection | undefined;
1023
+ /**
1024
+ * Pull a remote blob's bytes from a connected peer that holds them, over the
1025
+ * dedicated transfer lane (control plane + raw-HTTP data plane). A no-op if the
1026
+ * bytes are already local; resumable across sessions. `delegationTokens` proves
1027
+ * a cross-owner read; empty for the own-device case.
1028
+ */
1029
+ fetchAttachment(attachmentID: string, options?: {
1030
+ delegationTokens?: Array<string>;
1031
+ }): Promise<void>;
1032
+ /**
1033
+ * Pull a `did:kokuin:` controller's key-event log from a connected group peer
1034
+ * that holds it, over `controller/get-log`. The engine calls this on a
1035
+ * verify-time controller-store miss; the events self-authenticate, so the
1036
+ * caller's resolver folds + verifies them. `undefined` when no connected peer
1037
+ * answers with a non-empty log.
1038
+ */
1039
+ fetchControllerLog(did: string): Promise<Array<SignedEvent> | undefined>;
1022
1040
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/plugin-p2p",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "see LICENSE.md",
5
5
  "sideEffects": false,
6
6
  "type": "module",
@@ -14,23 +14,24 @@
14
14
  "LICENSE.md"
15
15
  ],
16
16
  "dependencies": {
17
- "@enkaku/client": "^0.20.0",
18
- "@enkaku/http-fetch": "^0.20.0",
19
- "@enkaku/http-serve": "^0.20.0",
20
- "@enkaku/protocol": "^0.20.0",
21
- "@enkaku/server": "^0.20.0",
22
- "@enkaku/transport": "^0.20.0",
23
- "@kokuin/capability": "^0.2.1",
24
- "@kokuin/token": "^0.3.0",
25
- "@kumiai/broadcast": "^0.5.0",
26
- "@kumiai/hub-protocol": "^0.5.0",
27
- "@kumiai/hub-server": "^0.5.0",
28
- "@kumiai/hub-tunnel": "^0.5.0",
29
- "@kumiai/mls": "^0.5.0",
30
- "@kumiai/mls-rpc": "^0.5.0",
31
- "@kumiai/rpc": "^0.5.0",
32
- "@noble/ciphers": "^2.2.0",
33
- "@noble/hashes": "^2.2.0",
17
+ "@enkaku/client": "^0.21.0",
18
+ "@enkaku/http-fetch": "^0.21.0",
19
+ "@enkaku/http-serve": "^0.21.0",
20
+ "@enkaku/protocol": "^0.21.0",
21
+ "@enkaku/server": "^0.21.2",
22
+ "@enkaku/transport": "^0.21.0",
23
+ "@kokuin/capability": "^0.3.0",
24
+ "@kokuin/controller": "^0.1.0",
25
+ "@kokuin/token": "^0.5.0",
26
+ "@kumiai/broadcast": "^0.7.0",
27
+ "@kumiai/hub-protocol": "^0.7.0",
28
+ "@kumiai/hub-server": "^0.7.0",
29
+ "@kumiai/hub-tunnel": "^0.7.0",
30
+ "@kumiai/mls": "^0.7.0",
31
+ "@kumiai/mls-rpc": "^0.7.0",
32
+ "@kumiai/rpc": "^0.7.0",
33
+ "@noble/ciphers": "^2.3.0",
34
+ "@noble/hashes": "^2.3.0",
34
35
  "@sozai/async": "^0.2.1",
35
36
  "@sozai/codec": "^0.4.0",
36
37
  "@sozai/event": "^0.1.3",
@@ -39,36 +40,47 @@
39
40
  "@sozai/schema": "^0.1.1",
40
41
  "@sozai/stream": "^0.2.0",
41
42
  "graphql": "^16.14.2",
42
- "kysely": "^0.29.4",
43
+ "kysely": "^0.29.5",
43
44
  "ts-mls": "2.0.0-rc.13",
44
- "@kubun/db": "^0.12.0",
45
- "@kubun/db-adapter": "^0.12.0",
46
- "@kubun/engine": "^0.12.0",
47
- "@kubun/graphql": "^0.12.0",
48
- "@kubun/hlc": "^0.12.0",
49
- "@kubun/http-util": "^0.12.0",
50
- "@kubun/logger": "^0.12.0",
51
- "@kubun/mutation": "^0.12.0",
52
- "@kubun/plugin-http": "^0.12.0",
53
- "@kubun/plugin-rpc": "^0.12.0",
54
- "@kubun/protocol": "^0.12.0",
55
- "@kubun/store-delegation": "^0.12.0",
56
- "@kubun/id": "^0.12.0",
57
- "@kubun/store-graph": "^0.12.0",
58
- "@kubun/store-p2p": "^0.12.0"
45
+ "@kubun/db": "^0.13.0",
46
+ "@kubun/db-adapter": "^0.13.0",
47
+ "@kubun/engine": "^0.13.0",
48
+ "@kubun/graphql": "^0.13.0",
49
+ "@kubun/hlc": "^0.13.0",
50
+ "@kubun/http-util": "^0.13.0",
51
+ "@kubun/id": "^0.13.0",
52
+ "@kubun/logger": "^0.13.0",
53
+ "@kubun/plugin-http": "^0.13.0",
54
+ "@kubun/mutation": "^0.13.0",
55
+ "@kubun/plugin-rpc": "^0.13.0",
56
+ "@kubun/protocol": "^0.13.0",
57
+ "@kubun/store-blob": "^0.13.0",
58
+ "@kubun/store-controller": "^0.13.0",
59
+ "@kubun/store-credential": "^0.13.0",
60
+ "@kubun/store-delegation": "^0.13.0",
61
+ "@kubun/store-graph": "^0.13.0",
62
+ "@kubun/store-p2p": "^0.13.0",
63
+ "@kubun/credential": "^0.13.0"
59
64
  },
60
65
  "devDependencies": {
61
- "@kumiai/hub-conformance": "^0.5.0",
62
- "@kumiai/rpc-conformance": "^0.5.0",
66
+ "@kumiai/hub-conformance": "^0.7.0",
67
+ "@kumiai/rpc-conformance": "^0.7.0",
63
68
  "@testcontainers/postgresql": "^12.1.0",
64
69
  "get-port": "^7.2.0",
65
- "@kubun/db-node-sqlite": "^0.12.0",
66
- "@kubun/client": "^0.12.0",
67
- "@kubun/plugin-connector": "^0.12.0",
68
- "@kubun/test-utils": "^0.12.0",
69
- "@kubun/db-postgres": "^0.12.0",
70
- "@kubun/hub": "^0.12.0",
71
- "@kubun/db-better-sqlite": "^0.12.0"
70
+ "@kubun/blob-backend": "^0.13.0",
71
+ "@kubun/client": "^0.13.0",
72
+ "@kubun/db-node-sqlite": "^0.13.0",
73
+ "@kubun/db-postgres": "^0.13.0",
74
+ "@kubun/hub": "^0.13.0",
75
+ "@kubun/plugin-blob": "^0.13.0",
76
+ "@kubun/plugin-connector": "^0.13.0",
77
+ "@kubun/plugin-workflow": "^0.13.0",
78
+ "@kubun/test-utils": "^0.13.0",
79
+ "@kubun/store-workflow": "^0.13.0",
80
+ "@kubun/db-better-sqlite": "^0.13.0"
81
+ },
82
+ "publishConfig": {
83
+ "access": "public"
72
84
  },
73
85
  "scripts": {
74
86
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",