@kubun/plugin-p2p 0.10.1 → 0.12.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 +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -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 +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -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 +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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,161 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { normalizeDID, verifyToken } from '@kokuin/token';
|
|
2
|
+
import { MAX_CAP_TTL_SECONDS } from '@kubun/store-delegation';
|
|
3
|
+
/**
|
|
4
|
+
* Verify a received capability grant and, when it is addressed to this device,
|
|
5
|
+
* store it as a held row so the engine's auto-attach can reuse it on later
|
|
6
|
+
* mutations.
|
|
7
|
+
*
|
|
8
|
+
* Returns `true` when the held row was inserted or materially changed (LWW
|
|
9
|
+
* upsert with a newer hlc and at least one differing content field). Returns
|
|
10
|
+
* `false` when no row change occurred — verification failure, a subject that
|
|
11
|
+
* is not the signer, audience mismatch, missing required claims, a lifetime
|
|
12
|
+
* above `MAX_CAP_TTL_SECONDS`, LWW lost against an existing row, or an
|
|
13
|
+
* idempotent re-broadcast with identical content. Never throws on a forged or
|
|
14
|
+
* malformed token — it is skipped like an unverifiable broadcast entry.
|
|
15
|
+
*
|
|
16
|
+
* Shared by the `delegation:share` broadcast path and the invite-bootstrap
|
|
17
|
+
* path so both apply identical verify + audience + null-guard + coercion logic.
|
|
18
|
+
*/ export async function storeReceivedGrant(params) {
|
|
19
|
+
const { delegationStore, p2pStore, token, groupID, hlc, selfDID, emitter, logger } = params;
|
|
20
|
+
// Verify the token's signature before trusting any of its claims. A forged
|
|
21
|
+
// or tampered token throws here; skip it — no row stored, no throw.
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
const verified = await verifyToken(token);
|
|
25
|
+
payload = verified.payload;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
logger?.warn('received grant token verification failed, skipping', {
|
|
28
|
+
groupID,
|
|
29
|
+
error
|
|
30
|
+
});
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
// Sender-bound: `sub` is the grantor the held row is filed under, and a
|
|
34
|
+
// signature only proves `iss`. Without this any co-member could file a row
|
|
35
|
+
// attributed to another peer — including one colliding with that peer's own
|
|
36
|
+
// `jti`, which is what makes the grantor-scoped revocation lookups
|
|
37
|
+
// unanswerable.
|
|
38
|
+
//
|
|
39
|
+
// The consequence is that **sub-delegation is not supported over this path**:
|
|
40
|
+
// a re-delegated grant, whose `sub` is the original grantor and whose `iss`
|
|
41
|
+
// is the re-delegator, is dropped here. Nothing mints one today
|
|
42
|
+
// (`grantWriteCapability` signs only for `identity.id`), but `checkCapability`
|
|
43
|
+
// does verify multi-hop chains, so enforcement would accept what this refuses
|
|
44
|
+
// to store. Supporting it means carrying and verifying the parent chain at
|
|
45
|
+
// this point, not relaxing the predicate — see
|
|
46
|
+
// `docs/agents/plans/backlog/sub-delegation-over-broadcast.md`.
|
|
47
|
+
if (normalizeDID(payload.sub) !== normalizeDID(payload.iss)) {
|
|
48
|
+
logger?.warn('received grant subject is not its signer, skipping', {
|
|
49
|
+
groupID
|
|
50
|
+
});
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// Only store grants addressed to this device. Both sides are folded: a
|
|
54
|
+
// `did:peer:4` audience reaches us in long form on first contact and short
|
|
55
|
+
// form thereafter, and a raw compare would silently discard our own grant.
|
|
56
|
+
if (normalizeDID(payload.aud) !== normalizeDID(selfDID)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// `iat` joins the guard because the TTL bound below is unanswerable without
|
|
60
|
+
// it, and an unanswerable bound is the hole this check exists to close. Both
|
|
61
|
+
// producers (`grantWriteCapability`, `KubunClient.mintWriteGrant`) stamp it.
|
|
62
|
+
if (payload.jti == null || payload.exp == null || payload.iat == null) {
|
|
63
|
+
logger?.warn('received grant token missing jti/exp/iat, skipping', {
|
|
64
|
+
groupID
|
|
65
|
+
});
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// A capability accepted here can be revoked, and a co-member retains that
|
|
69
|
+
// revocation only for `MAX_CAP_TTL_SECONDS` past the revocation's own `iat` —
|
|
70
|
+
// it holds no copy of the capability to read an expiry from. Accepting a
|
|
71
|
+
// longer-lived grant would let it outlive its revocation and start being
|
|
72
|
+
// honored again. Skipped like any other unusable broadcast, not thrown: the
|
|
73
|
+
// sender is not necessarily this device's resolver.
|
|
74
|
+
if (payload.exp - payload.iat > MAX_CAP_TTL_SECONDS) {
|
|
75
|
+
logger?.warn('received grant lifetime exceeds the maximum, skipping', {
|
|
76
|
+
groupID,
|
|
77
|
+
jti: payload.jti,
|
|
78
|
+
ttl: payload.exp - payload.iat,
|
|
79
|
+
max: MAX_CAP_TTL_SECONDS
|
|
80
|
+
});
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const resource = typeof payload.res === 'string' ? payload.res : JSON.stringify(payload.res);
|
|
84
|
+
const act = typeof payload.act === 'string' ? payload.act : JSON.stringify(payload.act);
|
|
85
|
+
const changed = await delegationStore.addDelegationToken({
|
|
86
|
+
jti: payload.jti,
|
|
87
|
+
grantor: payload.sub,
|
|
88
|
+
audience: payload.aud,
|
|
89
|
+
token,
|
|
90
|
+
resource,
|
|
91
|
+
act,
|
|
92
|
+
exp: payload.exp,
|
|
93
|
+
hlc
|
|
94
|
+
});
|
|
95
|
+
// Record the group this grant belongs to on the p2p edge so the p2p layer can
|
|
96
|
+
// resolve the cap's group for revoke-broadcast targeting and listing
|
|
97
|
+
// enrichment. Keyed by `jti`; idempotent on re-broadcast.
|
|
98
|
+
await p2pStore.addGroupDelegation({
|
|
99
|
+
group_id: groupID,
|
|
100
|
+
jti: payload.jti
|
|
101
|
+
});
|
|
102
|
+
// Pending revocations are blind cross-checks: when the matching cap finally
|
|
103
|
+
// arrives we either promote the revocation to verified (issuer matches) or
|
|
104
|
+
// drop it as a forged broadcast (issuer mismatch). Settled before any
|
|
105
|
+
// `delegationTokenAdded` emission so subscribers see consistent ground
|
|
106
|
+
// truth from the revocation backend on the same tick.
|
|
107
|
+
if (changed) {
|
|
108
|
+
const pending = await delegationStore.getPendingRevocationByIssuer(payload.jti, payload.sub);
|
|
109
|
+
if (pending != null) {
|
|
110
|
+
// Pin the `verified_at` we send to subscribers to the same value the
|
|
111
|
+
// store writes; passing it explicitly avoids a millisecond-scale
|
|
112
|
+
// skew between the row and the emitted event under clock-tick
|
|
113
|
+
// boundaries.
|
|
114
|
+
const verifiedAt = Math.floor(Date.now() / 1000);
|
|
115
|
+
const flipped = await delegationStore.markRevocationVerified(payload.jti, payload.sub, {
|
|
116
|
+
cap_exp: payload.exp,
|
|
117
|
+
verified_at: verifiedAt
|
|
118
|
+
});
|
|
119
|
+
if (flipped && emitter != null) {
|
|
120
|
+
await emitter.emit('delegationTokenRevoked', {
|
|
121
|
+
jti: payload.jti,
|
|
122
|
+
grantor: payload.sub,
|
|
123
|
+
audience: payload.aud,
|
|
124
|
+
revokerDID: pending.revoker_did,
|
|
125
|
+
revokedAt: pending.revoked_iat,
|
|
126
|
+
verifiedAt,
|
|
127
|
+
capExp: payload.exp,
|
|
128
|
+
groupID
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// A `jti` names a capability only within one grantor's namespace, and it
|
|
133
|
+
// travels to the whole group — so a pending row from another author is
|
|
134
|
+
// either a forgery or a claim about a different capability. Either way this
|
|
135
|
+
// grant cannot cross-check it and it can never bind here.
|
|
136
|
+
const dropped = await delegationStore.deletePendingRevocationsFromOtherIssuers(payload.jti, payload.sub);
|
|
137
|
+
if (dropped > 0) {
|
|
138
|
+
logger?.warn('dropped pending revocations not authored by the arriving cap’s issuer', {
|
|
139
|
+
jti: payload.jti,
|
|
140
|
+
dropped,
|
|
141
|
+
capGrantor: payload.sub,
|
|
142
|
+
groupID
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Emit only when the held row was actually inserted or materially changed.
|
|
147
|
+
// Idempotent re-broadcasts (LWW lost, or won with byte-identical content)
|
|
148
|
+
// are silent — subscribers should not see duplicates for a token they
|
|
149
|
+
// already received.
|
|
150
|
+
if (changed && emitter != null) {
|
|
151
|
+
await emitter.emit('delegationTokenAdded', {
|
|
152
|
+
jwt: token,
|
|
153
|
+
jti: payload.jti,
|
|
154
|
+
grantor: payload.sub,
|
|
155
|
+
audience: payload.aud,
|
|
156
|
+
resource,
|
|
157
|
+
exp: payload.exp
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return changed;
|
|
161
|
+
}
|
|
@@ -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. */
|
|
@@ -21,26 +21,29 @@ export type StoreReceivedRevocationParams = {
|
|
|
21
21
|
* This helper does not emit; downstream subscription wiring lives elsewhere.
|
|
22
22
|
*/
|
|
23
23
|
emitter?: P2PEventEmitter;
|
|
24
|
-
/** Optional logger — debug on verification failure or malformed payload
|
|
24
|
+
/** Optional logger — debug on verification failure or malformed payload. */
|
|
25
25
|
logger?: Logger;
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
28
|
* Verify a received revocation token and write its row to the P2P store.
|
|
29
29
|
*
|
|
30
|
-
* Verified-on-arrival: when
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
30
|
+
* Verified-on-arrival: when this device already holds a capability the
|
|
31
|
+
* record's own `iss` granted under that `jti`, the row is stored with
|
|
32
|
+
* `verified_at` populated so the hot-path `isRevoked` gate fires
|
|
33
|
+
* immediately. Otherwise the row is stored as pending (`verified_at` null)
|
|
34
|
+
* until a later `delegation:share` from that same issuer triggers a
|
|
35
|
+
* cross-check that flips it via `markRevocationVerified`.
|
|
36
|
+
*
|
|
37
|
+
* Holding someone else's capability under the same `jti` is not evidence
|
|
38
|
+
* that this record is forged — a `jti` is namespaced by its grantor and any
|
|
39
|
+
* co-member can reuse one — so such a record is stored pending like any
|
|
40
|
+
* other unmatched one. It binds nothing: every enforcement read is scoped to
|
|
41
|
+
* the revoker, and `storeReceivedGrant` drops it when the issuer's own
|
|
42
|
+
* capability arrives.
|
|
39
43
|
*
|
|
40
44
|
* Returns `true` when the row was inserted or materially changed (LWW
|
|
41
45
|
* upsert). Returns `false` on verification failure, non-revocation
|
|
42
|
-
* payload,
|
|
43
|
-
*
|
|
44
|
-
* unverifiable broadcast entry.
|
|
46
|
+
* payload, or a lost/idempotent LWW upsert. Never throws on a forged or
|
|
47
|
+
* malformed token — it is skipped like an unverifiable broadcast entry.
|
|
45
48
|
*/
|
|
46
49
|
export declare function storeReceivedRevocation(params: StoreReceivedRevocationParams): Promise<boolean>;
|
|
@@ -1 +1,83 @@
|
|
|
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 this device already holds a capability the
|
|
6
|
+
* record's own `iss` granted under that `jti`, the row is stored with
|
|
7
|
+
* `verified_at` populated so the hot-path `isRevoked` gate fires
|
|
8
|
+
* immediately. Otherwise the row is stored as pending (`verified_at` null)
|
|
9
|
+
* until a later `delegation:share` from that same issuer triggers a
|
|
10
|
+
* cross-check that flips it via `markRevocationVerified`.
|
|
11
|
+
*
|
|
12
|
+
* Holding someone else's capability under the same `jti` is not evidence
|
|
13
|
+
* that this record is forged — a `jti` is namespaced by its grantor and any
|
|
14
|
+
* co-member can reuse one — so such a record is stored pending like any
|
|
15
|
+
* other unmatched one. It binds nothing: every enforcement read is scoped to
|
|
16
|
+
* the revoker, and `storeReceivedGrant` drops it when the issuer's own
|
|
17
|
+
* capability arrives.
|
|
18
|
+
*
|
|
19
|
+
* Returns `true` when the row was inserted or materially changed (LWW
|
|
20
|
+
* upsert). Returns `false` on verification failure, non-revocation
|
|
21
|
+
* payload, or a lost/idempotent LWW upsert. Never throws on a forged or
|
|
22
|
+
* malformed token — it is skipped like an unverifiable broadcast entry.
|
|
23
|
+
*/ export async function storeReceivedRevocation(params) {
|
|
24
|
+
const { delegationStore, token, groupID, hlc, emitter, logger } = params;
|
|
25
|
+
let payload;
|
|
26
|
+
try {
|
|
27
|
+
const verified = await verifyToken(token);
|
|
28
|
+
payload = verified.payload;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
logger?.debug('received revocation token verification failed, skipping', {
|
|
31
|
+
groupID,
|
|
32
|
+
error
|
|
33
|
+
});
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (payload.rev !== true) {
|
|
37
|
+
logger?.debug('received revocation token is not a revocation record, skipping', {
|
|
38
|
+
groupID
|
|
39
|
+
});
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const { jti, iss, iat } = payload;
|
|
43
|
+
// Scoped to the record's own issuer. A co-member can mint a capability
|
|
44
|
+
// reusing a `jti` it saw broadcast, so asking for "the" capability named by a
|
|
45
|
+
// `jti` returns an arbitrary grantor's row — and the grantor's genuine
|
|
46
|
+
// revocation is the one with something to lose when the answer is wrong.
|
|
47
|
+
const cap = await delegationStore.getDelegationTokenByGrantorJTI({
|
|
48
|
+
jti,
|
|
49
|
+
grantor: iss
|
|
50
|
+
});
|
|
51
|
+
let verifiedAt = null;
|
|
52
|
+
let capExp = null;
|
|
53
|
+
if (cap != null) {
|
|
54
|
+
verifiedAt = Math.floor(Date.now() / 1000);
|
|
55
|
+
capExp = cap.exp;
|
|
56
|
+
}
|
|
57
|
+
const changed = await delegationStore.addRevocation({
|
|
58
|
+
jti,
|
|
59
|
+
revoker_did: iss,
|
|
60
|
+
revoked_iat: iat,
|
|
61
|
+
revocation_token: token,
|
|
62
|
+
verified_at: verifiedAt,
|
|
63
|
+
cap_exp: capExp,
|
|
64
|
+
hlc
|
|
65
|
+
});
|
|
66
|
+
// Only verified-on-arrival writes are binding to local subscribers — a
|
|
67
|
+
// pending row carries no enforcement weight until the deferred cross-check
|
|
68
|
+
// promotes it. The cross-check path (in `storeReceivedGrant`) emits there
|
|
69
|
+
// when it flips a row from pending to verified.
|
|
70
|
+
if (changed && verifiedAt != null && cap != null && emitter != null) {
|
|
71
|
+
await emitter.emit('delegationTokenRevoked', {
|
|
72
|
+
jti,
|
|
73
|
+
grantor: cap.grantor,
|
|
74
|
+
audience: cap.audience,
|
|
75
|
+
revokerDID: iss,
|
|
76
|
+
revokedAt: iat,
|
|
77
|
+
verifiedAt,
|
|
78
|
+
capExp,
|
|
79
|
+
groupID
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return changed;
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/hub/http-client.d.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
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
|
+
export type CreateHubClientOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* The hub's DID, resolved per (re)connect by the spawning caller — from the
|
|
7
|
+
* local hub row when it records one, otherwise from the hub's `/info`. Every
|
|
8
|
+
* token this client signs names it as the audience, and a hub always refuses a
|
|
9
|
+
* token that names nobody. Absent only when discovery failed: the connection
|
|
10
|
+
* is then attempted anyway and refused by the hub, which is what triggers the
|
|
11
|
+
* next discovery.
|
|
12
|
+
*/
|
|
13
|
+
serverID?: string;
|
|
14
|
+
};
|
|
15
|
+
export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
|
|
4
16
|
export type CreateHTTPHubClientParams = {
|
|
5
17
|
identity: Identity;
|
|
6
18
|
fetch?: typeof globalThis.fetch;
|
|
7
|
-
serverID?: string;
|
|
8
19
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* re-fire this hook, even if `iss` differs. Used by `HubConnectionPool` to
|
|
12
|
-
* drive TOFU pinning into the hub manager. Per-call `onServerDID` from
|
|
13
|
-
* `CreateHubClientOptions` overrides this factory-level default.
|
|
20
|
+
* Fallback audience for every client this factory builds, for a caller that
|
|
21
|
+
* knows the hub's DID up front. Per-call `serverID` wins when set.
|
|
14
22
|
*/
|
|
15
|
-
|
|
23
|
+
serverID?: string;
|
|
16
24
|
};
|
|
17
25
|
export declare function createHTTPHubClient(params: CreateHTTPHubClientParams): CreateHubClient;
|
package/lib/hub/http-client.js
CHANGED
|
@@ -1 +1,15 @@
|
|
|
1
|
-
import{Client
|
|
1
|
+
import { Client } from '@enkaku/client';
|
|
2
|
+
import { ClientTransport } from '@enkaku/http-fetch';
|
|
3
|
+
export function createHTTPHubClient(params) {
|
|
4
|
+
return async (hubURL, opts)=>{
|
|
5
|
+
const transport = new ClientTransport({
|
|
6
|
+
url: hubURL,
|
|
7
|
+
fetch: params.fetch
|
|
8
|
+
});
|
|
9
|
+
return new Client({
|
|
10
|
+
transport,
|
|
11
|
+
identity: params.identity,
|
|
12
|
+
serverID: opts?.serverID ?? params.serverID
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 { HubReceiveSubscription, LogHub, MailboxHub } 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. The signal is the adapter's own: a factory
|
|
11
|
+
* that reaches the network (hub discovery) must abandon it on dispose, or
|
|
12
|
+
* teardown waits out a request to a hub that is not answering.
|
|
13
|
+
*/
|
|
14
|
+
createClient: (signal: AbortSignal) => Client<HubProtocol> | Promise<Client<HubProtocol>>;
|
|
15
|
+
/** Authenticated DID of this device — drains receive and stamps publishes. */
|
|
16
|
+
localDID: string;
|
|
17
|
+
/**
|
|
18
|
+
* Called when the hub refused a request with `ACCESS_DENIED`, awaited before
|
|
19
|
+
* the reconnect backoff so anything it corrects is in place for the next dial.
|
|
20
|
+
* That code conflates a refused audience with a refused caller, so which one it
|
|
21
|
+
* was is the handler's to work out, not the adapter's.
|
|
22
|
+
*/
|
|
23
|
+
onAccessDenied?: () => Promise<void>;
|
|
24
|
+
logger?: Logger;
|
|
25
|
+
ackFlushMs?: number;
|
|
26
|
+
ackFlushMax?: number;
|
|
27
|
+
backoffBaseMs?: number;
|
|
28
|
+
backoffMaxMs?: number;
|
|
29
|
+
backoffJitter?: number;
|
|
30
|
+
};
|
|
31
|
+
/** What a subscriber may narrow its own drain to, beyond its DID. */
|
|
32
|
+
export type DeviceHubReceiveOptions = {
|
|
33
|
+
/**
|
|
34
|
+
* Deliver only frames the hub attested as sent by this DID.
|
|
35
|
+
*
|
|
36
|
+
* The narrowing happens on the fan-out, before anything is queued and long
|
|
37
|
+
* before anything is decrypted, which is what a directed session needs: MLS
|
|
38
|
+
* consumes a per-sender ratchet generation on open, so a session that opens a
|
|
39
|
+
* frame belonging to another has DESTROYED it — the session it was for waits
|
|
40
|
+
* out its idle timeout with nothing raised anywhere.
|
|
41
|
+
*/
|
|
42
|
+
senderDID?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Deliver only frames published to this topic.
|
|
45
|
+
*
|
|
46
|
+
* The tunnel transport already asks for this (`hub.receive(localDID, {
|
|
47
|
+
* topicID: receiveTopicID })`), and until it was honoured here the request
|
|
48
|
+
* went nowhere. It has to be applied on the fan-out for the same reason
|
|
49
|
+
* `senderDID` does: the encrypting transport decrypts a frame before the
|
|
50
|
+
* topic demux one layer in ever sees it, so a sink handed another lane's
|
|
51
|
+
* frame has consumed a ratchet generation and destroyed it.
|
|
52
|
+
*/
|
|
53
|
+
topicID?: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The device hub as this package uses it: a `MailboxHub` whose `receive` also
|
|
57
|
+
* takes {@link DeviceHubReceiveOptions}. Declared because the tunnel transport
|
|
58
|
+
* takes the narrower `MailboxHub`, so a caller that needs a filtered drain has
|
|
59
|
+
* to hold this type to ask for one.
|
|
60
|
+
*/
|
|
61
|
+
export type DeviceHub = MailboxHub & {
|
|
62
|
+
receive: (subscriberDID: string, options?: DeviceHubReceiveOptions) => HubReceiveSubscription;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The reconnecting adapter itself, as its owner holds it: a hub, plus the two
|
|
66
|
+
* things only whoever built the connection can do — retry it now, and close it.
|
|
67
|
+
* Kept off {@link DeviceHub} because that is the surface a tunnel session
|
|
68
|
+
* consumes, and a session has no connection of its own to retry.
|
|
69
|
+
*/
|
|
70
|
+
export type ReconnectingDeviceHub = LogHub & DeviceHub & {
|
|
71
|
+
/** Try the hub now rather than when the backoff says so. */
|
|
72
|
+
retryNow: () => Promise<boolean>;
|
|
73
|
+
dispose: () => Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
export declare function createHubLike(params: CreateHubLikeParams): ReconnectingDeviceHub;
|