-
Notifications
You must be signed in to change notification settings - Fork 10
Description
Context
Referencing to custom challenges by path doesn't really work since most challenges have their own dependencies. Custom challenges (like @mintpass/challenge and SpamBlocker) are full npm packages with their own dependencies (viem, keyv, etc.). Currently plebbit-js only supports built-in challenges (by name) or single-file challenges (by path). Neither works for npm packages that need node_modules resolved. This is extremely restrictive and makes it hard to release new challenges without rolling out new versions of clients.
This adds:
- A new
packagefield inSubplebbitChallengeSettingto reference installed npm packages - A challenge package manager that installs/removes packages via npm in
<dataPath>/challenges/ - RPC methods so any client (CLI, Seedit, etc.) can manage challenge packages
Usage Examples
Installing a challenge package
// Via plebbit-js API
await plebbit.installChallengePackage({ packageName: "@mintpass/challenge" });
await plebbit.installChallengePackage({ packageName: "@mintpass/challenge@1.0.0" }); // pinned
await plebbit.installChallengePackage({ packageName: "https://github.com/bitsocialhq/mintpass.git" }); // git
// Via CLI (later, in plebbit-cli)
bitsocial challenge install @mintpass/challengeConfiguring a community to use an installed package challenge
// Via plebbit-js API
await subplebbit.edit({
settings: {
challenges: [
{
package: "@mintpass/challenge",
options: {
chainTicker: "eth",
contractAddress: "0x123...",
requiredTokenType: "SMS"
}
},
{
name: "question", // built-in challenge alongside package challenge
options: { question: "What is the password?", answer: "secret" }
}
]
}
});
// Via CLI (later, in plebbit-cli)
bitsocial community edit mysub.eth \
--settings.challenges[0].package @mintpass/challenge \
--settings.challenges[0].options.chainTicker eth \
--settings.challenges[0].options.contractAddress 0x123... \
--settings.challenges[1].name question \
--settings.challenges[1].options.question "What is the password?" \
--settings.challenges[1].options.answer secretListing and removing
const packages = await plebbit.listChallengePackages();
// [{ name: "@mintpass/challenge", version: "^1.1.10" }]
await plebbit.removeChallengePackage({ packageName: "@mintpass/challenge" });Web UI Integration (Seedit, Plebones, etc.)
Because challenge management is exposed via RPC, web UIs can offer a seamless point-and-click experience. A community settings page in Seedit could look like:
Community Settings > Challenges
-
"Add Challenge" dropdown shows two sections:
- Built-in: question, captcha-canvas-v3, whitelist, blacklist, etc.
- Installed Packages: lists packages from
listChallengePackages()RPC call
-
"Install Package" button opens a text input where the user types an npm package name (e.g.,
@mintpass/challenge). Clicking "Install" callsinstallChallengePackage()RPC. A spinner shows while npm installs. On success, the package appears in the dropdown. -
Challenge configuration form — once a package challenge is selected, the UI reads
optionInputsfrom the challenge's metadata to render labeled form fields (chainTicker, contractAddress, etc.) with descriptions and placeholders. -
"Remove Package" button next to each installed package calls
removeChallengePackage()RPC.
The entire flow — install, configure, remove — happens through the same RPC methods the CLI uses. No SSH, no terminal, no Docker exec. A community owner on Seedit can install @mintpass/challenge and configure it on their community in under a minute, entirely from the browser.
Implementation Plan (plebbit-js)
All changes in the plebbit-js repo.
Step 1: Schema — add package field
File: src/subplebbit/schema.ts (~line 141-152)
- Add
package: z.string().optional()toSubplebbitChallengeSettingSchema - Update refine:
challengeData.path || challengeData.name || challengeData.package - Types in
src/subplebbit/types.tsauto-derive viaz.infer— no changes needed
Step 2: Error constants
File: src/errors.ts
Add after ERR_FAILED_TO_IMPORT_CHALLENGE_FILE_FACTORY:
ERR_FAILED_TO_INSTALL_CHALLENGE_PACKAGE
ERR_FAILED_TO_REMOVE_CHALLENGE_PACKAGE
ERR_FAILED_TO_IMPORT_CHALLENGE_PACKAGE
Step 3: Challenge package manager (new file)
New file: src/runtime/node/challenge-packages.ts
Manages <dataPath>/challenges/ as a standalone npm project:
<dataPath>/challenges/
├── package.json # auto-created (see below)
├── package-lock.json
└── node_modules/
├── @plebbit/plebbit-js/ # auto-installed so challenge peer deps resolve
└── @mintpass/challenge/ # installed package + its deps
The auto-created package.json:
{
"name": "plebbit-challenges",
"private": true,
"type": "module",
"dependencies": {
"@plebbit/plebbit-js": "<current-version>"
}
}plebbit-js is pre-installed as a dependency so that challenge packages with peerDependencies: { "@plebbit/plebbit-js": "..." } resolve correctly at runtime.
Functions:
ensureChallengesDir(dataPath)— create dir +package.json(with plebbit-js dep) if missingrunNpm(args, cwd)— spawnnpmchild process, return stdout/stderrinstallChallengePackage({ dataPath, packageName })—npm install <pkg>, return{ name, version }listChallengePackages({ dataPath })— readpackage.jsondependencies (excluding plebbit-js)removeChallengePackage({ dataPath, packageName })—npm uninstall <pkg>importChallengePackage(dataPath, packageName)— resolve withcreateRequirefrom challenges dir, thenimport(pathToFileURL(resolved).href), return.default
Package Resolution Details
import { createRequire } from "node:module";
const require = createRequire(path.join(challengesDir, "package.json"));
const resolvedPath = require.resolve("@mintpass/challenge");
// → <dataPath>/challenges/node_modules/@mintpass/challenge/dist/index.js
const factory = (await import(pathToFileURL(resolvedPath).href)).default;createRequirescoped to challenges dir → only resolves from thatnode_modulesrequire.resolvereadsmain/exportsfield → returns absolute path to entry pointpathToFileURL+ dynamicimport()loads as ESM- Node 22+
require.resolvesupportsexportsmap, scoped packages, all edge cases
Step 4: Challenge resolution — add package branch
File: src/runtime/node/subplebbit/challenges/index.ts
Add import: import { importChallengePackage } from "../../challenge-packages.js"
In getSubplebbitChallengeFromSubplebbitChallengeSettings (~line 355):
- Add optional
dataPath?: stringparameter - Add
else if (subplebbitChallengeSettings.package)branch that callsimportChallengePackage(dataPath, packageName)and validates withChallengeFileFactorySchema.parse()
In getPendingChallengesOrChallengeVerification (~line 104):
- Update validation check (~line 120) to include
package - Add
packagebranch in factory resolution (~line 125) usingsubplebbit._plebbit.dataPath
Update all call sites of getSubplebbitChallengeFromSubplebbitChallengeSettings to pass dataPath:
challenges/index.ts~line 176local-subplebbit.ts~lines 539, 2817, 2998db-handler.ts~line 521
Test files that call with only name-based settings continue to work since dataPath is optional.
Step 5: Plebbit class methods
File: src/plebbit/plebbit.ts
Add three methods (using dynamic import() to keep Node-only):
async installChallengePackage({ packageName }: { packageName: string })
async listChallengePackages()
async removeChallengePackage({ packageName }: { packageName: string })Each checks this.dataPath exists, delegates to challenge-packages module.
Step 6: RPC server methods
File: src/rpc/src/index.ts
Register three new methods following existing pattern:
installChallengePackage— parse{ packageName }from params, callplebbit.installChallengePackage()listChallengePackages— callplebbit.listChallengePackages()removeChallengePackage— parse{ packageName }from params, callplebbit.removeChallengePackage()
Step 7: RPC client methods
File: src/clients/rpc-client/plebbit-rpc-client.ts
- Add matching client methods that call the WebSocket RPC
File: src/plebbit/plebbit-with-rpc-client.ts
- Override the three Plebbit methods to delegate to RPC client
Step 8: Tests
New file: test/challenges/challenge-packages.test.ts
- Schema validation:
{ package: "some-pkg" }passes,{}fails - Install/list/remove lifecycle with a temp dataPath
- Challenge resolution from installed package
- Error on non-existent package
Verification
- Build:
npm run build(or project equivalent) - Run existing challenge tests to confirm no regressions
- Run new tests: install
@mintpass/challenge, configure a subplebbit withpackage: "@mintpass/challenge", verify challenge resolution works - Test RPC round-trip: start RPC server, call
installChallengePackagevia WebSocket client
Docker
Challenges dir lives at <dataPath>/challenges/. In Docker: XDG_DATA_HOME=/data → challenges at /data/challenges/.
Runtime install (development)
# /data is already a volume → persists across restarts
docker exec bitsocial bitsocial challenge install @mintpass/challenge
docker exec bitsocial bitsocial community edit mysub.eth \
--settings.challenges[0].package @mintpass/challenge \
--settings.challenges[0].options.chainTicker ethCustom Dockerfile (production)
FROM ghcr.io/plebbit/bitsocial:latest
RUN bitsocial challenge install @mintpass/challengePackages baked into the image, no volume needed for challenges.
Future: plebbit-cli (separate PR)
After plebbit-js is done, add to plebbit-cli:
bitsocial challenge install <package>— calls RPCinstallChallengePackagebitsocial challenge list— calls RPClistChallengePackagesbitsocial challenge remove <package>— calls RPCremoveChallengePackage