Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ approvedGitRepositories:
enableScripts: true

nodeLinker: node-modules

npmMinimalAgeGate: 0
Comment on lines +7 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Yarn npmMinimalAgeGate configuration default value

💡 Result:

The current default value for the Yarn npmMinimalAgeGate configuration is 1d (one day) [1][2]. This setting, introduced in Yarn 4.12, restricts the installation of packages to those published to the npm registry at least the specified duration ago, serving as a security measure to mitigate the risk of installing newly published, potentially malicious packages [1][2]. While the default value has undergone adjustments—including a period where it was set to 3 days (4320m) [3]—it is currently established as 1d [1][2]. Users can override this behavior by setting npmMinimalAgeGate to 0 in their .yarnrc.yml file, or by using the --no-time-gate flag during specific installation commands [1][3][2]. Additionally, the npmPreapprovedPackages setting allows for specific exceptions to this rule [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files matching .yarnrc.yml or yarnrc:"
fd -a '(^|/)yarnrc(\.yml|\.yaml)?$' . | sed 's#^\./##' || true

if [ -f .yarnrc.yml ]; then
  echo
  echo ".yarnrc.yml excerpt:"
  nl -ba .yarnrc.yml | sed -n '1,40p'
fi

echo
echo "Package manager declarations:"
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log(JSON.stringify({packageManager:p.packageManager,engines:p.engines},null,2))"
fi

echo
echo "Search for npmMinimalAgeGate / no-time-gate:"
rg -n "npmMinimalAgeGate|no-time-gate|time-gate|Minimal.*Age|AgeGate" . || true

Repository: bartholomej/node-csfd-api

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository files matching yarnrc:"
find . -type f \( -name '.yarnrc.yml' -o -name '.yarnrc.yaml' -o -name 'yarnrc.yml' -o -name 'yarnrc.yaml' \) -print

if [ -f .yarnrc.yml ]; then
  echo
  echo ".yarnrc.yml excerpt:"
  i=1
  while IFS= read -r line || [ -n "$line" ]; do
    printf '%5d  %s\n' "$i" "$line"
    [ "$i" -ge 80 ] && break
    i=$((i+1))
  done < .yarnrc.yml
fi

echo
echo "Package manager declarations:"
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log(JSON.stringify({packageManager:p.packageManager,engines:p.engines},null,2))"
fi

echo
echo "Search for npmMinimalAgeGate / no-time-gate:"
grep -RInE 'npmMinimalAgeGate|no-time-gate|time-gate|Minimal.*Age|AgeGate' . || true

Repository: bartholomej/node-csfd-api

Length of output: 584


Keep npmMinimalAgeGate above 0.

This project pins Yarn 4.18.0, where the default npm age-gate delay is active. Setting npmMinimalAgeGate: 0 removes that protection for every install path. Use the default or set a non-zero value with a short comment explaining the rationale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.yarnrc.yml around lines 7 - 8, Update the npmMinimalAgeGate setting in
.yarnrc.yml to use the default or another non-zero delay, and add a brief
comment explaining the rationale if configuring it explicitly; do not leave it
set to 0.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,5 @@
"node-csfd-api": "./dist/cli.js"
},
"sideEffects": false,
"packageManager": "yarn@4.14.1"
"packageManager": "yarn@4.18.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the published Yarn version and check CI/workflow pins.
curl -s https://registry.npmjs.org/@yarnpkg/cli-dist | jq -r '.versions | keys | map(select(startswith("4."))) | .[-8:]'
fd -H -t f -e yml -e yaml . .github --exec sh -c 'echo "== $1"; rg -n "yarn|corepack|node-version" "$1" || true' _ {}
rg -n '"__metadata"|version:' yarn.lock | head -20

Repository: bartholomej/node-csfd-api

Length of output: 2033


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packageManager =="
python3 - <<'PY'
import json
from pathlib import Path
p = json.loads(Path("package.json").read_text())
print(p.get("packageManager"))
PY

echo "== yarn.lock metadata/header candidates =="
rg -n '^[#] yarn.lock|^__metadata|^[^#].*:|  yarn_version:' yarn.lock | head -40

echo "== lock first 30 lines =="
sed -n '1,35p' yarn.lock

Repository: bartholomej/node-csfd-api

Length of output: 1926


🌐 Web query:

Yarn 4.18.0 @yarnpkg/cli-dist npm versions

💡 Result:

As of August 3, 2026, the version 4.18.0 of @yarnpkg/cli was published on GitHub on July 29, 2026 [1][2]. However, the corresponding npm package @yarnpkg/cli-dist does not currently list 4.18.0 as an available version [3][4]. The latest version published to the @yarnpkg/cli-dist npm registry is 4.17.0 [3][4]. While GitHub repositories and npm distribution packages for Yarn Berry (v4+) are linked, there can sometimes be a delay or specific build process requirements for the @yarnpkg/cli-dist package to be updated on npm after a release is tagged on GitHub [1]. Users are advised to check the official npm registry page for updates [3].

Citations:


Set packageManager to a published @yarnpkg/cli-dist version.

yarn@4.18.0 is not listed on npm, so Corepack will fail to enable this version during yarn install --immutable. Pin packageManager to an available @yarnpkg/cli-dist release or publish/update the artifact before use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 129, Update the packageManager entry to reference a
published `@yarnpkg/cli-dist` release instead of the unavailable yarn@4.18.0
value, ensuring Corepack can enable it during immutable installs.

}
276 changes: 276 additions & 0 deletions src/anubis/challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
import { DEFAULT_TIME_BUDGET_MS, solveProofOfWork } from './proof-of-work';

// Anubis (BotStopper by Techaro) is a proof-of-work anti-bot interstitial:
// instead of the page it serves an HTML challenge that a browser solves in
// JavaScript. This module replicates the protocol so a plain `fetch` can earn
// the auth cookie. See: https://github.com/TecharoHQ/anubis

const AUTH_COOKIE_NAME = 'techaro.lol-anubis-auth';
const VERIFY_COOKIE_NAME = 'techaro.lol-anubis-cookie-verification';
const PASS_CHALLENGE_PATH = '/.within.website/x/cmd/anubis/api/pass-challenge';

// Anubis picks a challenge method per request. The SHA-256 ones make the client
// burn CPU; `metarefresh` instead makes it sit out a declared delay. Anything
// else (e.g. `preact`) needs a real JS runtime and must fail immediately rather
// than burn the whole time budget computing a hash nobody asked for.
const PROOF_OF_WORK_ALGORITHMS = ['fast', 'slow'];
const METAREFRESH_ALGORITHM = 'metarefresh';

// Anubis states the wait in a `Refresh` header or its `<meta>` twin. This is
// only the fallback for a page that omits both, where the exchange URL has to
// be rebuilt from the challenge anyway.
const DEFAULT_METAREFRESH_DELAY_MS = 2000;

// Structural markers, deliberately not the localised body text: the
// interstitial is translated, so matching prose would both miss locales and
// risk false positives on user-generated content that quotes it.
const CHALLENGE_MARKERS = ['id="anubis_challenge"', '/.within.website/x/cmd/anubis/'];

export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;

interface ParsedChallenge {
rules: { algorithm: string; difficulty: number };
challenge: { id: string; randomData: string };
}

export interface ChallengeResult {
/** Cookie to replay, or `null` when the runtime's own cookie jar holds it. */
cookie: string | null;
/** True when Set-Cookie was hidden and the runtime now owns the cookie. */
platformCookieJar: boolean;
}

export interface PassChallengeParams {
/** Body of the interstitial page that was served instead of the content. */
html: string;
/** Response headers that came with it, carrying the verification cookie. */
headers: Headers;
/** The URL that was blocked; used as the redirect target after passing. */
url: string;
/** Headers to reuse, so the exchange looks like the original request. */
requestHeaders?: Headers;
fetch: FetchLike;
timeBudgetMs?: number;
}

export const isAnubisChallenge = (html: string): boolean =>
CHALLENGE_MARKERS.some((marker) => html.includes(marker));

const parseChallenge = (html: string): ParsedChallenge | null => {
const match = html.match(
/<script id="anubis_challenge" type="application\/json">([\s\S]*?)<\/script>/
);
if (!match) {
return null;
}
try {
return JSON.parse(match[1].trim()) as ParsedChallenge;
} catch {
return null;
}
};
Comment on lines +59 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Server-supplied challenge fields reach the solver without validation. parseChallenge casts arbitrary JSON to ParsedChallenge, so rules.algorithm, rules.difficulty, challenge.id, and challenge.randomData can be missing or of the wrong type. A missing rules throws a TypeError at src/anubis/challenge.ts line 218. A NaN or negative difficulty makes the solver return nonce 0 with a digest that satisfies nothing.

  • src/anubis/challenge.ts#L59-L71: check the four fields after JSON.parse and return null when any field has the wrong type.
  • src/anubis/proof-of-work.ts#L33-L41: reject a difficulty that is not a non-negative integer before the search starts.
📍 Affects 2 files
  • src/anubis/challenge.ts#L59-L71 (this comment)
  • src/anubis/proof-of-work.ts#L33-L41
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/anubis/challenge.ts` around lines 59 - 71, Validate the parsed result in
parseChallenge before returning it, requiring rules.algorithm and
challenge.id/challenge.randomData to be strings and rules.difficulty to be a
number; return null for missing or invalid fields. In
src/anubis/proof-of-work.ts lines 33-41, ensure the solver rejects difficulty
values that are not non-negative integers before beginning the search.


const readCookie = (headers: Headers, name: string): string | null => {
if (typeof headers.getSetCookie !== 'function') {
return null;
}
const cookie = headers
.getSetCookie()
.map((entry) => entry.split(';', 1)[0])
.find((pair) => pair.startsWith(`${name}=`) && pair.length > name.length + 1);
return cookie ?? null;
};

// Set-Cookie is a forbidden response header outside Node, so seeing none on a
// response that certainly carried them means the runtime (browser, React
// Native) is hiding them and keeping the cookies in its own jar instead.
const hidesSetCookie = (headers: Headers): boolean =>
typeof headers.getSetCookie !== 'function' || headers.getSetCookie().length === 0;
Comment on lines +84 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

hidesSetCookie conflates a missing cookie with a hidden cookie.

On Node, getSetCookie exists. If an interstitial arrives without any Set-Cookie header, getSetCookie() returns an empty array and this function reports true. passChallenge then sets platformCookieJar = true, and the client keeps that state. Every later fetchPage call sends credentials: 'include', which removes the credential-less default described in src/fetchers/index.ts lines 119-122. Separate the capability check from the cookie-presence check.

♻️ Proposed change
-const hidesSetCookie = (headers: Headers): boolean =>
-  typeof headers.getSetCookie !== 'function' || headers.getSetCookie().length === 0;
+// A runtime without `getSetCookie` cannot expose Set-Cookie at all. A runtime
+// that has it but returned nothing simply got a response without cookies.
+const hidesSetCookie = (headers: Headers): boolean =>
+  typeof headers.getSetCookie !== 'function';

Also applies to: 191-209

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/anubis/challenge.ts` around lines 84 - 88, Update hidesSetCookie and the
passChallenge state handling to distinguish runtimes that cannot expose
Set-Cookie from responses where Node explicitly exposes no cookies. Only set
platformCookieJar when the runtime lacks getSetCookie; preserve the
credential-less fetch behavior when getSetCookie exists but returns an empty
array, including the later fetchPage flow.


const REFRESH_HEADER_DIRECTIVE = /^\s*(\d+)\s*;\s*url=(.+)$/i;
const REFRESH_META_DIRECTIVE =
/<meta[^>]+http-equiv=["']?refresh["']?[^>]*content=["'](\d+)[^;]*;\s*url=([^"'>]+)/i;

interface RefreshDirective {
delayMs: number;
url: string;
}

/**
* The `<delay>; url=<target>` directive Anubis serves with a metarefresh
* challenge. It arrives as a `Refresh` header on some responses and as its
* `<meta http-equiv>` equivalent on others, so both are read.
*/
const readRefreshDirective = (html: string, headers: Headers): RefreshDirective | null => {
const directive =
headers.get('refresh')?.match(REFRESH_HEADER_DIRECTIVE) ?? html.match(REFRESH_META_DIRECTIVE);
if (!directive) {
return null;
}
return {
delayMs: Number(directive[1]) * 1000,
// Inside a meta attribute the query separators arrive HTML-escaped.
url: directive[2].trim().replace(/&amp;/g, '&')
};
};

const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

const proofOfWorkPassUrl = async (
url: string,
{ id, randomData }: ParsedChallenge['challenge'],
difficulty: number,
timeBudgetMs: number
): Promise<string | null> => {
const startedAt = Date.now();
const solution = await solveProofOfWork(randomData, difficulty, timeBudgetMs);
if (!solution) {
return null;
}

const passUrl = new URL(PASS_CHALLENGE_PATH, url);
passUrl.searchParams.set('id', id);
passUrl.searchParams.set('response', solution.hash);
passUrl.searchParams.set('nonce', String(solution.nonce));
passUrl.searchParams.set('redir', url);
passUrl.searchParams.set('elapsedTime', String(Date.now() - startedAt));
return passUrl.toString();
};

/**
* Metarefresh asks for patience rather than hashes: Anubis hands over the
* exchange URL up front but answers it with 403 until the delay it declared has
* actually elapsed, so the wait is the whole proof.
*/
const metarefreshPassUrl = async (
url: string,
{ id, randomData }: ParsedChallenge['challenge'],
directive: RefreshDirective | null,
timeBudgetMs: number
): Promise<string | null> => {
const delayMs = directive?.delayMs ?? DEFAULT_METAREFRESH_DELAY_MS;
// Waiting longer than the caller allowed is worse than not passing at all.
if (delayMs > timeBudgetMs) {
return null;
}

// Anubis' own URL is authoritative, so it is preferred over rebuilding one.
let passUrl: URL;
if (directive) {
passUrl = new URL(directive.url, url);
} else {
passUrl = new URL(PASS_CHALLENGE_PATH, url);
passUrl.searchParams.set('challenge', randomData);
passUrl.searchParams.set('id', id);
passUrl.searchParams.set('redir', url);
}

await wait(delayMs);
return passUrl.toString();
};

/**
* Solve the challenge on an interstitial page and exchange it for an Anubis
* auth cookie. Returns `null` if the challenge could not be passed.
*/
export const passChallenge = async ({
html: challengeHtml,
headers: challengeHeaders,
url,
requestHeaders,
fetch,
timeBudgetMs = DEFAULT_TIME_BUDGET_MS
}: PassChallengeParams): Promise<ChallengeResult | null> => {
let html = challengeHtml;
let headers = challengeHeaders;
let platformCookieJar = false;

// On a cookie-jar runtime the first request was made without credentials, so
// the jar never stored Anubis' verification cookie. Ask for a fresh challenge
// with credentials enabled and let the jar keep it this time.
if (hidesSetCookie(headers)) {
platformCookieJar = true;
const reissued = await fetch(url, {
credentials: 'include',
redirect: 'manual',
headers: requestHeaders
});
const reissuedHtml = await reissued.text();

// Credentials change the answer: the jar may already hold a valid auth
// cookie, in which case this sails straight past Anubis. There is then no
// challenge left to solve — only a request worth retrying with the jar.
if (!isAnubisChallenge(reissuedHtml)) {
return { cookie: null, platformCookieJar };
}

html = reissuedHtml;
headers = reissued.headers;
}

const parsed = parseChallenge(html);
if (!parsed) {
return null;
}

const { challenge, rules } = parsed;
let passUrl: string | null = null;
if (PROOF_OF_WORK_ALGORITHMS.includes(rules.algorithm)) {
passUrl = await proofOfWorkPassUrl(url, challenge, rules.difficulty, timeBudgetMs);
} else if (rules.algorithm === METAREFRESH_ALGORITHM) {
passUrl = await metarefreshPassUrl(
url,
challenge,
readRefreshDirective(html, headers),
timeBudgetMs
);
}
if (!passUrl) {
return null;
}

// Anubis requires the verification cookie it set on the interstitial as proof
// that cookies work; on a jar runtime the runtime itself attaches it.
const passHeaders = new Headers(requestHeaders);
const verifyCookie = readCookie(headers, VERIFY_COOKIE_NAME);
if (verifyCookie) {
passHeaders.set('Cookie', verifyCookie);
}

// `redirect: 'manual'` stops fetch from following the 302 to `redir`, which
// would discard the Set-Cookie we need to read off this very response.
const response = await fetch(passUrl, {
method: 'GET',
credentials: platformCookieJar ? 'include' : 'omit',
redirect: 'manual',
headers: passHeaders
});

const authCookie = readCookie(response.headers, AUTH_COOKIE_NAME);
if (authCookie) {
return { cookie: authCookie, platformCookieJar };
}

// No cookie in hand. A runtime that lets us read Set-Cookie would have shown
// it, so this is a failed exchange; only a jar runtime can have passed while
// keeping the cookie to itself.
if (!platformCookieJar) {
return null;
}

// A 302 to `redir` is Anubis' success signal, and the jar has just stored the
// cookie off it. Browsers report the unfollowed redirect as `opaqueredirect`.
if (response.status === 302 || response.type === 'opaqueredirect') {
return { cookie: null, platformCookieJar };
}

// React Native ignores `redirect: 'manual'` and follows the 302 itself, so
// what we hold is the page we were after — proof enough, unless Anubis is
// still challenging us.
if (response.ok) {
const body = await response.text();
return body && !isAnubisChallenge(body) ? { cookie: null, platformCookieJar } : null;
}

return null;
};
81 changes: 81 additions & 0 deletions src/anubis/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
type ChallengeResult,
type FetchLike,
isAnubisChallenge,
passChallenge
} from './challenge';

export interface AnubisClientOptions {
/** Defaults to the global `fetch`. */
fetch?: FetchLike;
/** Give up on a proof-of-work after this long. Defaults to 10 seconds. */
timeBudgetMs?: number;
}

export interface AnubisClient {
/** Whether a response body is an Anubis interstitial rather than content. */
isChallenge(html: string): boolean;
/**
* Solve the given interstitial and cache the resulting cookie. Resolves
* `true` when the request is worth retrying. Concurrent calls share a single
* proof-of-work, since the cookie they produce is shared anyway.
*/
pass(html: string, headers: Headers, url: string, requestHeaders?: Headers): Promise<boolean>;
/** The cached cookie, e.g. to persist between runs. */
getCookie(): string | null;
/** Seed the cache from a previous run or a per-tenant store. */
setCookie(cookie: string | null): void;
/** Forget the cookie so the next challenge is solved from scratch. */
reset(): void;
/** Whether cookies must be left to the runtime rather than sent by hand. */
usesPlatformCookieJar(): boolean;
}

/**
* An Anubis-aware cookie holder. Each client keeps its own cookie, so callers
* with separate egress IPs (Anubis binds the cookie to one) can hold one each.
*/
export const createAnubisClient = (options: AnubisClientOptions = {}): AnubisClient => {
const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
const timeBudgetMs = options.timeBudgetMs;

let cookie: string | null = null;
let platformCookieJar = false;
let pending: Promise<ChallengeResult | null> | null = null;

return {
isChallenge: isAnubisChallenge,
getCookie: () => cookie,
setCookie: (value) => {
cookie = value;
},
reset: () => {
cookie = null;
pending = null;
},
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

reset() does not stop an in-flight exchange from restoring the cookie.

reset() clears cookie and pending, but the promise created by an earlier pass() call keeps running. When it resolves, lines 76-77 write cookie and platformCookieJar again. A caller that resets after a block then keeps using the cookie it asked to discard. Track a generation counter and ignore results from an older generation.

♻️ Proposed change
   let cookie: string | null = null;
   let platformCookieJar = false;
   let pending: Promise<ChallengeResult | null> | null = null;
+  let generation = 0;
 
   return {
     isChallenge: isAnubisChallenge,
     getCookie: () => cookie,
     setCookie: (value) => {
       cookie = value;
     },
     reset: () => {
       cookie = null;
       pending = null;
+      generation++;
     },
     usesPlatformCookieJar: () => platformCookieJar,
 
     pass: async (html, headers, url, requestHeaders) => {
+      const startedAt = generation;
       if (!pending) {
       const result = await pending;
       if (!result) {
         return false;
       }
+      if (startedAt !== generation) {
+        return false;
+      }
       cookie = result.cookie;

Also applies to: 58-79

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/anubis/client.ts` around lines 52 - 55, Update the client state around
reset() and pass() so each reset advances a generation counter, and each
in-flight pass() captures its generation before awaiting. Ignore stale results
when the exchange resolves, preventing older requests from restoring cookie or
platformCookieJar after reset(), while preserving normal current-generation
behavior.

usesPlatformCookieJar: () => platformCookieJar,

pass: async (html, headers, url, requestHeaders) => {
if (!pending) {
pending = passChallenge({
html,
headers,
url,
requestHeaders,
fetch: doFetch,
timeBudgetMs
}).finally(() => {
pending = null;
});
}

const result = await pending;
if (!result) {
return false;
}
cookie = result.cookie;
platformCookieJar = result.platformCookieJar;
return true;
}
};
};
14 changes: 14 additions & 0 deletions src/anubis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Self-contained client for Anubis (BotStopper) proof-of-work challenges.
// Nothing here is ČSFD-specific: it takes a URL, a `fetch` and the interstitial
// it received, so it can be lifted out into its own package as-is.

export { createAnubisClient } from './client';
export type { AnubisClient, AnubisClientOptions } from './client';

export { isAnubisChallenge, passChallenge } from './challenge';
export type { ChallengeResult, FetchLike, PassChallengeParams } from './challenge';

export { DEFAULT_TIME_BUDGET_MS, solveProofOfWork } from './proof-of-work';
export type { ProofOfWork } from './proof-of-work';

export { sha256, toHex } from './sha256';
Loading