@kubun/plugin-p2p 0.10.0 → 0.11.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/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -1 +1,116 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { verifyToken } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* Verify a received capability grant and, when it is addressed to this device,
|
|
4
|
+
* store it as a held row so the engine's auto-attach can reuse it on later
|
|
5
|
+
* mutations.
|
|
6
|
+
*
|
|
7
|
+
* Returns `true` when the held row was inserted or materially changed (LWW
|
|
8
|
+
* upsert with a newer hlc and at least one differing content field). Returns
|
|
9
|
+
* `false` when no row change occurred — verification failure, audience
|
|
10
|
+
* mismatch, missing required claims, LWW lost against an existing row, or an
|
|
11
|
+
* idempotent re-broadcast with identical content. Never throws on a forged or
|
|
12
|
+
* malformed token — it is skipped like an unverifiable broadcast entry.
|
|
13
|
+
*
|
|
14
|
+
* Shared by the `delegation:share` broadcast path and the invite-bootstrap
|
|
15
|
+
* path so both apply identical verify + audience + null-guard + coercion logic.
|
|
16
|
+
*/ export async function storeReceivedGrant(params) {
|
|
17
|
+
const { delegationStore, p2pStore, token, groupID, hlc, selfDID, emitter, logger } = params;
|
|
18
|
+
// Verify the token's signature before trusting any of its claims. A forged
|
|
19
|
+
// or tampered token throws here; skip it — no row stored, no throw.
|
|
20
|
+
let payload;
|
|
21
|
+
try {
|
|
22
|
+
const verified = await verifyToken(token);
|
|
23
|
+
payload = verified.payload;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logger?.warn('received grant token verification failed, skipping', {
|
|
26
|
+
groupID,
|
|
27
|
+
error
|
|
28
|
+
});
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
// Only store grants addressed to this device.
|
|
32
|
+
if (payload.aud !== selfDID) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (payload.jti == null || payload.exp == null) {
|
|
36
|
+
logger?.warn('received grant token missing jti/exp, skipping', {
|
|
37
|
+
groupID
|
|
38
|
+
});
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const resource = typeof payload.res === 'string' ? payload.res : JSON.stringify(payload.res);
|
|
42
|
+
const act = typeof payload.act === 'string' ? payload.act : JSON.stringify(payload.act);
|
|
43
|
+
const changed = await delegationStore.addDelegationToken({
|
|
44
|
+
jti: payload.jti,
|
|
45
|
+
grantor: payload.sub,
|
|
46
|
+
audience: payload.aud,
|
|
47
|
+
token,
|
|
48
|
+
resource,
|
|
49
|
+
act,
|
|
50
|
+
exp: payload.exp,
|
|
51
|
+
hlc
|
|
52
|
+
});
|
|
53
|
+
// Record the group this grant belongs to on the p2p edge so the p2p layer can
|
|
54
|
+
// resolve the cap's group for revoke-broadcast targeting and listing
|
|
55
|
+
// enrichment. Keyed by `jti`; idempotent on re-broadcast.
|
|
56
|
+
await p2pStore.addGroupDelegation({
|
|
57
|
+
group_id: groupID,
|
|
58
|
+
jti: payload.jti
|
|
59
|
+
});
|
|
60
|
+
// Pending revocations are blind cross-checks: when the matching cap finally
|
|
61
|
+
// arrives we either promote the revocation to verified (issuer matches) or
|
|
62
|
+
// drop it as a forged broadcast (issuer mismatch). Settled before any
|
|
63
|
+
// `delegationTokenAdded` emission so subscribers see consistent ground
|
|
64
|
+
// truth from the revocation backend on the same tick.
|
|
65
|
+
if (changed) {
|
|
66
|
+
const pending = await delegationStore.getPendingRevocationByJTI(payload.jti);
|
|
67
|
+
if (pending != null) {
|
|
68
|
+
if (pending.revoker_did === payload.sub) {
|
|
69
|
+
// Pin the `verified_at` we send to subscribers to the same value the
|
|
70
|
+
// store writes; passing it explicitly avoids a millisecond-scale
|
|
71
|
+
// skew between the row and the emitted event under clock-tick
|
|
72
|
+
// boundaries.
|
|
73
|
+
const verifiedAt = Math.floor(Date.now() / 1000);
|
|
74
|
+
const flipped = await delegationStore.markRevocationVerified(payload.jti, {
|
|
75
|
+
cap_exp: payload.exp,
|
|
76
|
+
verified_at: verifiedAt
|
|
77
|
+
});
|
|
78
|
+
if (flipped && emitter != null) {
|
|
79
|
+
await emitter.emit('delegationTokenRevoked', {
|
|
80
|
+
jti: payload.jti,
|
|
81
|
+
grantor: payload.sub,
|
|
82
|
+
audience: payload.aud,
|
|
83
|
+
revokerDID: pending.revoker_did,
|
|
84
|
+
revokedAt: pending.revoked_iat,
|
|
85
|
+
verifiedAt,
|
|
86
|
+
capExp: payload.exp,
|
|
87
|
+
groupID
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
logger?.warn('pending revocation iss does not match arriving cap, dropping', {
|
|
92
|
+
jti: payload.jti,
|
|
93
|
+
pendingIss: pending.revoker_did,
|
|
94
|
+
capGrantor: payload.sub,
|
|
95
|
+
groupID
|
|
96
|
+
});
|
|
97
|
+
await delegationStore.deletePendingRevocation(payload.jti);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Emit only when the held row was actually inserted or materially changed.
|
|
102
|
+
// Idempotent re-broadcasts (LWW lost, or won with byte-identical content)
|
|
103
|
+
// are silent — subscribers should not see duplicates for a token they
|
|
104
|
+
// already received.
|
|
105
|
+
if (changed && emitter != null) {
|
|
106
|
+
await emitter.emit('delegationTokenAdded', {
|
|
107
|
+
jwt: token,
|
|
108
|
+
jti: payload.jti,
|
|
109
|
+
grantor: payload.sub,
|
|
110
|
+
audience: payload.aud,
|
|
111
|
+
resource,
|
|
112
|
+
exp: payload.exp
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return changed;
|
|
116
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Logger } from '@kubun/logger';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DelegationStoreAPI } from '@kubun/store-delegation';
|
|
3
3
|
import type { P2PEventEmitter } from './events.js';
|
|
4
4
|
export type StoreReceivedRevocationParams = {
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/** Delegation store the revocation row is written to. */
|
|
6
|
+
delegationStore: DelegationStoreAPI;
|
|
7
7
|
/** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
|
|
8
8
|
token: string;
|
|
9
9
|
/** Group the revocation belongs to. */
|
|
@@ -1 +1,82 @@
|
|
|
1
|
-
import{verifyToken
|
|
1
|
+
import { verifyToken } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* Verify a received revocation token and write its row to the P2P store.
|
|
4
|
+
*
|
|
5
|
+
* Verified-on-arrival: when the referenced capability is already known
|
|
6
|
+
* locally and the revocation's `iss` matches that capability's `grantor`,
|
|
7
|
+
* the row is stored with `verified_at` populated so the hot-path
|
|
8
|
+
* `isRevoked` gate fires immediately. When the capability is unknown
|
|
9
|
+
* locally, the row is stored as pending (`verified_at` null) until a later
|
|
10
|
+
* `delegation:share` triggers a cross-check that flips it via
|
|
11
|
+
* `markRevocationVerified`. A signature-verified token whose `iss` does
|
|
12
|
+
* not match a known capability's `grantor` is rejected outright — a forged
|
|
13
|
+
* revocation must not pollute the local store.
|
|
14
|
+
*
|
|
15
|
+
* Returns `true` when the row was inserted or materially changed (LWW
|
|
16
|
+
* upsert). Returns `false` on verification failure, non-revocation
|
|
17
|
+
* payload, iss/grantor mismatch, or a lost/idempotent LWW upsert. Never
|
|
18
|
+
* throws on a forged or malformed token — it is skipped like an
|
|
19
|
+
* unverifiable broadcast entry.
|
|
20
|
+
*/ export async function storeReceivedRevocation(params) {
|
|
21
|
+
const { delegationStore, token, groupID, hlc, emitter, logger } = params;
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
const verified = await verifyToken(token);
|
|
25
|
+
payload = verified.payload;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger?.debug('received revocation token verification failed, skipping', {
|
|
28
|
+
groupID,
|
|
29
|
+
error
|
|
30
|
+
});
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (payload.rev !== true) {
|
|
34
|
+
logger?.debug('received revocation token is not a revocation record, skipping', {
|
|
35
|
+
groupID
|
|
36
|
+
});
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const { jti, iss, iat } = payload;
|
|
40
|
+
const cap = await delegationStore.getDelegationTokenByJTI(jti);
|
|
41
|
+
let verifiedAt = null;
|
|
42
|
+
let capExp = null;
|
|
43
|
+
if (cap != null) {
|
|
44
|
+
if (cap.grantor !== iss) {
|
|
45
|
+
logger?.warn('received revocation iss does not match known capability grantor, skipping', {
|
|
46
|
+
groupID,
|
|
47
|
+
jti,
|
|
48
|
+
iss,
|
|
49
|
+
grantor: cap.grantor
|
|
50
|
+
});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
verifiedAt = Math.floor(Date.now() / 1000);
|
|
54
|
+
capExp = cap.exp;
|
|
55
|
+
}
|
|
56
|
+
const changed = await delegationStore.addRevocation({
|
|
57
|
+
jti,
|
|
58
|
+
revoker_did: iss,
|
|
59
|
+
revoked_iat: iat,
|
|
60
|
+
revocation_token: token,
|
|
61
|
+
verified_at: verifiedAt,
|
|
62
|
+
cap_exp: capExp,
|
|
63
|
+
hlc
|
|
64
|
+
});
|
|
65
|
+
// Only verified-on-arrival writes are binding to local subscribers — a
|
|
66
|
+
// pending row carries no enforcement weight until the deferred cross-check
|
|
67
|
+
// promotes it. The cross-check path (in `storeReceivedGrant`) emits there
|
|
68
|
+
// when it flips a row from pending to verified.
|
|
69
|
+
if (changed && verifiedAt != null && cap != null && emitter != null) {
|
|
70
|
+
await emitter.emit('delegationTokenRevoked', {
|
|
71
|
+
jti,
|
|
72
|
+
grantor: cap.grantor,
|
|
73
|
+
audience: cap.audience,
|
|
74
|
+
revokerDID: iss,
|
|
75
|
+
revokedAt: iat,
|
|
76
|
+
verifiedAt,
|
|
77
|
+
capExp,
|
|
78
|
+
groupID
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return changed;
|
|
82
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
export type AckBatchParams = {
|
|
3
|
+
flushMs: number;
|
|
4
|
+
flushMax: number;
|
|
5
|
+
send: (ids: Array<string>) => Promise<void>;
|
|
6
|
+
logger?: Logger;
|
|
7
|
+
};
|
|
8
|
+
export declare class AckBatch {
|
|
9
|
+
#private;
|
|
10
|
+
constructor(params: AckBatchParams);
|
|
11
|
+
add(id: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Drain the pending id set and send it via the configured sender. Never
|
|
14
|
+
* rejects: send errors are logged and the ids are dropped silently (the hub
|
|
15
|
+
* will redeliver unacknowledged messages, so the next receive will produce a
|
|
16
|
+
* fresh ack).
|
|
17
|
+
*/
|
|
18
|
+
flush(): Promise<void>;
|
|
19
|
+
dispose(): void;
|
|
20
|
+
}
|
|
21
|
+
export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
|
|
22
|
+
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export class AckBatch {
|
|
2
|
+
#flushMs;
|
|
3
|
+
#flushMax;
|
|
4
|
+
#send;
|
|
5
|
+
#logger;
|
|
6
|
+
#ids = new Set();
|
|
7
|
+
#timer = null;
|
|
8
|
+
constructor(params){
|
|
9
|
+
this.#flushMs = params.flushMs;
|
|
10
|
+
this.#flushMax = params.flushMax;
|
|
11
|
+
this.#send = params.send;
|
|
12
|
+
this.#logger = params.logger;
|
|
13
|
+
}
|
|
14
|
+
add(id) {
|
|
15
|
+
const wasEmpty = this.#ids.size === 0;
|
|
16
|
+
this.#ids.add(id);
|
|
17
|
+
if (this.#ids.size >= this.#flushMax) {
|
|
18
|
+
if (this.#timer !== null) {
|
|
19
|
+
clearTimeout(this.#timer);
|
|
20
|
+
this.#timer = null;
|
|
21
|
+
}
|
|
22
|
+
void this.flush();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (wasEmpty && this.#timer === null) {
|
|
26
|
+
this.#timer = setTimeout(()=>{
|
|
27
|
+
this.#timer = null;
|
|
28
|
+
void this.flush();
|
|
29
|
+
}, this.#flushMs);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Drain the pending id set and send it via the configured sender. Never
|
|
34
|
+
* rejects: send errors are logged and the ids are dropped silently (the hub
|
|
35
|
+
* will redeliver unacknowledged messages, so the next receive will produce a
|
|
36
|
+
* fresh ack).
|
|
37
|
+
*/ async flush() {
|
|
38
|
+
if (this.#timer !== null) {
|
|
39
|
+
clearTimeout(this.#timer);
|
|
40
|
+
this.#timer = null;
|
|
41
|
+
}
|
|
42
|
+
if (this.#ids.size === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const snapshot = Array.from(this.#ids);
|
|
46
|
+
this.#ids.clear();
|
|
47
|
+
try {
|
|
48
|
+
await this.#send(snapshot);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
this.#logger?.error('ack batch send failed', {
|
|
51
|
+
error: err,
|
|
52
|
+
count: snapshot.length
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
dispose() {
|
|
57
|
+
if (this.#timer !== null) {
|
|
58
|
+
clearTimeout(this.#timer);
|
|
59
|
+
this.#timer = null;
|
|
60
|
+
}
|
|
61
|
+
this.#ids.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// --- Backoff ---
|
|
65
|
+
export function computeBackoff(attempt, base, max, jitter) {
|
|
66
|
+
const exponential = base * 2 ** attempt;
|
|
67
|
+
const jittered = exponential * (1 + jitter * (2 * Math.random() - 1));
|
|
68
|
+
const clamped = Math.min(jittered, max);
|
|
69
|
+
return clamped < 0 ? 0 : clamped;
|
|
70
|
+
}
|
|
71
|
+
export function abortableSleep(ms, signal) {
|
|
72
|
+
return new Promise((resolve, reject)=>{
|
|
73
|
+
if (signal.aborted) {
|
|
74
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const timer = setTimeout(()=>{
|
|
78
|
+
signal.removeEventListener('abort', onAbort);
|
|
79
|
+
resolve();
|
|
80
|
+
}, ms);
|
|
81
|
+
const onAbort = ()=>{
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
signal.removeEventListener('abort', onAbort);
|
|
84
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
85
|
+
};
|
|
86
|
+
signal.addEventListener('abort', onAbort);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
import { Disposer } from '@enkaku/async';
|
|
2
|
-
import type { EventEmitter } from '@enkaku/event';
|
|
3
1
|
import type { AnyClientMessageOf, AnyServerMessageOf, ClientTransportOf, ProtocolDefinition } from '@enkaku/protocol';
|
|
4
2
|
import type { TransportEvents } from '@enkaku/transport';
|
|
3
|
+
import { Disposer } from '@sozai/async';
|
|
4
|
+
import type { EventEmitter } from '@sozai/event';
|
|
5
5
|
export type ServerDIDObserver = (serverDID: string) => void | Promise<void>;
|
|
6
6
|
export type ServerDIDMismatchObserver = (params: {
|
|
7
7
|
hubURL: string;
|
|
8
8
|
expectedServerDID: string;
|
|
9
9
|
observedServerDID: string;
|
|
10
10
|
}) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Fired once per freshly-spawned hub client when its first signed response
|
|
13
|
+
* arrives. The hub URL is bound into the call site; consumers (hub manager
|
|
14
|
+
* `captureServerDID`) use the URL to look up the local hub row and apply the
|
|
15
|
+
* TOFU conflict matrix. Errors are caught and logged — capture never blocks
|
|
16
|
+
* message forwarding, even on `HubServerDIDConflictError`.
|
|
17
|
+
*/
|
|
18
|
+
export type OnServerDIDObserved = (params: {
|
|
19
|
+
hubURL: string;
|
|
20
|
+
serverDID: string;
|
|
21
|
+
}) => void | Promise<void>;
|
|
11
22
|
export type DIDObservingTransportParams<Protocol extends ProtocolDefinition> = {
|
|
12
23
|
inner: ClientTransportOf<Protocol>;
|
|
13
24
|
/**
|
|
@@ -1 +1,126 @@
|
|
|
1
|
-
import{Disposer
|
|
1
|
+
import { Disposer } from '@sozai/async';
|
|
2
|
+
import { HubServerDIDMismatchError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a `ClientTransportOf<Protocol>` to enforce hub-server-DID
|
|
5
|
+
* TOFU + pinned-mismatch semantics on the receive side.
|
|
6
|
+
*
|
|
7
|
+
* - `expectedServerDID == null`: TOFU mode. First message bearing
|
|
8
|
+
* `payload.iss` fires `onServerDID(iss)` exactly once; subsequent messages
|
|
9
|
+
* never re-fire, even with a differing `iss`. Every message is forwarded
|
|
10
|
+
* to the inner Client untouched.
|
|
11
|
+
* - `expectedServerDID != null` and the first observed `iss` matches: the
|
|
12
|
+
* pin is already authoritative; `onServerDID` is NOT fired and forwarding
|
|
13
|
+
* continues normally.
|
|
14
|
+
* - `expectedServerDID != null` and the first observed `iss` differs: the
|
|
15
|
+
* wrapper enters a permanent failed state (`HubServerDIDMismatchError`).
|
|
16
|
+
* The current `read()` call AND every subsequent `read()` reject with the
|
|
17
|
+
* error; the offending message is NOT delivered. Inner-transport lifecycle
|
|
18
|
+
* stays the pool's responsibility — the wrapper does not dispose its inner.
|
|
19
|
+
*
|
|
20
|
+
* Extends `Disposer` and matches the `ClientTransportOf<Protocol>` shape
|
|
21
|
+
* structurally so it can be passed straight to `Client<Protocol>`.
|
|
22
|
+
*/ export class DIDObservingTransport extends Disposer {
|
|
23
|
+
#inner;
|
|
24
|
+
#onServerDID;
|
|
25
|
+
#expectedServerDID;
|
|
26
|
+
#hubURL;
|
|
27
|
+
#onMismatch;
|
|
28
|
+
#fired = false;
|
|
29
|
+
#failure = null;
|
|
30
|
+
constructor(params){
|
|
31
|
+
super({
|
|
32
|
+
signal: params.inner.signal,
|
|
33
|
+
dispose: async (reason)=>{
|
|
34
|
+
await params.inner.dispose(reason);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
this.#inner = params.inner;
|
|
38
|
+
this.#onServerDID = params.onServerDID;
|
|
39
|
+
this.#expectedServerDID = params.expectedServerDID;
|
|
40
|
+
this.#hubURL = params.hubURL;
|
|
41
|
+
this.#onMismatch = params.onMismatch;
|
|
42
|
+
}
|
|
43
|
+
get events() {
|
|
44
|
+
return this.#inner.events;
|
|
45
|
+
}
|
|
46
|
+
getWritable() {
|
|
47
|
+
return this.#inner.getWritable();
|
|
48
|
+
}
|
|
49
|
+
async read() {
|
|
50
|
+
if (this.#failure != null) {
|
|
51
|
+
throw this.#failure;
|
|
52
|
+
}
|
|
53
|
+
const result = await this.#inner.read();
|
|
54
|
+
const failure = this.#observe(result);
|
|
55
|
+
if (failure != null) {
|
|
56
|
+
throw failure;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
async write(value) {
|
|
61
|
+
await this.#inner.write(value);
|
|
62
|
+
}
|
|
63
|
+
[Symbol.asyncIterator]() {
|
|
64
|
+
return {
|
|
65
|
+
next: async ()=>{
|
|
66
|
+
const result = await this.read();
|
|
67
|
+
if (result.done) {
|
|
68
|
+
return {
|
|
69
|
+
done: true,
|
|
70
|
+
value: result.value ?? null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
done: false,
|
|
75
|
+
value: result.value
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Inspect a freshly-read result for `payload.iss`. Returns a
|
|
82
|
+
* `HubServerDIDMismatchError` to be thrown by the caller when in pinned
|
|
83
|
+
* mode and the observed DID does not match — in that case the wrapper
|
|
84
|
+
* also enters a permanent failed state and the offending message must NOT
|
|
85
|
+
* be returned to the caller. Returns `null` otherwise.
|
|
86
|
+
*/ #observe(result) {
|
|
87
|
+
if (this.#fired || result.done || result.value == null) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const payload = result.value.payload;
|
|
91
|
+
const iss = payload?.iss;
|
|
92
|
+
if (typeof iss !== 'string' || iss.length === 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
this.#fired = true;
|
|
96
|
+
if (this.#expectedServerDID != null) {
|
|
97
|
+
if (iss === this.#expectedServerDID) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const error = new HubServerDIDMismatchError({
|
|
101
|
+
hubURL: this.#hubURL ?? '',
|
|
102
|
+
expectedServerDID: this.#expectedServerDID,
|
|
103
|
+
observedServerDID: iss
|
|
104
|
+
});
|
|
105
|
+
this.#failure = error;
|
|
106
|
+
if (this.#onMismatch != null) {
|
|
107
|
+
try {
|
|
108
|
+
this.#onMismatch({
|
|
109
|
+
hubURL: this.#hubURL ?? '',
|
|
110
|
+
expectedServerDID: this.#expectedServerDID,
|
|
111
|
+
observedServerDID: iss
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
// observer errors must not mask the typed mismatch path
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
// TOFU mode — fire-and-forget: forwarding must not wait on the observer.
|
|
120
|
+
// Errors are swallowed at the wrapper; the pool-side handler logs them.
|
|
121
|
+
void Promise.resolve().then(()=>this.#onServerDID(iss)).catch(()=>{
|
|
122
|
+
// intentional: capture failures don't break the read loop in TOFU mode
|
|
123
|
+
});
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/lib/hub/errors.d.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
export declare class ReconnectingError extends Error {
|
|
2
|
-
readonly hubURL: string;
|
|
3
|
-
readonly attempt: number;
|
|
4
|
-
constructor(hubURL: string, attempt: number);
|
|
5
|
-
}
|
|
6
1
|
/**
|
|
7
2
|
* Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
|
|
8
3
|
* does not match the pinned `expectedServerDID`. The transport enters a
|
|
@@ -17,12 +12,13 @@ export declare class ReconnectingError extends Error {
|
|
|
17
12
|
* message is delivered, when a row was already pinned at connect time.
|
|
18
13
|
*/
|
|
19
14
|
export declare class HubServerDIDMismatchError extends Error {
|
|
20
|
-
|
|
21
|
-
readonly expectedServerDID: string;
|
|
22
|
-
readonly observedServerDID: string;
|
|
15
|
+
#private;
|
|
23
16
|
constructor(params: {
|
|
24
17
|
hubURL: string;
|
|
25
18
|
expectedServerDID: string;
|
|
26
19
|
observedServerDID: string;
|
|
27
20
|
});
|
|
21
|
+
get hubURL(): string;
|
|
22
|
+
get expectedServerDID(): string;
|
|
23
|
+
get observedServerDID(): string;
|
|
28
24
|
}
|
package/lib/hub/errors.js
CHANGED
|
@@ -1 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
|
|
3
|
+
* does not match the pinned `expectedServerDID`. The transport enters a
|
|
4
|
+
* permanent failed state: no message bearing the offending `iss` is delivered
|
|
5
|
+
* to the inner Client, and every subsequent `read()` rejects with the same
|
|
6
|
+
* error. In-flight Enkaku RPCs see this error as the `cause` of the Client's
|
|
7
|
+
* "Transport read failed" abort.
|
|
8
|
+
*
|
|
9
|
+
* Distinct from `HubServerDIDConflictError` (raised by manager-level
|
|
10
|
+
* `captureServerDID` when the pin row already differs from the observed DID
|
|
11
|
+
* on the TOFU capture path); this class is raised client-side, before any
|
|
12
|
+
* message is delivered, when a row was already pinned at connect time.
|
|
13
|
+
*/ export class HubServerDIDMismatchError extends Error {
|
|
14
|
+
#hubURL;
|
|
15
|
+
#expectedServerDID;
|
|
16
|
+
#observedServerDID;
|
|
17
|
+
constructor(params){
|
|
18
|
+
super(`hub at ${params.hubURL} signed responses with ${params.observedServerDID}; pinned to ${params.expectedServerDID}`);
|
|
19
|
+
this.name = 'HubServerDIDMismatchError';
|
|
20
|
+
this.#hubURL = params.hubURL;
|
|
21
|
+
this.#expectedServerDID = params.expectedServerDID;
|
|
22
|
+
this.#observedServerDID = params.observedServerDID;
|
|
23
|
+
}
|
|
24
|
+
get hubURL() {
|
|
25
|
+
return this.#hubURL;
|
|
26
|
+
}
|
|
27
|
+
get expectedServerDID() {
|
|
28
|
+
return this.#expectedServerDID;
|
|
29
|
+
}
|
|
30
|
+
get observedServerDID() {
|
|
31
|
+
return this.#observedServerDID;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/lib/hub/http-client.d.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
1
|
+
import { Client } from '@enkaku/client';
|
|
2
|
+
import type { Identity } from '@kokuin/token';
|
|
3
|
+
import type { HubProtocol } from '@kumiai/hub-protocol';
|
|
4
|
+
import { type ServerDIDMismatchObserver, type ServerDIDObserver } from './did-observing-transport.js';
|
|
5
|
+
export type CreateHubClientOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* Per-call observer fired once when the first signed response arrives. Set by
|
|
8
|
+
* the caller so observed server DIDs can be routed through TOFU capture with
|
|
9
|
+
* the hub URL bound. Factories that don't honour this opt simply skip TOFU
|
|
10
|
+
* capture for the spawned client.
|
|
11
|
+
*/
|
|
12
|
+
onServerDID?: ServerDIDObserver;
|
|
13
|
+
/**
|
|
14
|
+
* Pinned `server_did` resolved from the local hub row at spawn time. When set,
|
|
15
|
+
* the underlying `DIDObservingTransport` enforces it: a non-matching first
|
|
16
|
+
* response raises `HubServerDIDMismatchError` and no message is delivered to
|
|
17
|
+
* the inner Client (hard-fail path). When unset, the wrapper falls back to
|
|
18
|
+
* TOFU capture via `onServerDID`.
|
|
19
|
+
*/
|
|
20
|
+
expectedServerDID?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Side-channel notifier fired when a pinned-DID mismatch is detected. Lets the
|
|
23
|
+
* caller log + evict on top of the typed error that propagates through the
|
|
24
|
+
* read loop.
|
|
25
|
+
*/
|
|
26
|
+
onMismatch?: ServerDIDMismatchObserver;
|
|
27
|
+
};
|
|
28
|
+
export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
|
|
4
29
|
export type CreateHTTPHubClientParams = {
|
|
5
30
|
identity: Identity;
|
|
6
31
|
fetch?: typeof globalThis.fetch;
|
package/lib/hub/http-client.js
CHANGED
|
@@ -1 +1,32 @@
|
|
|
1
|
-
import{Client
|
|
1
|
+
import { Client } from '@enkaku/client';
|
|
2
|
+
import { ClientTransport } from '@enkaku/http-fetch';
|
|
3
|
+
import { DIDObservingTransport } from './did-observing-transport.js';
|
|
4
|
+
export function createHTTPHubClient(params) {
|
|
5
|
+
return async (hubURL, opts)=>{
|
|
6
|
+
const inner = new ClientTransport({
|
|
7
|
+
url: hubURL,
|
|
8
|
+
fetch: params.fetch
|
|
9
|
+
});
|
|
10
|
+
const observer = opts?.onServerDID ?? params.onServerDID;
|
|
11
|
+
const expectedServerDID = opts?.expectedServerDID;
|
|
12
|
+
const onMismatch = opts?.onMismatch;
|
|
13
|
+
// Wrap whenever any DID-aware behaviour is needed: TOFU capture (observer),
|
|
14
|
+
// pinned-DID enforcement (expectedServerDID), or mismatch side-channel.
|
|
15
|
+
const needsWrapper = observer != null || expectedServerDID != null || onMismatch != null;
|
|
16
|
+
const transport = needsWrapper ? new DIDObservingTransport({
|
|
17
|
+
inner,
|
|
18
|
+
// Wrapper requires an observer; supply a no-op when only enforcement
|
|
19
|
+
// is requested. The TOFU branch in the wrapper is gated by
|
|
20
|
+
// `expectedServerDID == null` regardless.
|
|
21
|
+
onServerDID: observer ?? (()=>{}),
|
|
22
|
+
expectedServerDID,
|
|
23
|
+
hubURL,
|
|
24
|
+
onMismatch
|
|
25
|
+
}) : inner;
|
|
26
|
+
return new Client({
|
|
27
|
+
transport,
|
|
28
|
+
identity: params.identity,
|
|
29
|
+
serverID: params.serverID
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Client } from '@enkaku/client';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import type { HubProtocol } from '@kumiai/hub-protocol';
|
|
4
|
+
import type { HubLike } from '@kumiai/hub-tunnel';
|
|
5
|
+
export type CreateHubLikeParams = {
|
|
6
|
+
/**
|
|
7
|
+
* Builds a fresh `Client<HubProtocol>` bound to one hub URL + device identity.
|
|
8
|
+
* Called once on the first connect and again on every reconnect. Injected
|
|
9
|
+
* rather than constructed here so the adapter is testable against a fake
|
|
10
|
+
* client without standing up HTTP.
|
|
11
|
+
*/
|
|
12
|
+
createClient: () => Client<HubProtocol> | Promise<Client<HubProtocol>>;
|
|
13
|
+
/** Authenticated DID of this device — drains receive and stamps publishes. */
|
|
14
|
+
localDID: string;
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
ackFlushMs?: number;
|
|
17
|
+
ackFlushMax?: number;
|
|
18
|
+
backoffBaseMs?: number;
|
|
19
|
+
backoffMaxMs?: number;
|
|
20
|
+
backoffJitter?: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function createHubLike(params: CreateHubLikeParams): HubLike & {
|
|
23
|
+
dispose: () => Promise<void>;
|
|
24
|
+
};
|