@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
|
@@ -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
|
package/lib/hub/wiring.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|