@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
@@ -4,6 +4,16 @@ import { normalizeDID } from '@kokuin/token';
4
4
  * exactly. A future direct transport is a new capability, not a wider match here.
5
5
  */ export const MERKLE_SYNC_PROTOCOL = 'kubun/sync-merkle';
6
6
  export const MERKLE_SYNC_VERSION = 1;
7
+ /**
8
+ * "I serve credential catch-up" — advertised by a device that registered a
9
+ * credential store, so a co-member knows to reconcile credentials over the same
10
+ * tunnel after a doc catch-up. Absent it, the credential lane is skipped: a
11
+ * device without a credential store opts out by construction.
12
+ */ export const CREDENTIAL_SYNC_PROTOCOL = 'credential-sync';
13
+ export const CREDENTIAL_SYNC_VERSION = 1;
14
+ export function answersCredentialSync(row) {
15
+ return row.capabilities.some((capability)=>capability.protocol === CREDENTIAL_SYNC_PROTOCOL && capability.version === CREDENTIAL_SYNC_VERSION);
16
+ }
7
17
  /**
8
18
  * Availability tiers, best first. A device's own claim about itself, agreed by
9
19
  * nobody: one that overstates it wastes a session, so there is no authority here
@@ -6,7 +6,7 @@ import type { HLC } from '@kubun/hlc';
6
6
  import type { Logger } from '@kubun/logger';
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 { BuildLedgerRedrive } from '../groups/group-peer-manager.js';
@@ -108,6 +108,11 @@ export type SetupHubRelayParams = {
108
108
  * engine's single instance so roster writes share the device's one clock.
109
109
  */
110
110
  hlc: HLC;
111
+ /**
112
+ * The engine's future-drift bound, forwarded to the apply path so a peer's
113
+ * stamp is bounded there the same way the graph lane bounds a mutation's.
114
+ */
115
+ maxDriftMS: number;
111
116
  /**
112
117
  * Reconnect-backoff overrides forwarded to every hub adapter. Test-only; when
113
118
  * omitted the adapter runs on its production defaults.
package/lib/hub/wiring.js CHANGED
@@ -10,7 +10,7 @@ import { HubTunnelSyncProvider } from '../sync/hub-tunnel-sync-provider.js';
10
10
  * once per candidate before reporting `no-route`.
11
11
  */ const DEFAULT_TUNNEL_IDLE_TIMEOUT_MS = 30_000;
12
12
  export function setupHubRelay(params) {
13
- const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
13
+ const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, maxDriftMS, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
14
14
  const unsubscribes = [];
15
15
  // Late-bound `ready` resolving to the live manager. The fire-and-forget
16
16
  // schedulers chain off it so a caller can enqueue work before boot completes;
@@ -81,6 +81,7 @@ export function setupHubRelay(params) {
81
81
  graphStore,
82
82
  graph,
83
83
  hlc,
84
+ maxDriftMS,
84
85
  localDID: identity.id,
85
86
  identity,
86
87
  emitter,
package/lib/index.d.ts CHANGED
@@ -7,8 +7,12 @@ import type { StoreUnreadableMode } from './types.js';
7
7
  export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, type RequireGroupAdminParams, requireGroupAdmin, } from './context/require-admin.js';
8
8
  export type { ContextDeps } from './context/types.js';
9
9
  export { ADMIN_ROLE_ENTRY_TYPE, type AdminRoleValue, type AdminRosterState, foldAdminRoster, } from './groups/admin-roster.js';
10
- export { type AffectedRow, type GroupBroadcastMessage, type MutationApplyEntry, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
10
+ export { type AffectedRow, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
11
11
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
12
+ export type { CredentialKeyBundle, CredentialKeyGrantEntry, CredentialKeyGrantWrapping, GroupBroadcastMessage, MutationApplyEntry, } from './groups/broadcast-message.js';
13
+ export { type ApplyCredentialKeyGrantFrameParams, type ApplyCredentialReconcileParams, type ApplyCredentialReconcileResult, applyCredentialKeyGrantFrame, applyCredentialReconcile, type CredentialReconcileTombstone, } from './groups/credential-apply.js';
14
+ export { CredentialGrantNotReadable, CredentialRecipientNotInGroup, type GrantCredentialKeyParams, type GrantCredentialKeyResult, grantCredentialKeyToMember, } from './groups/credential-grant.js';
15
+ export { type CredentialKeyGrantClaim, credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, signCredentialKeyGrant, type VerifiedCredentialKeyGrant, verifyCredentialKeyGrant, } from './groups/credential-grant-token.js';
12
16
  export { createFilteredGenerator, createP2PEventEmitter, type P2PEventEmitter, type P2PEventMap, } from './groups/events.js';
13
17
  export { buildKubunGroupAnchorExtension, type GroupAnchorApp, readRecoverySecret, } from './groups/group-anchor.js';
14
18
  export { GroupHandleRegistry, type GroupHandleRegistryParams, type SeedParams, } from './groups/group-handle-registry.js';
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { ServerTransport } from '@enkaku/http-serve';
2
2
  import { Server } from '@enkaku/server';
3
3
  import { DirectTransports } from '@enkaku/transport';
4
4
  import { isFullIdentity } from '@kokuin/token';
5
+ import { createDeviceAuthority } from '@kubun/credential';
5
6
  import { resolveAllowedOrigin } from '@kubun/http-util';
6
7
  import { getGraphStore } from '@kubun/store-graph';
7
8
  import { p2pStoreDefinition } from '@kubun/store-p2p';
@@ -17,7 +18,11 @@ import { GroupHealthMonitor } from './groups/group-health-monitor.js';
17
18
  import { GroupManager } from './groups/manager.js';
18
19
  import { createHTTPHubClient } from './hub/http-client.js';
19
20
  import { setupHubRelay } from './hub/wiring.js';
21
+ import { fetchAttachment } from './peer/blob-fetch.js';
22
+ import { createBlobHandlers } from './peer/blob-handlers.js';
20
23
  import { PeerConnectionRegistry } from './peer/connection-registry.js';
24
+ import { fetchControllerLog } from './peer/controller-fetch.js';
25
+ import { createControllerHandlers } from './peer/controller-handlers.js';
21
26
  import { createPeerHandlers } from './peer/handlers.js';
22
27
  import { createP2PSchemaExtension } from './schema.js';
23
28
  import { wireAccessDefaultSender } from './sync/access-default-sender.js';
@@ -29,6 +34,9 @@ export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, requireGroupAd
29
34
  export { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster } from './groups/admin-roster.js';
30
35
  export { processBroadcast } from './groups/broadcast.js';
31
36
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
37
+ export { applyCredentialKeyGrantFrame, applyCredentialReconcile } from './groups/credential-apply.js';
38
+ export { CredentialGrantNotReadable, CredentialRecipientNotInGroup, grantCredentialKeyToMember } from './groups/credential-grant.js';
39
+ export { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, signCredentialKeyGrant, verifyCredentialKeyGrant } from './groups/credential-grant-token.js';
32
40
  export { createFilteredGenerator, createP2PEventEmitter } from './groups/events.js';
33
41
  export { buildKubunGroupAnchorExtension, readRecoverySecret } from './groups/group-anchor.js';
34
42
  export { GroupHandleRegistry } from './groups/group-handle-registry.js';
@@ -114,6 +122,7 @@ export function createP2PPlugin(options) {
114
122
  runtime: params.runtime,
115
123
  registry,
116
124
  hlc,
125
+ maxDriftMS: params.maxDriftMS,
117
126
  stores: params.db,
118
127
  emitter,
119
128
  logger: params.getLogger('group-manager')
@@ -218,6 +227,7 @@ export function createP2PPlugin(options) {
218
227
  runtime: params.runtime,
219
228
  db: params.db,
220
229
  hlc,
230
+ maxDriftMS: params.maxDriftMS,
221
231
  graph: params.graph,
222
232
  emitter,
223
233
  createHubClient,
@@ -318,6 +328,7 @@ export function createP2PPlugin(options) {
318
328
  deviceStores: params.db,
319
329
  adapter,
320
330
  hlc,
331
+ maxDriftMS: params.maxDriftMS,
321
332
  emitter,
322
333
  runtime: params.runtime,
323
334
  autoAcceptPeers: options?.autoAcceptPeers,
@@ -342,11 +353,15 @@ export function createP2PPlugin(options) {
342
353
  identity,
343
354
  logger: params.getLogger('peer-handlers'),
344
355
  autoAcceptPeers: options?.autoAcceptPeers,
345
- runPrepareJoin: (callerDID)=>createJoinContext({
346
- viewerDID: callerDID
356
+ runPrepareJoin: (callerDID)=>createJoinContext(// Out-of-band peer-handler context for the verified caller; the join ops
357
+ // never consult the authority, so a self-scoped device authority fits.
358
+ {
359
+ viewerDID: callerDID,
360
+ credentialAuthority: createDeviceAuthority(callerDID)
347
361
  }, buildContextDeps(params.db)).prepareRequest(),
348
362
  runCompleteJoin: (callerDID, invitePayload)=>createJoinContext({
349
- viewerDID: callerDID
363
+ viewerDID: callerDID,
364
+ credentialAuthority: createDeviceAuthority(callerDID)
350
365
  }, buildContextDeps(params.db)).complete(invitePayload),
351
366
  runInvite: (callerDID, groupID, joinRequest)=>serveGroupInvite(buildContextDeps(params.db), {
352
367
  callerDID,
@@ -358,6 +373,23 @@ export function createP2PPlugin(options) {
358
373
  ...param
359
374
  })
360
375
  });
376
+ // Blob transfer control-lane handlers, mounted on the same peer server.
377
+ const blobHandlers = createBlobHandlers({
378
+ db: params.db,
379
+ identity,
380
+ logger: params.getLogger('blob-handlers'),
381
+ autoAcceptPeers: options?.autoAcceptPeers,
382
+ getRandomID: ()=>params.runtime.getRandomID()
383
+ });
384
+ // Controller log-sharing handlers, mounted on the same peer server. Serves a
385
+ // did:kokuin: controller's stored log to a peer we share a group with; the
386
+ // recipient folds + verifies it independently.
387
+ const controllerHandlers = createControllerHandlers({
388
+ db: params.db,
389
+ identity,
390
+ logger: params.getLogger('controller-handlers'),
391
+ autoAcceptPeers: options?.autoAcceptPeers
392
+ });
361
393
  const pluginAPI = {
362
394
  hubReady: hub.ready,
363
395
  addPeer: (config)=>syncManager.addPeer({
@@ -395,7 +427,15 @@ export function createP2PPlugin(options) {
395
427
  gatherPeers: async (groupID, options)=>await (await hub.presence()).gather(groupID, options),
396
428
  refreshPeerPresence: async (groupID, options)=>await (await hub.presence()).refresh(groupID, options),
397
429
  retryHubConnection: async ()=>await hub.retryHubs(),
398
- getPeerConnection: (peerDID)=>peerConnections.get(peerDID)
430
+ getPeerConnection: (peerDID)=>peerConnections.get(peerDID),
431
+ fetchAttachment: (attachmentID, options)=>fetchAttachment({
432
+ peerConnections,
433
+ getBlobAPI: ()=>params.engine.getAPI('blob'),
434
+ fetch: params.runtime.fetch
435
+ }, attachmentID, options),
436
+ fetchControllerLog: (did)=>fetchControllerLog({
437
+ peerConnections
438
+ }, did)
399
439
  };
400
440
  let httpSyncTransport;
401
441
  let httpSyncServer;
@@ -453,17 +493,29 @@ export function createP2PPlugin(options) {
453
493
  });
454
494
  httpPeerServer = new Server({
455
495
  runtime: params.runtime,
456
- handlers: peerHandlers,
496
+ handlers: {
497
+ ...peerHandlers,
498
+ ...blobHandlers,
499
+ ...controllerHandlers
500
+ },
457
501
  identity,
458
502
  // Open at the procedure layer, like the sync server: each peer handler
459
503
  // gates the verified caller itself (forged-`sub` rejection +
460
- // `checkPeerAccess`). Covers `peer/*` and the dance `group/*`.
504
+ // `checkPeerAccess`). Covers `peer/*`, the dance `group/*`, the blob
505
+ // control lane (`blob/*`, which additionally scope-authorizes), and
506
+ // the controller log-sharing lane (`controller/*`).
461
507
  accessRules: {
462
508
  'peer/*': {
463
509
  allow: true
464
510
  },
465
511
  'group/*': {
466
512
  allow: true
513
+ },
514
+ 'blob/*': {
515
+ allow: true
516
+ },
517
+ 'controller/*': {
518
+ allow: true
467
519
  }
468
520
  },
469
521
  logger: params.getLogger('peer-http-server'),
@@ -0,0 +1,45 @@
1
+ import type { Fetch } from '@sozai/runtime';
2
+ import type { BlobManifestResult } from '../protocol.js';
3
+ import type { PeerConnection, PeerConnectionRegistry } from './connection-registry.js';
4
+ export type BlobFetchAPI = {
5
+ getAttachment(attachmentID: string): Promise<{
6
+ state: string;
7
+ } | null>;
8
+ beginFetch(params: {
9
+ attachmentID: string;
10
+ chunkSize: number;
11
+ chunkDigests: Array<Uint8Array>;
12
+ }): Promise<void>;
13
+ getPresentChunks(attachmentID: string): Promise<Array<number>>;
14
+ stageChunk(params: {
15
+ attachmentID: string;
16
+ index: number;
17
+ offset: number;
18
+ bytes: Uint8Array;
19
+ }): Promise<void>;
20
+ completeFetch(attachmentID: string): Promise<void>;
21
+ };
22
+ export type PeerConnectionLister = {
23
+ list(): Array<PeerConnection>;
24
+ };
25
+ export type FetchAttachmentDeps = {
26
+ peerConnections: PeerConnectionRegistry;
27
+ getBlobAPI: () => Promise<BlobFetchAPI>;
28
+ fetch: Fetch;
29
+ };
30
+ export type FetchAttachmentOptions = {
31
+ delegationTokens?: Array<string>;
32
+ };
33
+ export declare function discoverHolder(peerConnections: PeerConnectionLister, attachmentID: string): Promise<PeerConnection | null>;
34
+ export declare function requestManifest(holder: PeerConnection, attachmentID: string, delegationTokens: Array<string>): Promise<BlobManifestResult>;
35
+ export declare function fetchChunkBytes(params: {
36
+ holder: PeerConnection;
37
+ attachmentID: string;
38
+ manifest: Pick<BlobManifestResult, 'chunkSize' | 'contentLength' | 'capability'>;
39
+ index: number;
40
+ fetch: Fetch;
41
+ }): Promise<{
42
+ offset: number;
43
+ bytes: Uint8Array;
44
+ }>;
45
+ export declare function fetchAttachment(deps: FetchAttachmentDeps, attachmentID: string, options?: FetchAttachmentOptions): Promise<void>;
@@ -0,0 +1,89 @@
1
+ import { BLOB_BYTE_ROUTE_PREFIX } from '@kubun/store-blob';
2
+ import { fromB64 } from '@sozai/codec';
3
+ // Ask each live peer whether it holds the blob; the first that does is the
4
+ // holder. Null when none of the connected peers has the bytes.
5
+ export async function discoverHolder(peerConnections, attachmentID) {
6
+ for (const connection of peerConnections.list()){
7
+ const haves = await connection.client.request('blob/query-haves', {
8
+ param: {
9
+ attachmentIDs: [
10
+ attachmentID
11
+ ]
12
+ }
13
+ });
14
+ if (haves.have.includes(attachmentID)) return connection;
15
+ }
16
+ return null;
17
+ }
18
+ // Fetch the holder's manifest + a signed, short-TTL read capability for this
19
+ // blob over the Enkaku control plane.
20
+ export function requestManifest(holder, attachmentID, delegationTokens) {
21
+ return holder.client.request('blob/manifest', {
22
+ param: {
23
+ attachmentID,
24
+ delegationTokens
25
+ }
26
+ });
27
+ }
28
+ // Range-GET a single chunk's bytes from the holder's raw-HTTP data plane.
29
+ // Returns the byte offset (so the caller can stage at the right position) and
30
+ // the bytes. Throws on a non-OK response — a per-chunk failure the caller
31
+ // surfaces.
32
+ export async function fetchChunkBytes(params) {
33
+ const { holder, attachmentID, manifest, index } = params;
34
+ const start = index * manifest.chunkSize;
35
+ const end = Math.min(start + manifest.chunkSize, manifest.contentLength) - 1;
36
+ const byteURL = new URL(`${BLOB_BYTE_ROUTE_PREFIX}/${attachmentID}`, holder.endpoint).toString();
37
+ const response = await params.fetch(byteURL, {
38
+ headers: {
39
+ Authorization: `Bearer ${manifest.capability}`,
40
+ Range: `bytes=${start}-${end}`
41
+ }
42
+ });
43
+ if (!response.ok) {
44
+ throw new Error(`Blob range fetch failed for chunk ${index}: HTTP ${response.status}`);
45
+ }
46
+ return {
47
+ offset: start,
48
+ bytes: new Uint8Array(await response.arrayBuffer())
49
+ };
50
+ }
51
+ // Pull a remote blob's bytes from a connected peer that holds them: discover a
52
+ // holder, fetch its manifest + a signed capability, range-download every missing
53
+ // chunk over the raw-HTTP data plane, and persist through plugin-blob (which
54
+ // verifies each chunk and the whole blob). A no-op if the bytes are already
55
+ // local; resumable — only missing chunks are re-requested.
56
+ export async function fetchAttachment(deps, attachmentID, options) {
57
+ const blob = await deps.getBlobAPI();
58
+ const existing = await blob.getAttachment(attachmentID);
59
+ if (existing?.state === 'local') return;
60
+ const delegationTokens = options?.delegationTokens ?? [];
61
+ const holder = await discoverHolder(deps.peerConnections, attachmentID);
62
+ if (holder == null) {
63
+ throw new Error(`No connected peer holds blob ${attachmentID}`);
64
+ }
65
+ const manifest = await requestManifest(holder, attachmentID, delegationTokens);
66
+ await blob.beginFetch({
67
+ attachmentID,
68
+ chunkSize: manifest.chunkSize,
69
+ chunkDigests: manifest.chunkDigests.map((digest)=>fromB64(digest))
70
+ });
71
+ const present = new Set(await blob.getPresentChunks(attachmentID));
72
+ for(let index = 0; index < manifest.chunkCount; index++){
73
+ if (present.has(index)) continue;
74
+ const { offset, bytes } = await fetchChunkBytes({
75
+ holder,
76
+ attachmentID,
77
+ manifest,
78
+ index,
79
+ fetch: deps.fetch
80
+ });
81
+ await blob.stageChunk({
82
+ attachmentID,
83
+ index,
84
+ offset,
85
+ bytes
86
+ });
87
+ }
88
+ await blob.completeFetch(attachmentID);
89
+ }
@@ -0,0 +1,11 @@
1
+ import type { OwnIdentity } from '@kokuin/token';
2
+ import type { KubunDB } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ export type CreateBlobHandlersParams = {
5
+ db: KubunDB;
6
+ identity: OwnIdentity;
7
+ logger: Logger;
8
+ autoAcceptPeers?: Array<string>;
9
+ getRandomID: () => string;
10
+ };
11
+ export declare function createBlobHandlers(params: CreateBlobHandlersParams): Record<string, unknown>;
@@ -0,0 +1,123 @@
1
+ import { createCapability } from '@kokuin/capability';
2
+ import { stringifyToken } from '@kokuin/token';
3
+ import { getBlobStore } from '@kubun/store-blob';
4
+ import { createDelegationRevocationChecker, getDelegationStore } from '@kubun/store-delegation';
5
+ import { getGraphStore } from '@kubun/store-graph';
6
+ import { getP2PStore } from '@kubun/store-p2p';
7
+ import { toB64 } from '@sozai/codec';
8
+ import { checkPeerAccess, verifiedCaller } from '../context/types.js';
9
+ import { authorizeScope } from '../sync/authorize.js';
10
+ import { wrapHandlers } from '../util/handler-error.js';
11
+ // A fetch capability only has to survive one download's handshake + transfer.
12
+ // Short-lived so a leaked token is quickly useless; the byte route re-checks it.
13
+ const FETCH_CAP_TTL_SECONDS = 300;
14
+ export function createBlobHandlers(params) {
15
+ const { db, identity, logger, autoAcceptPeers, getRandomID } = params;
16
+ // Same scope resolvers the sync lane uses, so a blob authorizes against
17
+ // identical (owner, model, circle) scope as the documents that reference it.
18
+ let cachedRevocationChecker = null;
19
+ async function getRevocationChecker() {
20
+ if (cachedRevocationChecker == null) {
21
+ cachedRevocationChecker = createDelegationRevocationChecker(await getDelegationStore(db));
22
+ }
23
+ return cachedRevocationChecker;
24
+ }
25
+ let cachedCircleReadGrant = null;
26
+ async function getCircleReadGrant() {
27
+ if (cachedCircleReadGrant == null) {
28
+ const [graphStore, p2pStore] = await Promise.all([
29
+ getGraphStore(db),
30
+ getP2PStore(db)
31
+ ]);
32
+ cachedCircleReadGrant = {
33
+ getUserModelReadDefault: (ownerDID, modelID)=>graphStore.getUserModelAccessDefault(ownerDID, modelID, 'read'),
34
+ listAccessibleCircles: (viewerDID, circleIDs)=>p2pStore.listAccessibleCircles(viewerDID, circleIDs),
35
+ listMemberGroupIDs: (viewerDID, groupIDs)=>p2pStore.listMemberGroupIDs(viewerDID, groupIDs)
36
+ };
37
+ }
38
+ return cachedCircleReadGrant;
39
+ }
40
+ // D19: a blob is in a peer's scope iff some document that references it is.
41
+ // Authorize against the referencing documents' (owner, model) scopes; any one
42
+ // in scope grants the fetch.
43
+ async function authorizeBlobFetch(viewerDID, attachmentID, delegationTokens) {
44
+ const [blobStore, graphStore] = await Promise.all([
45
+ getBlobStore(db),
46
+ getGraphStore(db)
47
+ ]);
48
+ const refs = await blobStore.getRefsForAttachment(attachmentID);
49
+ if (refs.length === 0) return false;
50
+ const revocationChecker = await getRevocationChecker();
51
+ const circleReadGrant = await getCircleReadGrant();
52
+ for (const ref of refs){
53
+ const document = await graphStore.getDocument(ref.document_id);
54
+ if (document == null) continue;
55
+ const ok = await authorizeScope({
56
+ viewerDID,
57
+ ownerDID: document.owner,
58
+ modelID: ref.model_id,
59
+ delegationTokens,
60
+ revocationChecker,
61
+ circleReadGrant
62
+ });
63
+ if (ok) return true;
64
+ }
65
+ return false;
66
+ }
67
+ return wrapHandlers({
68
+ 'blob/query-haves': async (ctx)=>{
69
+ const callerDID = verifiedCaller(ctx.message.payload);
70
+ checkPeerAccess(callerDID, identity.id, autoAcceptPeers);
71
+ const store = await getBlobStore(db);
72
+ const have = [];
73
+ for (const attachmentID of ctx.param.attachmentIDs){
74
+ const entry = await store.getEntry(attachmentID);
75
+ if (entry != null && entry.state === 'local') have.push(attachmentID);
76
+ }
77
+ logger.debug('blob/query-haves served', {
78
+ callerDID,
79
+ have: have.length
80
+ });
81
+ return {
82
+ have
83
+ };
84
+ },
85
+ 'blob/manifest': async (ctx)=>{
86
+ const callerDID = verifiedCaller(ctx.message.payload);
87
+ checkPeerAccess(callerDID, identity.id, autoAcceptPeers);
88
+ const { attachmentID, delegationTokens } = ctx.param;
89
+ const store = await getBlobStore(db);
90
+ const entry = await store.getEntry(attachmentID);
91
+ if (entry == null || entry.state !== 'local') {
92
+ throw new Error(`Blob ${attachmentID} not held locally`);
93
+ }
94
+ const authorized = await authorizeBlobFetch(callerDID, attachmentID, delegationTokens);
95
+ if (!authorized) {
96
+ throw new Error(`Blob ${attachmentID} not in caller scope`);
97
+ }
98
+ const digests = await store.getChunkDigests(attachmentID);
99
+ const iat = Math.floor(Date.now() / 1000);
100
+ const capability = await createCapability(identity, {
101
+ sub: identity.id,
102
+ aud: callerDID,
103
+ act: 'blob/read',
104
+ res: `urn:kubun:blob:${attachmentID}`,
105
+ iat,
106
+ exp: iat + FETCH_CAP_TTL_SECONDS,
107
+ jti: getRandomID()
108
+ });
109
+ logger.debug('blob/manifest served', {
110
+ callerDID,
111
+ attachmentID,
112
+ chunks: digests.length
113
+ });
114
+ return {
115
+ chunkSize: entry.chunk_size,
116
+ chunkCount: digests.length,
117
+ contentLength: entry.content_length,
118
+ chunkDigests: digests.map((digest)=>toB64(digest)),
119
+ capability: stringifyToken(capability)
120
+ };
121
+ }
122
+ });
123
+ }
@@ -0,0 +1,6 @@
1
+ import { type SignedEvent } from '@kokuin/controller';
2
+ import type { PeerConnectionLister } from './blob-fetch.js';
3
+ export type ControllerLogFetchDeps = {
4
+ peerConnections: PeerConnectionLister;
5
+ };
6
+ export declare function fetchControllerLog(deps: ControllerLogFetchDeps, did: string): Promise<Array<SignedEvent> | undefined>;
@@ -0,0 +1,59 @@
1
+ import { LOG_FORKED, resolveBranchesAsync } from '@kokuin/controller';
2
+ // Reduce the logs gathered from peers to the single authoritative one, reusing
3
+ // kokuin's own arbitration (never reimplementing the fold). Trusting the first
4
+ // non-empty answer would let a stale/hostile peer that responds first suppress a
5
+ // revocation another peer already holds — the resolver never sees the competing
6
+ // branch, so its `history` guard cannot catch it. Arbitrating every candidate
7
+ // closes that: the branch carrying the latest `rev` (or a superseding recovery)
8
+ // wins here before the caller ever compares against its cache.
9
+ //
10
+ // `resolveBranchesAsync` resolves all candidates AT ONCE and is the primitive
11
+ // built for this. A sequential pairwise reduction would be order-dependent: it
12
+ // can report duplicity between two losing branches before reaching the one that
13
+ // supersedes both — e.g. a recovery rotate presented after two conflicting
14
+ // revokes — which kokuin's `resolveContenders` documents as unsound. It also
15
+ // folds each branch itself and drops the unfoldable, so a garbage answer is
16
+ // ignored rather than poisoning the result.
17
+ async function selectAuthoritativeLog(did, candidates) {
18
+ if (candidates.length === 0) return undefined;
19
+ const resolved = await resolveBranchesAsync(did, candidates, {});
20
+ if (resolved.ok) {
21
+ // A branch left out for want of a capability verifier (the engine configures
22
+ // none) means the winner among the rest may not be the true authority; be
23
+ // conservative and decline so the caller keeps its cached log.
24
+ return resolved.unverified > 0 ? undefined : resolved.winner;
25
+ }
26
+ // A genuine fork must surface — the caller's resolver re-throws it and emits
27
+ // `engine:controller:forked`. Any other failure (no valid branch, all
28
+ // unverifiable) leaves nothing to hand back.
29
+ if (resolved.failure === 'duplicity') throw new Error(`${LOG_FORKED}: ${did}`);
30
+ return undefined;
31
+ }
32
+ // Pull a `did:kokuin:` controller's key-event log from connected group peers.
33
+ // Every reachable peer is asked (not just the first responder), and a peer that
34
+ // errors or denies us is skipped rather than aborting the sweep — so one
35
+ // hostile/offline peer can neither suppress a revocation another peer holds nor
36
+ // deny controller verification entirely. The gathered logs are arbitrated by
37
+ // `selectAuthoritativeLog`; a genuine fork among them throws `LOG_FORKED` for
38
+ // the caller's resolver to surface. No connected peer holds it -> undefined.
39
+ //
40
+ // The events are self-authenticating (inception digest == DID, every event
41
+ // signed), so nothing here trusts a peer's answer for integrity: the arbitration
42
+ // folds every candidate, and the caller's resolver folds the winner again
43
+ // against its own history.
44
+ export async function fetchControllerLog(deps, did) {
45
+ const candidates = [];
46
+ for (const connection of deps.peerConnections.list()){
47
+ try {
48
+ const { events } = await connection.client.request('controller/get-log', {
49
+ param: {
50
+ did
51
+ }
52
+ });
53
+ if (events.length > 0) candidates.push(events);
54
+ } catch {
55
+ // Skip an erroring/denying peer; the remaining peers still answer.
56
+ }
57
+ }
58
+ return selectAuthoritativeLog(did, candidates);
59
+ }
@@ -0,0 +1,10 @@
1
+ import type { OwnIdentity } from '@kokuin/token';
2
+ import type { KubunDB } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ export type CreateControllerHandlersParams = {
5
+ db: KubunDB;
6
+ identity: OwnIdentity;
7
+ logger: Logger;
8
+ autoAcceptPeers?: Array<string>;
9
+ };
10
+ export declare function createControllerHandlers(params: CreateControllerHandlersParams): Record<string, unknown>;
@@ -0,0 +1,28 @@
1
+ import { getControllerStore } from '@kubun/store-controller';
2
+ import { checkPeerAccess, verifiedCaller } from '../context/types.js';
3
+ import { wrapHandlers } from '../util/handler-error.js';
4
+ export function createControllerHandlers(params) {
5
+ const { db, identity, logger, autoAcceptPeers } = params;
6
+ return wrapHandlers({
7
+ 'controller/get-log': async (ctx)=>{
8
+ const callerDID = verifiedCaller(ctx.message.payload);
9
+ checkPeerAccess(callerDID, identity.id, autoAcceptPeers);
10
+ const store = await getControllerStore(db);
11
+ // A miss is an empty log, not a throw: the log is self-authenticating, so
12
+ // there is nothing to refuse, and the requester decides what an empty
13
+ // answer means (whether to retry another peer).
14
+ const events = await store.get(ctx.param.did) ?? [];
15
+ logger.debug('controller/get-log served', {
16
+ callerDID,
17
+ did: ctx.param.did,
18
+ events: events.length
19
+ });
20
+ // The stored log is Array<SignedEvent>; the wire schema types it as an
21
+ // opaque object array and the recipient re-verifies, so the shape is safe
22
+ // to hand back as-is.
23
+ return {
24
+ events: events
25
+ };
26
+ }
27
+ });
28
+ }