Skip to content

Proposal for Challenge Package Management for plebbit-js #72

@Rinse12

Description

@Rinse12

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:

  1. A new package field in SubplebbitChallengeSetting to reference installed npm packages
  2. A challenge package manager that installs/removes packages via npm in <dataPath>/challenges/
  3. 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/challenge

Configuring 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 secret

Listing 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

  1. "Add Challenge" dropdown shows two sections:

    • Built-in: question, captcha-canvas-v3, whitelist, blacklist, etc.
    • Installed Packages: lists packages from listChallengePackages() RPC call
  2. "Install Package" button opens a text input where the user types an npm package name (e.g., @mintpass/challenge). Clicking "Install" calls installChallengePackage() RPC. A spinner shows while npm installs. On success, the package appears in the dropdown.

  3. Challenge configuration form — once a package challenge is selected, the UI reads optionInputs from the challenge's metadata to render labeled form fields (chainTicker, contractAddress, etc.) with descriptions and placeholders.

  4. "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() to SubplebbitChallengeSettingSchema
  • Update refine: challengeData.path || challengeData.name || challengeData.package
  • Types in src/subplebbit/types.ts auto-derive via z.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 missing
  • runNpm(args, cwd) — spawn npm child process, return stdout/stderr
  • installChallengePackage({ dataPath, packageName })npm install <pkg>, return { name, version }
  • listChallengePackages({ dataPath }) — read package.json dependencies (excluding plebbit-js)
  • removeChallengePackage({ dataPath, packageName })npm uninstall <pkg>
  • importChallengePackage(dataPath, packageName) — resolve with createRequire from challenges dir, then import(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;
  • createRequire scoped to challenges dir → only resolves from that node_modules
  • require.resolve reads main/exports field → returns absolute path to entry point
  • pathToFileURL + dynamic import() loads as ESM
  • Node 22+ require.resolve supports exports map, 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?: string parameter
  • Add else if (subplebbitChallengeSettings.package) branch that calls importChallengePackage(dataPath, packageName) and validates with ChallengeFileFactorySchema.parse()

In getPendingChallengesOrChallengeVerification (~line 104):

  • Update validation check (~line 120) to include package
  • Add package branch in factory resolution (~line 125) using subplebbit._plebbit.dataPath

Update all call sites of getSubplebbitChallengeFromSubplebbitChallengeSettings to pass dataPath:

  • challenges/index.ts ~line 176
  • local-subplebbit.ts ~lines 539, 2817, 2998
  • db-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, call plebbit.installChallengePackage()
  • listChallengePackages — call plebbit.listChallengePackages()
  • removeChallengePackage — parse { packageName } from params, call plebbit.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

  1. Build: npm run build (or project equivalent)
  2. Run existing challenge tests to confirm no regressions
  3. Run new tests: install @mintpass/challenge, configure a subplebbit with package: "@mintpass/challenge", verify challenge resolution works
  4. Test RPC round-trip: start RPC server, call installChallengePackage via 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 eth

Custom Dockerfile (production)

FROM ghcr.io/plebbit/bitsocial:latest
RUN bitsocial challenge install @mintpass/challenge

Packages 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 RPC installChallengePackage
  • bitsocial challenge list — calls RPC listChallengePackages
  • bitsocial challenge remove <package> — calls RPC removeChallengePackage

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions