@bitsocial/pubsub-voting 0.6.0 → 0.6.1

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/README.md CHANGED
@@ -42,7 +42,7 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
42
42
  | `chains` | `ChainClientFactory` | yes | resolves the chain a contest counts in (`{ chainId }`, from `criteria.bucketChainId`) to a viem `PublicClient`; every gate rule and the weight rule read through it. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
43
43
  | `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
44
44
  | `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
45
- | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
45
+ | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one signed `PUT /routing/v1/providers` per router — IPIP-0526: the record carries a `Signature` made by the node's own libp2p key and a fresh `Payload.Timestamp`, both required by [pkc-http-router](https://github.com/pkcprotocol/pkc-http-router), the router implementation this library targets, which rejects the whole PUT with 403 otherwise; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
46
46
 
47
47
  A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names its chain only by `bucketChainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
48
48
 
@@ -85,7 +85,7 @@ const voter = new PubsubVoter({
85
85
  });
86
86
  ```
87
87
 
88
- Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingFetchError` if the node lacks a usable pubsub service, blockstore, or libp2p fetch service — the library fails fast rather than letting a later `publish`/`subscribe`/`fetch` fail obscurely. ("Bitswap" is not a separately checkable property — it is a block broker wired beneath `blockstore` — so the validated guarantee is a well-formed blockstore, the surface bitswap retrieves through. The fetch service carries the checkpoint root-record pull; the library registers its own responder on it.)
88
+ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingFetchError` if the node lacks a usable pubsub service, blockstore, or libp2p fetch service (and `MissingPrivateKeyError` if `httpRouterUrls` is set but the node exposes no signing key, since provider records must be signed) — the library fails fast rather than letting a later `publish`/`subscribe`/`fetch` fail obscurely. ("Bitswap" is not a separately checkable property — it is a block broker wired beneath `blockstore` — so the validated guarantee is a well-formed blockstore, the surface bitswap retrieves through. The fetch service carries the checkpoint root-record pull; the library registers its own responder on it.)
89
89
 
90
90
  ### Read a tally reactively
91
91
 
package/dist/errors.d.ts CHANGED
@@ -68,6 +68,17 @@ export declare class MissingBlockstoreError extends Error {
68
68
  export declare class MissingFetchError extends Error {
69
69
  constructor();
70
70
  }
71
+ /**
72
+ * Thrown at construction when `PubsubVoterOptions.httpRouterUrls` is set but the injected Helia
73
+ * node's libp2p exposes no signing key (neither `libp2p.privateKey` nor `libp2p.components.privateKey`).
74
+ * Delegated-routing providers are announced as IPIP-0526 **signed** records — the production router
75
+ * verifies by default and rejects the whole PUT otherwise — so a node that cannot sign cannot
76
+ * announce, and announcing unsigned would silently leave the seeder undiscoverable on every
77
+ * verifying router. See DESIGN.md "Provider-record announces".
78
+ */
79
+ export declare class MissingPrivateKeyError extends Error {
80
+ constructor();
81
+ }
71
82
  /**
72
83
  * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
73
84
  * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
package/dist/errors.js CHANGED
@@ -103,6 +103,25 @@ export class MissingFetchError extends Error {
103
103
  this.name = "MissingFetchError";
104
104
  }
105
105
  }
106
+ /**
107
+ * Thrown at construction when `PubsubVoterOptions.httpRouterUrls` is set but the injected Helia
108
+ * node's libp2p exposes no signing key (neither `libp2p.privateKey` nor `libp2p.components.privateKey`).
109
+ * Delegated-routing providers are announced as IPIP-0526 **signed** records — the production router
110
+ * verifies by default and rejects the whole PUT otherwise — so a node that cannot sign cannot
111
+ * announce, and announcing unsigned would silently leave the seeder undiscoverable on every
112
+ * verifying router. See DESIGN.md "Provider-record announces".
113
+ */
114
+ export class MissingPrivateKeyError extends Error {
115
+ constructor() {
116
+ super("The injected Helia node's libp2p exposes no private key (`libp2p.privateKey` / " +
117
+ "`libp2p.components.privateKey`), so provider records cannot be signed. Delegated " +
118
+ "Routing V1 routers verify the IPIP-0526 signature and reject unsigned records, so " +
119
+ "`httpRouterUrls` needs a node that can sign for its own peer id. Pass a real libp2p " +
120
+ "node, or drop `httpRouterUrls` to stop announcing. See DESIGN.md " +
121
+ "\"Provider-record announces\".");
122
+ this.name = "MissingPrivateKeyError";
123
+ }
124
+ }
106
125
  /**
107
126
  * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
108
127
  * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
@@ -1,13 +1,15 @@
1
1
  import type { Announcer, AnnouncerOptions } from "./types.js";
2
2
  /**
3
3
  * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
4
- * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
4
+ * "Deferred pkc-js work", provider-record announces): one **signed** `PUT /routing/v1/providers`
5
5
  * per configured router per tick, kubo's body shape —
6
- * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
7
- * across ALL joined contests. Unsigned is correct against the production router
8
- * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
9
- * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
10
- * source IP which a seeder announcing its own addresses passes naturally.
6
+ * `{ Providers: [{ Schema: "peer", Signature, Payload: { ID, Addrs, Keys, Timestamp } }] }` —
7
+ * with `Keys` batched across ALL joined contests. The signature is IPIP-0526, built by
8
+ * {@link signedProvidersBody} over the exact payload bytes put on the wire; the production router
9
+ * verifies by default and answers 403 for the whole request otherwise, so an unsigned record made
10
+ * the announcing node absent from every verifying router (issue #38). Its address anti-spoofing
11
+ * keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's source IP — which a seeder
12
+ * announcing its own addresses passes naturally.
11
13
  *
12
14
  * Addresses: the announceable set is `getMultiaddrs()` filtered to public/DNS addrs plus
13
15
  * exactly-unspecified addrs (`0.0.0.0`/`::`), which the production router rewrites to the PUT's
@@ -1,12 +1,15 @@
1
+ import { requireAnnounceSigner, signedProvidersBody } from "./record.js";
1
2
  /**
2
3
  * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
3
- * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
4
+ * "Deferred pkc-js work", provider-record announces): one **signed** `PUT /routing/v1/providers`
4
5
  * per configured router per tick, kubo's body shape —
5
- * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
6
- * across ALL joined contests. Unsigned is correct against the production router
7
- * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
8
- * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
9
- * source IP which a seeder announcing its own addresses passes naturally.
6
+ * `{ Providers: [{ Schema: "peer", Signature, Payload: { ID, Addrs, Keys, Timestamp } }] }` —
7
+ * with `Keys` batched across ALL joined contests. The signature is IPIP-0526, built by
8
+ * {@link signedProvidersBody} over the exact payload bytes put on the wire; the production router
9
+ * verifies by default and answers 403 for the whole request otherwise, so an unsigned record made
10
+ * the announcing node absent from every verifying router (issue #38). Its address anti-spoofing
11
+ * keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's source IP — which a seeder
12
+ * announcing its own addresses passes naturally.
10
13
  *
11
14
  * Addresses: the announceable set is `getMultiaddrs()` filtered to public/DNS addrs plus
12
15
  * exactly-unspecified addrs (`0.0.0.0`/`::`), which the production router rewrites to the PUT's
@@ -113,7 +116,7 @@ export function sentinelAddrs(addrs) {
113
116
  }
114
117
  return [...sentinels];
115
118
  }
116
- /** One unsigned kubo-shape provider PUT; throws on timeout or a non-2xx answer. */
119
+ /** One signed kubo-shape provider PUT; throws on timeout or a non-2xx answer. */
117
120
  async function putProviders(baseUrl, body, timeoutMs) {
118
121
  const endpoint = `${baseUrl.replace(/\/+$/, "")}/routing/v1/providers`;
119
122
  const res = await fetch(endpoint, {
@@ -132,6 +135,10 @@ export function makeAnnouncer(options) {
132
135
  const intervalMs = options.intervalMs ?? ANNOUNCE_INTERVAL_MS;
133
136
  const debounceMs = options.debounceMs ?? ANNOUNCE_DEBOUNCE_MS;
134
137
  const timeoutMs = options.timeoutMs ?? ANNOUNCE_ROUTER_TIMEOUT_MS;
138
+ // Resolved once, at construction: a node that cannot sign for its own peer id can only
139
+ // produce records the routers reject, so that is a config error to raise now, not an
140
+ // announce to make hourly and have refused (see MissingPrivateKeyError).
141
+ const signer = requireAnnounceSigner(options.libp2p);
135
142
  let started = false;
136
143
  let intervalTimer;
137
144
  let debounceTimer;
@@ -159,9 +166,19 @@ export function makeAnnouncer(options) {
159
166
  // announce nothing — the production router drops addr-less providers anyway.
160
167
  if (keys.length === 0 || addrs.length === 0)
161
168
  continue;
162
- const body = JSON.stringify({
163
- Providers: [{ Schema: "peer", Payload: { ID: options.libp2p.peerId.toString(), Addrs: addrs, Keys: keys } }]
164
- });
169
+ let body;
170
+ try {
171
+ // Fresh clock reading per tick: the router bounds a record's staleness and
172
+ // skew, so a cached timestamp ages into a rejection (see record.ts).
173
+ body = await signedProvidersBody({ peerId: options.libp2p.peerId.toString(), addrs, keys, timestamp: Date.now() }, signer);
174
+ }
175
+ catch (error) {
176
+ // A key that cannot sign fails every router identically; report it on each
177
+ // (the announcer never throws into the voter) and let the next tick retry.
178
+ for (const url of options.routerUrls)
179
+ options.onError?.(url, error);
180
+ continue;
181
+ }
165
182
  await Promise.all(options.routerUrls.map(async (url) => {
166
183
  try {
167
184
  await putProviders(url, body, timeoutMs);
@@ -0,0 +1,57 @@
1
+ import type { AnnounceSigner, AnnouncerLibp2p } from "./types.js";
2
+ /**
3
+ * The signed provider record the announcer PUTs (IPIP-0526, as implemented by the production
4
+ * router — pkc-http-router `lib/signature.ts`). Verification is ON by default there, so an
5
+ * unsigned record is a 403 for the WHOLE request: the announcing node is simply absent from
6
+ * every verifying router (issue #38 — four of six default routers rejected every announce).
7
+ *
8
+ * Two things make a record verifiable:
9
+ *
10
+ * - **`Signature`** — multibase (base64, the `m` prefix) over the sha256 digest of the raw
11
+ * `Payload` bytes, made by the private key the announcing peer id was derived from. libp2p's
12
+ * `PrivateKey.sign` is what the router's key types line up with: an ed25519 key signs those 32
13
+ * digest bytes directly (`crypto.verify(null, digest, key, sig)`), a secp256k1 key hashes them
14
+ * again through ECDSA (`crypto.verify("sha256", digest, key, sig)`) — both are exactly
15
+ * `sign(digest)` on this side. The public key is recovered from `Payload.ID` itself (an identity
16
+ * multihash), so nothing else has to travel.
17
+ * - **`Payload.Timestamp`** — epoch milliseconds, read fresh per announce: the router rejects a
18
+ * missing one, and bounds it against replay (24 h stale / 1 h future). Never cache it.
19
+ *
20
+ * **The bytes signed must be the bytes sent.** The router verifies against the `Payload` byte
21
+ * range it locates in the raw request body (`extractRawPayloads`), not against a re-serialized
22
+ * parse of it — so serializing the payload to sign it and then handing the enclosing object to
23
+ * `JSON.stringify` a second time is a real (and observed) failure mode: any difference in key
24
+ * order, spacing, or number formatting reads as `invalid_signature`. Hence this builder
25
+ * serializes the payload ONCE and splices that exact string into the body it returns; the body is
26
+ * a string all the way to `fetch`, and no object is ever re-serialized.
27
+ */
28
+ /** What the announcer signs with: libp2p's `PrivateKey` surface, narrowed to `sign`. */
29
+ export type { AnnounceSigner };
30
+ /**
31
+ * Find the injected node's signing key. libp2p keeps it on the `components` registry of the
32
+ * running node (it is deliberately absent from the public `Libp2p` interface, which exposes only
33
+ * the derived `peerId`); `privateKey` is checked first so a host that surfaces it directly — or a
34
+ * test double — is honoured without reaching into internals. Structural, like every other host
35
+ * probe in `transport/`: the injected node is `unknown` shaped, never trusted by type.
36
+ *
37
+ * Throws {@link MissingPrivateKeyError} when no key is reachable, at construction, rather than
38
+ * announcing records every verifying router will reject.
39
+ */
40
+ export declare function requireAnnounceSigner(libp2p: AnnouncerLibp2p): AnnounceSigner;
41
+ /** What one announce says: who, where, which CIDs, and when it was said. */
42
+ export interface AnnounceRecord {
43
+ /** The announcing node's peer id — also where the router recovers the verifying key from. */
44
+ peerId: string;
45
+ /** Already filtered/synthesized by the caller (see `announceableAddrs` / `sentinelAddrs`). */
46
+ addrs: readonly string[];
47
+ /** Every joined contest's criteria CID + checkpoint root + chunk CIDs, batched. */
48
+ keys: readonly string[];
49
+ /** Epoch ms, read fresh per announce (the router bounds staleness and skew). */
50
+ timestamp: number;
51
+ }
52
+ /**
53
+ * Build the signed `PUT /routing/v1/providers` body, as the exact string to send. Returns a
54
+ * string — not an object — on purpose: the signature covers the `Payload` substring inside it,
55
+ * and re-serializing anything here would break it (see the module comment).
56
+ */
57
+ export declare function signedProvidersBody(record: AnnounceRecord, signer: AnnounceSigner): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { base64 } from "multiformats/bases/base64";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import { MissingPrivateKeyError } from "../../errors.js";
4
+ /**
5
+ * Find the injected node's signing key. libp2p keeps it on the `components` registry of the
6
+ * running node (it is deliberately absent from the public `Libp2p` interface, which exposes only
7
+ * the derived `peerId`); `privateKey` is checked first so a host that surfaces it directly — or a
8
+ * test double — is honoured without reaching into internals. Structural, like every other host
9
+ * probe in `transport/`: the injected node is `unknown` shaped, never trusted by type.
10
+ *
11
+ * Throws {@link MissingPrivateKeyError} when no key is reachable, at construction, rather than
12
+ * announcing records every verifying router will reject.
13
+ */
14
+ export function requireAnnounceSigner(libp2p) {
15
+ const direct = libp2p.privateKey;
16
+ if (isSigner(direct))
17
+ return direct;
18
+ const component = libp2p.components?.privateKey;
19
+ if (isSigner(component))
20
+ return component;
21
+ throw new MissingPrivateKeyError();
22
+ }
23
+ function isSigner(value) {
24
+ return value !== null && typeof value === "object" && typeof value.sign === "function";
25
+ }
26
+ /**
27
+ * Build the signed `PUT /routing/v1/providers` body, as the exact string to send. Returns a
28
+ * string — not an object — on purpose: the signature covers the `Payload` substring inside it,
29
+ * and re-serializing anything here would break it (see the module comment).
30
+ */
31
+ export async function signedProvidersBody(record, signer) {
32
+ const payloadJson = JSON.stringify({
33
+ ID: record.peerId,
34
+ Addrs: record.addrs,
35
+ Keys: record.keys,
36
+ Timestamp: record.timestamp
37
+ });
38
+ const digest = (await sha256.digest(new TextEncoder().encode(payloadJson))).digest;
39
+ const signature = base64.encode(await signer.sign(digest));
40
+ return `{"Providers":[{"Schema":"peer","Signature":${JSON.stringify(signature)},"Payload":${payloadJson}}]}`;
41
+ }
@@ -26,9 +26,28 @@ export interface AnnouncerLibp2p {
26
26
  getMultiaddrs(): Array<{
27
27
  toString(): string;
28
28
  }>;
29
+ /**
30
+ * The node's own signing key, if it is surfaced directly. libp2p's public `Libp2p` interface
31
+ * exposes only the derived `peerId`, so this is normally absent and the key is read from
32
+ * {@link AnnouncerLibp2p.components} instead — both are optional because the announcer probes
33
+ * the injected node structurally (see `requireAnnounceSigner`).
34
+ */
35
+ privateKey?: unknown;
36
+ /** libp2p's component registry on the running node — where `privateKey` actually lives. */
37
+ components?: {
38
+ privateKey?: unknown;
39
+ };
29
40
  addEventListener(type: "self:peer:update", listener: () => void): void;
30
41
  removeEventListener(type: "self:peer:update", listener: () => void): void;
31
42
  }
43
+ /**
44
+ * The signing surface a provider record needs: libp2p's `PrivateKey.sign`, narrowed to the one
45
+ * call the announcer makes. The announcer hands it the sha256 digest of the payload bytes and
46
+ * multibase-encodes what comes back — see `record.ts` for why that is what the router verifies.
47
+ */
48
+ export interface AnnounceSigner {
49
+ sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
50
+ }
32
51
  export interface AnnouncerOptions {
33
52
  /** Delegated Routing V1 base URLs to PUT provider records to. Empty means never announce. */
34
53
  routerUrls: readonly string[];
@@ -47,8 +47,13 @@ export interface GossipGateDeps {
47
47
  cid: CID;
48
48
  bundle: VotesBundle;
49
49
  }>;
50
- /** The full validity pipeline for one bundle (see verify/bundle.ts). */
51
- verifier: BundleVerifier;
50
+ /**
51
+ * The full validity pipeline for one bundle (see verify/bundle.ts). Declared as the `verify`
52
+ * half of {@link BundleVerifier}: the gate never runs a stage on its own (no `verifyOffline`,
53
+ * no `checkGates` — those belong to the cold-join chase and `Contest.checkEligibility`), and
54
+ * asking only for what it calls keeps that true.
55
+ */
56
+ verifier: Pick<BundleVerifier, "verify">;
52
57
  /**
53
58
  * Clock-aware freshness guard, kept OUT of the pure (cacheable) verifier: is this bundle's
54
59
  * bucket sample block already reachable from our chain head? A bundle dated to a future
@@ -44,5 +44,8 @@ export interface CachingBundleVerifier {
44
44
  * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
45
45
  * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
46
46
  * cannot pin it.
47
+ *
48
+ * Takes the `verify` half of a {@link BundleVerifier}: the cache memoizes whole-pipeline verdicts
49
+ * by CID and has nothing to say about the offline stage or a gate check, so it asks for neither.
47
50
  */
48
- export declare function makeCachingVerifier(verifier: BundleVerifier, cache: VerdictCache): CachingBundleVerifier;
51
+ export declare function makeCachingVerifier(verifier: Pick<BundleVerifier, "verify">, cache: VerdictCache): CachingBundleVerifier;
@@ -46,6 +46,9 @@ export function makeVerdictCache(maxEntries = 4096) {
46
46
  * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
47
47
  * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
48
48
  * cannot pin it.
49
+ *
50
+ * Takes the `verify` half of a {@link BundleVerifier}: the cache memoizes whole-pipeline verdicts
51
+ * by CID and has nothing to say about the offline stage or a gate check, so it asks for neither.
49
52
  */
50
53
  export function makeCachingVerifier(verifier, cache) {
51
54
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -25,6 +25,7 @@
25
25
  "build": "tsc -p tsconfig.json",
26
26
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
27
  "typecheck:examples": "tsc -p tsconfig.examples.json --noEmit",
28
+ "typecheck:tests": "tsc -p tsconfig.test.json",
28
29
  "typecheck:bench": "npm run build && tsc -p benchmark/tsconfig.json --noEmit",
29
30
  "test": "vitest run",
30
31
  "test:coverage": "vitest run --coverage",