` at the end of ``, so it looks harmless in a DOM dump; the toolbar actually lives in its open shadow root and is fixed to the bottom-right corner — the same corner as the app's scroll button. `element.click()` from `eval` bypasses hit-testing and still works, so a scripted check can pass while every real pointer click fails.
- **Impact:** A whole verification pass reads as a product bug in the control being tested. `navigator.webdriver` is `false` in playwright-cli sessions, so the app cannot detect automation on its own.
- **Mitigation:** `scripts/pw-session.sh open` now registers `window.__NO_DEV_TOOLBAR__ = true` via `page.addInitScript` and reloads, so sessions opened through the wrapper never mount the toolbar. Only the toolbar is suppressed — `react-scan` itself stays enabled, so `__getReactScanReport` and `__ELEMENT_SOURCE__` keep working for the profiler and element-inspection skills. When driving `playwright-cli` directly, set the same flag before load, or expect corner clicks to be intercepted.
- **Status:** confirmed
### `skills add` installs Codex and Cursor copies into the gitignored `.agents/` directory
- **Date:** 2026-08-18
- **Observed by:** Tommaso + Claude
- **Context:** Installing the `improve-threejs` skill from `millionco/react-doctor` with the `skills` CLI (`vercel-labs/skills`).
- **What was surprising:** `npx skills add
--skill --agent codex` and `--agent cursor` both write to `.agents/skills//`, not to `.codex/skills/` or `.cursor/skills/`. `AGENTS.md` forbids a repo-level `.agents/` directory and `.gitignore:29` ignores it, so both copies are silently untracked. Only `--agent claude-code` writes to the expected `.claude/skills/`. Separately, the documented comma-separated form (`--agent claude-code,codex,cursor`) fails with "Invalid agents" and installs nothing, even though each name is valid on its own.
- **Impact:** The install reports success while two of the three toolchain copies land somewhere that will never be committed, so Codex and Cursor silently lack the skill after a fresh clone. The comma form can also produce a no-op install that reads as a success.
- **Mitigation:** Run `skills add` once per agent with a single `--agent` value. Use `--agent claude-code` for the `.claude/skills/` copy, then `cp -R .claude/skills/ .codex/skills/` and `cp -R .claude/skills/ .cursor/skills/`, and `rm -rf .agents`. Confirm with `git status` that all three copies show as untracked additions before committing.
- **Status:** confirmed
### Long-Running Agent Workflow
Source: https://docs.bitsocial.net/agent-playbooks/long-running-agent-workflow/
Path: docs/agent-playbooks/long-running-agent-workflow.md
Description: Use this playbook when a task is likely to span multiple sessions, handoffs, or spawned agents.
# Long-Running Agent Workflow
Use this playbook when a task is likely to span multiple sessions, handoffs, or spawned agents.
## Goals
- Give each fresh session a fast way to regain context
- Keep work incremental instead of one-shotting a large change
- Catch a broken local baseline before adding more code
- Leave durable artifacts that the next session can trust
## Where to Keep State
- Use `docs/agent-runs//` when humans, review bots, or multiple toolchains need the same task state.
- Use a tool-local directory such as `.codex/runs//` only when the task state is intentionally local to one workstation or one toolchain.
- Do not hide multi-session shared state in a private scratch file if another contributor or agent will need it later.
## Required Files
Create these files at the start of the long-running task:
- `feature-list.json`
- `progress.md`
Use the templates in `docs/agent-playbooks/templates/feature-list.template.json` and `docs/agent-playbooks/templates/progress.template.md`.
Prefer JSON for the feature list so agents can update a small number of fields without rewriting the whole document.
## Session Start Checklist
1. Run `pwd`.
2. Read `progress.md`.
3. Read `feature-list.json`.
4. Run `git log --oneline -20`.
5. Run `./scripts/agent-init.sh --smoke`.
6. Choose exactly one highest-priority item that is still `pending`, `in_progress`, or `blocked`.
If the smoke step fails, fix the broken baseline before implementing a new feature slice.
## Session Rules
- Work on one feature or task slice at a time.
- Keep the feature list machine-readable and stable. Update status, notes, files, and verification fields instead of rewriting unrelated items.
- Only mark an item verified after running the command or user flow listed in that item.
- Use spawned agents for bounded slices, not for overall task-state ownership.
- When a child agent owns one item, give it the exact item id, acceptance criteria, and files it may touch.
## Session End Checklist
1. Append a short progress entry to `progress.md`.
2. Update the touched item in `feature-list.json`.
3. Record the exact commands run for verification.
4. Capture blockers, follow-ups, and the next best item to resume.
## Recommended Progress Entry Shape
Use a short structure like:
```markdown
## 2026-03-17 14:30
- Item: F003
- Summary: Updated the browser-check flow to use the shared init/bootstrap path.
- Files: `.cursor/agents/browser-check.md`, `.codex/agents/browser-check.toml`
- Verification: `corepack yarn build:verify`, `corepack yarn lint`, `corepack yarn typecheck`
- Next: Run the smoke flow and update the task-board status.
```
### Skills and Tools
Source: https://docs.bitsocial.net/agent-playbooks/skills-and-tools/
Path: docs/agent-playbooks/skills-and-tools.md
Description: Use this playbook when setting up/adjusting skills and external tooling.
# Skills and Tools
Use this playbook when setting up/adjusting skills and external tooling.
## Recommended Skills
### Context7 (library docs)
For up-to-date docs on libraries.
```bash
npx skills add https://github.com/intellectronica/agent-skills --skill context7
```
### Playwright CLI
Use `playwright-cli` for browser automation (navigation, interaction, screenshots, tests, extraction).
When using `playwright-cli` for repo UI verification, do not stop after one engine. Run the relevant flow in all three main browser engines:
- `chrome` for Blink
- `firefox` for Gecko
- `webkit` for Safari/WebKit coverage
Use separate named sessions per engine so evidence stays isolated, but run those sessions sequentially. Only one Playwright browser session may be active at a time, machine-wide, because the contended resource is machine RAM and CPU rather than the repository. Open and close sessions through `./scripts/pw-session.sh`; it holds that shared lock so concurrent agents defer and retry browser work instead of saturating the machine. If an engine is intentionally skipped, record why.
During iteration, use Chrome/Blink only. Run the full Chrome, Firefox, and WebKit sequence once the change is ready for final verification. Reuse each engine session for desktop and mobile by resizing it, close it in a finally-style cleanup, and only then open the next engine.
```bash
./scripts/pw-session.sh open verify-chrome https://bitsocial.localhost --browser=chrome
playwright-cli -s=verify-chrome snapshot
./scripts/pw-session.sh close verify-chrome
```
When the slot is busy, `open` exits 75; block on `./scripts/pw-session.sh open --wait[=SECONDS] ...` (default 300s) instead of retrying by hand. A lock left behind by an interrupted workflow is reclaimed automatically, because `open` drops any slot whose recorded browser is no longer running. Inspect the holder with `./scripts/pw-session.sh status`; `release ` is a last resort for the rare case where `status` cannot verify the browser state.
```bash
npm install -g @playwright/cli@latest
playwright-cli install --skills
```
Skill install locations:
- `.cursor/skills/playwright-cli/`
- `.claude/skills/playwright-cli/`
### Vercel React Best Practices
For deeper React/Next performance guidance.
```bash
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices
```
### Find Skills
Discover/install skills from the open ecosystem.
```bash
npx skills add https://github.com/vercel-labs/skills --skill find-skills
```
## MCP Policy Rationale
Avoid GitHub MCP and browser MCP servers for this project because they add significant tool-schema/context overhead.
- GitHub operations: use `gh` CLI.
- Browser operations: use `playwright-cli`.
## Model Availability
- `composer-2` is available only in Cursor. Do not configure it under `.claude/` or `.codex/`.
- Codex does not document a `latest` model alias. Committed custom-agent TOMLs under `.codex/**/agents/*.toml` omit both `model` and `model_reasoning_effort` so they inherit the current parent session settings.
### Feature List Template
Source: https://docs.bitsocial.net/agent-playbooks/templates/feature-list-template/
Path: docs/agent-playbooks/templates/feature-list-template.mdx
Description: Machine-readable template used for long-running multi-session task tracking.
# Feature List Template
This page mirrors the JSON template shipped in
`docs/agent-playbooks/templates/feature-list.template.json` so it can be browsed directly inside the
docs site as well.
```json
{
"task": "replace-with-task-slug",
"last_updated": "YYYY-MM-DD",
"items": [
{
"id": "F001",
"priority": 1,
"status": "pending",
"description": "Describe one end-to-end feature or one reviewable task slice.",
"verification": ["List the command or user-visible check that proves this item works."],
"files": [],
"notes": ""
}
]
}
```
### Progress Log
Source: https://docs.bitsocial.net/agent-playbooks/templates/progress.template/
Path: docs/agent-playbooks/templates/progress.template.md
Description: Append one entry per session.
# Progress Log
Append one entry per session.
## YYYY-MM-DD HH:MM
- Item: F001
- Summary: Replace this with the session summary.
- Files: `path/to/file`
- Verification: `corepack yarn build:verify`, `corepack yarn lint`, `corepack yarn typecheck`
- Blockers: none
- Next: Replace this with the next best follow-up.
### Translations Workflow
Source: https://docs.bitsocial.net/agent-playbooks/translations/
Path: docs/agent-playbooks/translations.md
Description: This project uses i18next translation files in public/translations/{lang}/default.json.
# Translations Workflow
This project uses i18next translation files in `public/translations/{lang}/default.json`.
## Rule
Do not manually edit every language file. Use `scripts/update-translations.js`.
## Add or Update a Key
1. Create a temporary dictionary file, e.g. `translations-temp.json`:
```json
{
"en": "English text",
"es": "Spanish text",
"fr": "French text",
"de": "German text"
}
```
2. Apply the translation map:
```bash
node scripts/update-translations.js --key my_new_key --map translations-temp.json --include-en --write
```
3. Delete the temporary dictionary file.
## Other Useful Commands
```bash
# Copy a key from English to all languages (dry run then write)
node scripts/update-translations.js --key some_key --from en --dry
node scripts/update-translations.js --key some_key --from en --write
# Delete a key from all languages
node scripts/update-translations.js --key obsolete_key --delete --write
# Audit for unused translation keys
node scripts/update-translations.js --audit --dry
node scripts/update-translations.js --audit --write
```
### Content Discovery
Source: https://docs.bitsocial.net/content-discovery/
Path: docs/content-discovery.md
Description: How Bitsocial separates peer discovery from app-level curation.
# Content Discovery
Bitsocial does not put one global feed, search index, or ranking algorithm in the protocol. It
separates content discovery into two layers:
1. **Network lookup** finds the peers currently serving a known community.
2. **App curation** decides which communities, boards, lists, or posts a product shows first.
This keeps the protocol small while leaving room for many discovery experiences to compete.
## Network lookup
Every community has a stable address derived from its public key. When a client already knows that
address, it queries lightweight HTTP routers to find peers that announced themselves as providers
for it.
The routers only return provider peer addresses. They do not store posts, metadata, user lists, or a
human-readable directory of communities. After the client receives peer addresses, it connects to
those peers and fetches the latest community metadata plus content pointers, then fetches the actual
post data by hash.
This answers the protocol question: "Where can I fetch the latest state for this community?"
## App curation
The separate product question is: "Which communities should a user see first?"
Bitsocial leaves that to apps, lists, and users instead of baking one answer into the network.
Examples include:
- a client showing communities the user already follows
- a curated default list for a Reddit-style app
- directory slots for an imageboard-style app
- search or ranking indexes maintained by a specific app
- direct links shared by users
Apps can index, rank, filter, or highlight different things without turning those choices into
protocol law. If one app's discovery surface is not useful, another app can build a different one on
the same underlying communities.
## Current apps
5chan currently uses familiar directory paths such as `/b/` or `/g/`. Directory assignments are
managed through a public list today, with future versions expected to support in-app board creation
and voting for directory slots.
Seedit uses default community lists for its front page. Communities can still be created and shared
outside that default list.
In both cases, the app-level list helps users find something to open, and the protocol-level lookup
then resolves the chosen community to peers.
## Why this split matters
A single decentralized network still needs good discovery, but the discovery layer should be
replaceable. Bitsocial's core protocol focuses on addressability, peer lookup, publishing, and
anti-spam. Curation lives above that layer, where apps can experiment with directories, default
lists, feeds, search, voting, and moderation policies without requiring a network-wide migration.
### i18n Default Language Policy
Source: https://docs.bitsocial.net/i18n-default-language-policy/
Path: docs/i18n-default-language-policy.md
Description: Anonymous visitors resolve language in this order:
# i18n Default Language Policy
Anonymous visitors resolve language in this order:
1. `?lang=` query param
2. Saved language selector choice (`localStorage` in the browser, mirrored to the `i18nextLng` cookie for SSR)
3. Browser/device language when it is one of our supported locales
4. Fall back to `en`
Country or region is not used to override a supported browser/device language. For example, `de-DE` resolves to German, `nl-NL` resolves to Dutch, and `pt-PT` resolves to Portuguese because those locale families are supported.
If no browser/device language matches a supported locale, visitors receive the English default.
### A complete layman explanation of the Bitsocial protocol
Source: https://docs.bitsocial.net/layman-protocol-explanation/
Path: docs/layman-protocol-explanation.md
Description: A plain-English walkthrough of Bitsocial communities, peer lookup, publishing, anti-spam challenges, moderation, and apps.
# A complete layman explanation of the Bitsocial protocol
This page explains Bitsocial without assuming you already understand peer-to-peer networking,
cryptographic keys, IPFS, or pubsub.
Some details are simplified on purpose. For the more technical version, read the
[Peer-to-Peer Protocol](https://docs.bitsocial.net/peer-to-peer-protocol/) page.
## The short version
Bitsocial is a protocol for social apps where communities are owned by keys instead of by a
company database.
A Bitsocial community has an address. Apps use that address to find peers that are serving the
community, fetch the latest posts from those peers, and publish new posts through a peer-to-peer
message channel. Before a post is accepted, the community can require an anti-spam challenge such
as a captcha, invite code, payment, token check, AI moderation check, allowlist, or any other rule
that can be coded.
That is the core idea:
1. A community is controlled by a private key.
2. The public key gives the community a stable address.
3. Peers help readers find and fetch the community.
4. A community node accepts or rejects new posts.
5. Anti-spam policy belongs to each community, not to one global platform.
## Why hashes matter
A hash is a short fingerprint for data.
If two people hash the exact same file, they get the same fingerprint. If the file changes, the
fingerprint changes. That makes hashes useful for finding and checking data without trusting a
company to tell you what the file is.
Peer-to-peer systems use this idea constantly. Instead of asking one website for "the file named
photo.png", a peer can ask the network for the data with a specific fingerprint. If another peer
returns the wrong data, the hash check fails.
Bitsocial uses hashes and content identifiers for post data and other pieces of community state.
The important point is simple: data can be addressed by what it is, not only by where a company
hosted it.
## Why public keys matter
A public key and a private key are a matched pair.
The private key is secret. It is the thing that gives control. The public key is safe to share. It
lets everyone else check that a message, update, or moderation action really came from the matching
private key.
This is how Bitsocial avoids normal platform accounts. A company does not need to issue the
identity. A database row does not need to define the owner. The keypair is the authority.
In plain terms:
- the private key is the owner's control handle
- the public key is the public identity or address
- signatures prove that an action came from the owner
## What a Bitsocial community is
A Bitsocial community is not just a page in one app.
It has its own keypair. The public key gives the community a stable network address. The private key
controls updates to the community's state, such as metadata, rules, moderator list, challenge
configuration, and the pointers to the latest accepted content.
That means a community can outlive one interface. One app can show it as a board. Another app can
show it as a forum. A future app can show it in a profile-based feed. The app can change, but the
community address still points to the same owned community.
## How reading works
When a user opens a Bitsocial community, the app does not ask one central database for the page.
The flow is closer to this:
1. The app already knows the community address, or gets it from a list, link, search surface, or
human-readable name.
2. The app asks lightweight routers which peers currently provide that community address.
3. The routers return peer addresses only. They do not return posts, rules, profiles, or community
metadata.
4. The app connects to peers and fetches the latest community state.
5. That state contains pointers to post content.
6. The app fetches the post content from peers and renders it in a normal social interface.
The router is only a lookup helper. It is closer to asking "who has this?" than asking "please
serve me the whole website."
For more detail on this split, read [Content Discovery](https://docs.bitsocial.net/content-discovery/).
## How posting works
Posting is different from reading because open peer-to-peer networks can be spammed.
Bitsocial handles publishing through a challenge-response flow:
1. The user writes a post or reply.
2. The app joins the community's peer-to-peer message topic.
3. The app asks the community node for a challenge.
4. The community node sends back the challenge.
5. The user or app completes the challenge.
6. The app sends the post plus the challenge answer.
7. The community node checks the answer and the post.
8. If it passes, the community node accepts the post into the community's next update.
9. Other readers fetch the updated community state from peers.
The challenge happens before the post becomes part of the accepted community state. That is the
important difference from systems where spam is accepted first and hidden later.
## Why anti-spam challenges matter
Most social platforms turn anti-spam into platform policy. One company decides what counts as a
valid account, valid post, valid reach, or valid user.
Bitsocial separates those things. The protocol gives communities a way to require a challenge
before accepting a post, but it does not force every community to use the same challenge.
One community might use a captcha. Another might use invite codes. Another might require an SMS
check, a payment, an NFT, a token balance, an AI moderation score, a proof of reputation, a
community-specific allowlist, or a custom rule.
That flexibility matters because spam changes. A protocol-level spam rule becomes stale. A
community-level challenge can evolve without migrating the whole network.
For the focused explanation, read [Custom Anti-Spam Challenges](https://docs.bitsocial.net/custom-challenges/).
## How moderation works
Bitsocial is not moderation-free. It is moderation without one global super-admin.
A community can have owners and moderators. Moderator addresses are part of the community state.
When a moderator takes an action, that action can be signed. The community node and clients can
check the signature against the moderator list.
That gives moderation a local scope:
- a community owner controls that community
- moderators act through keys the community recognizes
- apps can still choose what they index, rank, hide, or highlight
- no protocol-level company account can erase every identity or seize every community
In practice, this means a community can remove spam or enforce rules inside its own space without
turning its rules into law for the entire network.
For the policy view, read [Local Moderation, Not Global Bans](https://docs.bitsocial.net/local-moderation/).
## What apps add
The protocol does not decide what the whole product should look like.
An app adds the human experience around the protocol:
- default community lists
- search and discovery
- feeds and ranking
- layout and posting interface
- media handling
- moderation tools
- mobile, desktop, or browser packaging
- business model and defaults
That is why Bitsocial can support different app styles. 5chan can feel like an imageboard. Seedit
can feel like forum-style discussion. Other clients can build different discovery surfaces,
ranking systems, moderation views, or community defaults while still using compatible Bitsocial
communities underneath.
The protocol keeps ownership and publishing portable. Apps compete on product quality.
## What public RPC adds
Running a peer-to-peer community node directly is powerful, but not everyone wants to manage an
always-on machine.
Public RPC is the service layer that can make Bitsocial more convenient. A public RPC provider can
help users manage communities from a phone or lightweight client, while the long-term ownership
model should still let users move away, self-host, or choose a competing provider.
The distinction matters:
- RPC can help with uptime and convenience
- RPC should not become permanent custody
- the owner relationship should remain tied to keys, not to one provider's database
The first service built on this model is Forge RPC by [Bitsocial Forge](https://bitsocialforge.com/#forge-rpc), which builds on the [Delegated IPNS](https://github.com/pkcprotocol/pkc-js/blob/master/docs/protocol/delegated-ipns.md) protocol design.
## What Bitsocial is not
Bitsocial is not a blockchain social network. Social media does not need every post to become a
transaction in one global ledger.
Bitsocial is not federation in the ActivityPub sense. A community does not need to be an account on
one server with one domain, one admin, and one server database.
Bitsocial is also not one app. It is a shared protocol layer for apps, communities, nodes, routers,
RPC providers, discovery services, anti-spam modules, and moderation tools.
The point is not that every user needs to understand all of this before posting. The point is that
the product can feel normal while the ownership model underneath is different.
## Where to go next
- [Peer-to-Peer Protocol](https://docs.bitsocial.net/peer-to-peer-protocol/) explains the technical flow.
- [Content Discovery](https://docs.bitsocial.net/content-discovery/) explains network lookup versus app curation.
- [Custom Anti-Spam Challenges](https://docs.bitsocial.net/custom-challenges/) explains the challenge system.
- [Identity and Community Ownership](https://docs.bitsocial.net/identity-and-ownership/) explains key-controlled
ownership.
- [Build your own client](https://docs.bitsocial.net/build-your-own-client/) explains how independent apps can build on
the same network.
### BSO Token History
Source: https://docs.bitsocial.net/token-history/
Path: docs/token-history.md
Description: The full generation history of the BSO token, from its 2021 Avalanche origin to today's immutable, adminless Ethereum contract.
# BSO Token History
BSO is a provenance coin. The protocol behind Bitsocial is open, and the token and chain are
optional by design: anyone can fork the code, run their own client, or build their own economy on
top of it. What cannot be forked away is provenance. BSO has been the official Bitsocial token
since day one, and every migration since then is verifiable on-chain.
This page lists every generation of the token, in order, with full contract addresses so anyone can
check the record independently.
## Gen 1: the origin, Avalanche, 2021
- **Chain**: Avalanche
- **Year**: 2021
- **Address**: `0x625fc9bb971bb305a2ad63252665dcfe9098bee9`
- **Explorer**: [Snowscan](https://snowscan.xyz/address/0x625fc9bb971bb305a2ad63252665dcfe9098bee9)
This is where BSO started. The full supply was airdropped, with no presale and no team allocation
carved out ahead of the community. The contract was an upgradeable proxy, which was standard
practice at the time and let the team ship fixes during the token's early life.
## Gen 2: the move to Ethereum, 2024
- **Chain**: Ethereum
- **Year**: 2024
- **Address**: `0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f`
- **Explorer**: [Etherscan](https://etherscan.io/token/0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f)
Gen 2 moved BSO from Avalanche to Ethereum, where the rest of the Bitsocial Chain roadmap is built.
Like Gen 1, this contract was still an upgradeable proxy, kept for one more generation while the
final, permanent contract was prepared.
## Gen 3: fully immutable, 2025
- **Chain**: Ethereum
- **Year**: 2025
- **Address**: `0xB50cea4c109dc223A10d44c14f521CaeD91DaB5A`
- **Explorer**: [Etherscan](https://etherscan.io/token/0xB50cea4c109dc223A10d44c14f521CaeD91DaB5A)
Gen 3 is the current and final BSO contract. It is fully immutable and adminless:
- no mint function, so supply cannot be inflated
- no owner address, so no one can unilaterally change contract behavior
- no pause function, so transfers cannot be frozen
- no proxy pattern, so the logic itself cannot be swapped out later
This is the end state the first two generations were built toward: a token with no admin keys left
to hold.
## How the migrations worked
Each migration, Gen 1 to Gen 2 and Gen 2 to Gen 3, was a passive 1:1 airdrop. Holders did not need
to submit a claim, sign a message, or take any action at all. Balances on the old contract were
read directly and mirrored 1:1 onto the new contract, so a holder's position was preserved exactly
across the migration.
Because both the old and new contracts remain public and on-chain, every step of this process is
independently verifiable. Anyone can compare historical holder snapshots from Gen 1 or Gen 2 against
current Gen 3 balances and confirm the migration matched what it claimed to do. No part of this
history depends on trusting Bitsocial's word for it.
## Verify everything
Do not take any of this on faith. Check the record directly:
- Gen 1 on [Snowscan](https://snowscan.xyz/address/0x625fc9bb971bb305a2ad63252665dcfe9098bee9)
- Gen 2 on [Etherscan](https://etherscan.io/token/0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f)
- Gen 3 on [Etherscan](https://etherscan.io/token/0xB50cea4c109dc223A10d44c14f521CaeD91DaB5A)
- the current chain site at [chain.bitsocial.net](https://chain.bitsocial.net)
If an address does not match what is listed here, it is not the official BSO token.
## Companion project READMEs
### Bitsocial Web
Repository: bitsocialnet/bitsocial-web
Source: https://github.com/bitsocialnet/bitsocial-web#readme
Description: Bitsocial Web is the public web monorepo for Bitsocial.
# Bitsocial Web
Bitsocial Web is the public web monorepo for Bitsocial.
It currently serves:
- `https://bitsocial.net/` for the public Bitsocial landing/about site
- `https://chain.bitsocial.net/` for the Bitsocial Chain landing site (BSO token and L2 appchain)
- `https://docs.bitsocial.net/` for Docusaurus docs
- `https://stats.bitsocial.net/` for the Grafana-backed stats dashboard
Chain, docs, and stats are served on their own subdomains while remaining in this monorepo. Legacy `/docs` and `/stats` paths on `bitsocial.net` redirect permanently to their dedicated subdomains.
## Repo Layout
```text
about/ Public Bitsocial landing/about site
chain/ Bitsocial Chain landing site (BSO token and L2 appchain)
docs/ Docusaurus docs, i18n files, and contributor playbooks
stats/ Grafana, Prometheus, Docker Compose, deploy config, and monitor package
scripts/ Shared repo scripts and agent hooks
```
Each top-level subproject has its own local documentation:
- [`about/README.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/about/README.md)
- [`docs/README.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/docs/README.md)
- [`stats/README.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/stats/README.md)
The `chain/` workspace is a standalone Vite app for the Bitsocial Chain marketing site. Run it with `corepack yarn start:chain` or build it with `corepack yarn build:chain`.
The repo root remains the orchestration layer for installs, verification, and cross-project commands.
## Getting Started
Use the pinned Node.js version from [`.nvmrc`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/.nvmrc) and Yarn 4 via Corepack.
```bash
nvm install && nvm use
corepack enable
corepack yarn install
```
The main local URL is:
- `https://bitsocial.localhost`
Portless keeps a stable named local URL. On non-`master` branches, the repo can fall back to a branch-scoped `*.bitsocial.localhost` route so parallel worktrees do not collide.
## Common Commands
```bash
corepack yarn start
corepack yarn start:android-usb
corepack yarn start:ios-sim
corepack yarn llms:generate
corepack yarn build:verify
corepack yarn build
corepack yarn docs:build:verify
corepack yarn lint
corepack yarn typecheck
corepack yarn format:check
corepack yarn doctor
corepack yarn build:stats-dashboards
corepack yarn stats:up
corepack yarn stats:down
corepack yarn stats:logs
```
## Verification
Before committing code changes, run:
```bash
corepack yarn build:verify
corepack yarn lint
corepack yarn typecheck
corepack yarn format:check
```
Use `corepack yarn build` only when you intentionally need the full production build, including
the multi-locale docs output.
If you changed React UI logic in `about/src/**`, also run:
```bash
corepack yarn doctor
```
If dependencies or manifests changed, also run:
```bash
corepack yarn deps:check-pinned
corepack yarn deps:check-hardened
corepack yarn knip
```
If public-facing English content changed, regenerate the LLM indexes:
```bash
corepack yarn llms:generate
```
This keeps `about/public/llms*.txt`, `chain/public/llms*.txt`, and `docs/static/llms*.txt`
aligned with the landing pages, docs pages, project directory data, public README text, and
the generator itself.
## Subproject Notes
### `chain/`
- Contains the Bitsocial Chain landing site for BSO and the proposed L2 appchain
- Standalone Vite app under `chain/`; served at `https://chain.bitsocial.net/`
- Has a translated language-selection shell, while the substantive Chain narrative is currently authored in English
### `about/`
- Contains the Bitsocial landing/about site
- Currently also contains the public `/projects` catalog and `/apps/:slug` app detail routes
- Keeps static assets and translations in `about/public/`
- Should not be treated as the long-term home for the project catalog or the blog
### `docs/`
- Contains the canonical Docusaurus project
- Translation source lives in `docs/i18n/`
- Contributor playbooks and long-running agent state also live here
### `stats/`
- Contains the Grafana/Prometheus stack and deployment files
- The executable monitor service lives in `stats/monitor/`
- Public traffic is served at `stats.bitsocial.net`, with Grafana hosted on the VPS origin
### Future Splits
- The existing project catalog at `/projects` is expected to be extracted from `about/` into its own subproject, and later may move to `apps.bitsocial.net`.
- The blog is also expected to become its own subproject rather than continue to live under `about/`.
- `about/` should stay focused on explaining Bitsocial as a whole.
## Translations
Landing-site translations live under:
- `about/public/translations/{lang}/default.json`
The Chain site's translated interface strings live under:
- `chain/public/translations/{lang}/default.json`
The Chain landing narrative is currently authored in English in `chain/src/sections/`.
Docs translations live under:
- `docs/i18n/{lang}/...`
For translation workflow details, see [`docs/agent-playbooks/translations.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/docs/agent-playbooks/translations.md).
## AI Contributor Policy
This repo uses tracked AI workflow files and instructions. Read [`AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/AGENTS.md) before making changes.
Relevant local rules also live in:
- [`about/AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/about/AGENTS.md)
- [`about/src/AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/about/src/AGENTS.md)
- [`docs/AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/docs/AGENTS.md)
- [`stats/AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/stats/AGENTS.md)
- [`scripts/AGENTS.md`](https://github.com/bitsocialnet/bitsocial-web/blob/HEAD/scripts/AGENTS.md)
## Deployment Shape
- `bitsocial.net` is served by Vercel
- `chain.bitsocial.net` is served from the standalone Chain app
- `docs.bitsocial.net` is served from the docs build
- `stats.bitsocial.net` routes to the VPS-hosted Grafana stack
- Legacy `/docs` and `/stats` paths on `bitsocial.net` return permanent redirects to those subdomains
- `newsletter.bitsocial.net` remains separate
### Verified App Mirror Deployments
The `/apps` catalog can show a verified check next to app mirrors when a mirror serves
the same HTML entrypoint hash as an official GitHub release artifact. This is meant to
attest release parity, not domain ownership.
For 5chan and Seedit verified web links, the Vercel `5chan` and `seedit` projects are
configured so Git pushes do not automatically create deployments:
```text
gitProviderOptions.createDeployments = "disabled"
```
Production domains such as `5chan.cc`, `5channel.org`, `5chan.app`, `seedit.app`,
`www.seedit.app`, and `p2p.seedit.app` should only be updated from a GitHub release
or tag deployment. Do not point those domains at a raw `master` deployment unless
that deployment has first been promoted into an official release and its static HTML
artifact hash has been checked.
When adding or updating mirror verification metadata in `about/src/lib/apps-data.ts`,
verify the live mirror HTML against the matching release ZIP before showing the badge.
If the live hash does not match a GitHub release artifact, leave the mirror unverified
until the mirror is redeployed from the release or a new release is cut for the served
artifact.
## Commit Workflow
This repo uses Commitizen for Conventional Commits.
- Interactive: `corepack yarn commit`
- Non-interactive: `git commit -m "type(scope): message"`
The Husky Commitizen hook may print a `/dev/tty` warning in non-interactive shells, but the commit
can still succeed when the message is already provided. Use `--no-verify` only when you explicitly
need to bypass local hooks.
### 5chan
Repository: bitsocialnet/5chan
Source: https://github.com/bitsocialnet/5chan#readme
Description: 5chan is a serverless, adminless, decentralized and open source imageboard built on the Bitsocial protocol. It features the classic imageboard directory structure, but with a crucial difference: anyone can create and own boards, and multiple boards can compete for each directory slot .
# 5chan
5chan is a serverless, adminless, decentralized and open-source imageboard built on the [Bitsocial protocol](https://bitsocial.net). It features the classic imageboard directory structure, but with a crucial difference: **anyone can create and own boards, and multiple boards can compete for each directory slot**.
## Key Features
### Decentralized Board Ownership
Unlike traditional imageboards, 5chan has no global admins or central authority. Anyone can create unlimited boards using [5chan Board Manager](https://github.com/bitsocialnet/5chan-board-manager). Each board owner runs their own P2P node that users connect to peer-to-peer, giving them complete control over their board's content, moderation, and rules.
### Competitive Directory System
5chan maintains the familiar imageboard directory structure (Japanese Culture, Video Games, Interests, Creative, etc.), but introduces competition: **multiple boards can compete for each directory slot**. For example, there can be unlimited "Business & Finance" boards, but only the highest-voted one appears in the directory on the homepage.
Currently, directory assignments are temporarily handpicked by developers through GitHub pull requests. In the future, each directory will have its own voting page. 5chan Pass holders are expected to participate in directory voting, while final governance mechanics are still being designed to include BSO-holder alignment instead of pass-only final control.
### How It Works
- **Current System**: Developers manually curate directory assignments by reviewing pull requests to the [5chan directory files](https://github.com/bitsocialnet/lists/tree/master/5chan-directories).
- **Future System**: Each directory will have its own voting page listing the boards competing for that slot. 5chan Pass holders are expected to participate in directory voting, while final governance mechanics are still being designed to include BSO-holder alignment instead of pass-only final control.
- **Accessing Boards**: Users can access any board at any time using its address, regardless of directory assignment. Boards can be accessed via the search bar, by subscribing to them (which adds them to the top bar), or by directly navigating to their address.
### Future Roadmap
#### In-App Board Creation
Creating boards directly from the 5chan web app (5chan.app) is planned. This requires connecting via RPC to a bitsocial node—technically already possible, but there's no default connection configured. A default connection would require a public RPC service (similar to what Infura provides for crypto wallets, but for bitsocial nodes). This would allow all users to be connected to a P2P node by default using a free tier subscription in the background, potentially monetized via ads injected in the RPC service-owned boards.
#### Directory Voting
Directory voting pages are planned for each slot on 5chan. These pages will list the competing boards for that directory, and 5chan Pass holders are expected to participate in voting. Final governance mechanics are still being designed to include BSO-holder alignment instead of pass-only final control.
## Downloads
- **Web version**: https://5chan.app (also available using Brave/IPFS Companion on https://5chan.eth)
- **Desktop version** (full P2P bitsocial node, seeds automatically): Available for Mac/Windows/Linux, [download from the release page](https://github.com/bitsocialnet/5chan/releases/latest)
- **Mobile version**: Available for Android, [download from the release page](https://github.com/bitsocialnet/5chan/releases/latest)
## Run 5chan in Your Browser With a Local Node
If you want the full P2P node but prefer opening 5chan in your normal browser instead of using the desktop app, use [bitsocial-cli](https://github.com/bitsocialnet/bitsocial-cli). It runs the Bitsocial/IPFS node and serves the bundled 5chan Web UI locally, so you do not need to run this repository separately.
```sh-session
npm install -g @bitsocial/bitsocial-cli
bitsocial daemon
```
When the daemon starts, it prints a `WebUI (5chan - Imageboard-style UI)` URL. Open that URL in your browser to use 5chan through your local node. See the [bitsocial-cli daemon docs](https://github.com/bitsocialnet/bitsocial-cli#running-daemon) for details.
## Creating a Board
In the bitsocial protocol, a 5chan board is called a _community_. To deliver the expected 5chan imageboard UX, a board should run on a bitsocial node together with [5chan Board Manager](https://github.com/bitsocialnet/5chan-board-manager). The board manager applies imageboard-style lifecycle rules that bitsocial communities do not enforce by themselves: thread limits, bump limits, archived-thread retention, and purging of author-deleted content.
To create and run a board:
1. Follow the Docker Compose flow in the [5chan-board-manager README](https://github.com/bitsocialnet/5chan-board-manager#docker-compose-recommended);
2. Create your community and add it to 5chan Board Manager using the commands shown there;
3. Keep the manager running so it can apply 5chan board behavior such as thread archiving, bump limits, and retention cleanup.
Once created, anyone can connect to your community using any bitsocial client (such as 5chan) by using the community address. The address is not stored in any central database—bitsocial is a pure peer-to-peer protocol.
Without 5chan Board Manager, a community can still be opened in 5chan, but it will not behave like a conventional imageboard board: old threads will not be archived when they fall past the last page, bump limits will not be enforced, archived threads will not be purged after the retention window, and author-deleted content will not be automatically purged.
**Note**: Creating boards directly from the 5chan web app is planned for the future (see [Future Roadmap](#future-roadmap)).
## Submitting Your Board to a Directory
To have your board appear in a directory on the 5chan homepage:
1. Ensure your board meets these requirements:
- Active and well-moderated
- Relevant to the directory category
- **99% uptime** (since a board acts like its own server—it's a P2P node)
2. Open a pull request on GitHub by editing the relevant file in the [5chan-directories folder](https://github.com/bitsocialnet/lists/tree/master/5chan-directories)
3. Add your board's entry with:
- Title: in the format `/directoryCode/ - Title`, e.g. "/biz/ - Business & Finance";
- Address: the bitsocial community address, whether IPNS key (`12KooW...`) or readable crypto address (`mydomain.eth`);
- NSFW status: `true` or `false`, must match the standard classification for the directory code.
4. The developers will review your PR and merge it if approved
**Note**: Even if your board isn't assigned to a directory, users can still access it at any time using its bitsocial community address. Directory assignment only affects visibility on the homepage.
## Development
### Prerequisites
- Node.js 22.12.0, pinned in [`.nvmrc`](https://github.com/bitsocialnet/5chan/blob/HEAD/.nvmrc)
- Corepack enabled once per machine: `corepack enable`
### Contributor Setup
1. Run `nvm install && nvm use`
2. Run `corepack enable` once
3. Use plain `yarn install`, `yarn build`, and `yarn test`
### Setup
1. Clone the repository
2. Install dependencies: `yarn install`
3. Start the web client: `yarn start`
The dev server normally runs at https://5chan.localhost via [Portless](https://github.com/vercel-labs/portless), which gives each Bitsocial project a stable, named URL instead of a random port. Portless 0.11 serves this URL through an HTTPS proxy on port 443, so the first `yarn start` after install or proxy reset may prompt for sudo; accept the prompt so the URL can stay portless. On non-`master` branches, or when another legacy process is already holding the canonical route, `yarn start` will automatically use a branch-scoped `*.5chan.localhost` URL instead of failing. To bypass Portless and use a plain Vite dev server, run `PORTLESS=0 yarn start`; it will start at `http://localhost:3000` and automatically fall forward to the next free port if `3000` is already in use.
For device testing on a USB-connected Android phone (without relying on `5chan.localhost` DNS from the device):
- `yarn start:android-usb` starts Vite bound to `127.0.0.1` and runs `adb reverse`, so the phone can load the dev site at `http://localhost:3000`. When the server is up, it opens that URL in each connected device’s default browser via `adb`. Set `ANDROID_USB_OPEN_BROWSER=0` to skip auto-open. Requires [Android platform-tools](https://developer.android.com/tools/releases/platform-tools) (`adb` on your `PATH`), USB debugging enabled, and the device showing as `device` in `adb devices`.
### Scripts
- **Web client**: `yarn start` (https://5chan.localhost)
- **Web client (Android phone over USB)**: `yarn start:android-usb` (see above)
- **Electron client** (must start web client first): `yarn electron`
- **Electron client** (don't delete data): `yarn electron:no-delete-data`
- **Web client and electron client**: `yarn electron:start`
- **Web client and electron client** (don't delete data): `yarn electron:start:no-delete-data`
### Challenge Types
Bitsocial communities can require users to solve one or more anti-spam challenges before a publication is accepted. 5chan already supports multiple challenge types, including `url/iframe` challenges so [Mintpass](https://github.com/bitsocialnet/mintpass) communities can run their iframe flow directly inside a modal. The modal first shows a hostname confirmation (showing only the host for mintpass.org, full URL otherwise), then opens the HTTPS iframe with the current theme, replaces `{userAddress}` tokens with the signed-in address, and submits automatically when the user finishes.
5chan also has an optional UX integration with [`@bitsocial/ai-moderation-challenge`](https://github.com/bitsocialnet/ai-moderation-challenge): when a failed verification contains the exact message `This media was already posted recently.`, 5chan abandons the rejected pending publication, returns to the originating post form, preserves the draft, and shows the message inline. This string is an intentionally small cross-package contract rather than a package dependency. Challenges that omit it, or return any other error, continue through the normal generic challenge-error flow.
### Build
The Linux/Windows/macOS/Android build scripts are in [.github/workflows/release.yml](https://github.com/bitsocialnet/5chan/blob/master/.github/workflows/release.yml)
## License
5chan is open-source software (GPL-3.0-or-later) with no owner—anyone can host their own instance on any domain. The operator of any domain is merely hosting the web app and does not own, create, moderate, or control 5chan or any board content, which is stored peer-to-peer and generated by board owners and users.
### Seedit
Repository: bitsocialnet/seedit
Source: https://github.com/bitsocialnet/seedit#readme
Description: Seedit is a serverless, adminless, decentralized and open source (old)reddit alternative built on the Bitsocial protocol. Like reddit, anyone can create a seedit community. Unlike reddit, communities are independently owned, subscriptions point directly to community addresses, and Seedit's default communities can evolve with the network.
# Seedit
Seedit is a serverless, adminless, decentralized and open-source (old)reddit alternative built on the [Bitsocial protocol](https://bitsocial.net). Like reddit, anyone can create a seedit community. Unlike reddit, communities are independently owned, subscriptions point directly to community addresses, and Seedit's default communities can evolve with the network.
- Seedit web version: https://seedit.app — or, using Brave/IPFS Companion: https://seedit.eth
### Downloads
- Seedit desktop version (full p2p bitsocial node, seeds automatically): available for Mac/Windows/Linux, [download link in the release page](https://github.com/bitsocialnet/seedit/releases/latest)
- Seedit mobile version: available for Android, [download link in the release page](https://github.com/bitsocialnet/seedit/releases/latest)
## How to create a community
To run a community, you can choose between two options:
1. If you prefer to use a **GUI**, download the desktop version of the Seedit client, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialnet/seedit/releases/latest). Create a community using using the familiar old.reddit-like UI, and modify its settings to your liking. The app runs an IPFS node, meaning you have to keep it running to have your board online.
2. If you prefer to use a **command line interface**, install bitsocial-cli, available for Windows, MacOS and Linux: [latest release](https://github.com/bitsocialnet/bitsocial-cli/releases/latest). Follow the instructions in the readme of the repo. When running the daemon for the first time, it will output WebUI links you can use to manage your community with the ease of the GUI.
Peers can connect to your bitsocial community using any bitsocial client, such as Seedit or [5chan](https://github.com/bitsocialnet/5chan). They only need the community address, which is not stored in any central database, as bitsocial is a pure peer-to-peer protocol.
### How to add a default community
Seedit's versioned default communities are published in Bitsocial's [seedit-default-subscriptions.json list](https://github.com/bitsocialnet/lists/blob/master/seedit-default-subscriptions.json). New accounts subscribe to these communities by default. When the list changes, existing users can review the update and choose which additions to join; Seedit never removes a manually chosen subscription. You can open a pull request in that repository to propose a community for the list.
### How directory routes work
Seedit separates short discovery routes from subscriptions. A route such as `/s/pics` always opens the finalized winner of the `pics` directory, so valuable short routes are not permanently controlled by squatters or global administrators. The topbar therefore displays `pics`, regardless of the winner's exact address. The directory model is designed for decentralized voting; during bootstrap, finalized snapshots are published through [bitsocialnet/lists](https://github.com/bitsocialnet/lists).
Joining still subscribes to that winner's exact community address, and the home feed reads those exact subscriptions directly. Directory winner changes never silently replace a subscription unless the user explicitly enabled automatic switching; otherwise Seedit lets the user switch, keep both communities, or keep the current one. Post permalinks also use exact community addresses so shared links remain durable. See the [directory-routes architecture decision](https://github.com/bitsocialnet/seedit/blob/HEAD/docs/architecture/directory-routes.md) for the complete model.
## Contributor setup
1. `nvm install && nvm use`
2. Run `corepack enable` once on your machine
3. Use plain `yarn install`, `yarn build`, and `yarn test` from then on
## To run locally
1. `yarn install` to install Seedit dependencies
2. `yarn start` to run the web client
The default web dev server runs at `https://seedit.localhost` via [Portless](https://github.com/vercel-labs/portless), so it can share the same proxy as other Bitsocial projects without colliding on raw Vite ports. On non-`master` branches, or when another legacy process is already holding the canonical route, `yarn start` automatically uses a branch-scoped `*.seedit.localhost` URL instead of failing, and repeated branch-scoped runs keep suffixing (`-2`, `-3`, ...) until they find a free route. To bypass Portless and use plain Vite directly, run `PORTLESS=0 yarn start`; it will probe from port `3000` unless you pin `PORT` yourself.
### Scripts:
- Web client: `yarn start` (`https://seedit.localhost`)
- Electron client (must start web client first): `yarn electron`
- Electron client and don't delete data: `yarn electron:no-delete-data`
- Web client and electron client: `yarn electron:start` (forces `PORTLESS=0 PORT=3000` and uses `http://localhost:3000`)
- Web client and electron client and don't delete data: `yarn electron:start:no-delete-data` (forces `PORTLESS=0 PORT=3000` and uses `http://localhost:3000`)
### Build:
The linux/windows/mac/android build scripts are in https://github.com/bitsocialnet/seedit/blob/master/.github/workflows/release.yml
### bitbones
Repository: bitsocialnet/bitbones
Source: https://github.com/bitsocialnet/bitbones#readme
Description: bitbones is a bare bones GUI client for the Bitsocial protocol — the smallest useful surface over @bitsocial/bitsocial react hooks. It exists to make the protocol legible: almost no styling, no product opinions, and every view a thin wrapper over one hook, so it is the fastest place to reproduce a bug or try a hooks change against real communities.
# bitbones
bitbones is a bare bones GUI client for the [Bitsocial protocol](https://bitsocial.net) — the smallest useful surface over [`@bitsocial/bitsocial-react-hooks`](https://github.com/bitsocialnet/bitsocial-react-hooks). It exists to make the protocol legible: almost no styling, no product opinions, and every view a thin wrapper over one hook, so it is the fastest place to reproduce a bug or try a hooks change against real communities.
- Web version: https://bitbones.app
- Desktop version (full p2p bitsocial node, seeds automatically): available for Mac/Windows/Linux, [download from the release page](https://github.com/bitsocialnet/bitbones/releases/latest)
- Mobile version: available for Android, [download from the release page](https://github.com/bitsocialnet/bitbones/releases/latest)
## Switching default community lists
bitbones does not curate its own default communities. The feed reads whichever client's list you pick, straight from [bitsocialnet/lists](https://github.com/bitsocialnet/lists):
- **seedit** — [`seedit-default-subscriptions.json`](https://github.com/bitsocialnet/lists/blob/master/seedit-default-subscriptions.json), the exact communities new Seedit accounts subscribe to.
- **5chan** — [`5chan-directories/`](https://github.com/bitsocialnet/lists/tree/master/5chan-directories), resolved the way 5chan resolves it: one winning board per directory code, ranked by score, then by when it was added, then by address.
The switch sits at the left of the menu bar on the `p/all` feed and shows both client marks, the active one lit. The selection persists in `localStorage` under `bitbonesDefaultList`. Both lists are also vendored into `src/data/` at build time by `yarn sync:lists`, so bitbones still has a feed when GitHub is unreachable.
seedit is the default because its list is 10 communities against 5chan's 64, and every extra community is another name resolution and page fetch on a cold peer-to-peer start.
## Development
```sh-session
nvm install
nvm use
corepack enable
yarn install
yarn start
```
| command | what it does |
| --- | --- |
| `yarn start` | vite dev server on http://localhost:5173 |
| `yarn build` | production build into `build/` |
| `yarn sync:lists` | refresh the vendored default community lists from bitsocialnet/lists |
| `yarn lint` | oxlint |
| `yarn type-check` | `tsc --noEmit` over `src/` |
| `yarn prettier` | oxfmt |
| `yarn knip` | unused/undeclared dependency check |
| `yarn electron:dev` | run the desktop app against the dev server |
| `yarn electron:make` | package the desktop app |
## Origins
bitbones is a port of an existing GPL bare-bones client onto the Bitsocial stack: `@bitsocial/bitsocial-react-hooks` for all protocol access, communities throughout, and the dependency stack brought up to the same versions 5chan and Seedit run. The first commit in this repository is the unmodified upstream source, so the entire port is reviewable as a single diff.
## License
GPL-3.0-or-later — see [LICENSE](https://github.com/bitsocialnet/bitbones/blob/HEAD/LICENSE).
### HuggingSocial
Repository: Rinse12/huggingsocial
Source: https://github.com/Rinse12/huggingsocial#readme
Description: Website: huggingsocial.co
# HuggingSocial
**Website: [huggingsocial.co](https://huggingsocial.co/)**
**BitTorrent moves the weights. BitSocial keeps the index and the conversation out of anyone's hands.**
HuggingSocial is a peer-to-peer place to find, discuss and download open AI models and datasets. It is a
model hub that no company owns: the list of models, the model cards, the comments and the votes all
live on a peer-to-peer network, and the model files themselves are fetched over BitTorrent (or plain
HTTP mirrors), verified on your machine with checksums.
## Why is this needed?
Almost all open AI models live on one website, Hugging Face. In August 2026 Nvidia agreed to buy it for
roughly $13 billion. Whatever you think of Nvidia, the open-model ecosystem now has a single owner for
its hosting, and the places where people *talk* about models (Reddit, Discord) are also companies
that can change their rules, sell, or be pressured at any time.
When one entity controls where models are stored, the next thing that gets controlled is which models
are visible, which are removed, and which conversations are allowed. Hugging Face has already removed
models it did not like ([GPT-4chan](https://thegradient.pub/gpt-4chan-lessons/)), and corporate takedowns
have wiped community copies ([Meta's DMCA against 403 LLaMA repositories](https://github.com/github/dmca/blob/master/2023/03/2023-03-21-meta.md)).
A torrent index would help with storage, but torrent indexes get taken down too,
and a torrent tells you nothing about *who* published a file.
HuggingSocial fixes the three parts together:
- **Discovery.** The index of models is held by communities ("hubs") that are controlled by cryptographic
keys, not by accounts on a website. Nobody can delete a hub except its owner.
- **Provenance.** Every model listing is signed by whoever published it, and every download is checked
against the checksums in that listing. A poisoned re-upload will simply fail verification.
- **Discourse.** Model cards, benchmarks, "does this quant run on my GPU", fine-tune requests and
license debates happen in the same hubs, with moderation set by each hub, not by a platform.
## How it works (short version)
- **Hubs** are communities on [BitSocial](https://bitsocial.net), a peer-to-peer social protocol built on
IPFS/libp2p. Each hub sets its own rules and anti-spam challenge.
- A **model listing** is a post in a hub: name, version, license, model card, file checksums, and a list of
download sources (Hugging Face URL, magnet link, IPFS, …).
- The **client** (web app, or desktop app with a built-in torrent engine) downloads from any of those
sources, verifies the files, and can keep seeding them so others get them faster.
- **HuggingSocial hosts nothing.** We build the client. Anyone can run a hub, an index, or a seedbox.
## Status
In design and early development. Sign up to the newsletter on the website to hear when there's
something to run. Technical design is in [DESIGN.md](https://github.com/Rinse12/huggingsocial/blob/HEAD/DESIGN.md); open work is in [TODO.md](https://github.com/Rinse12/huggingsocial/blob/HEAD/TODO.md).
## License
Free software, [GPL-3.0-or-later](https://github.com/Rinse12/huggingsocial/blob/HEAD/LICENSE): the clients, the tooling, the website. Contributions
welcome: pick something from [TODO.md](https://github.com/Rinse12/huggingsocial/blob/HEAD/TODO.md) or open an issue.
### MintPass - NFT Authentication Middleware for Bitsocial
Repository: bitsocialnet/mintpass
Source: https://github.com/bitsocialnet/mintpass#readme
Description: MintPass is an NFT based authentication system that provides verified identity proofs for decentralized communities. It began as an anti‑spam challenge for Bitsocial communities, and it works equally well for other protocols and social applications. Users mint a non‑transferable verification NFT (e.g., after SMS OTP) that communities can check to reduce sybil attacks, such as fake upvotes/downvotes, fake conversations, and users evading bans.
# MintPass - NFT Authentication Middleware for Bitsocial
MintPass is an NFT-based authentication system that provides verified identity proofs for decentralized communities. It began as an anti‑spam challenge for Bitsocial communities, and it works equally well for other protocols and social applications. Users mint a non‑transferable verification NFT (e.g., after SMS OTP) that communities can check to reduce sybil attacks, such as fake upvotes/downvotes, fake conversations, and users evading bans.
## How people use MintPass
1) Visit `mintpass.org/request`, enter a phone number, and complete SMS OTP.
2) MintPass mints an NFT (on testnet in this reference deployment) to your wallet or records an equivalent “verified” state when on‑chain minting is disabled.
3) Communities (e.g., Bitsocial communities) check ownership of the NFT to treat you as authenticated for anti‑spam.
The request form looks like this:
## Project Structure
```
mintpass/
├── contracts/ # MintPassV1 smart contract and tooling
├── challenge/ # Bitsocial challenge implementation (“mintpass”)
├── web/ # Next.js website + API (mintpass.org)
├── docs/ # Documentation and specifications
├── tests/ # Cross‑component integration tests
└── scripts/ # Deployment and utilities
```
### Subprojects
- `contracts/`: Solidity contracts (MintPassV1). Versioned, role‑based minting, token types per NFT (type 0 = SMS). See `contracts/README.md`.
- `challenge/`: The Bitsocial challenge that checks for a MintPass NFT and applies additional rules (e.g., transfer cooldowns) to resist sybils.
- `web/`: The user‑facing site and serverless backend. Sends SMS codes, verifies OTP, and mints or records successful verification. See `web/README.md`.
## Privacy and anti‑sybil design (high level)
- Short‑lived operational data (OTP codes, verification markers, rate‑limit state) stored in Redis with TTLs.
- Persistent “mint association” between wallet and phone to prevent duplicate mints.
- Optional IP reputation (VPN/proxy) and phone‑risk checks, optional geoblocking, and per‑IP cooldowns.
- Secrets live only in environment variables; logs avoid PII and never include OTPs or private keys.
## Getting started
1. Run `nvm install && nvm use`
2. Run `corepack enable` once per machine so `yarn` resolves to the pinned Yarn 4 release
3. Use plain `yarn install`, `yarn build`, and `yarn test`
- Contracts: `cd contracts && yarn install && yarn test`
- Challenge: `cd challenge && yarn install && yarn test`
- Web: `cd web && yarn install && yarn dev` then open `https://mintpass.localhost/request`
## Using MintPass in your community
Community owners add the MintPass challenge to their community settings. When enabled, every publication (post, reply, vote) requires the author to hold a valid MintPass NFT. The challenge is published as [`@bitsocial/mintpass-challenge`](https://www.npmjs.com/package/@bitsocial/mintpass-challenge) on npm.
### With pkc-js over RPC
If your RPC server is already running, first install the challenge on the server:
```bash
bitsocial challenge install @bitsocial/mintpass-challenge
```
Then from your RPC client, connect and set the challenge on your community by name — no npm install or challenge registration needed on the client side:
```ts
import PKC from "@pkcprotocol/pkc-js";
const pkc = await PKC({
pkcRpcClientsOptions: ["ws://localhost:9138"]
});
const community = await pkc.createCommunity({ address: "your-community-address.bso" });
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/mintpass-challenge",
options: {
chainTicker: "base",
contractAddress: "0x13d41d6B8EA5C86096bb7a94C3557FCF184491b9",
requiredTokenType: "0",
transferCooldownSeconds: "604800"
}
}
]
}
});
```
### With pkc-js (TypeScript)
Install the challenge package:
```bash
npm install @bitsocial/mintpass-challenge
```
Register the challenge and configure your community:
```typescript
import PKC from '@pkcprotocol/pkc-js'
import mintpassChallenge from '@bitsocial/mintpass-challenge'
// Register the challenge so it can be referenced by name
PKC.challenges['@bitsocial/mintpass-challenge'] = mintpassChallenge
const pkc = await PKC({ /* your pkc options */ })
const community = await pkc.createCommunity({ address: 'your-community.bso' })
await community.edit({
settings: {
challenges: [{
name: '@bitsocial/mintpass-challenge',
options: {
chainTicker: 'base',
contractAddress: '0x13d41d6B8EA5C86096bb7a94C3557FCF184491b9',
requiredTokenType: '0',
bindToFirstAuthor: 'true',
transferCooldownSeconds: '604800',
}
}]
}
})
```
#### Challenge options
All option values must be strings (pkc-js challenge convention).
| Option | Default | Description |
|--------|---------|-------------|
| `chainTicker` | `"base"` | Chain where MintPass is deployed |
| `contractAddress` | Base Sepolia default | Contract address (auto-detected for supported chains) |
| `requiredTokenType` | `"0"` | Token type (0 = SMS, 1 = Email) |
| `bindToFirstAuthor` | `"true"` | Bind NFT to first author per community |
| `transferCooldownSeconds` | `"604800"` | Cooldown after NFT transfer (1 week) |
| `error` | Default message | Custom error (`{authorAddress}` placeholder supported) |
| `rpcUrl` | Chain default | Optional custom RPC URL |
### With bitsocial-cli
Install the challenge package:
```bash
bitsocial challenge install @bitsocial/mintpass-challenge
```
Edit your community to use the challenge:
```bash
bitsocial community edit your-community.bso \
'--settings.challenges[0].name' @bitsocial/mintpass-challenge \
'--settings.challenges[0].options.chainTicker' base \
'--settings.challenges[0].options.contractAddress' '0x13d41d6B8EA5C86096bb7a94C3557FCF184491b9' \
'--settings.challenges[0].options.requiredTokenType' '0' \
'--settings.challenges[0].options.bindToFirstAuthor' 'true' \
'--settings.challenges[0].options.transferCooldownSeconds' '604800'
```
See the [bitsocial-cli documentation](https://github.com/bitsocialnet/bitsocial-cli) for full CLI reference.
## Where MintPass is useful
While designed for Bitsocial, any decentralized or serverless social app can use MintPass NFTs as a lightweight proof‑of‑personhood. Apps only need to check ownership of a token type (e.g., type 0 for SMS) to gate actions or increase trust in votes and reports.
## Roadmap and considerations
We plan to support multiple authentication methods alongside SMS OTP to fit different threat models and UX constraints:
- Add a “pay‑to‑mint” option with a small fee that is high enough to deter bulk purchases but low enough for regular users.
- Add additional human‑verification signals (e.g., email, government‑backed KYC providers, or proofs such as biometrics/world‑ID systems) when they can be integrated without compromising decentralization goals.
- Expand admin tooling, heuristics, and optional device signals to further reduce abuse.
These items are exploratory; concrete work will land incrementally and stay configurable so communities can choose what they trust.
## Technology Stack
- **Smart Contracts**: Solidity, Hardhat/Foundry
- **Website**: Next.js, React, Ethereum (ethers)
- **Challenges**: TypeScript, pkc-js integration
- **Deployment**: Base network (L2)
## License
MintPass is fully open source — you're free to run your own. It's a mixed-license monorepo (see [LICENSE](https://github.com/bitsocialnet/mintpass/blob/HEAD/LICENSE)):
- `web/` (the mintpass.org hosted site + API): AGPL-3.0-or-later
- `challenge/` (`@bitsocial/mintpass-challenge`): GPL-3.0-or-later
- `contracts/` (`@bitsocial/mintpass-contracts`): MIT
A hosted version is available at [mintpass.org](https://mintpass.org).
### @bitsocial/ai-moderation-challenge
Repository: bitsocialnet/ai-moderation-challenge
Source: https://github.com/bitsocialnet/ai-moderation-challenge#readme
Description: Automatic PKC challenge that evaluates Bitsocial comment content against community.rules with an OpenAI compatible model endpoint. The package runs on the community node and does not require a hosted Bitsocial moderation server.
# @bitsocial/ai-moderation-challenge
Automatic PKC challenge that evaluates Bitsocial comment content against `community.rules` with an OpenAI-compatible model endpoint. The package runs on the community node and does not require a hosted Bitsocial moderation server.
## Installation
```bash
bitsocial challenge install @bitsocial/ai-moderation-challenge
```
## Configuration
Install this challenge twice: one `allow` branch and one `review` branch. The `review` branch uses PKC `pendingApproval` to route rule-breaking comments to the moderator queue, and returns the AI model reason in `commentUpdate.reason` when the installed PKC runtime supports pending-approval metadata.
```js
[
{ name: "@bitsocial/spam-blocker-challenge" },
{
name: "@bitsocial/ai-moderation-challenge",
options: {
apiUrl: "https://api.x.ai/v1/chat/completions",
apiFormat: "chat-completions",
apiKey: "xai-...",
model: "grok-4.6",
reasoningEffort: "high",
triageApiKey: "sk-...",
triageModel: "gpt-5.6-luna",
triageReasoningEffort: "none",
branch: "allow",
promptUrl: "https://prompt.example.com/v1/prompts/ai-moderation.md",
promptBearerToken: "shared-secret-token"
},
exclude: [{ challenges: [2] }]
},
{
name: "@bitsocial/ai-moderation-challenge",
options: {
apiUrl: "https://api.x.ai/v1/chat/completions",
apiFormat: "chat-completions",
apiKey: "xai-...",
model: "grok-4.6",
reasoningEffort: "high",
triageApiKey: "sk-...",
triageModel: "gpt-5.6-luna",
triageReasoningEffort: "none",
branch: "review",
promptUrl: "https://prompt.example.com/v1/prompts/ai-moderation.md",
promptBearerToken: "shared-secret-token"
},
pendingApproval: true,
exclude: [{ challenges: [1] }]
}
];
```
Challenge options are private community-node settings in `pkc-js`: nothing in `options` is copied into the public community challenge metadata unless the owner names it in `publicOptions` (see [Settings validation and public options](#settings-validation-and-public-options)). Keep local settings backups private because they can contain provider keys or prompt access tokens.
Production operators should keep the real moderation prompt in a private node-local file referenced by `promptPath`, or in a private HTTPS endpoint referenced by `promptUrl` plus `promptBearerToken`. Do not commit production prompts to public repositories; the built-in prompt is only a public fallback and the challenge emits a warning when it is used.
## Options
| Option | Default | Description |
| ----------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `apiUrl` | `https://api.openai.com/v1/responses` | Full OpenAI-compatible endpoint URL |
| `apiFormat` | `responses` | Request/response format: `responses` or `chat-completions` |
| `apiKey` | none | Private provider API key; leave empty for self-hosted endpoints that do not require one |
| `model` | `gpt-5.4-nano` | Model name sent to the provider |
| `fallbackModel` | none | Secondary model used once when the primary model returns HTTP 429 |
| `reasoningEffort` | none | Optional primary-model reasoning effort: `none`, `low`, `medium`, `high`, `xhigh`, or `max` |
| `triageApiUrl` | `https://api.openai.com/v1/responses` | Full endpoint URL for the optional first-pass triage model |
| `triageApiFormat` | `responses` | Triage request/response format: `responses` or `chat-completions` |
| `triageApiKey` | none | Private triage-provider API key |
| `triageModel` | none | Optional first-pass model; setting it enables the two-stage cascade |
| `triageReasoningEffort` | none | Optional triage-model reasoning effort: `none`, `low`, `medium`, `high`, `xhigh`, or `max` |
| `branch` | `allow` | Branch mode: `allow` or `review` |
| `prompt` | built-in prompt | Private inline system prompt text |
| `promptPath` | none | Private file path for a system prompt on the community node; `~` expands to the home directory |
| `promptUrl` | none | Private HTTPS URL for a remotely hosted system prompt |
| `promptBearerToken` | none | Private bearer token sent only when fetching `promptUrl` |
| `cachePath` | `~/.bitsocial-ai-moderation-cache.json` | Private JSON verdict cache path; set to an empty string to disable persistent caching |
| `auditLogPath` | `~/.bitsocial-ai-moderation-audit.jsonl` | Private JSONL verdict audit log path; set to an empty string to disable audit logging |
| `rejectDuplicateMedia` | `false` | Reject top-level posts that reuse an image, video, or audio URL from a non-archived post in the same community |
| `error` | `Rejected by Bitsocial AI moderation.` | Error shown when content edits are rejected or moderation is unavailable for an edit |
Prompt source precedence is `prompt` > `promptPath` > `promptUrl` > built-in fallback. If multiple private prompt sources are configured, the challenge uses the highest-precedence source and emits a warning about the ignored source.
Remote prompts must use HTTPS. When `promptBearerToken` is set, it is sent as an `Authorization: Bearer ...` header rather than in the URL. Prefer `.md` or `text/markdown` for human-maintained prompts and `.txt` or `text/plain` for plain text prompts; the model only receives the fetched text, so the file extension itself does not change model behavior.
For providers exposing the chat-completions API shape, set both `apiFormat` and `apiUrl`:
```js
{
name: "@bitsocial/ai-moderation-challenge",
options: {
branch: "allow",
apiFormat: "chat-completions",
apiUrl: "https://provider.example/v1/chat/completions",
apiKey: "provider-key",
model: "provider-model",
reasoningEffort: "high"
}
}
```
OpenAI-compatible APIs are a practical compatibility convention, not a formal open standard. Test custom providers before enabling the challenge on live communities.
Provider API keys are only sent to HTTPS endpoints. Keyless HTTP endpoints remain available for trusted local or self-hosted deployments.
To enable 5chan-style exact-media rejection, set `rejectDuplicateMedia: "true"` on both the `allow` and `review` challenge entries. PKC challenge option values are strings; leaving this option unset preserves the default and does not perform the deterministic hard-rejection check.
## Settings validation and public options
`pkc-js` 0.0.85+ validates `community.settings.challenges[i]` on every community edit, creation, and start. For this challenge that means:
- Option keys that are not listed in the table above are rejected as typos by `pkc-js` itself.
- The challenge's `validateChallengeSettings` hook rejects the same option errors that would otherwise fail every publication: an `apiUrl` or `triageApiUrl` that is not `http`/`https`, a keyed provider URL that is not HTTPS, a `promptUrl` that is not `https`, an unknown API format, reasoning effort, or `branch`, or a `rejectDuplicateMedia` value other than `true`/`false`. The hook is synchronous and never contacts a provider, so a missing or wrong API key is only discovered when a publication is moderated (fail closed).
- If `promptPath` does not exist on the node, the hook logs it through `pkc-logger` but does not reject the settings, so a prompt file that is created later does not block the community.
- Rejections fail the offending `community.edit()`; at start they surface as community `error` events with code `ERR_CHALLENGE_SETTINGS_VALIDATION_FAILED` and the community still starts. Existing settings that were silently broken start emitting these errors after upgrading.
Every option is private by default. An owner can publish specific options by naming them in `publicOptions`, and `pkc-js` then copies their values into the public `community.challenges[i].publicOptions`. The hook refuses to publish `apiKey`, `triageApiKey`, and `promptBearerToken` because they are credentials. Everything else is the owner's call: publishing `prompt`, `promptUrl`, or `promptPath` is a transparency choice, but it lets users read the moderation prompt and try to game it, and publishing provider URLs, `cachePath`, or `auditLogPath` reveals private node details. Most communities should leave `publicOptions` unset.
```js
{
name: "@bitsocial/ai-moderation-challenge",
options: { apiKey: "sk-...", branch: "allow", rejectDuplicateMedia: "true" },
publicOptions: ["branch", "rejectDuplicateMedia"]
}
```
## Behavior
- New comments with verdict `allow` publish normally.
- New comments with verdict `review` are sent to pending approval with the redacted model reason attached for the author.
- New comments are also sent to pending approval if the model API is unavailable.
- When `triageModel` is configured, its `allow` verdict is final and avoids a primary-model call. A triage `review` verdict or triage-provider failure calls the primary reviewer, and only that reviewer's verdict can send content to pending approval.
- Each provider request times out after 30 seconds. A triage timeout escalates to the primary reviewer; a primary-reviewer timeout follows the same fail-closed path as other provider failures.
- When `fallbackModel` is configured, an HTTP 429 from the primary model is retried once with the fallback model before the publication is sent to pending approval.
- Comments sent to pending approval because moderation is unavailable include a generic moderator-visible reason; provider details remain in the private audit log.
- Content edits with verdict `review` are rejected until PKC supports pending approval for edits.
- Content edits are rejected if the model API is unavailable.
- Delete-only edits and non-comment publication types bypass AI moderation.
- When `rejectDuplicateMedia` is `"true"`, new top-level posts reuse neither an exact image, video, or audio URL from a non-archived top-level post nor an in-flight media URL in the same community. This option is disabled by default and is configured independently by each community operator. The deterministic check covers every non-archived thread, runs before any model request, and never enters pending approval; URL comparison upgrades HTTP to HTTPS, ignores fragments and default ports, and retains query parameters.
- The challenge sends text, title, submission time, link URL/domain/path, URL-path date hints, flags, flairs, community address/title/description, `community.rules`, and a bounded activity-relative list of recent top-level posts for duplicate-thread checks when the local community database is available.
- The model payload explicitly labels publication fields as untrusted user content, not instructions.
- The challenge does not fetch linked publication media or user-submitted URLs. `promptUrl` is an operator-configured private prompt source, not publication content.
- Remote prompts are fetched without following redirects, with a 5 second timeout, capped at 64 KiB, cached in memory for 5 minutes, and reused from the last in-memory copy if a refresh fails. If the first remote prompt fetch fails, moderation fails closed for the allow branch.
- Two branch invocations for the same publication reuse one in-process verdict promise.
- Successful verdicts are cached in a private JSON file keyed by a SHA-256 hash over primary and triage model/provider config, community context including duplicate-check context, target content, and the final prompt hash. The cache does not store the raw prompt or API keys.
- Verdicts are written to a private JSONL audit log with the model stage, model reason, raw publication fields, and hashes/metadata for correlation. The audit log does not store the raw prompt, API keys, prompt URL, or prompt bearer token.
## Moderation Audit Community
The challenge writes one private JSONL audit entry per model verdict. To mirror those entries into a Bitsocial community for moderators, create an unnamed local community with a single `question` challenge, store the answer in a private node-local file, and run the publisher script on the node:
```bash
node scripts/publish-audit-log-to-community.mjs \
--community 12D3KooW... \
--audit-log ~/.bitsocial-ai-moderation-audit.jsonl \
--challenge-answer-file ~/.bitsocial-ai-moderation-mod-log-password \
--follow
```
The publisher creates a persistent local signer at `~/.bitsocial-ai-moderation-mod-log-signer.json`, stores its read offset in `~/.bitsocial-ai-moderation-mod-log-state.json`, and submits the private challenge answer when publishing. Each mod-log post includes the AI action, verdict reason, matched rule indexes, source community, publication kind, author identifiers, available CIDs, link metadata, content/title, provider/model, cache key, prompt hash, and rule hash.
## Test Coverage
The coverage badge reports line coverage generated with `yarn test:coverage`. On pushes to `master`, CI writes a Shields-compatible endpoint payload and publishes it to GitHub Pages.
The test suite covers the moderation-critical flow: OpenAI-compatible Responses and chat-completions requests include `community.rules`, triage approvals avoid the primary reviewer, triage reviews and outages escalate, model `review` verdicts fail the `allow` branch and pass the `review` branch used with `pendingApproval`, provider outages and malformed responses route new comments to review, and content edits are rejected on review or outage.
## Publishing
The first npm publish must create the package before trusted publishing can be configured:
```bash
npm publish --access public
```
After the package exists, configure npm trusted publishing:
- Publisher: GitHub Actions
- Organization: `bitsocialnet`
- Repository: `ai-moderation-challenge`
- Workflow filename: `publish.yml`
- Environment: leave blank
Equivalent npm CLI command:
```bash
npm trust github @bitsocial/ai-moderation-challenge --repo bitsocialnet/ai-moderation-challenge --file publish.yml
```
Future releases publish automatically when `package.json` version changes on `master`. The publish workflow skips versions that already exist on npm.
### BitsocialSpamBlocker
Repository: bitsocialnet/spam-blocker
Source: https://github.com/bitsocialnet/spam-blocker#readme
Description: A centralized spam detection service that evaluates publications and provides risk scores to help communities filter spam. This public workspace contains the integration packages:
# BitsocialSpamBlocker
## Overview
A centralized spam detection service that evaluates publications and provides risk scores to help communities filter spam. This public workspace contains the integration packages:
1. **Challenge Package** (`@bitsocial/spam-blocker-challenge`) - package for community integration
2. **Shared Package** (`@bitsocial/spam-blocker-shared`) - shared schemas and response types used by the public integration surface
## Licensing
This monorepo uses package-specific licensing:
- `packages/challenge`: `GPL-3.0-or-later`. This is the public challenge package for community integrations.
- `packages/shared`: `MIT`. This is the permissive shared-types package intended to be reused by both private services and public integrations.
The hosted Bitsocial spam blocker server implementation now lives in a separate private repository and is not included here. The repo root is marked `UNLICENSED` so the workspace metadata does not imply a single open-source license for the whole repository.
The official hosted service at `https://spamblocker.bitsocial.net/api/v1` is the default and strongly recommended integration because its centralized history enables cross-community abuse and signer-cluster detection. `serverUrl` remains configurable for compatible independent services, but the official hosted server's proprietary implementation is not distributed by this repository.
See [Hosted Spam Blocker Source Policy](https://github.com/bitsocialnet/spam-blocker/blob/HEAD/docs/architecture/hosted-service-source-policy.md) for the rationale, transparency commitments, and conditions for reconsidering this boundary.
## Development Workflow
Repo-specific AI workflow guidance lives in:
- [`AGENTS.md`](https://github.com/bitsocialnet/spam-blocker/blob/HEAD/AGENTS.md)
- [`CLAUDE.md`](https://github.com/bitsocialnet/spam-blocker/blob/HEAD/CLAUDE.md)
- [`docs/agent-playbooks/hooks-setup.md`](https://github.com/bitsocialnet/spam-blocker/blob/HEAD/docs/agent-playbooks/hooks-setup.md)
- [`docs/agent-playbooks/skills-and-tools.md`](https://github.com/bitsocialnet/spam-blocker/blob/HEAD/docs/agent-playbooks/skills-and-tools.md)
The repo is intended to be worked with Corepack-managed Yarn and repo-managed agent hooks. See the playbooks for the recommended setup and verification flow.
Quick setup for a fresh machine:
1. `corepack enable`
2. `corepack yarn install`
3. `./scripts/install-default-agent-skills.sh`
**Important:**
- `packages/shared` defines the public response schemas used by the challenge package and by hosted server integrations.
- The hosted server implementation is private; this README documents the public integration contract exposed to the challenge package.
## Repository Structure
```
bitsocial-spam-blocker/
├── package.json # Root workspace config
├── tsconfig.base.json
├── docs/ # Architecture decisions and agent playbooks
├── packages/
│ ├── challenge/ # package for community owners
│ │ └── src/
│ │ └── index.ts # ChallengeFileFactory
│ └── shared/ # Shared types
│ └── src/types.ts
```
## API Endpoints
### POST /api/v1/evaluate
Evaluate publication risk. The server tracks author history internally, so no completion tokens are needed.
Requests are signed by the community signer to prevent abuse (e.g., someone unrelated to the community querying the engine to doxx users). The server validates the request signature and ensures the signer matches the community (for domain addresses, the server resolves the community via `bitsocial.getCommunity` and compares `community.signature.publicKey`). Resolved community public keys are cached in-memory for 12 hours to reduce repeated lookups. The HTTP server initializes a single shared bitsocial instance and only destroys it when the server shuts down.
The public challenge package does not call this endpoint from `getChallenge()`. It returns `/api/v1/iframe/:sessionId/lazy#payload=...` instead. The signed payload is stored in the URL fragment, which is not sent on the initial iframe GET; the lazy iframe posts it to `/evaluate` only after the user opens the iframe.
**Request Format:** `Content-Type: application/cbor`
The request body is CBOR-encoded (not JSON). This preserves `Uint8Array` types during transmission and ensures signature verification works correctly.
**Request:**
```typescript
// The request wraps the DecryptedChallengeRequestMessageTypeWithcommunityAuthor from bitsocial-js
// communityAddress is required; author.community is optional (undefined for first-time publishers)
// The signature is created by CBOR-encoding the signed properties, then signing with Ed25519
{
challengeRequest: DecryptedChallengeRequestMessageTypeWithcommunityAuthor;
sessionId?: string; // Optional client-generated ID for consent-gated lazy iframes
evaluationOptions?: {
autoAcceptThreshold?: number;
autoRejectThreshold?: number;
};
timestamp: number; // Unix timestamp (seconds)
signature: {
signature: Uint8Array; // Ed25519 signature of CBOR-encoded signed properties
publicKey: Uint8Array; // 32-byte Ed25519 public key
type: "ed25519";
signedPropertyNames: Array<"challengeRequest" | "sessionId" | "evaluationOptions" | "timestamp">;
}
}
```
**Response:**
```typescript
{
riskScore: number; // 0.0 to 1.0
explanation?: string; // Human-readable reasoning for the score
sessionId: string;
challengeUrl: string; // Full URL: https://spamblocker.bitsocial.net/api/v1/iframe/{sessionId}
challengeExpiresAt?: number; // Unix timestamp, 1 hour from creation
}
```
The response always includes the concrete challenge session URL. For the public challenge package's lazy iframe flow, low-risk and high-risk sessions are handled inside the lazy iframe without redirecting to the full challenge UI. Medium-risk sessions redirect to `challengeUrl`.
### POST /api/v1/challenge/verify
Called by the community's challenge code to verify that the user completed the iframe challenge. The server tracks challenge completion state internally - no token is passed from the user.
**Request must be signed by the community** (same signing mechanism as /evaluate), using the same signing key that was used for the evaluate request.
**Request Format:** `Content-Type: application/cbor`
**Request:**
```typescript
{
sessionId: string; // The sessionId from the /evaluate response
timestamp: number; // Unix timestamp (seconds)
signature: {
signature: Uint8Array; // Ed25519 signature of CBOR-encoded signed properties
publicKey: Uint8Array; // 32-byte Ed25519 public key
type: "ed25519";
signedPropertyNames: ["sessionId", "timestamp"];
}
}
```
**Response:**
```typescript
{
success: boolean;
error?: string; // If success is false
// The following fields are returned on success, allowing the challenge
// code to make additional filtering decisions
ipRisk?: number; // 0.0 to 1.0, risk score based on IP analysis
ipAddressCountry?: string; // ISO 3166-1 alpha-2 country code (e.g., "US", "RU")
challengeType?: string; // What challenge was sent (e.g., "turnstile", "hcaptcha")
ipTypeEstimation?: string; // "residential" | "vpn" | "proxy" | "tor" | "datacenter" | "unknown"
}
```
### GET /api/v1/iframe/:sessionId/lazy
Serves the consent-gated bootstrap iframe used by the public challenge package. This route does not create a challenge session by itself. After the iframe opens, its script reads the signed CBOR payload from the URL fragment, posts it to `/api/v1/evaluate`, then either completes/fails immediately or redirects to `/api/v1/iframe/:sessionId`.
### GET /api/v1/iframe/:sessionId
Serves the iframe challenge page. The iframe uses an **OAuth-first** flow where OAuth is the primary trust signal and CAPTCHA is a fallback.
- **OAuth providers** (primary): GitHub, Google, Twitter, Yandex, TikTok, Discord, Reddit
- **CAPTCHA provider** (fallback): Cloudflare Turnstile
> **Privacy note**: For OAuth providers, the server only verifies successful authentication - it does NOT share account identifiers (username, email) with the community. The hosted operator stores IP evidence for abuse prevention, including signer-cluster detection; only the country code and risk result are shared with the community, never the raw IP address.
**Iframe logic (OAuth-first):**
When OAuth providers are configured, the iframe shows OAuth buttons as the primary challenge:
1. **Initial view**: OAuth sign-in buttons. If CAPTCHA alone can pass at this score level, a "I don't have a social account" link is also shown.
2. **After first OAuth**: If `riskScore × oauthMultiplier < passThreshold` → session completes. Otherwise, "Additional verification needed" view shows remaining providers and optional CAPTCHA.
3. **CAPTCHA fallback**: Shown when the user clicks "I don't have a social account". If OAuth was already completed, the combined multiplier (OAuth × CAPTCHA) is applied.
When no OAuth is configured, a turnstile-only CAPTCHA iframe is served.
**Challenge completion flow:**
1. User signs in via OAuth (or solves CAPTCHA fallback)
2. Server applies score adjustment and determines if session passes
3. If more verification needed, iframe transitions to "need more" view
4. Once passed, iframe shows "Verification complete!"
5. The user clicks "done" in their bitsocial client (the client provides this button outside the iframe)
6. The client sends a `ChallengeAnswer` with an empty string to the community
7. The community's challenge code calls `/api/v1/challenge/verify` to check if the session is completed
### POST /api/v1/challenge/complete
Called by the iframe after the user solves the CAPTCHA (as a fallback in the OAuth-first flow). Validates the Turnstile response, then applies score adjustment to decide whether the session passes.
**Request:**
```typescript
{
sessionId: string;
challengeResponse: string; // Token from the challenge provider
challengeType?: string; // e.g., "turnstile" (default)
}
```
**Response:**
```typescript
{
success: boolean;
error?: string; // Error message on failure
passed?: boolean; // Whether the challenge is fully passed (session completed)
oauthRequired?: boolean; // Whether OAuth is required (CAPTCHA alone is not enough)
}
```
**Score adjustment logic:** After validating the CAPTCHA, the server checks if OAuth was already completed. If so, the combined multiplier is used: `adjustedScore = riskScore × oauthMultiplier × captchaMultiplier`. Otherwise: `adjustedScore = riskScore × captchaMultiplier`. If `adjustedScore < challengePassThreshold`, the session is marked `completed` and `passed: true` is returned. Otherwise, the CAPTCHA is marked complete but the session stays `pending`, and `passed: false, oauthRequired: true` is returned.
### OAuth Routes
**GET /api/v1/oauth/:provider/start?sessionId=...** — Initiates the OAuth flow. Generates state, stores it in the database, and redirects the user to the OAuth provider's authorization page.
**GET /api/v1/oauth/:provider/callback** — OAuth callback handler. Exchanges the authorization code for a token, retrieves the user identity, then applies score adjustment:
- **First OAuth**: If `riskScore × oauthMultiplier < passThreshold` → session completed. Otherwise, marks `oauthCompleted` and session stays pending ("need more" state).
- **Second OAuth**: Must be from a different provider. Applies `riskScore × oauthMultiplier × secondOauthMultiplier`. If below threshold → session completed.
- Multiple OAuth identities are accumulated as a JSON array in the session's `oauthIdentity` field.
**GET /api/v1/oauth/status/:sessionId** — Polling endpoint used by the iframe to check OAuth status. Returns `{ completed, oauthCompleted, needsMore, firstProvider, status }`.
## Challenge Flow (Detailed)
The challenge flow uses **server-side state tracking** - no tokens are passed from the iframe to the user's client. This matches the standard bitsocial iframe challenge pattern (used by mintpass and others).
**OAuth is the primary challenge.** The iframe shows OAuth sign-in buttons first. CAPTCHA is available as a fallback for users without social accounts. After the user completes verification, the server adjusts the risk score. If the adjusted score is below the pass threshold, the session completes. For high-risk users, additional verification (second OAuth from a different provider, or CAPTCHA) may be required.
```
getChallenge() -> lazy iframe URL (no spam blocker server request)
|
v
User opens iframe -> lazy iframe posts signed payload to /evaluate -> riskScore
|
├─ < autoAcceptThreshold -> auto_accept (verify succeeds immediately)
├─ >= autoRejectThreshold -> auto_reject (verify fails immediately)
└─ between -> create session (store riskScore), return challengeUrl
|
v
Iframe serves OAuth buttons (primary) + optional CAPTCHA fallback link
|
├─ User signs in via OAuth -> callback applies score adjustment
| |
| ├─ riskScore x oauthMultiplier < passThreshold?
| | YES -> mark "completed" -------------------------> /verify -> success
| |
| └─ NO -> mark oauthCompleted, session stays "pending"
| Iframe shows "need more" view
| |
| ├─ User signs in with 2nd OAuth (different provider)
| | -> riskScore x oauthMult x 2ndOauthMult < threshold?
| | YES -> completed ------------------> /verify -> success
| |
| └─ User completes CAPTCHA
| -> riskScore x oauthMult x captchaMult < threshold?
| YES -> completed ------------------> /verify -> success
|
└─ User clicks "I don't have a social account" -> CAPTCHA fallback
|
├─ riskScore x captchaMultiplier < passThreshold?
| YES -> mark "completed" -------------------------> /verify -> success
|
└─ NO -> mark captchaCompleted, return { oauthRequired: true }
Iframe redirects back to OAuth view
```
```
1. Client sends ChallengeRequest to the community.
2. Community challenge code returns a lazy spam blocker iframe URL with a signed payload in the URL fragment.
3. Client asks the user before opening the iframe.
4. After the user opens the iframe, the iframe posts the signed payload to /evaluate.
5. /evaluate creates the session and either completes, fails, or returns the full challengeUrl.
6. Medium-risk sessions redirect to /iframe/:sessionId for OAuth and/or CAPTCHA.
7. When done, the iframe posts a ChallengeAnswer with an empty string.
8. Community verify("") calls /challenge/verify with the pre-generated sessionId.
9. The community applies post-challenge IP filters and accepts or rejects the publication.
```
## Risk Score
The risk score is a value between 0.0 and 1.0 that indicates the likelihood a publication is spam or malicious. It's calculated as a weighted combination of multiple factors including account age, karma, author reputation, content analysis, velocity, IP intelligence, and signer-cluster behavior.
Detailed risk-scoring implementation notes live with the private server codebase. This public repo documents the exposed API contract and the public challenge/shared packages.
### Signer-cluster Sybil resistance
The hosted service links distinct signer public keys first observed on the same confirmed user-origin IP. This makes disposable Bitsocial accounts less useful for evading a community ban, manufacturing a conversation, or voting on related accounts' content.
- A few signers are allowed to accommodate users who cannot yet sync their Bitsocial account across devices. The first three signers in the rolling 30-day window are normally treated as legitimate multi-device use.
- Additional signers progressively raise risk and appear as operator evidence. Rapid account churn, ban evasion, repeated failures, or replies/votes between signers linked to the same IP can require stronger verification.
- Severe rapid or compound abuse can cause a temporary IP-level rejection while the rolling evidence remains active. Operators may also apply a persistent manual IP block.
This is a centralized, service-local defense rather than a protocol-wide Bitsocial identity or ban. Shared households, offices, and carrier networks can legitimately reuse an IP, so the signal is graduated and is not treated as proof that multiple signers are one person. The hosted challenge UI warns users about the policy.
## Indexer
The server includes a background indexer that crawls the Bitsocial network to build author reputation data. It:
- Indexes communities and their comments/posts
- Follows `author.previousCommentCid` chains to discover new communities
- Tracks modQueue to see which authors get accepted/rejected
- Detects bans/removals by monitoring CommentUpdate availability
- Provides network-wide author reputation data for risk scoring
Indexer implementation details live with the private server codebase.
**Tier Thresholds (configurable per community via challenge options):**
- `riskScore < autoAcceptThreshold` → Complete after iframe evaluation (no OAuth/CAPTCHA)
- `autoAcceptThreshold <= riskScore < oauthSufficientThreshold` → One OAuth is sufficient (`oauth_sufficient`)
- `oauthSufficientThreshold <= riskScore < autoRejectThreshold` → OAuth + more needed (`oauth_plus_more`)
- `riskScore >= autoRejectThreshold` → Reject after iframe evaluation
**Score Adjustment (configurable on server):**
OAuth is the primary trust signal. CAPTCHA is a fallback for users without social accounts.
| Path | Formula | Default | Pass if |
| ------------------------ | ----------------------------------- | ----------------- | ------------------------ |
| OAuth alone | score × oauthScoreMultiplier | score × 0.6 | < challengePassThreshold |
| CAPTCHA alone (fallback) | score × captchaScoreMultiplier | score × 0.7 | < challengePassThreshold |
| OAuth + second OAuth | score × oauthMult × secondOauthMult | score × 0.6 × 0.5 | < challengePassThreshold |
| OAuth + CAPTCHA | score × oauthMult × captchaMult | score × 0.6 × 0.7 | < challengePassThreshold |
With default values (threshold 0.4):
- One OAuth sufficient when raw score < ~0.67
- CAPTCHA alone sufficient when raw score < ~0.57
- OAuth + second OAuth sufficient when raw score < ~1.33 (all non-auto-rejected pass)
- OAuth + CAPTCHA sufficient when raw score < ~0.95 (most non-auto-rejected pass)
## Dynamic Rate Limiting
An opt-in pre-check that hard-rejects publications (HTTP 429) when an author exceeds their budget. This runs before risk scoring and prevents manual spammers who solve CAPTCHAs from posting at high rates.
**Enabling:** Pass `rateLimitConfig: {}` in `RouteOptions` to enable with defaults. Omit it to disable entirely.
**Dynamic budgets:** Each author gets a budget multiplier based on `ageFactor × reputationFactor` (clamped 0.25–5.0):
| Account Age | ageFactor | | Condition | reputationFactor |
| -------------------- | --------- | --- | ---------------------- | ---------------- |
| No history / < 1 day | 0.5 | | Any active bans | 0.5 |
| 1–7 days | 0.75 | | Removal rate > 30% | 0.5 |
| 7–30 days | 1.0 | | Removal rate 15–30% | 0.75 |
| 30–90 days | 1.5 | | No history or < 15% | 1.0 |
| 90–365 days | 2.0 | | < 5% AND > 10 comments | 1.25 |
| > 365 days | 3.0 | | | |
**Base limits (at 1.0× multiplier), effective = `max(1, floor(base × multiplier))`:**
| Type | Hourly | Daily |
| ------------- | ------ | ------- |
| post | 4 | 20 |
| reply | 6 | 60 |
| vote | 10 | 200 |
| **aggregate** | **40** | **250** |
Check order: per-type hourly → per-type daily → aggregate hourly → aggregate daily. Only user-generated content (posts, replies, votes) is rate-limited. The challenge package accepts community-level actions (commentEdit, commentModeration, communityEdit) without calling the evaluate endpoint since they don't require spam detection.
## Challenge Verification
Challenge completion is tracked **server-side** in the database - no tokens are passed to the user's client.
When a user completes the iframe challenge:
1. The iframe shows OAuth sign-in buttons; user signs in with a provider
2. The OAuth callback applies score adjustment (`riskScore × oauthMultiplier`)
3. If the adjusted score is below `challengePassThreshold` → session marked `completed`
4. If not → `oauthCompleted` is set, iframe shows "need more" view with remaining providers and optional CAPTCHA
5. User completes second OAuth (different provider) or CAPTCHA → combined multiplier applied → session marked `completed`
6. Alternatively, user can use CAPTCHA fallback from the start ("I don't have a social account")
7. The user clicks "done" in their bitsocial client
8. The client sends a `ChallengeAnswer` with an empty string to the community
9. The community's challenge code calls `/api/v1/challenge/verify` with the `sessionId`
10. The server checks `session.status === "completed"` and returns success + IP intelligence
**Session expiry:** 1 hour from creation
## Database Schema (SQLite + better-sqlite3)
**Tables:**
Author columns store the full `author` object from each publication (for example, `DecryptedChallengeRequestMessageTypeWithcommunityAuthor.comment.author`).
### `comments`
Stores comment publications for analysis and rate limiting.
- `sessionId` TEXT PRIMARY KEY (foreign key of challengeSessions)
- `author` TEXT NOT NULL -- is actually a JSON
- `communityAddress` TEXT NOT NULL
- `parentCid` TEXT (null for posts, set for replies)
- `content` TEXT
- `link` TEXT
- `linkWidth` INTEGER
- `linkHeight` INTEGER
- `postCid` TEXT
- `signature` TEXT NOT NULL
- `title` TEXT
- `timestamp` INTEGER NOT NULL
- `linkHtmlTagName` TEXT
- `flair` TEXT
- `spoiler` INTEGER (BOOLEAN 0/1)
- `protocolVersion` TEXT NOT NULL
- `nsfw` INTEGER (BOOLEAN 0/1)
- `receivedAt` INTEGER NOT NULL
### `votes`
Stores vote publications.
- `sessionId` TEXT PRIMARY KEY (foreign key of challengeSessions)
- `author` TEXT NOT NULL -- is actually a json
- `communityAddress` TEXT NOT NULL
- `commentCid` TEXT NOT NULL
- `signature` TEXT NOT NULL
- `protocolVersion` TEXT NOT NULL
- `vote` INTEGER NOT NULL (-1, 0 or 1)
- `timestamp` INTEGER NOT NULL
- `receivedAt` INTEGER NOT NULL
### `challengeSessions`
Tracks challenge sessions. Sessions are kept permanently for historical analysis. Internal timestamps (completedAt, expiresAt, receivedChallengeRequestAt, authorAccessedIframeAt) are in milliseconds.
- `sessionId` TEXT PRIMARY KEY -- UUID v4
- `communityPublicKey` TEXT
- `status` TEXT DEFAULT 'pending' (pending, completed, failed)
- `completedAt` INTEGER
- `expiresAt` INTEGER NOT NULL
- `receivedChallengeRequestAt` INTEGER NOT NULL
- `authorAccessedIframeAt` INTEGER -- when did the author access the iframe?
- `oauthIdentity` TEXT -- format: "provider:userId" or JSON array '["provider:userId", ...]'
- `challengeTier` TEXT -- 'oauth_sufficient' or 'oauth_plus_more' (determined by score thresholds)
- `captchaCompleted` INTEGER DEFAULT 0 -- 1 if CAPTCHA portion completed
- `oauthCompleted` INTEGER DEFAULT 0 -- 1 if first OAuth completed
- `riskScore` REAL -- the risk score at evaluation time (used for score adjustment after OAuth/CAPTCHA)
### `ipRecords`
Stores raw IP addresses associated with authors (captured via iframe). One record per challenge.
- `sessionId` TEXT NOT NULL (foreign key to challengeSessions.sessionId) PRIMARY KEY
- `ipAddress` TEXT NOT NULL -- ip address string representation
- `isVpn` INTEGER (BOOLEAN 0/1)
- `isProxy` INTEGER (BOOLEAN 0/1)
- `isTor` INTEGER (BOOLEAN 0/1)
- `isDatacenter` INTEGER (BOOLEAN 0/1)
- `countryCode` TEXT -- ISO 3166-1 alpha-2 country code
- `timestamp` INTEGER NOT NULL -- when did we query the ip provider
### `oauthStates`
Ephemeral table for CSRF protection during OAuth flow. Internal timestamps (createdAt, expiresAt) are in milliseconds.
- `state` TEXT PRIMARY KEY
- `sessionId` TEXT NOT NULL (foreign key to challengeSessions)
- `provider` TEXT NOT NULL -- 'github', 'google', 'twitter', etc.
- `codeVerifier` TEXT -- PKCE code verifier (required for google, twitter)
- `createdAt` INTEGER NOT NULL
- `expiresAt` INTEGER NOT NULL
## Challenge Code
Implements pkc-js `ChallengeFileFactory`:
```typescript
// Usage in community settings
{
"challenges": [{
"name": "@bitsocial/spam-blocker-challenge",
"options": {
"serverUrl": "https://spamblocker.bitsocial.net/api/v1",
"autoAcceptThreshold": "0.2",
"autoRejectThreshold": "0.8",
"countryBlacklist": "RU,CN,KP",
"blockVpn": "true",
"blockTor": "true"
},
"exclude": [
{ "role": ["owner", "admin", "moderator"] },
{ "postScore": 100 }
]
}]
}
```
When calling `/api/v1/evaluate`, the `author.community` field in the publication
(e.g., `challengeRequest.comment.author.community`) may be `undefined` for first-time
publishers who have never posted in the community before. The community populates this
field from its internal database of author history, so new authors won't have it set.
### Configuration Options (Challenge Package)
| Option | Default | Description |
| --------------------- | ------------------------------------------ | ------------------------------------------------------------------------------ |
| `serverUrl` | `https://spamblocker.bitsocial.net/api/v1` | URL of the BitsocialSpamBlocker server (must be http/https) |
| `autoAcceptThreshold` | `0.2` | Complete verification after iframe evaluation when risk is below this score |
| `autoRejectThreshold` | `0.8` | Reject after iframe evaluation when risk is above this score |
| `countryBlacklist` | `""` | Comma-separated ISO 3166-1 alpha-2 country codes to block (e.g., `"RU,CN,KP"`) |
| `maxIpRisk` | `1.0` | Reject if ipRisk from /verify exceeds this threshold |
| `blockVpn` | `false` | Reject publications from VPN IPs (`true`/`false` only) |
| `blockProxy` | `false` | Reject publications from proxy IPs (`true`/`false` only) |
| `blockTor` | `false` | Reject publications from Tor exit nodes (`true`/`false` only) |
| `blockDatacenter` | `false` | Reject publications from datacenter IPs (`true`/`false` only) |
**Post-challenge filtering:** After a user completes a challenge, the `/verify` response includes IP intelligence data. The challenge code uses the above options to reject publications even after successful challenge completion (e.g., if the user is from a blacklisted country or using a VPN).
**Error Handling:** If the server is unreachable, the challenge code returns a failed challenge result instead of silently accepting or rejecting user-generated content. Community-level actions are accepted locally without contacting the server.
**Privacy of options:** The `options` object (including `serverUrl` and all threshold/filtering settings) is **not** exposed in the public `community.challenges` IPFS record. bitsocial-js strips `options` when computing the public `communityChallenge` from `communityChallengeSetting`, so only `type`, `description`, and `exclude` are published. This means the server URL, thresholds, and filtering rules remain private to the community operator.
### Server Configuration (separate from challenge)
These settings are configured on the HTTP server, not in the challenge package:
**Required:**
- `DATABASE_PATH`: Path to the SQLite database file. Use `:memory:` for in-memory.
**Challenge providers:**
- `TURNSTILE_SITE_KEY`: Cloudflare Turnstile site key
- `TURNSTILE_SECRET_KEY`: Cloudflare Turnstile secret key
- `BASE_URL`: Base URL for OAuth callbacks (e.g., `https://spamblocker.bitsocial.net`)
**IP Intelligence:**
- `IPAPI_KEY`: ipapi.is API key for IP intelligence lookups (optional — works without key)
**Challenge tier thresholds:**
- `AUTO_ACCEPT_THRESHOLD`: Complete immediately after evaluation below this score (default: 0.2)
- `OAUTH_SUFFICIENT_THRESHOLD`: Scores between autoAccept and this pass with one OAuth (default: 0.4)
- `AUTO_REJECT_THRESHOLD`: Reject immediately after evaluation at or above this score (default: 0.8)
**Score adjustment (OAuth-first model):**
- `OAUTH_SCORE_MULTIPLIER`: Multiplier applied after first OAuth, in (0, 1] (default: 0.6)
- `SECOND_OAUTH_SCORE_MULTIPLIER`: Multiplier applied after second OAuth from different provider, in (0, 1] (default: 0.5)
- `CAPTCHA_SCORE_MULTIPLIER`: Multiplier applied after CAPTCHA (fallback), in (0, 1] (default: 0.7)
- `CHALLENGE_PASS_THRESHOLD`: Adjusted score must be below this, in (0, 1) (default: 0.4)
**OAuth providers** (each requires both CLIENT_ID and CLIENT_SECRET):
- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`
- `TWITTER_CLIENT_ID` / `TWITTER_CLIENT_SECRET`
- `YANDEX_CLIENT_ID` / `YANDEX_CLIENT_SECRET`
- `TIKTOK_CLIENT_ID` / `TIKTOK_CLIENT_SECRET`
- `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET`
- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET`
**Risk factor disabling:**
- `DISABLED_RISK_FACTORS`: Comma-separated list of risk factor names to disable. Disabled factors get `weight=0` and their weight is redistributed to remaining factors. Valid values: `commentContentTitleRisk`, `commentUrlRisk`, `velocityRisk`, `accountAge`, `karmaScore`, `ipRisk`, `signerClusterRisk`, `networkBanHistory`, `modqueueRejectionRate`, `networkRemovalRate`, `socialVerification`, `walletVerification`. Example: `DISABLED_RISK_FACTORS=walletVerification`
**Other:**
- `PORT`: Server port (default: 3000)
- `HOST`: Server host (default: 0.0.0.0)
- `LOG_LEVEL`: Set to `silent` to disable logging
- `PKC_RPC_URL`: PKC RPC URL for community resolution
- `ALLOW_NON_DOMAIN_COMMUNITIES`: Set to `true` to allow non-domain community addresses
## Key Design Decisions
- **Database:** SQLite with better-sqlite3, no ORM
- **Content Analysis:** Server-side setting, enabled by default
- **Primary Challenge Provider:** Cloudflare Turnstile (free, privacy-friendly)
- **Challenge Model:** CAPTCHA-first with score-based OAuth gating (CAPTCHA always required; OAuth only if score remains too high after adjustment)
- **OAuth Library:** Arctic (lightweight, supports many providers)
- **Error Handling:** Return failed challenge results on server errors (no silent failures)
- **IP Storage:** Raw IPs stored (not hashed) for accurate analysis
- **IP Intelligence:** ipapi.is (external HTTP API, best-effort, works without API key)
- **Ephemeral Sessions:** Challenge sessions auto-purge after 1 hour
## Privacy Considerations
- Raw IPs are stored for spam detection purposes
- Content analysis is performed on the server
- IP intelligence lookups are sent to ipapi.is when enabled
- OAuth identity (provider:userId) is stored server-side but never shared with communities
- All data is visible to the server operator
- Public integration contract documented here; hosted implementation is private
- Explanation field shows reasoning for scores
## Known Limitations
- IP intelligence fields are best-effort estimates and can be wrong (e.g., VPNs, residential IPs, or misclassification)
- Treat IP intelligence as informational and use it only for rejection decisions
- IP intelligence fields are optional and may be removed from the engine response in the future; challenge code only applies IP filtering options when they are present
- IP-based options are intentionally rejection-only; we do not support IP-derived auto-approval (e.g., a country whitelist), because it is easy to game and can be used to flood a community
- IP-linked signer clusters are probabilistic: shared networks can group legitimate users, while attackers can rotate IPs or use proxies. The allowance and compound-signal escalation reduce false positives but cannot eliminate them.
## Verification Plan
1. Build the public packages with `corepack yarn build`
2. Type-check the public packages with `corepack yarn type-check`
3. Run the challenge-package tests with `corepack yarn test`
4. Integrate the challenge package with a server instance that implements the API contract documented in this README
5. Verify end-to-end flow against a local or hosted private server deployment
## Reference Files
- bitsocial-js challenge example: `pkc-js/src/runtime/node/community/challenges/bitsocial-js-challenges/captcha-canvas-v3/index.ts`
- bitsocial-js schemas: `pkc-js/src/community/schema.ts`
- bitsocial-js challenge orchestration: `pkc-js/src/runtime/node/community/challenges/index.ts`
- MintPass iframe challenge: https://github.com/bitsociallabs/mintpass/tree/master/challenge
### @bitsocial/r9k-challenge
Repository: bitsocialnet/r9k-challenge
Source: https://github.com/bitsocialnet/r9k-challenge#readme
Description: Robot9000 style originality challenge for Bitsocial communities.
# @bitsocial/r9k-challenge
Robot9000-style originality challenge for Bitsocial communities.
This package runs on a Bitsocial community owner node as a deterministic Bitsocial challenge. It is not specific to 5chan; any Bitsocial community can use it when the operator wants Robot9000-style anti-repost behavior. It does not use AI. It scans the community owner's local comments database for exact text reposts after Robot9000 normalization and applies escalating temporary bans for failed attempts.
Robot9000 is based on the anti-repost idea originally described by Randall Munroe in [ROBOT9000 and #xkcd-signal: Attacking Noise in Chat](https://blog.xkcd.com/2008/01/14/robot9000-and-xkcd-signal-attacking-noise-in-chat/).
## Installation
Run this on the Bitsocial node that owns the community:
```bash
bitsocial challenge install @bitsocial/r9k-challenge
```
This challenge is listed in the Bitsocial project directory under anti-spam:
https://bitsocial.net/projects?category=anti-spam
## Configuration
Add the challenge to the community's `settings.challenges`:
```js
[
{ name: "@bitsocial/spam-blocker-challenge" },
{ name: "@bitsocial/r9k-challenge" },
];
```
Default options implement Robot9000-style behavior:
| Option | Default | Behavior |
| ----------------------------------- | --------------------------------------- | ------------------------------------------------------------------- |
| `statePath` | `~/.bitsocial-r9k-challenge-state.json` | Private JSON state for temporary bans and accepted-hash race guards |
| `minimumOriginalContentLength` | `16` | Requires this many normalized text characters |
| `transgressionDecayIntervalSeconds` | `86400` | Forgives one transgression per day |
| `penaltyBaseSeconds` | `2` | Temporary ban duration is `2^n` seconds |
| `maxPenaltySeconds` | empty | No cap by default |
| `blockUnicode` | `true` | Rejects non-ASCII text |
| `stripBacklinks` | `true` | Ignores numeric backlinks like `>>123` |
| `requireText` | `true` | Rejects posts without title/body text |
| `error` | `Rejected by Robot9000.` | Error prefix shown to users |
Posts require enough original content to avoid low-effort duplicates. This package uses `16` normalized characters as its default and keeps it configurable per board.
## Behavior
- Exact normalized text reposts are rejected by scanning the owner node's `comments` SQLite table during `getChallenge()`.
- Numeric backlinks like `>>1` do not count toward originality.
- Images, media links, and URLs are not included in the originality hash.
- Unicode is rejected by default.
- Posts need text; image-only posts fail.
- A failed originality attempt temporarily bans the author for `2^n` seconds, where `n` is the current transgression count.
- The transgression count decays by one every `transgressionDecayIntervalSeconds`.
- State stores SHA-256 hashes for newly accepted text plus temporary-ban counters, not raw post text. Existing-board originality comes from the local database scan.
## Ban Semantics
Bitsocial exposes `author.community.banExpiresAt` for moderator bans, but a challenge failure happens before the offending comment is accepted and therefore has no accepted `commentCid` to moderate. This package enforces the same timed-ban behavior inside the challenge state and rejects the author's later challenge requests until `banExpiresAt`.
If Bitsocial later exposes a safe challenge-side author-ban API for rejected publications, this package can map the same penalty state to native `banExpiresAt`.
## Development
```bash
corepack yarn install
corepack yarn type-check
corepack yarn test
corepack yarn test:coverage
corepack yarn build
```
## Test Coverage
The test suite covers Robot9000 normalization, minimum text checks, Unicode blocking, image/link exclusion, escalating and decaying temporary bans, content edits, missing database fail-closed behavior, and duplicate detection against both the challenge state file and a real in-memory SQLite `comments`/`commentUpdates` database.
The coverage badge reports line coverage generated with `yarn test:coverage`. On pushes to `master`, CI writes a Shields-compatible endpoint payload and publishes it to GitHub Pages.
These tests exercise the challenge package directly. They do not start a full Bitsocial node or publish over the network.
## Publishing
Create the GitHub release and changelog with release-it:
```bash
corepack yarn release 0.1.0
```
The command expects the current branch to have an upstream and needs a GitHub token that can create releases. It writes `CHANGELOG.md`, creates a `v0.1.0` tag, and opens the GitHub release.
The first npm publish must create the package before trusted publishing can be configured:
```bash
npm publish --access public
```
After the package exists, configure npm trusted publishing:
- Publisher: GitHub Actions
- Organization: `bitsocialnet`
- Repository: `r9k-challenge`
- Workflow filename: `publish.yml`
- Environment: leave blank
Equivalent npm CLI command:
```bash
npm trust github @bitsocial/r9k-challenge --repo bitsocialnet/r9k-challenge --file publish.yml
```
Future releases publish automatically when `package.json` version changes on `master`. The publish workflow skips versions that already exist on npm.
### @bitsocial/wordfilter-challenge
Repository: bitsocialnet/wordfilter-challenge
Source: https://github.com/bitsocialnet/wordfilter-challenge#readme
Description: A pkc js challenge that makes community wordfilters a real rule instead of a cosmetic display filter. It implements the wordfilter/v1 contract, which is what publishing clients actually code against.
# @bitsocial/wordfilter-challenge
A [pkc-js](https://github.com/pkcprotocol/pkc-js) challenge that makes community wordfilters a real rule instead of a cosmetic display filter. It implements the [`wordfilter/v1` contract](#the-wordfilterv1-contract), which is what publishing clients actually code against.
> **Status: implemented.** This README is the spec, and `src/` follows it. Tracked in [pkc-js#281](https://github.com/pkcprotocol/pkc-js/issues/281).
## What it does
A community configures a list of replacements, for example `cloud` becomes `butt`. Publishing clients apply those replacements before signing, so the signed comment, its CID, and what every client renders all contain `butt`. The original text does not exist anywhere.
The community node does not do the replacing. It only checks: if a publication still contains a filtered word, it is rejected.
## Why clients have to do the replacing
This is the part that surprises people coming from imageboard software, so it is worth being explicit.
On a traditional imageboard the server owns the post text and rewrites it freely at submission time. [jschan](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/filteractions.js) mutates `req.body` before the insert; vichan calls `wordfilters($post['body'])` in `post.php` before `body_nomarkup` is even derived. Neither keeps the pre-filter original.
pkc-js cannot work that way. The author signs the publication, and the signature covers `content`. If the community rewrote `content` afterwards, the signature would no longer verify and every client would reject the comment as forged.
So the replacement has to happen on the side holding the signing key, which is the publishing client. The community's only available action is to accept or reject. In other words this package does not implement a wordfilter, it implements **a proof that a wordfilter was applied**.
A client that does not implement the replacement cannot publish unfiltered text. It simply gets rejected, which is the intended failure mode.
## The `wordfilter/v1` contract
A publishing client has to recognise "this community wants word replacements applied before I sign" from the published community record alone. The record deliberately does not say which challenge produced it: `path`, `name` and `options` are all stripped when `community.settings.challenges[i]` becomes the public `community.challenges[i]`. Only `publicOptions`, the subset the owner opted into publishing, survives.
So the signal a client keys off is an option key that names a **contract** rather than a package:
| Public option | Required | Meaning |
|---|---|---|
| `wordfilter/v1/rules` | yes | JSON array of `{ src, dst }`. Its presence is what identifies the contract |
| `wordfilter/v1/fieldNames` | no | JSON array of dot-notation paths, each starting with the publication type. Defaults to the list under [Default fields](#default-fields) |
This package is one implementation. Anything that publishes `wordfilter/v1/rules` with the semantics below claims the contract, and a client written against it keeps working with implementations that did not exist when the client was written. Keying on a package name instead, whether through a hardcoded `@bitsocial/wordfilter-challenge` or a generic `wordfilters` option, would enshrine one implementation in every UI and lock out every fork, competitor and in-house variant that behaves identically. See [issue #1](https://github.com/bitsocialnet/wordfilter-challenge/issues/1).
The namespace covers exactly what a publishing client must read, and nothing else. `error` is not in it, because no client reads `error`: the community returns it in the rejection. An implementation is free to name or shape that option however it likes.
`wordfilter/v2/rules` would be a different key. A client that understands both reads both, and a community can publish both during a transition without either side guessing.
### Default fields
When a community does not publish `wordfilter/v1/fieldNames`, both sides check this list. Every path starts with the publication type, followed by the field's path inside that publication exactly as it is on the wire.
| Publication type | Default paths |
|---|---|
| `comment` | `comment.content`, `comment.title`, `comment.author.displayName` |
| `commentEdit` | `commentEdit.content`, `commentEdit.reason`, `commentEdit.author.displayName` |
| `vote` | `vote.author.displayName` |
The defaults cover what an ordinary author publishes. Moderator and owner text is not in them: a moderation's reason and a community edit's title and description can be filtered by naming their paths in `wordfilter/v1/fieldNames`, as `commentModeration.commentModeration.reason`, `communityEdit.communityEdit.title` and `communityEdit.communityEdit.description`. The doubled segment is the wire shape, not a typo: the publication type is `commentModeration`, and pkc-js puts the moderation's fields under a `commentModeration` property of it; likewise for `communityEdit`.
Naming the publication type in every path is deliberate. `content` on a comment and `content` on a comment edit are the same field today, but nothing guarantees they stay that way, and a bare `content` could not tell them apart. A path whose first segment is not one of the five publication types is rejected by `validateChallengeSettings`, and by `getChallenge` for a config that predates that check, because it would match nothing on any request and the wordfilter would have quietly stopped filtering.
## For UI client developers
This is the section that matters if you are building a frontend that publishes to communities using this contract.
### 1. Read the rules off the community
Collect the rules from every challenge on the community that publishes `wordfilter/v1/rules`, in challenge order. A community may have more than one.
```js
const isRule = (rule) => typeof rule?.src === "string" && typeof rule?.dst === "string";
const isFieldName = (name) => typeof name === "string";
function getWordfilterConfigs(community) {
return (community.challenges ?? [])
.filter((challenge) => challenge.publicOptions?.["wordfilter/v1/rules"])
.map((challenge) => {
try {
const rules = JSON.parse(challenge.publicOptions["wordfilter/v1/rules"]);
const fieldNames = challenge.publicOptions["wordfilter/v1/fieldNames"]
? JSON.parse(challenge.publicOptions["wordfilter/v1/fieldNames"])
: [
"comment.content",
"comment.title",
"comment.author.displayName",
"commentEdit.content",
"commentEdit.reason",
"commentEdit.author.displayName",
"vote.author.displayName"
];
// JSON.parse is happy to hand back null, an object or an array of numbers, and
// applyWordfilters would throw on any of them. Treat the wrong shape like bad JSON.
if (!Array.isArray(rules) || !rules.every(isRule)) return undefined;
if (!Array.isArray(fieldNames) || !fieldNames.every(isFieldName)) return undefined;
return { rules, fieldNames };
} catch {
// A rule set you cannot parse is skipped, never fatal. Worst case the community
// rejects the publication with a readable error.
return undefined;
}
})
.filter(Boolean);
}
```
Note what this function does not do: it never looks at which challenge implementation produced the options, because the record does not say and it does not need to.
Skip anything you cannot parse, and anything that parses to the wrong shape. Never throw, and never refuse to load the community: a malformed rule set must cost the filter, not the whole board. This package's own `validateChallengeSettings` will not let a community publish a malformed rule set, but the contract is open to other implementations and your client cannot know which one it is talking to.
### 2. Apply the rules, looping until the text stops changing
**Do not apply the rules once.** A replacement can create a new match by joining with the text around it. With rules `[{src: "ab", dst: "c"}, {src: "x", dst: "b"}]`, the input `"ax"` becomes `"ab"` in a single pass, which still contains a filtered word, and the community will reject it.
Loop until the output is stable:
```js
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// The `() => dst` replacer, not a plain `dst`: `String.replace` reads `$&` and friends out of a string
// replacement, and rules are literal on both sides.
const applyOnce = (text, rules) =>
rules.reduce((acc, { src, dst }) => acc.replace(new RegExp(escapeRegExp(src), "gi"), () => dst), text);
export function applyWordfilters(text, rules, maxPasses = 8) {
let out = text;
for (let pass = 0; pass < maxPasses; pass++) {
const next = applyOnce(out, rules);
if (next === out) return out;
out = next;
}
throw new Error("wordfilter rules did not stabilise");
}
```
Stable output contains no filtered word by definition, which is exactly what the community checks for.
Copying that function is the expected path: your client sees the challenge through `community.challenges` and has no reason to depend on the package that produced it. If you do already bundle npm packages, the same function is exported as `applyWordfilters` from `@bitsocial/wordfilter-challenge`, with nothing from pkc-js behind it.
### 3. Apply it before creating the publication
Filter the text before it reaches `pkc.createComment()`, so the transformation happens before any signature exists. Nothing needs to re-sign, and there is no window where an unfiltered draft is signed.
```js
const configs = getWordfilterConfigs(community);
// One call per field, carrying the rules of every challenge that covers that field. Do not apply the
// challenges one at a time: a later challenge's replacement can reintroduce an earlier challenge's
// src, and the earlier challenge would then reject the publication. The loop inside applyWordfilters
// only sees that interaction when both rule sets are in the same call.
const rulesFor = (fieldName) =>
configs.filter(({ fieldNames }) => fieldNames.includes(fieldName)).flatMap(({ rules }) => rules);
if (content) content = applyWordfilters(content, rulesFor("comment.content"));
if (title) title = applyWordfilters(title, rulesFor("comment.title"));
if (displayName) displayName = applyWordfilters(displayName, rulesFor("comment.author.displayName"));
const comment = await pkc.createComment({ content, title, author: { displayName }, communityAddress, signer });
await comment.publish();
```
The challenge runs on every publication type, not only new comments. The [default field list](#default-fields) also names the text of a comment edit and the display name on a vote, so the same `rulesFor` call applies before each of these, with the path prefixed by the publication type:
```js
// Comment edit: content and reason are top-level on the edit, displayName is under author.
const edit = await pkc.createCommentEdit({
commentCid,
content: applyWordfilters(newContent, rulesFor("commentEdit.content")),
reason: applyWordfilters(reason, rulesFor("commentEdit.reason")),
author: { displayName: applyWordfilters(displayName, rulesFor("commentEdit.author.displayName")) },
communityAddress,
signer
});
// Vote: the only text on it is the display name.
const vote = await pkc.createVote({
commentCid,
vote: 1,
author: { displayName: applyWordfilters(displayName, rulesFor("vote.author.displayName")) },
communityAddress,
signer
});
```
Those paths are the defaults, so they are the minimum. If a community publishes other paths in `wordfilter/v1/fieldNames`, run the same call on whatever your client puts at those paths; the community checks exactly the configured paths and nothing else, so an unfiltered custom field is a rejection just like an unfiltered display name. The paths a community is most likely to add are moderator and owner text, which `rulesFor` returns empty for unless the community names them:
```js
// Comment moderation: the moderator's reason lives under the publication's commentModeration property,
// hence the doubled segment.
const moderation = await pkc.createCommentModeration({
commentCid,
commentModeration: {
removed: true,
reason: applyWordfilters(reason, rulesFor("commentModeration.commentModeration.reason"))
},
communityAddress,
signer
});
// Community edit: title and description live under the publication's communityEdit property.
const communityEdit = await pkc.createCommunityEdit({
communityEdit: {
title: applyWordfilters(title, rulesFor("communityEdit.communityEdit.title")),
description: applyWordfilters(description, rulesFor("communityEdit.communityEdit.description"))
},
communityAddress,
signer
});
```
The `wordfilter/v1/fieldNames` entries are dot-notation paths, the same convention `publication-match` uses for `propertyName`, resolved against the challenge request's publication map rather than the publication itself. So the first segment names the publication type, and a path is simply absent, and passes, on a request carrying a different type: `commentEdit.content` never sees a new comment, and `comment.content` never sees an edit.
Two challenges on the same community whose rules undo each other, `foo → bar` in one and `bar → foo` in another, cannot be caught by either challenge's `validateChallengeSettings`, which sees only its own settings. The merged call then either throws `did not stabilise` or settles on text that one of the challenges still rejects, and the community returns that challenge's `error`. Both are the right outcome: no text containing either word can satisfy both challenges, so the board is misconfigured and the owner has to fix it. Handle it the same way as any other rejection, in step 5.
### 4. Show the user what happened
Nothing in the protocol requires it, but the author is signing text they did not type. Showing the filtered result before publishing, or a note afterwards, avoids the surprise. This is a UX decision, not a correctness one.
### 5. Handle rejection
Your copy of the community record can be older than the community's current settings. An owner who adds a rule starts enforcing it immediately, while your client keeps publishing against the rules it last saw.
When that happens the publication is rejected with the challenge's `error` message. Refresh the community, re-apply, and let the author retry. **Do not re-sign automatically:** that would silently publish text the author never reviewed, which is the exact failure this design exists to prevent.
## Requirements
- pkc-js `>=0.0.85`, for `publicOptions` ([pkc-js#282](https://github.com/pkcprotocol/pkc-js/issues/282)) and `validateChallengeSettings` ([pkc-js#283](https://github.com/pkcprotocol/pkc-js/issues/283))
- Node.js `>=22`
- ESM-only environment
## Install
### With bitsocial-cli
```bash
bitsocial challenge install @bitsocial/wordfilter-challenge
```
```bash
bitsocial community edit your-community.bso \
'--settings.challenges[0].name' @bitsocial/wordfilter-challenge \
'--settings.challenges[0].options.wordfilter/v1/rules' '[{"src":"cloud","dst":"butt"}]' \
'--settings.challenges[0].publicOptions[0]' wordfilter/v1/rules
```
### With pkc-js over RPC
Install the challenge on the RPC server, then set it on your community by name. Nothing has to be installed on the client side:
```bash
bitsocial challenge install @bitsocial/wordfilter-challenge
```
### With pkc-js (TypeScript)
Running your own node locally, without RPC:
```bash
npm install @bitsocial/wordfilter-challenge
```
```ts
import PKC from "@pkcprotocol/pkc-js";
import { wordfilterChallenge } from "@bitsocial/wordfilter-challenge";
PKC.challenges["@bitsocial/wordfilter-challenge"] = wordfilterChallenge;
```
## Configuration
For community owners, set in `settings.challenges`:
```js
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/wordfilter-challenge",
options: {
"wordfilter/v1/rules": JSON.stringify([
{ src: "cloud", dst: "butt" },
{ src: "millennials", dst: "snake people" },
{ src: "spamword", dst: "" }
]),
"wordfilter/v1/fieldNames": JSON.stringify([
"comment.content",
"comment.title",
"comment.author.displayName",
"commentEdit.content",
"commentEdit.reason",
"commentEdit.author.displayName",
"vote.author.displayName"
]),
error: "This board replaces certain words. Please repost with the replacements applied."
},
publicOptions: ["wordfilter/v1/rules", "wordfilter/v1/fieldNames", "error"]
}
]
}
});
```
`publicOptions` is **required**, not decorative. Options are private by default in pkc-js, and a client that cannot read the rules cannot satisfy them. The challenge's `validateChallengeSettings` hook rejects the edit if `wordfilter/v1/rules` is missing from `publicOptions`, so the failure surfaces when you save rather than when every author starts getting rejected.
### Options
| Option | Required | Must be in `publicOptions` | Description |
|---|---|---|---|
| `wordfilter/v1/rules` | yes | yes | JSON array of `{ src, dst }`, applied in array order, cascading |
| `wordfilter/v1/fieldNames` | no | when set | JSON array of dot-notation paths, each starting with the publication type. Defaults to the list under [Default fields](#default-fields) |
| `error` | no | owner's call | Message shown to the author when a publication is rejected |
An option has to be public when a client cannot satisfy the challenge without reading it. That covers `wordfilter/v1/rules` always, and `wordfilter/v1/fieldNames` whenever the owner sets it: a client filtering the default fields cannot satisfy a community checking a different set, and nothing in the published record would explain the rejections. Those two are also exactly the options the contract namespaces, for the same reason. `error` is returned in the rejection itself, so publishing it is transparency rather than a requirement, and it keeps its plain name.
### Validation
`validateChallengeSettings` rejects at edit time:
- `wordfilter/v1/rules` missing from `publicOptions`, or unparseable JSON, or not an array of `{ src, dst }` strings
- an empty `src`
- `src === dst`
- the same `src` in more than one rule
- any `dst` containing any `src`
- more than 64 rules, or a `src` or `dst` longer than 128 characters
- `wordfilter/v1/fieldNames`, when set, unparseable, not an array of non-empty strings, containing a path whose first segment is not a publication type (`comment`, `vote`, `commentEdit`, `commentModeration`, `communityEdit`), or missing from `publicOptions`
The `src === dst`, duplicate `src` and `dst`-contains-`src` checks all compare case-insensitively, because matching is case-insensitive: `{src: "LOL", dst: "lol"}` replaces nothing just as surely as `{src: "lol", dst: "lol"}` does.
The `dst` containing `src` rule is what guarantees the client's loop terminates. Without it a rule like `lol` becomes `lolol` produces output that always contains a filtered word, making every post permanently unpublishable.
## Migrating from 0.1.x and 0.2.0
0.2.0 already uses the `wordfilter/v1` option keys; only the paths inside `wordfilter/v1/fieldNames` change for it, see below. 0.1.x used bare `wordfilters` and `fieldNames` option names, which made the package rather than the contract the thing clients keyed off. Rename both, in `options` and in `publicOptions`:
| 0.1.x | Now |
|---|---|
| `wordfilters` | `wordfilter/v1/rules` |
| `fieldNames` | `wordfilter/v1/fieldNames` |
| `error` | `error`, unchanged |
The paths inside `fieldNames` change too, for 0.2.0 as much as 0.1.x. Both resolved them against the publication, so `content` meant "content on whatever was published". `wordfilter/v1/fieldNames` now resolves them against the challenge request's publication map, so every path starts with the publication type, and a bare `content` is rejected rather than silently matching nothing:
| 0.1.x / 0.2.0 path | Now |
|---|---|
| `content` | `comment.content`, and `commentEdit.content` if edits should be filtered too |
| `title` | `comment.title` |
| `author.displayName` | one entry per publication type: `comment.author.displayName`, `commentEdit.author.displayName`, `vote.author.displayName` |
If 0.1.x or 0.2.0 ran with the default field list, dropping `fieldNames` entirely and taking the new defaults is the closest equivalent. A 0.2.0 community that set `wordfilter/v1/fieldNames` with bare paths fails loudly at the next start, `validateChallengeSettings` naming the offending path, until the paths are prefixed.
There is no fallback to the old names, on purpose. A wordfilter that quietly stops filtering is the exact failure this package exists to prevent, so an unmigrated community fails loudly instead: pkc-js rejects the now-undeclared option at community start with `ERR_CHALLENGE_OPTION_NOT_DECLARED_IN_OPTION_INPUTS`, naming the offending option, and `validateChallengeSettings` rejects the edit if you try to save it.
Clients written against 0.1.x need the same rename in whatever they copied from this README.
## Matching semantics
- **Literal, never regex.** vichan supports arbitrary PCRE, but only because its config is a server-local file the operator wrote. Here the rules ship inside a signed record that every browser client downloads and executes, so patterns are literal strings.
- **Case-insensitive**, following vichan's `str_ireplace`. `cloud`, `Cloud`, and `CLOUD` are all replaced. Casing is not preserved: all three become `butt`.
- **Cascading in array order**, and rules from multiple wordfilter challenges compose in challenge order.
- **Absent fields pass cleanly.** A vote has no `content`, and that is not a failure. (`publication-match` treats a missing property as a failure, which would reject every vote. Do not copy that.)
## What this does not catch
Deliberate evasion. `c l o u d`, `clοud` (with a Greek omicron), and zero-width-joined variants pass straight through.
That is a hard limit, not an oversight. jschan has a `strictFiltering` mode that matches against NFD-stripped, zero-width-stripped and alphanumeric-only permutations of the post, and it is necessarily **detect-only**: normalisation is not invertible, so once you have matched a normalised form you no longer know where in the original text to splice a replacement. Evasion-resistant matching and replacement are mutually exclusive.
For blocking evasive spam and slurs outright, use pkc-js's built-in `publication-match` challenge. Rejecting a publication does not require knowing where the match was. Note that `exclude` can skip a challenge for moderators or high-karma authors, which applies to this package too.
## Prior art
- jschan, which powers soyjak.party: [`checkfilters.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/checkfilters.js), [`filteractions.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/filteractions.js), [`getfilterstrings.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/getfilterstrings.js)
- vichan: [`wordfilters()`](https://github.com/vichan-devel/vichan/blob/master/inc/functions.php#L1798)
Both rewrite server-side at post time and keep no copy of the original.
## Development
```bash
npm install
npm run typecheck
npm test
npm run build
```
| Path | What it is |
|---|---|
| `src/wordfilter-challenge.ts` | The `ChallengeFileFactory`: `optionInputs`, `getChallenge`, `validateChallengeSettings` |
| `src/apply-wordfilters.ts` | The client-side replacement loop and the `wordfilter/v1` option keys. Imports nothing, so it bundles for a browser |
| `src/types.ts` | Re-exports of the pkc-js challenge types |
## License
GPL-3.0-or-later, the same as pkc-js.
### @bitsocial/captcha-canvas-challenge
Repository: bitsocialnet/captcha-canvas-challenge
Source: https://github.com/bitsocialnet/captcha-canvas-challenge#readme
Description: Standalone captcha canvas challenge extracted from pkc js, packaged with independent dependencies.
# @bitsocial/captcha-canvas-challenge
Standalone captcha canvas challenge extracted from `pkc-js`, packaged with independent dependencies.
## Security Warning
This captcha can be brute-forced easily and should not be used to protect a real community.
## Status
- `@pkcprotocol/pkc-js` is a dev dependency used only for TypeScript types. It is not required at runtime.
## Requirements
- Node.js `>=22`
- ESM-only environment
## Using captcha-canvas in your community
Community owners add the captcha-canvas challenge to their community settings. When enabled, every publication (post, reply, vote) requires the author to solve a captcha. The challenge is published as [`@bitsocial/captcha-canvas-challenge`](https://www.npmjs.com/package/@bitsocial/captcha-canvas-challenge) on npm.
The challenge name is the npm package name, `@bitsocial/captcha-canvas-challenge`, on every install path — that's the key in `PKC.challenges` and the value passed as `community.settings.challenges[].name`. `bitsocial challenge install` registers it this way automatically; manual registration should match.
### With pkc-js over RPC
If your RPC server is already running, first install the challenge on the server:
```bash
bitsocial challenge install @bitsocial/captcha-canvas-challenge
```
Then from your RPC client, connect and set the challenge on your community by name — no npm install or challenge registration needed on the client side:
```ts
import PKC from "@pkcprotocol/pkc-js";
const pkc = await PKC({
pkcRpcClientsOptions: ["ws://localhost:9138"]
});
const community = await pkc.createCommunity({ address: "your-community-address.bso" });
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/captcha-canvas-challenge",
options: {
characters: "6",
width: "300",
height: "100",
colors: "#32cf7e"
}
}
]
}
});
```
### With pkc-js (TypeScript)
Install the challenge package:
```bash
npm install @bitsocial/captcha-canvas-challenge
```
Register the challenge and configure your community:
```typescript
import PKC from '@pkcprotocol/pkc-js'
import { captchaCanvasChallenge } from '@bitsocial/captcha-canvas-challenge'
// Register the challenge so it can be referenced by name
PKC.challenges['@bitsocial/captcha-canvas-challenge'] = captchaCanvasChallenge
const pkc = await PKC({ /* your pkc options */ })
const community = await pkc.createCommunity({ address: 'your-community.bso' })
await community.edit({
settings: {
challenges: [{
name: '@bitsocial/captcha-canvas-challenge',
options: {
characters: '6',
width: '300',
height: '100',
colors: '#32cf7e',
}
}]
}
})
```
#### Challenge options
All option values must be strings (pkc-js challenge convention).
| Option | Default | Description |
|--------|---------|-------------|
| `characters` | `"6"` | Amount of characters of the captcha |
| `height` | `"100"` | Height of the captcha in pixels |
| `width` | `"300"` | Width of the captcha in pixels |
| `colors` | `"#32cf7e"` | Colors of the captcha text as hex comma separated values |
### With bitsocial-cli
Install the challenge package:
```bash
bitsocial challenge install @bitsocial/captcha-canvas-challenge
```
Edit your community to use the challenge:
```bash
bitsocial community edit your-community.bso \
'--settings.challenges[0].name' @bitsocial/captcha-canvas-challenge \
'--settings.challenges[0].options.characters' '6' \
'--settings.challenges[0].options.width' '300' \
'--settings.challenges[0].options.height' '100' \
'--settings.challenges[0].options.colors' '#32cf7e'
```
See the [bitsocial-cli documentation](https://github.com/bitsocialnet/bitsocial-cli) for full CLI reference.
## Scripts
```bash
npm run typecheck
npm run build
npm test
```
### @bitsocial/voucher-challenge
Repository: bitsocialnet/voucher-challenge
Source: https://github.com/bitsocialnet/voucher-challenge#readme
Description: Standalone voucher challenge extracted from plebbit js, packaged with independent dependencies.
# @bitsocial/voucher-challenge
Standalone voucher challenge extracted from `plebbit-js`, packaged with independent dependencies.
## How it works
Community owners configure a list of voucher codes on their community. When a user publishes for the first time, they are prompted to enter a voucher code. Once redeemed, the voucher becomes permanently bound to the user's `author.address` — that author can continue using the same voucher for future publications (posts, replies, votes), but no other author can claim it. This prevents voucher sharing across different users.
## Requirements
- Node.js `>=22`
- ESM-only environment
## Install
### With bitsocial-cli
```bash
bitsocial challenge install @bitsocial/voucher-challenge
```
Edit your community to use the challenge:
```bash
bitsocial community edit your-community.bso \
'--settings.challenges[0].name' @bitsocial/voucher-challenge \
'--settings.challenges[0].options.vouchers' 'VOUCHER1,VOUCHER2,VOUCHER3'
```
### With pkc-js over RPC
If your RPC server is already running, first install the challenge on the server:
```bash
bitsocial challenge install @bitsocial/voucher-challenge
```
Then from your RPC client, connect and set the challenge on your community by name — no npm install or challenge registration needed on the client side:
```ts
import PKC from "@pkcprotocol/pkc-js";
const pkc = await PKC({
pkcRpcClientsOptions: ["ws://localhost:9138"]
});
const community = await pkc.createCommunity({ address: "your-community-address.bso" });
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/voucher-challenge",
options: {
vouchers: "VOUCHER1,VOUCHER2,VOUCHER3"
}
}
]
}
});
```
### With pkc-js (TypeScript)
If you are running your own node locally without connecting over RPC, you can install via npm and register the challenge manually:
```bash
npm install @bitsocial/voucher-challenge
```
```ts
import PKC from "@pkcprotocol/pkc-js";
import { voucherChallenge } from "@bitsocial/voucher-challenge";
PKC.challenges["@bitsocial/voucher-challenge"] = voucherChallenge;
```
Then set the challenge on your community:
```ts
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/voucher-challenge",
options: {
vouchers: "VOUCHER1,VOUCHER2,VOUCHER3"
}
}
]
}
});
```
## Challenge Options
All option values must be strings.
| Option | Default | Description |
|--------|---------|-------------|
| `question` | `"What is your voucher code?"` | The interactive prompt the user is asked to type an answer to |
| `vouchers` | *(required)* | Comma-separated list of voucher codes |
| `description` | — | Informational text shown in the UI explaining what the challenge is about |
| `invalidVoucherError` | Default message | Error shown for invalid voucher codes |
| `alreadyRedeemedError` | Default message | Error shown when a voucher is already redeemed by another author |
## Scripts
```bash
npm run typecheck
npm run build
npm test
```
### @bitsocial/evm-contract-challenge
Repository: bitsocialnet/evm-contract-challenge
Source: https://github.com/bitsocialnet/evm-contract-challenge#readme
Description: An automatic challenge for @pkcprotocol/pkc js communities that verifies an author's EVM wallet address meets a condition from a smart contract call.
# @bitsocial/evm-contract-challenge
An automatic challenge for `@pkcprotocol/pkc-js` communities that verifies an author's EVM wallet address meets a condition from a smart contract call.
## How it works
When an author publishes to a community with this challenge enabled, the community node calls a read-only smart contract method with the author's wallet address as the argument and compares the return value against a configured condition (e.g. `>1000`). The challenge tries three sources for the wallet address:
1. **Wallet address** — the `author.wallets[chainTicker]` address, verified via EIP-191 signature
2. **ENS/BSO domain** — if the author's address is a `.eth` or `.bso` domain, it resolves to an on-chain address
3. **NFT avatar** — the current owner of the author's avatar NFT
If any source produces a wallet that passes the contract call condition, the challenge succeeds. No user interaction is required.
## Requirements
- Node.js `>=22`
- ESM-only environment
## Install
### With bitsocial-cli
```bash
bitsocial challenge install @bitsocial/evm-contract-challenge
```
Edit your community to use the challenge:
```bash
bitsocial community edit your-community.bso \
'--settings.challenges[0].name' @bitsocial/evm-contract-challenge \
'--settings.challenges[0].options.chainTicker' eth \
'--settings.challenges[0].options.address' '0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f' \
'--settings.challenges[0].options.abi' '{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}' \
'--settings.challenges[0].options.condition' '>10000000000000000000' \
'--settings.challenges[0].options.error' 'You need at least 10 Bitsocial tokens to post.'
```
### With pkc-js over RPC
If your RPC server is already running, first install the challenge on the server:
```bash
bitsocial challenge install @bitsocial/evm-contract-challenge
```
Then from your RPC client, connect and set the challenge on your community by name — no npm install or challenge registration needed on the client side:
```ts
import PKC from "@pkcprotocol/pkc-js";
const pkc = await PKC({
pkcRpcClientsOptions: ["ws://localhost:9138"]
});
const community = await pkc.createCommunity({ address: "your-community-address.bso" });
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/evm-contract-challenge",
options: {
chainTicker: "eth",
address: "0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f",
abi: '{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}',
condition: ">10000000000000000000",
error: "You need at least 10 Bitsocial tokens to post."
}
}
]
}
});
```
### With pkc-js (TypeScript)
If you are running your own node locally without connecting over RPC, you can install via npm and register the challenge manually:
```bash
npm install @bitsocial/evm-contract-challenge
```
```ts
import PKC from "@pkcprotocol/pkc-js";
import { evmContractChallenge } from "@bitsocial/evm-contract-challenge";
PKC.challenges["@bitsocial/evm-contract-challenge"] = evmContractChallenge;
```
Then set the challenge on your community:
```ts
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/evm-contract-challenge",
options: {
chainTicker: "eth",
address: "0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f",
abi: '{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}',
condition: ">10000000000000000000",
error: "You need at least 10 Bitsocial tokens to post."
}
}
]
}
});
```
## Example Challenges
Each example uses a read-only contract function that takes a single `address` argument. The `condition` compares against the raw return value including decimal places (e.g. 10 USDC with 6 decimals = `10000000` raw).
### Common ABIs
**`balanceOf`** — standard ERC-20 / ERC-721 token balance:
```json
{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}
```
**`getScore`** — Gitcoin Passport score (returns `uint256` with 4 decimals):
```json
{"inputs":[{"internalType":"address","name":"user","type":"address"}],"name":"getScore","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}
```
### Examples
| Description | `chainTicker` | `address` | ABI | `condition` |
|---|---|---|---|---|
| At least 10 Bitsocial (BSO) tokens | `eth` | `0xEA81DaB2e0EcBc6B5c4172DE4c22B6Ef6E55Bd8f` | [`balanceOf`](#balanceof-abi) | `>10000000000000000000` |
| Minimum 10 USDC | `eth` | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | [`balanceOf`](#balanceof-abi) | `>10000000` |
| Any WETH balance | `eth` | `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` | [`balanceOf`](#balanceof-abi) | `>0` |
| Gitcoin Passport score above 20 (proof of personhood) | `op` | `0xd6c51bB9E23bD7f1fEa22A3F2f85E3BFC8338Cb0` | [`getScore`](#getscore-abi) | `>200000` |
| At least 10 MATIC on Polygon | `matic` | `0x0000000000000000000000000000000000001010` | [`balanceOf`](#balanceof-abi) | `>10000000000000000000` |
| Any stETH balance (Lido staked ETH) | `eth` | `0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84` | [`balanceOf`](#balanceof-abi) | `>0` |
> Setting `rpcUrls` is recommended on every chain, including Ethereum mainnet. See [Challenge Options](#challenge-options).
## Challenge Options
All option values must be strings.
| Option | Required | Default | Description |
|--------|----------|---------|-------------|
| `chainTicker` | yes | `"eth"` | The chain ticker (e.g. `eth`, `matic`) |
| `rpcUrls` | see below | — | Comma-separated JSON-RPC URLs for the chain |
| `address` | yes | — | The contract address to call |
| `abi` | yes | — | The ABI of the contract method as a JSON object (not an array) |
| `condition` | yes | — | Condition the return value must pass (`=`, `>`, or `<` followed by a value, e.g. `>1000`) |
| `error` | no | `"Contract call response doesn't pass condition."` | Custom error message shown when the condition fails |
### When `rpcUrls` is required
`rpcUrls` is **required unless `chainTicker` is one of the tickers below**, which have a built-in RPC endpoint:
`eth`, `matic` / `pol`, `op`, `arb`, `base`, `avax`, `bnb` / `bsc`, `gno` / `xdai`, `celo`, `ftm`, `linea`, `scroll`, `zksync`, `blast`
Setting it is still **recommended even for those**: the built-in endpoints are shared public RPCs and will rate-limit a busy community, which shows up as authors being rejected at random rather than as an obvious failure.
An unsupported ticker with no `rpcUrls` is rejected when you save the challenge settings, rather than failing later for every author who tries to publish.
## Publishing Options
`community.settings.challenges[i].options` is private. Nothing in it is published unless you name the option in `publicOptions`, which copies it into the public `community.challenges[i].publicOptions` record.
**Recommended: publish everything except `rpcUrls`.**
```ts
await community.edit({
settings: {
challenges: [
{
name: "@bitsocial/evm-contract-challenge",
options: {
/* ... */
},
publicOptions: ["chainTicker", "address", "abi", "condition", "error"]
}
]
}
});
```
| Option | Publish? | Why |
|--------|----------|-----|
| `chainTicker` | recommended | Already public in effect — the challenge's `type` is `chain/` |
| `address` | recommended | A contract address is public on-chain data; hiding it buys no security |
| `abi` | recommended | The signature of a public method on that contract |
| `condition` | recommended | The threshold that makes the other three actionable |
| `error` | recommended | Authors see this string anyway when they fail |
| `rpcUrls` | **never — refused** | RPC URLs commonly embed a provider API key |
Published together, `chainTicker` + `address` + `abi` + `condition` let a client tell an author exactly what they are missing *before* they publish and burn a challenge attempt. Published partially, none of that works, so treat the four as one unit.
Keeping them private is a legitimate choice — you may not want the exact threshold known — and this package will not stop you. `rpcUrls` is the one exception: naming it in `publicOptions` is refused outright, because publishing a URL like `https://eth-mainnet.g.alchemy.com/v2/` leaks a paid credential to everyone, permanently, and no client ever uses these endpoints. Only the community node does.
## Settings Validation
The challenge validates its own settings whenever you create a community, edit one, or start one, via pkc-js's `validateChallengeSettings` hook. A bad setting is rejected as a failed edit instead of silently rejecting every author at publish time.
Rejected settings:
- `publicOptions` naming `rpcUrls`
- `rpcUrls` entries that are not valid URLs, or that do not use `http:` / `https:`
- `rpcUrls` omitted for a `chainTicker` with no built-in RPC
- `address` that is not a well-formed EVM address
- `abi` that is not valid JSON, or not a single function entry taking one `address` and returning at least one value
- `condition` with no supported operator (`=`, `>`, `<`), no value after the operator, or an ordering comparison against a non-numeric value (e.g. `>abc`, which would otherwise compare as text)
pkc-js additionally rejects an option name that no option above declares, and any required option left unset, before this challenge's own validation runs.
## Multiple RPC URLs
You can provide multiple RPC endpoints as a comma-separated string:
```
https://eth.llamarpc.com,https://rpc.ankr.com/eth,https://eth.drpc.org
```
When multiple URLs are provided, viem's [`fallback`](https://viem.sh/docs/clients/transports/fallback) transport is used with automatic ranking enabled (`rank: true`). This means:
- Requests are sent to the highest-ranked RPC endpoint
- If a request fails, it automatically falls back to the next endpoint
- viem periodically pings all endpoints in the background and reorders them by latency and stability
- A single URL works the same as before (no fallback overhead)
- If `rpcUrls` is omitted, the chain's built-in RPC is used, which requires a supported `chainTicker` (see [When `rpcUrls` is required](#when-rpcurls-is-required))
This improves reliability — if one RPC provider goes down, the challenge automatically uses the next available endpoint.
## Scripts
```bash
npm run typecheck
npm run build
npm test
```
### @bitsocial/flags-challenge
Repository: bitsocialnet/flags-challenge
Source: https://github.com/bitsocialnet/flags-challenge#readme
Description: Verified flag issuer challenge for Bitsocial communities and clients.
# @bitsocial/flags-challenge
Verified flag issuer challenge for Bitsocial communities and clients.
This package runs on a Bitsocial community owner node as a challenge. It is not specific to 5chan: any Bitsocial client can use the same challenge pattern with its own issuer service, namespace, and flag profile. The first bundled profile is the 5chan profile. Country flags are issued by `flags.5chan.app`; `/pol/` memeflags and `/mlp/` pony flags are validated as board-choice flairs without an issuer iframe.
For issuer-verified country flags, the challenge writes two pieces of data:
- immutable comment data under a client namespace such as `comment["5chan"]`;
- a compatibility mirror under `commentUpdate.author.community.flairs`, so clients that already render author flairs can show the same flag.
For `/pol/` memeflags and `/mlp/` pony flags, clients should publish the selected flag as normal comment flair data. The challenge validates that the requested family is allowed and that the code is known, then accepts the publication without contacting the issuer service.
The namespace is configurable. For example, a future Seedit profile could write to `comment["seedit"]` while using its own issuer and flag metadata.
## Installation
Run this on the Bitsocial node that owns the community:
```bash
bitsocial challenge install @bitsocial/flags-challenge
```
## Configuration
Add the challenge to the community's `settings.challenges`:
```js
[{ name: "@bitsocial/flags-challenge" }];
```
Default options use the first bundled profile and target the 5chan issuer:
| Option | Default | Behavior |
| -------------- | -------------------------------- | ----------------------------------------------------- |
| `serviceUrl` | `https://flags.5chan.app/api/v1` | Flag issuer service endpoint |
| `issuer` | `flags.5chan.app` | Expected issuer name in signed assertions |
| `namespace` | `5chan` | Top-level comment object key for immutable data |
| `profile` | `5chan` | Flag profile to validate; this release supports 5chan |
| `allowedFlags` | `country,pol,pony` | Comma-separated flag families accepted by the board |
| `emitFlair` | `true` | Mirrors the verified flag to author community flairs |
| `error` | `Flag verification failed.` | Error prefix shown if verification fails |
Boards can restrict flag families:
```js
[
{
name: "@bitsocial/flags-challenge",
options: {
allowedFlags: "country,pol",
},
},
];
```
For `/mlp/`, use:
```js
[
{
name: "@bitsocial/flags-challenge",
options: {
allowedFlags: "pony",
},
},
];
```
## 5chan Flag Requests
Clients request a flag by publishing a challenge-readable flag value on the comment, usually through `flair`:
```js
{
flair: { type: "country", code: "auto", text: "flag:country:auto" }
}
```
Supported 5chan request strings:
- `flag:country:auto`
- `flag:pol:AC`
- `flag:pony:AJ`
Country flags use `auto` because the issuer service must derive the country from the challenge iframe request IP. The challenge does not trust a client-provided country code as proof of location. `/pol/` memeflags and `/mlp/` pony flags are free user choices, so they do not require the iframe flow.
## Verified Comment Shape
For a country flag, the challenge returns a result like:
```js
{
success: true,
comment: {
"5chan": {
country: "US",
flag: {
type: "country",
code: "US",
text: "flag:country:us",
label: "US"
},
issuer: "flags.5chan.app",
issuedAt: 1770000000,
signature: {
publicKey: "flag-service-public-key",
signature: "flag-service-signature",
type: "ed25519"
}
}
},
commentUpdate: {
author: {
community: {
flairs: [{ text: "flag:country:us", type: "country", code: "US" }]
}
}
}
}
```
For `/pol/` memeflags and `/mlp/` pony flags, clients render the selected flag from the comment flair fields they publish. The challenge does not add a signed `comment["5chan"]` assertion for those board-choice flags.
## Issuer Service Contract
The challenge creates a lazy iframe URL:
```text
https://flags.5chan.app/api/v1/iframe//lazy#payload=
```
The payload includes:
- the original challenge request;
- `requestedFlag`;
- `profile`, `namespace`, and `issuer`;
- a community-signed CBOR payload signature.
When the user completes the iframe flow, the challenge calls:
```text
POST https://flags.5chan.app/api/v1/challenge/verify
Content-Type: application/cbor
Accept: application/json
```
The issuer should return:
```js
{
success: true,
flag: {
type: "country",
code: "US",
issuer: "flags.5chan.app",
issuedAt: 1770000000,
signature: {
publicKey: "flag-service-public-key",
signature: "flag-service-signature",
type: "ed25519"
}
}
}
```
For country flags, the service can also return `{ success: true, country: "US", issuer, issuedAt, signature }`.
Issuer service deployment is intentionally outside this package. The challenge only defines the community-node integration and the issuer API contract.
## Development
```bash
corepack yarn install
corepack yarn type-check
corepack yarn test
corepack yarn test:coverage
corepack yarn build
```
## Publishing
Create the GitHub release and changelog with release-it:
```bash
corepack yarn release 0.1.0
```
The command expects the current branch to have an upstream and needs a GitHub token that can create releases. It writes `CHANGELOG.md`, creates a `v0.1.0` tag, and opens the GitHub release.
The first npm publish must create the package before trusted publishing can be configured:
```bash
npm publish --access public
```
After the package exists, configure npm trusted publishing:
- Publisher: GitHub Actions
- Organization: `bitsocialnet`
- Repository: `flags-challenge`
- Workflow filename: `publish.yml`
- Environment: leave blank
Equivalent npm CLI command:
```bash
npm trust github @bitsocial/flags-challenge --repo bitsocialnet/flags-challenge --file publish.yml
```
Future releases publish automatically when `package.json` version changes on `master`. The publish workflow skips versions that already exist on npm.
### bitsocial-cli: A Bitsocial Node with WebSocket and Command Line Interface
Repository: bitsocialnet/bitsocial-cli
Source: https://github.com/bitsocialnet/bitsocial-cli#readme
Description: What is Bitsocial?
# bitsocial-cli: A Bitsocial Node with WebSocket and Command Line Interface
## Table of contents
- [What is Bitsocial?](#what-is-bitsocial)
- [What is bitsocial-cli?](#what-is-bitsocial-cli)
- [Install](#install)
- [Docker](#docker)
- [Usage](#usage)
- [Commands](#commands)
- [Contribution](#contribution)
- [Feedback](#feedback)
## What is bitsocial-cli?
`bitsocial-cli` is an interface to the backend of PKC protocol using [pkc-js](https://github.com/pkcprotocol/pkc-js). Users can run and manage their communities using it. It is written in Typescript and designed to receive commands via CLI and WebSocket.
- Runs an IPFS and Bitsocial node
- Command Line interface to manage Bitsocial communities
- WebSocket RPC to access and control your communities and publications
- Includes Web UIs like Seedit where you can browse the network and manage your community
## Install
Requires Node.js 22 or later. We recommend using [nvm](https://github.com/nvm-sh/nvm) to install and manage Node.js versions.
```sh-session
npm install -g @bitsocial/bitsocial-cli
```
To install a specific version:
```sh-session
npm install -g @bitsocial/bitsocial-cli@0.19.39
```
To update to the latest version:
```sh-session
bitsocial update install
```
### Build from source (optional)
If you want to build from source directly:
```
git clone https://github.com/bitsocialnet/bitsocial-cli
cd bitsocial-cli
npm ci
npm run build
npx oclif manifest
npm run ci:download-web-uis
./bin/run --help
```
After running the last command you should be able to run commands directly against `./bin/run`, for example `./bin/run daemon`
## Docker
You can run bitsocial-cli as a Docker container. The container runs the daemon and exposes the RPC + web UI on port 9138, the Kubo IPFS API on port 50019, and the IPFS Gateway on port 6473.
Once your container is running, you can use one of the bundled web UIs to browse the Bitsocial network and manage your communities -- no CLI commands needed. The web UIs provide a full-featured interface for creating communities, moderating, and browsing content entirely through your browser. All the Web UIs are interopable so you can post and read from whichever you like and you can see your own content on each client.
If you're a power user, you can also run CLI commands against the running container with `docker exec`:
```sh-session
docker exec bitsocial bitsocial community list
```
### Data paths inside the container
| Path | Description |
|---|---|
| `/data/bitsocial` | Bitsocial data directory |
| `/data/bitsocial/communities` | Community SQLite databases |
| `/data/bitsocial/.bitsocial-cli.ipfs` | Kubo IPFS repository |
| `/logs/bitsocial` | Log files |
The Docker volumes `bitsocial-data:/data` and `bitsocial-logs:/logs` are mapped to `/data` and `/logs` inside the container. The `bitsocial` subdirectory is created automatically by the application.
### Docker Compose (recommended)
Copy the example compose file and start the node:
```sh-session
cp docker-compose.example.yml docker-compose.yml
docker compose up -d
```
View the startup logs to find your auth key URL:
```sh-session
docker compose logs -f
```
The output will include lines like:
```
pkc rpc: listening on ws://localhost:9138/ (secret auth key for remote connections)
WebUI (seedit - Similar to old reddit UI): http://:9138//seedit (secret auth key for remote connections)
```
Open the WebUI URL in your browser to start using Bitsocial.
#### Viewing logs
There are two ways to view logs from a Docker container:
**Quick logs** — shows stdout output only (startup messages, errors):
```sh-session
docker compose logs -f # Docker Compose
docker logs -f bitsocial # Docker Run
```
**Full debug logs** — shows the complete daemon log including debug/trace output:
```sh-session
docker exec bitsocial bitsocial logs -f
```
The `bitsocial logs` command supports several filtering flags:
```sh-session
docker exec bitsocial bitsocial logs -f # follow (stream new lines)
docker exec bitsocial bitsocial logs -n 100 # last 100 lines
docker exec bitsocial bitsocial logs --since 1h # entries from the last hour
docker exec bitsocial bitsocial logs --until 30m # entries up to 30 minutes ago
```
Debug and trace logs are written only to the log file, not to stdout, so `docker logs` will not show them. Use `bitsocial logs` inside the container for the full picture.
#### Example docker-compose.yml
```yaml
services:
bitsocial:
image: ghcr.io/bitsocialnet/bitsocial-cli:latest
container_name: bitsocial
restart: unless-stopped
ports:
- "9138:9138" # PKC RPC + Web UI
- "50019:50019" # Kubo IPFS API
- "6473:6473" # IPFS Gateway
volumes:
- bitsocial-data:/data
- bitsocial-logs:/logs
environment:
- DEBUG=bitsocial*, pkc*, -pkc*trace
# Set a fixed auth key (useful for bookmarking the web UI URL).
# If left unset, a random key is generated on first start.
# - PKC_RPC_AUTH_KEY=your-custom-auth-key-here
# Override Kubo IPFS bind addresses / ports:
# - KUBO_RPC_URL=http://0.0.0.0:50019/api/v0
# - IPFS_GATEWAY_URL=http://0.0.0.0:6473
volumes:
bitsocial-data:
bitsocial-logs:
```
### Docker Run
```sh-session
docker run -d \
--name bitsocial \
--restart unless-stopped \
-p 9138:9138 \
-p 50019:50019 \
-p 6473:6473 \
-v bitsocial-data:/data \
-v bitsocial-logs:/logs \
ghcr.io/bitsocialnet/bitsocial-cli:latest
```
With a custom auth key:
```sh-session
docker run -d \
--name bitsocial \
--restart unless-stopped \
-p 9138:9138 \
-p 50019:50019 \
-p 6473:6473 \
-v bitsocial-data:/data \
-v bitsocial-logs:/logs \
-e PKC_RPC_AUTH_KEY=my-secret-key \
ghcr.io/bitsocialnet/bitsocial-cli:latest
```
### Building the Docker image locally
```sh-session
docker build -t bitsocial-cli .
docker run -p 9138:9138 -p 50019:50019 -p 6473:6473 bitsocial-cli
```
## Usage
### The data/config directory of Bitsocial
This is the default directory where bitsocial-cli will keep its config, as well as data for local communities:
- macOS: ~/Library/Application Support/bitsocial
- Windows: %LOCALAPPDATA%\bitsocial
- Linux: ~/.local/share/bitsocial
### The logs directory of Bitsocial
bitsocial-cli will keep logs in this directory, with a cap of 10M per log file.
- macOS: ~/Library/Logs/bitsocial
- Windows: %LOCALAPPDATA%\bitsocial\Log
- Linux: ~/.local/state/bitsocial
### Running Daemon
In Bash (or powershell if you're on Windows), run `bitsocial daemon` to able to connect to the network. You need to have the `bitsocial daemon` terminal running to be able to execute other commands.
```sh-session
$ bitsocial daemon
IPFS API listening on: http://localhost:5001/api/v0
IPFS Gateway listening on: http://localhost:6473
pkc rpc: listening on ws://localhost:9138 (local connections only)
pkc rpc: listening on ws://localhost:9138/ (secret auth key for remote connections)
Bitsocial data path: /root/.local/share/bitsocial
Communities in data path: [ 'anime-and-manga.bso' ]
WebUI (5chan - Imageboard-style UI): http://localhost:9138//5chan (local connections only)
WebUI (5chan - Imageboard-style UI): http://:9138//5chan (secret auth key for remote connections)
WebUI (seedit - Similar to old reddit UI): http://localhost:9138//seedit (local connections only)
WebUI (seedit - Similar to old reddit UI): http://:9138//seedit (secret auth key for remote connections)
```
Once `bitsocial daemon` is running, you can create and manage your communities through the web interfaces, either seedit or 5chan. All the interfaces are interoperable. If you're a power user and prefer CLI, then you can take a look at the commands below.
If you need to view detailed protocol or IPFS logs for debugging, you can use `bitsocial logs`. For example, `bitsocial logs --tail 50` shows the last 50 lines, or `bitsocial logs --since 1h` shows logs from the past hour.
#### Creating your first community
```sh-session
$ bitsocial community create --title "Hello World!" --description "This is gonna be great"
12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
```
#### Listing all your communities
```sh-session
$ bitsocial community list
Address Started
──────────────────────────────────────────────────── ───────
12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu true
business-and-finance.bso true
censorship-watch.bso true
health-nutrition-science.bso true
movies-tv-anime.bso true
anime-and-manga.bso true
politically-incorrect.bso true
reddit-screenshots.bso false
videos-livestreams-podcasts.bso false
```
#### Adding a role moderator to your community
```sh-session
$ bitsocial community edit mysub.bso '--roles["author-address.bso"].role' moderator
```
#### Adding a role owner to your community
```sh-session
$ bitsocial community edit mysub.bso '--roles["author-address.bso"].role' owner
```
#### Adding a role admin to your community
```sh-session
$ bitsocial community edit mysub.bso '--roles["author-address.bso"].role' admin
```
#### Removing a role
```sh-session
$ bitsocial community edit mysub.bso '--roles["author-address.bso"]' null
```
## Commands
* [`bitsocial challenge add PACKAGE`](#bitsocial-challenge-add-package)
* [`bitsocial challenge i PACKAGE`](#bitsocial-challenge-i-package)
* [`bitsocial challenge install PACKAGE`](#bitsocial-challenge-install-package)
* [`bitsocial challenge list`](#bitsocial-challenge-list)
* [`bitsocial challenge ls`](#bitsocial-challenge-ls)
* [`bitsocial challenge remove NAME`](#bitsocial-challenge-remove-name)
* [`bitsocial challenge rm NAME`](#bitsocial-challenge-rm-name)
* [`bitsocial challenge un NAME`](#bitsocial-challenge-un-name)
* [`bitsocial challenge uninstall NAME`](#bitsocial-challenge-uninstall-name)
* [`bitsocial community create`](#bitsocial-community-create)
* [`bitsocial community delete ADDRESSES`](#bitsocial-community-delete-addresses)
* [`bitsocial community edit ADDRESS`](#bitsocial-community-edit-address)
* [`bitsocial community export [ADDRESS]`](#bitsocial-community-export-address)
* [`bitsocial community get [ADDRESS]`](#bitsocial-community-get-address)
* [`bitsocial community list`](#bitsocial-community-list)
* [`bitsocial community start ADDRESSES`](#bitsocial-community-start-addresses)
* [`bitsocial community stop ADDRESSES`](#bitsocial-community-stop-addresses)
* [`bitsocial daemon`](#bitsocial-daemon)
* [`bitsocial help [COMMAND]`](#bitsocial-help-command)
* [`bitsocial logs`](#bitsocial-logs)
* [`bitsocial update check`](#bitsocial-update-check)
* [`bitsocial update install [VERSION]`](#bitsocial-update-install-version)
* [`bitsocial update versions`](#bitsocial-update-versions)
## `bitsocial challenge add PACKAGE`
Install a challenge package (npm package name, git URL, tarball URL, or local path)
```
USAGE
$ bitsocial challenge add PACKAGE --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)
FLAGS
--pkcOptions.dataPath= Data path to install the challenge into
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
ALIASES
$ bitsocial challenge i
$ bitsocial challenge add
EXAMPLES
$ bitsocial challenge install @bitsocial/mintpass-challenge
$ bitsocial challenge install @bitsocial/mintpass-challenge@1.0.0
$ bitsocial challenge install github:user/repo
$ bitsocial challenge install https://example.com/my-challenge-1.0.0.tar.gz
$ bitsocial challenge install ./my-local-challenge
```
## `bitsocial challenge i PACKAGE`
Install a challenge package (npm package name, git URL, tarball URL, or local path)
```
USAGE
$ bitsocial challenge i PACKAGE --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)
FLAGS
--pkcOptions.dataPath= Data path to install the challenge into
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
ALIASES
$ bitsocial challenge i
$ bitsocial challenge add
EXAMPLES
$ bitsocial challenge install @bitsocial/mintpass-challenge
$ bitsocial challenge install @bitsocial/mintpass-challenge@1.0.0
$ bitsocial challenge install github:user/repo
$ bitsocial challenge install https://example.com/my-challenge-1.0.0.tar.gz
$ bitsocial challenge install ./my-local-challenge
```
## `bitsocial challenge install PACKAGE`
Install a challenge package (npm package name, git URL, tarball URL, or local path)
```
USAGE
$ bitsocial challenge install PACKAGE --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
PACKAGE Package specifier — anything npm can install (name, name@version, git URL, tarball URL, local path)
FLAGS
--pkcOptions.dataPath= Data path to install the challenge into
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Install a challenge package (npm package name, git URL, tarball URL, or local path)
ALIASES
$ bitsocial challenge i
$ bitsocial challenge add
EXAMPLES
$ bitsocial challenge install @bitsocial/mintpass-challenge
$ bitsocial challenge install @bitsocial/mintpass-challenge@1.0.0
$ bitsocial challenge install github:user/repo
$ bitsocial challenge install https://example.com/my-challenge-1.0.0.tar.gz
$ bitsocial challenge install ./my-local-challenge
```
_See code: [src/cli/commands/challenge/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/challenge/install.ts)_
## `bitsocial challenge list`
List installed challenge packages
```
USAGE
$ bitsocial challenge list [-q] [--pkcOptions.dataPath ]
FLAGS
-q, --quiet Only display challenge names
--pkcOptions.dataPath= Data path where challenges are installed
DESCRIPTION
List installed challenge packages
ALIASES
$ bitsocial challenge ls
EXAMPLES
$ bitsocial challenge list
$ bitsocial challenge list -q
```
_See code: [src/cli/commands/challenge/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/challenge/list.ts)_
## `bitsocial challenge ls`
List installed challenge packages
```
USAGE
$ bitsocial challenge ls [-q] [--pkcOptions.dataPath ]
FLAGS
-q, --quiet Only display challenge names
--pkcOptions.dataPath= Data path where challenges are installed
DESCRIPTION
List installed challenge packages
ALIASES
$ bitsocial challenge ls
EXAMPLES
$ bitsocial challenge list
$ bitsocial challenge list -q
```
## `bitsocial challenge remove NAME`
Remove an installed challenge package
```
USAGE
$ bitsocial challenge remove NAME --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
FLAGS
--pkcOptions.dataPath= Data path where challenges are installed
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Remove an installed challenge package
ALIASES
$ bitsocial challenge uninstall
$ bitsocial challenge rm
$ bitsocial challenge un
EXAMPLES
$ bitsocial challenge remove my-challenge
$ bitsocial challenge remove @scope/my-challenge
```
_See code: [src/cli/commands/challenge/remove.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/challenge/remove.ts)_
## `bitsocial challenge rm NAME`
Remove an installed challenge package
```
USAGE
$ bitsocial challenge rm NAME --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
FLAGS
--pkcOptions.dataPath= Data path where challenges are installed
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Remove an installed challenge package
ALIASES
$ bitsocial challenge uninstall
$ bitsocial challenge rm
$ bitsocial challenge un
EXAMPLES
$ bitsocial challenge remove my-challenge
$ bitsocial challenge remove @scope/my-challenge
```
## `bitsocial challenge un NAME`
Remove an installed challenge package
```
USAGE
$ bitsocial challenge un NAME --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
FLAGS
--pkcOptions.dataPath= Data path where challenges are installed
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Remove an installed challenge package
ALIASES
$ bitsocial challenge uninstall
$ bitsocial challenge rm
$ bitsocial challenge un
EXAMPLES
$ bitsocial challenge remove my-challenge
$ bitsocial challenge remove @scope/my-challenge
```
## `bitsocial challenge uninstall NAME`
Remove an installed challenge package
```
USAGE
$ bitsocial challenge uninstall NAME --pkcRpcUrl [--pkcOptions.dataPath ]
ARGUMENTS
NAME The challenge package name (e.g., my-challenge or @scope/my-challenge)
FLAGS
--pkcOptions.dataPath= Data path where challenges are installed
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Remove an installed challenge package
ALIASES
$ bitsocial challenge uninstall
$ bitsocial challenge rm
$ bitsocial challenge un
EXAMPLES
$ bitsocial challenge remove my-challenge
$ bitsocial challenge remove @scope/my-challenge
```
## `bitsocial community create`
Create a community with specific properties. A newly created community will be started after creation and be able to receive publications. For a list of properties, visit https://github.com/pkcprotocol/pkc-js
```
USAGE
$ bitsocial community create --pkcRpcUrl [--privateKeyPath ] [-f ]
FLAGS
-f, --jsonFile= Path to a JSON/JSONC file containing create options (supports comments)
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
--privateKeyPath= Private key (PEM) of the community signer that will be used to determine address (if
address is not a domain). If it's not provided then PKC will generate a private key
DESCRIPTION
Create a community with specific properties. A newly created community will be started after creation and be able to
receive publications. For a list of properties, visit https://github.com/pkcprotocol/pkc-js
EXAMPLES
Create a community with title 'Hello Plebs' and description 'Welcome'
$ bitsocial community create --title 'Hello Plebs' --description 'Welcome'
Create a community using options from a JSON/JSONC file
$ bitsocial community create --jsonFile ./create-options.json
```
_See code: [src/cli/commands/community/create.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/create.ts)_
## `bitsocial community delete ADDRESSES`
Delete a community permanently.
```
USAGE
$ bitsocial community delete ADDRESSES... --pkcRpcUrl
ARGUMENTS
ADDRESSES... Addresses of communities to delete. Separated by space
FLAGS
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Delete a community permanently.
EXAMPLES
$ bitsocial community delete plebbit.bso
$ bitsocial community delete 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
```
_See code: [src/cli/commands/community/delete.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/delete.ts)_
## `bitsocial community edit ADDRESS`
Edit a community's properties. For a list of properties, visit https://github.com/pkcprotocol/pkc-js
```
USAGE
$ bitsocial community edit ADDRESS --pkcRpcUrl [-f ]
ARGUMENTS
ADDRESS Address of the community to edit. It could be the name domain, or a public key
FLAGS
-f, --jsonFile= Path to a JSON/JSONC file containing edit options (supports comments)
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Edit a community's properties. For a list of properties, visit https://github.com/pkcprotocol/pkc-js
Merge behavior with CLI flags:
- Objects are merged with the community's current state (new keys are added, existing keys are overwritten).
- Arrays are extended: new values are prepended to the existing array.
- Setting a value to null removes it (e.g. --roles['mod.bso'] null).
Merge behavior with --jsonFile:
- Objects are merged the same way as CLI flags.
- Arrays are replaced entirely (RFC 7396 JSON Merge Patch semantics).
- When both --jsonFile and CLI flags are provided, CLI flags take priority.
For modifying complex settings like challenges, consider using a web UI instead: https://bitsocial.net/apps
EXAMPLES
Change the name of the community
$ bitsocial community edit 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu --name newName.bso
Add the author address 'esteban.bso' as an admin on the community
$ bitsocial community edit mycommunity.bso '--roles["esteban.bso"].role' admin
Add two challenges to the community. The first challenge will be a question and answer, and the second will be an
image captcha
$ bitsocial community edit mycommunity.bso --settings.challenges[0].name question \
--settings.challenges[0].options.question "what is the password?" --settings.challenges[0].options.answer \
thepassword --settings.challenges[1].name captcha-canvas-v3
Change the title and description
$ bitsocial community edit mycommunity.bso --title "This is the new title" --description "This is the new \
description"
Remove a role from a moderator/admin/owner
$ bitsocial community edit bitsocial.bso --roles['rinse12.bso'] null
Enable settings.fetchThumbnailUrls to fetch the thumbnail of url submitted by authors
$ bitsocial community edit bitsocial.bso --settings.fetchThumbnailUrls
disable settings.fetchThumbnailUrls
$ bitsocial community edit bitsocial.bso --settings.fetchThumbnailUrls=false
Edit a community using options from a JSON/JSONC file
$ bitsocial community edit bitsocial.bso --jsonFile ./edit-options.json
```
_See code: [src/cli/commands/community/edit.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/edit.ts)_
## `bitsocial community export [ADDRESS]`
Export a local community to a SQLite snapshot file. The export runs on the RPC server (daemon); once finished the snapshot is downloaded and its sha256 checksum is verified. Pass --includePrivateKey to produce a restorable backup that keeps the community's address.
```
USAGE
$ bitsocial community export [ADDRESS] --pkcRpcUrl [--name ] [--publicKey ] [-o ]
[--includePrivateKey] [--force] [-q]
ARGUMENTS
[ADDRESS] Address of the community to export
FLAGS
-o, --path= Destination file for the downloaded snapshot (default:
/exports/_.sqlite)
-q, --quiet Suppress progress output; only print the path of the downloaded snapshot
--force Overwrite the destination file if it already exists
--includePrivateKey Ask the RPC server to include the community signer's private key in the export. Required for
a restorable backup that keeps the same community address. The daemon may refuse (see
`bitsocial daemon --no-allowPrivateKeyExport`)
--name= Name of the community to export
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
--publicKey= Public key of the community to export
DESCRIPTION
Export a local community to a SQLite snapshot file. The export runs on the RPC server (daemon); once finished the
snapshot is downloaded and its sha256 checksum is verified. Pass --includePrivateKey to produce a restorable backup
that keeps the community's address.
EXAMPLES
$ bitsocial community export plebmusic.bso
$ bitsocial community export plebmusic.bso --includePrivateKey -o ./backups/plebmusic.sqlite
$ bitsocial community export --name my-community
$ bitsocial community export --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
```
_See code: [src/cli/commands/community/export.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/export.ts)_
## `bitsocial community get [ADDRESS]`
Fetch a local or remote community, and print its json in the terminal
```
USAGE
$ bitsocial community get [ADDRESS] --pkcRpcUrl [--name ] [--publicKey ]
ARGUMENTS
[ADDRESS] Address of the community to fetch
FLAGS
--name= Name of the community to fetch
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
--publicKey= Public key of the community to fetch
DESCRIPTION
Fetch a local or remote community, and print its json in the terminal
EXAMPLES
$ bitsocial community get plebmusic.bso
$ bitsocial community get 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
$ bitsocial community get --name my-community
$ bitsocial community get --publicKey 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
```
_See code: [src/cli/commands/community/get.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/get.ts)_
## `bitsocial community list`
List your communities
```
USAGE
$ bitsocial community list --pkcRpcUrl [-q]
FLAGS
-q, --quiet Only display community addresses (much faster: skips the per-community 'started' lookup)
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
List your communities
EXAMPLES
$ bitsocial community list -q
$ bitsocial community list
```
_See code: [src/cli/commands/community/list.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/list.ts)_
## `bitsocial community start ADDRESSES`
Start a community
```
USAGE
$ bitsocial community start ADDRESSES... --pkcRpcUrl [--concurrency ]
ARGUMENTS
ADDRESSES... Addresses of communities to start. Separated by space
FLAGS
--concurrency= [default: 5] Number of communities to start in parallel
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Start a community
EXAMPLES
$ bitsocial community start plebbit.bso
$ bitsocial community start 12D3KooWG3XbzoVyAE6Y9vHZKF64Yuuu4TjdgQKedk14iYmTEPWu
Start all communities in your data path
$ bitsocial community start $(bitsocial community list -q)
Start communities sequentially (no concurrency)
$ bitsocial community start $(bitsocial community list -q) --concurrency 1
```
_See code: [src/cli/commands/community/start.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/start.ts)_
## `bitsocial community stop ADDRESSES`
Stop a community. The community will not publish or receive any publications until it is started again.
```
USAGE
$ bitsocial community stop ADDRESSES... --pkcRpcUrl
ARGUMENTS
ADDRESSES... Addresses of communities to stop. Separated by space
FLAGS
--pkcRpcUrl= (required) [default: ws://localhost:9138/] URL to PKC RPC
DESCRIPTION
Stop a community. The community will not publish or receive any publications until it is started again.
EXAMPLES
$ bitsocial community stop plebbit.bso
$ bitsocial community stop Qmb99crTbSUfKXamXwZBe829Vf6w5w5TktPkb6WstC9RFW
```
_See code: [src/cli/commands/community/stop.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/community/stop.ts)_
## `bitsocial daemon`
Run a network-connected Bitsocial node. Once the daemon is running you can create and start your communities and receive publications from users. The daemon will also serve web ui on http that can be accessed through a browser on any machine. Within the web ui users are able to browse, create and manage their communities fully P2P.
```
USAGE
$ bitsocial daemon --pkcRpcUrl --logPath [--chainProviderUrls ...] [--enableIpfsGc]
[--ipfsGcIntervalMinutes ] [--allowPrivateKeyExport]
FLAGS
--[no-]allowPrivateKeyExport Allow RPC clients to request community exports that include the community signer's
private key (`bitsocial community export --includePrivateKey`). Disable with
--no-allowPrivateKeyExport when exposing the RPC to untrusted clients
--chainProviderUrls=... [default:
https://eth.drpc.org,https://ethereum.publicnode.com,https://ethereum-rpc.publicnode.
com,https://rpc.mevblocker.io,https://1rpc.io/eth,https://eth-pokt.nodies.app] RPC
URL(s) for .bso name resolution. Can be specified multiple times.
--[no-]enableIpfsGc Periodically garbage-collect the IPFS repo over the kubo RPC API while the daemon is
up. Only reclaims unpinned blocks — pinned data and MFS are never collected. Disable
with --no-enableIpfsGc
--ipfsGcIntervalMinutes= [default: 60] How often to garbage-collect the IPFS repo, in minutes
--logPath= (required) [default: /home/runner/.local/state/bitsocial] Specify a directory which
will be used to store logs
--pkcRpcUrl= (required) [default: ws://localhost:9138/] Specify PKC RPC URL to listen on
DESCRIPTION
Run a network-connected Bitsocial node. Once the daemon is running you can create and start your communities and
receive publications from users. The daemon will also serve web ui on http that can be accessed through a browser on
any machine. Within the web ui users are able to browse, create and manage their communities fully P2P.
Options can be passed to the RPC's instance through flag --pkcOptions.optionName. For a list of pkc options
(https://github.com/pkcprotocol/pkc-js?tab=readme-ov-file#pkcoptions)
If you need to modify ipfs config, you should head to {bitsocial-data-path}/.ipfs-bitsocial-cli/config and modify the
config file
EXAMPLES
$ bitsocial daemon
$ bitsocial daemon --pkcRpcUrl ws://localhost:53812
$ bitsocial daemon --pkcOptions.dataPath /tmp/bitsocial-datapath/
$ bitsocial daemon --pkcOptions.kuboRpcClientsOptions[0] https://remoteipfsnode.com
$ bitsocial daemon --chainProviderUrls https://mainnet.infura.io/v3/YOUR_KEY
$ bitsocial daemon --no-allowPrivateKeyExport
```
_See code: [src/cli/commands/daemon.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/daemon.ts)_
## `bitsocial help [COMMAND]`
Display help for bitsocial.
```
USAGE
$ bitsocial help [COMMAND...] [-n]
ARGUMENTS
[COMMAND...] Command to show help for.
FLAGS
-n, --nested-commands Include all nested commands in the output.
DESCRIPTION
Display help for bitsocial.
```
## `bitsocial logs`
View the latest BitSocial daemon log file. By default dumps the full log and exits. Use --follow to stream new output in real-time (like tail -f).
```
USAGE
$ bitsocial logs [-f] [-n ] [--since ] [--until ] [--logPath ] [--stdout |
--stderr]
FLAGS
-f, --follow Follow log output in real-time (like tail -f)
-n, --tail= [default: all] Number of log entries to show from the end. Use "all" to show everything.
--logPath= Specify the directory containing log files
--since= Show logs since timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s,
42m, 2h, 1d)
--stderr Show only stderr log entries (output of pkc-logger library)
--stdout Show only stdout log entries
--until= Show logs before timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s,
42m, 2h, 1d)
DESCRIPTION
View the latest BitSocial daemon log file. By default dumps the full log and exits. Use --follow to stream new output
in real-time (like tail -f).
EXAMPLES
$ bitsocial logs
$ bitsocial logs -f
$ bitsocial logs -n 50
$ bitsocial logs --since 5m
$ bitsocial logs --since 2026-01-02T13:23:37Z --until 2026-01-02T14:00:00Z
$ bitsocial logs --since 1h -f
$ bitsocial logs --stdout
$ bitsocial logs --stderr
$ bitsocial logs --stdout -f
```
_See code: [src/cli/commands/logs.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/logs.ts)_
## `bitsocial update check`
Check if a newer version of bitsocial is available on npm
```
USAGE
$ bitsocial update check
DESCRIPTION
Check if a newer version of bitsocial is available on npm
EXAMPLES
$ bitsocial update check
```
_See code: [src/cli/commands/update/check.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/update/check.ts)_
## `bitsocial update install [VERSION]`
Install a specific version of bitsocial from npm
```
USAGE
$ bitsocial update install [VERSION] [--force] [--restart-daemons]
ARGUMENTS
[VERSION] [default: latest] Version to install (e.g. "0.19.40" or "latest")
FLAGS
--force Reinstall even if already on the requested version
--[no-]restart-daemons Stop all running daemons, update, and restart them with the same settings
DESCRIPTION
Install a specific version of bitsocial from npm
EXAMPLES
$ bitsocial update install
$ bitsocial update install latest
$ bitsocial update install 0.19.40
$ bitsocial update install --force
$ bitsocial update install --no-restart-daemons
```
_See code: [src/cli/commands/update/install.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/update/install.ts)_
## `bitsocial update versions`
List available bitsocial versions on npm
```
USAGE
$ bitsocial update versions [--limit ]
FLAGS
--limit= [default: 20] Maximum number of versions to display
DESCRIPTION
List available bitsocial versions on npm
EXAMPLES
$ bitsocial update versions
$ bitsocial update versions --limit 5
```
_See code: [src/cli/commands/update/versions.ts](https://github.com/bitsocialnet/bitsocial-cli/blob/v0.19.96/src/cli/commands/update/versions.ts)_
## Contribution
We're always happy to receive pull requests. Few things to keep in mind:
- This repo follows [Angular commit conventions](https://github.com/angular/angular/blob/main/CONTRIBUTING.md). Easiest way to follow these conventions is by using `npm run commit` instead of `git commit`
- If you're adding a feature, make sure to add tests to your pull requests
## Feedback
We would love your feedback on our community channels
### bitsocial-seeder
Repository: bitsocialnet/bitsocial-seeder
Source: https://github.com/bitsocialnet/bitsocial-seeder#readme
Description: Seeds Bitsocial community first pages, post update CIDs, pubsub topic routing CIDs, and pubsub topics through a bitsocial daemon.
# bitsocial-seeder
Seeds Bitsocial community first pages, post-update CIDs, pubsub topic routing CIDs, and pubsub topics through a `bitsocial daemon`.
It also seeds [directory vote contests](#seed-pubsub-votes-directory-contests), which needs no daemon at all.
For community seeding it reuses an already-running Kubo and PKC RPC when one is available. If it cannot find a local daemon, it starts the bundled `@bitsocial/bitsocial-cli` daemon automatically and seeds through that node. A [votes-only seeder](#run-a-votes-only-seeder) never requires or starts one.
## Is this the only way to seed?
No — and for most users it is not the recommended way. Bitsocial desktop apps such as the 5chan Electron app already seed automatically while they are running. If many users keep an app open, the network is well served without anyone running a dedicated seeder. **The apps are the load-bearing seeders of the protocol; this repo is supplemental.**
`bitsocial-seeder` exists for operators who want to contribute consistent 24/7 seeding capacity from a VPS or a spare machine — for example, running closer to popular communities to lower fetch latency, or keeping data available during quiet periods when few app users are online. It is helpful but not required.
This is also an **experimental repository**. Releases are cut frequently, internals change without warning between minor versions, and the project is treated as a place to try ideas that benefit the protocol but are not on its critical path. Please file issues if you hit anything; expect bumps.
## Setup
The fastest path is Docker Compose. Recommended for unattended VPS seeders — it gives you a predictable service wrapper, simpler updates, and fewer local Node/native dependency surprises.
**1. Clone and start the container:**
```sh
git clone https://github.com/bitsocialnet/bitsocial-seeder.git
cd bitsocial-seeder
docker compose up -d
```
**2. Watch the logs to confirm it's seeding:**
```sh
docker compose logs -f
```
Within a couple of minutes you should see lines like:
```
discovered N communities to seed
seeding N communities
some-community.bso updated 2 minutes ago, page cids: 0, post updates cids: 3, ...
some-community.bso queueing pubsub routing provide bafkrei...
some-community.bso pinned Qm... in 1.2s
```
That's it — you're seeding. The container bundles its own Bitsocial daemon (Kubo IPFS + PKC), discovers communities from the official [5chan](https://github.com/bitsocialnet/lists/tree/master/5chan-directories) and [Seedit](https://github.com/bitsocialnet/lists/tree/master/seedit-directories) directory sources, and pins their content. It re-reads both sources on the normal discovery interval, so communities added to either directory are seeded without another `bitsocial-seeder` upgrade.
It also seeds the [5chan directory votes](#seed-pubsub-votes-directory-contests) by default, so you will see votes lines alongside the community ones:
```
votes joined 63 contests
votes fetch serve 5chan-dir-g (12 bundles)
```
Votes seeding starts an embedded libp2p node that wants two open ports (`6742`/`6743`) and does a few minutes of AutoTLS setup on first run. Set `VOTES_MANIFEST_SOURCES=none` to switch it off and seed communities only.
Directory voting is still on **testnet** — the contests are gated by the `5chan Pass` ERC-721 on Base Sepolia, so seeding them costs only the node and some testnet RPC reads.
**3. (Optional) Cap the workload on small VPSes:**
By default there is no cap — the seeder seeds every community in the configured public lists:
```sh
MAX_COMMUNITIES=10 PIN_CONCURRENCY=1 docker compose up -d
```
See [VPS Sizing](#vps-sizing) for capacity guidance.
Compose pulls `ghcr.io/bitsocialnet/bitsocial-seeder:latest` by default. To pin a specific version, edit `docker-compose.yml` and set `image: ghcr.io/bitsocialnet/bitsocial-seeder:0.6.1`.
### Run without Docker (npm)
For local testing or Node-first operators (Node 24+ required):
```sh
npx @bitsocial/bitsocial-seeder
```
Or install globally:
```sh
npm install -g @bitsocial/bitsocial-seeder
bitsocial-seeder
```
Same environment variables as the Docker image. Reuses an already-running Bitsocial daemon when one is reachable, otherwise starts the bundled one.
### Updating
If you installed with Docker Compose, update the repository and recreate the
container with the latest image:
```sh
cd /opt/bitsocial-seeder && git pull --ff-only && docker compose pull && docker compose up -d --force-recreate
```
The `/data` Docker volume is preserved, so the seeder database and bundled
daemon data survive the update.
If you installed globally with npm, update to the latest release with:
```sh
npm install -g @bitsocial/bitsocial-seeder@latest
```
Restart the running `bitsocial-seeder` process after an npm update. Installing
the new package does not restart an existing process automatically.
### Seed a different list of communities
Override `COMMUNITY_LIST_SOURCES` with one or more comma-separated URLs or local file paths pointing at JSON files in the format `{"communities": [{"address": "...", "publicKey": "..."}]}`. See [Configuration](#configuration) for the full list of env vars.
### Add private communities to seed
Set `COMMUNITY_EXTRA_LIST_SOURCES` to add operator-specific communities without replacing the default public lists:
```sh
COMMUNITY_EXTRA_LIST_SOURCES=/data/extra-communities.json docker compose up -d
```
Example `/data/extra-communities.json`:
```json
{"communities": [{"address": "my-community.bso", "publicKey": "12D3KooW..."}]}
```
Extra sources use the same format as `COMMUNITY_LIST_SOURCES`, can be URLs, files, or directories of JSON files, and are re-read on the normal discovery interval. `MAX_COMMUNITIES` caps only the public list entries; explicitly configured extra communities are always included. If an extra entry has the same `publicKey` or address as a public entry, the extra entry wins.
### Verify what's being seeded
The seeder's state lives in a SQLite database at `SEEDER_DB_PATH` (default `/data/seeder.db` in Docker):
```sh
docker compose exec bitsocial-seeder sqlite3 /data/seeder.db \
'SELECT address FROM communities'
```
See [State](#state) for the schema and other tables you can query.
### Seed pubsub votes (directory contests)
The seeder seeds [`@bitsocial/pubsub-voting`](https://github.com/bitsocialnet/pubsub-voting) directory contests (e.g. 5chan's board-slot voting) **by default**, from the published 5chan manifest:
```sh
# the default — no configuration needed
VOTES_MANIFEST_SOURCES=https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directory-criteria.jsonc
```
It is on by default because a directory contest is only as live as the seeders holding its checkpoint: browser voters cannot dial each other, so a cold-joining voter with no reachable seeder sees an empty tally. Override the variable to seed a different manifest (`{ defaults, contests }` JSONC/JSON, one derived criteria document per slot), or set it to `none` to opt out:
```sh
VOTES_MANIFEST_SOURCES=none docker compose up -d
```
With a manifest configured the seeder starts an embedded libp2p/Helia node for the votes mesh — the daemon's Kubo cannot fill this role over RPC (no topic validators, no peer scoring, no libp2p-fetch registration), so votes seeding is **Helia-only**: verified vote bundles and checkpoint chunks persist in the node's own on-disk blockstore (`VOTES_BLOCKSTORE_PATH`), and each contest's checkpoint snapshot persists under `VOTES_DATA_PATH` so a restart keeps the tally, with no Kubo involvement. The seeder then:
- joins every derived contest read-only (it never publishes a ballot, so it carries no voting identity at all) and keeps the set reconciled against the manifests every `VOTES_RECONCILE_INTERVAL_MS`,
- serves checkpoint root records over libp2p-fetch to cold-joining voters (this registration is automatic on join),
- announces its votes peer as the provider of each contest's criteria CID, checkpoint root, and chunk CIDs on the Routing V1 HTTP routers (`VOTES_HTTP_ROUTER_URLS`), which is how voters' `findProviders()` discovers it — the library's built-in announcer re-announces hourly and debounces on joins and checkpoint changes.
Browser voters can only dial **WSS** (and browsers cannot dial each other — the gossipsub mesh forms through publicly dialable seeders), so the node runs **AutoTLS** (`libp2p.direct`): the node learns its public address the same way the daemon's Kubo does (identify observed-addresses from the bootstrap connections, confirmed by AutoNAT dial-backs), then the ACME broker issues a real TLS certificate and the node announces a browser-dialable `/dns4/.libp2p.direct/.../tls/ws` address — no reverse proxy, no manual multiaddr config. The certificate takes a few minutes on first run (ACME + DNS propagation) and persists in `VOTES_DATASTORE_PATH` across restarts; watch the log for `AutoTLS certificate provisioned` and the announced addrs. Open `VOTES_LIBP2P_TCP_PORT` and `VOTES_LIBP2P_WS_PORT` in the firewall.
Nothing in the votes path needs a Bitsocial daemon, but a **reachable Kubo (`KUBO_RPC_URL`) makes it faster**, and the seeder polls for one every 5 minutes rather than only at startup, so it picks up a Kubo that appears, restarts, or moves later:
- **it borrows Kubo's confirmed public IP** onto the votes ports. Behind provider NAT the machine's own interfaces carry only private addresses, and js-libp2p's AutoNAT — fed by just the four bootstrap connections — can take a long time to confirm a public one, which stalls both the router announces (private addrs are dropped) and AutoTLS (it waits for a confirmed address). Kubo, with far more peers, has usually settled this already. Look for `announcing the daemon Kubo's confirmed public IP(s)` in the log. This assumes the NAT forwards the votes ports to the same place it forwards Kubo's, which holds on 1:1 provider NAT and open-firewall hosts; if it does not, AutoNAT dial-backs revalidate the borrowed address after a few minutes and drop it.
- **it serves Kubo's browser-dialable addrs** to connected voters over the `bitsocial-seeder/peers` fetch key, so a browser can start dialing the community-content node while its votes checkpoint pull is still running (~1.4s saved on the first leaderboard). This is worth having on a votes-only seeder too: what matters is that *some* community-serving Kubo is reachable at `KUBO_RPC_URL`, not that this process is the one pinning the communities.
Both are best-effort. With no Kubo reachable the seeder logs nothing about it and votes seeding runs normally — AutoNAT just takes longer to make the node publicly announceable.
The votes peer identity persists in `VOTES_PEER_KEY_PATH` so announced provider records (and the AutoTLS domain, which embeds the peer id) stay valid across restarts — treat that key file and the `votes-keychain.pass` next to it as part of the seeder's state.
Chain verification reads each contest's gate on-chain. Since pubsub-voting 0.5.0 a contest names its chain once, as a numeric `bucketChainId` — there is no ticker in the document at all, and RPC endpoints are each client's own setting (so operators can swap endpoints without forking topics). Multiple URLs per chain are queried **in parallel** (every request races all endpoints, first success wins — a dead RPC costs nothing); ETH mainnet defaults to the same six public RPCs bitsocial-cli hardcodes for pkc-js, other chains default to their viem chain's public RPC, and a busy public seeder should point `VOTES_CHAIN_RPC_URLS` (JSON, keyed by chain id, e.g. `'{"84532":["https://my-base-sepolia-rpc"]}'` for the published 5chan manifest) at its own. Ticker keys from before 0.5.0 (`baseSepolia`, `base`, `eth`) still resolve, so an existing deployment's config keeps working across the bump; the chain id is the spelling to write new config in. Votes carry community names whose claims are verified through `.bso` resolution (an ETH mainnet read) — `VOTES_ETH_RPC_URLS` sets the ETH mainnet RPCs used for both name resolution and eth-gated contest verification (an explicit `VOTES_CHAIN_RPC_URLS` `"eth"` entry still wins for verification), defaulting to the same resolver providers bitsocial-cli gives pkc-js; a seeder whose resolvers are down counts (and therefore serves) almost nothing.
The log answers the questions production debugging asks — did a voter ever connect (`votes conn open`), join a topic (`votes topic subscribe`), pull the checkpoint (`votes fetch serve`, with the decoded bundle count — a root record is constant-size whether the contest is empty or not, so only the decoded `count` distinguishes "no votes" from "checkpoint didn't load"), or publish a vote (`votes gossip ... live vote bundle`)?
### Run a votes-only seeder
Set `COMMUNITY_LIST_SOURCES=none` to skip community seeding entirely and dedicate the machine to directory contests:
```sh
COMMUNITY_LIST_SOURCES=none docker compose up -d
```
The seeder then never runs discovery, subscribes to no community pubsub topics, and pins nothing — it boots straight into the votes workers and logs `community seeding disabled (COMMUNITY_LIST_SOURCES=none), seeding votes only (no bitsocial daemon needed)`. `none` is what distinguishes "zero sources" from "unset" (an unset or empty variable falls through to the defaults), and the same spelling works for `VOTES_MANIFEST_SOURCES` to get the mirror config: communities only, no votes. Setting *both* to `none` leaves nothing to seed, and the seeder exits with `nothing to seed`.
**A votes-only seeder never requires a Bitsocial daemon and never starts one.** Only the community half talks to a daemon (PKC RPC for community updates, Kubo for pinning and pubsub), so `COMMUNITY_LIST_SOURCES=none` is also the switch that takes the daemon out of the picture: no PKC RPC probe, no `SEEDER_DAEMON_AUTOSTART`, no bundled daemon, no `SEEDER_DAEMON_DATA_PATH` on disk. That matters on a machine where something else owns the daemon: before this, a votes-only container restarting during daemon maintenance would spawn its own bundled daemon, claim the PKC RPC port, and block the real daemon from coming back.
So on a votes-only seeder:
| variable | status |
| --- | --- |
| `COMMUNITY_LIST_SOURCES=none` | the switch |
| `KUBO_RPC_URL` | **optional, still useful** — the public-IP borrow and the browser pre-warm hint above, both best-effort and polled |
| `PKC_RPC_URL`, `IPFS_GATEWAY_URL` | unused |
| `SEEDER_DAEMON_AUTOSTART`, `SEEDER_DAEMON_DATA_PATH`, `SEEDER_DAEMON_LOG_PATH`, `SEEDER_DAEMON_READY_*` | never consulted |
| `VOTES_*` | the configuration that matters |
Only `VOTES_LIBP2P_TCP_PORT` and `VOTES_LIBP2P_WS_PORT` need to be open inbound. Keep `network_mode: host` (the compose default on Linux) even with no daemon: it publishes those two ports *and* lets the container reach a host Kubo on `127.0.0.1`, which bridge networking would not.
If you are switching an existing seeder to votes-only, the bundled daemon's data directory is left behind and is safe to delete once nothing is seeding communities — `/data/bitsocial` and `/data/logs` inside the volume, per `SEEDER_DAEMON_DATA_PATH` / `SEEDER_DAEMON_LOG_PATH`.
## Configuration
The default config expects:
- PKC RPC: `ws://127.0.0.1:9138`
- Kubo RPC: `http://127.0.0.1:50019/api/v0`
- community lists: official `bitsocialnet/lists` 5chan and Seedit directory files
- daemon data: `/data/bitsocial`
On Linux hosts the compose file uses `network_mode: host`, so the container can reach the host daemon through `127.0.0.1`.
If no host daemon is running, the container starts its bundled daemon on those same local RPC addresses.
**A daemon that is down does not stop a combined seeder from seeding votes.** On a seeder running both halves, the daemon is a dependency of the community half only, so an unreachable or slow-to-start daemon no longer exits the process — the votes workers start immediately and the daemon is retried in the background (10s, doubling to 5 minutes), logging `community seeding is DOWN … votes seeding is unaffected` on each attempt. The community workers start as soon as it comes up. This matters under `restart: unless-stopped`: exiting used to crash-loop the container, and every restart re-ran the votes cold join and re-announced to the routers, degrading votes seeding for a reason unrelated to it. A **communities-only** seeder still exits on a daemon failure, because nothing would be left running.
Useful environment overrides:
```sh
# Community seeding only; a votes-only seeder never opens the PKC RPC
PKC_RPC_URL=ws://127.0.0.1:9138
# Required for community seeding; optional (but useful) for votes — see "Run a votes-only seeder"
KUBO_RPC_URL=http://127.0.0.1:50019/api/v0
# Kubo RPC used for pubsub subscriptions; defaults to KUBO_RPC_URL
PUBSUB_KUBO_RPC_URL=http://127.0.0.1:50019/api/v0
IPFS_GATEWAY_URL=http://127.0.0.1:6473
# Default; 'none' disables community seeding (votes-only seeder, which needs no daemon)
COMMUNITY_LIST_SOURCES=https://api.github.com/repos/bitsocialnet/lists/contents/5chan-directories?ref=master,https://api.github.com/repos/bitsocialnet/lists/contents/seedit-directories?ref=master
COMMUNITY_EXTRA_LIST_SOURCES=/data/extra-communities.json
# How often the community list sources are re-read ("the discovery interval"); default 10 minutes
DISCOVER_INTERVAL_MS=600000
# Minimum time between re-providing a community's pubsub routing CIDs; default 6 hours
PUBSUB_ROUTING_PROVIDE_INTERVAL_MS=21600000
# Only consulted when community seeding is on; a votes-only seeder never autostarts a daemon
SEEDER_DAEMON_AUTOSTART=true
SEEDER_DAEMON_DATA_PATH=/data/bitsocial
SEEDER_DAEMON_LOG_PATH=/data/logs
# How long to wait for an autostarted daemon's RPCs to come up, and how long they must
# stay up before the daemon counts as ready
SEEDER_DAEMON_READY_TIMEOUT_MS=120000
SEEDER_DAEMON_READY_STABLE_MS=2500
SEEDER_DB_PATH=/data/seeder.db
# Legacy JSON state file migrated into the database on first start; the mirror write can
# be disabled with SEEDER_STATE_WRITE_FILE=false
SEEDER_STATE_PATH=/data/seederState.json
SEEDER_STATE_WRITE_FILE=true
# No default cap — all discovered public-list communities are seeded unless this is set
MAX_COMMUNITIES=
# Default 2; the Docker image and compose file set 1
PIN_CONCURRENCY=2
SEEDER_UPDATE_CHECK_ENABLED=true
SEEDER_UPDATE_CHECK_INTERVAL_MS=86400000
SEEDER_UPDATE_CHECK_TIMEOUT_MS=5000
# Default; 'none' disables votes seeding (communities-only seeder)
VOTES_MANIFEST_SOURCES=https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directory-criteria.jsonc
VOTES_HTTP_ROUTER_URLS=https://peers.pleb.bot,https://routing.lol,https://peers.forumindex.com,https://peers.plebpubsub.xyz,https://routerofbitsocial.xyz,https://bsotracker.online
VOTES_LIBP2P_HOST=0.0.0.0
VOTES_LIBP2P_TCP_PORT=6742
VOTES_LIBP2P_WS_PORT=6743
VOTES_RECONCILE_INTERVAL_MS=600000
# Per-chain RPC override, keyed by chain id; the published 5chan manifest counts in Base Sepolia
VOTES_CHAIN_RPC_URLS='{"84532":["https://sepolia.base.org"]}'
VOTES_ETH_RPC_URLS=https://eth.drpc.org,https://ethereum-rpc.publicnode.com
VOTES_PEER_KEY_PATH=/data/votes-peer.key
VOTES_BLOCKSTORE_PATH=/data/votes-blockstore
VOTES_DATASTORE_PATH=/data/votes-datastore
VOTES_DATA_PATH=/data/votes-cache
VOTES_FETCH_MAX_STREAMS=256
VOTES_UPDATE_CONCURRENCY=8
# util.inspect depth for object logging (unset = Node's util.inspect default)
DEBUG_DEPTH=6
```
### Public seeder defaults
`COMMUNITY_LIST_SOURCES` points at the GitHub contents APIs for both
`bitsocialnet/lists/5chan-directories` and `bitsocialnet/lists/seedit-directories`.
The seeder re-reads every non-default JSON file from both folders on the normal
discovery interval. New directory files and changes to existing files therefore
reach current seeders without another package release or restart.
Older releases and old Docker Compose files only poll `5chan-directories`,
because Compose sets this environment variable explicitly. For compatibility,
public seed targets that those installs must see can temporarily be mirrored in
`bitsocialnet/lists/5chan-directories/bitsocial-seeder-communities.json`.
Existing seeders fetch every JSON file in that folder except `*-defaults.json`,
while 5chan clients and stats tooling only treat files named
`5chan--directory.json` as real 5chan directories.
Keep that compatibility mirror until old folder-only installs have had time to
upgrade. Current installs use the two directory folders themselves as the
canonical public sources, avoiding a generated aggregate that could drift from
the client directory lists.
## State
The seeder keeps its operational state in a single SQLite file at `SEEDER_DB_PATH` (defaults to `./seeder.db`, set to `/data/seeder.db` in the Docker image). The file holds the seeded community list, per-pin bookkeeping for stale-pin GC, the pubsub-routing re-provide throttle, and the durable work queues + scheduler powered by [honker](https://github.com/russellromney/honker).
On first start the seeder will migrate any pre-existing `seederState.json` into the database. After migration the JSON file is no longer read or written and can be removed at the operator's discretion.
Inspect state with the host's `sqlite3` against the file directly, e.g. `sqlite3 /data/seeder.db 'SELECT community_key, address FROM communities'`.
The seeder checks npm for a newer `@bitsocial/bitsocial-seeder` release on
startup and once per day after that. When a newer release exists, it prints an
update notice in the logs. Set `SEEDER_UPDATE_CHECK_ENABLED=false` to disable
that check.
The same check also watches the bundled runtime packages, including
`@bitsocial/bitsocial-cli` and `@pkcprotocol/pkc-js`. Upgrading the seeder
package or Docker image upgrades the bundled daemon used by
`SEEDER_DAEMON_AUTOSTART=true`. If the seeder is reusing an already-running
external `bitsocial daemon`, upgrade and restart that daemon separately; the
seeder does not install over or restart externally managed daemon processes.
## VPS Sizing
For public seeding, size the host like a small Kubo node plus a lightweight Node.js seeder process.
The seeder wrapper is small, but with `SEEDER_DAEMON_AUTOSTART=true` it also runs a bundled Bitsocial daemon and Kubo IPFS node. A [votes-only seeder](#run-a-votes-only-seeder) runs neither, so it needs far less — the embedded votes node plus its blockstore and checkpoint snapshots, which is a small fraction of these numbers.
Recommended starting point:
- CPU: 2 vCPU.
- Memory: 4 GiB can work for a low-cost trial, especially with swap, but 6 GiB or more is the safer target for unattended public seeders. Kubo's [published baseline](https://docs.ipfs.tech/install/command-line/#system-requirements) is 6 GiB memory and 2 CPU cores.
- Disk: 20 GiB minimum free space for Docker, logs, seeder state, and the IPFS repo; 50 GiB or more is more comfortable for long-running nodes or larger `MAX_COMMUNITIES` values.
- Network: stable public IPv4 or IPv6 with unrestricted outbound TCP/UDP. Allow inbound Kubo swarm traffic if possible, usually TCP/UDP 4001 with the default Kubo config, but keep PKC and Kubo RPC ports private to the host.
- Transfer: avoid tiny metered bandwidth caps. Start with at least 1 TB/month included transfer and monitor provider-level bandwidth, not only `ipfs stats bw`.
The compose file ships with memory guardrails: `NODE_OPTIONS: "--max-old-space-size=1024"` caps the seeder's V8 heap (Node's default limit scales with host RAM, and lazy GC otherwise lets a long-running seeder balloon toward ~4 GiB on an 8 GiB host), and `mem_limit: 2g` is a hard container backstop that also covers native/buffer memory.
Raise both if you seed many more communities than the defaults and the seeder gets memory-starved; on tighter hosts, lower `MAX_COMMUNITIES` before lowering the caps.
The default community sources are dozens of small directory communities plus a short supplemental seeder list, not full media archiving.
Disk and bandwidth mostly scale with `MAX_COMMUNITIES`, pinned page/update size, pubsub activity, and Kubo/libp2p overhead.
On small VPSes, set `MAX_COMMUNITIES` (unset = no cap) and keep `PIN_CONCURRENCY=1` (the Docker default).
Bitsocial configures delegated HTTP routing/tracker endpoints for provider lookups, so it should be lighter than an untuned Kubo node doing full DHT provider sweeps.
It still runs Kubo and joins pubsub topics, so treat it as Kubo-class infrastructure rather than a static HTTP service.
## Local Development
```sh
npm install
npm test
npm start
```
## Releases
Releases are driven by the `version` in `package.json`. On a successful push to
`master`, CI updates `CHANGELOG.md`, pushes versioned Docker image tags, publishes
`@bitsocial/bitsocial-seeder` to npm with trusted publishing, and creates the
matching GitHub Release.
The npm trusted publisher should be configured for:
- npm package: `@bitsocial/bitsocial-seeder`
- GitHub repository: `bitsocialnet/bitsocial-seeder`
- workflow filename: `release.yml`
- allowed action: `npm publish`
The package must exist on npm before trusted publishing can be configured. After
the first package version exists, future releases should publish through CI
without long-lived npm tokens.
For initial npm bootstrap, backfill historical package versions before cutting
the next release if you want npm to show the full release line. Publish `0.1.0`
and `0.1.1` only from their matching release code plus the minimum npm metadata
needed for the scoped package; do not publish current code under an old version.
### Bitsocial Pubsub Provider
Repository: bitsocialnet/pubsub-provider
Source: https://github.com/bitsocialnet/pubsub-provider#readme
Description: Run a Bitsocial pubsub fallback provider with a bundled Kubo node.
# Bitsocial Pubsub Provider
Run a Bitsocial pubsub fallback provider with a bundled Kubo node.
Modern Bitsocial apps such as 5chan use pure p2p in the browser by default. This provider is still useful as an optional fallback for clients that disable browser p2p, need a non-p2p pubsub relay, or want a public gateway/provider bundle for compatibility.
## What It Runs
- a public HTTP proxy for pubsub, gateway, name-provider, and delegated routing routes
- a bundled Kubo node with pubsub enabled
- a delegated HTTP routing provider at `/routing/v1/providers`
- optional ENS/SNS proxy helpers when chain RPC environment variables are configured
It includes the delegated HTTP routing compatibility endpoint that old provider clients used for provider lookup/provide requests. You can still run a dedicated tracker separately if you want that role split out.
## Ports
The defaults are chosen so this can run next to `bitsocial-seeder` on the same host.
| Purpose | Default | Notes |
| --- | ---: | --- |
| Public HTTP proxy | `8000` in the app, `80` in Docker Compose | Set `PUBSUB_PROVIDER_HTTP_PORT` for the host port in Compose. |
| Kubo swarm | `4002` TCP/UDP | Avoids the seeder's default Kubo swarm port `4001`. |
| Kubo API | `5001` local only | Used internally by the proxy. |
| Kubo gateway | `8080` local only | Used internally by the proxy. |
## Quick Start
The published Docker image includes the Kubo binary, so normal Docker users do not need to download Kubo during container startup.
```sh
git clone https://github.com/bitsocialnet/pubsub-provider.git
cd pubsub-provider
docker compose pull
docker compose up -d
```
The default Compose file runs the pinned published image. To build from local source instead:
```sh
docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d
```
Check logs:
```sh
docker logs --follow pubsub-provider
```
Test the proxy:
```sh
curl http://127.0.0.1/commit-hash
```
## Configuration
Useful environment overrides:
```sh
PUBSUB_PROVIDER_HTTP_PORT=80
PUBSUB_PROVIDER_SWARM_PORT=4002
PUBSUB_PROVIDER_PORTS=8000
KUBO_RPC_URL=http://127.0.0.1:5001/api/v0
IPFS_GATEWAY_URL=http://127.0.0.1:8080
HTTP_ROUTER_URLS=https://example-router.invalid
PUBSUB_PROVIDER_ROUTING_STORE_PATH=
BASIC_AUTH_USERNAME=
BASIC_AUTH_PASSWORD=
IPFS_GATEWAY_USE_SUBDOMAINS=false
SHUTDOWN_KEY=
ETH_PROVIDER_URL=
ETH_PROVIDER_URL_WS=
SOL_PROVIDER_URL=
```
Source/local runs download Kubo automatically when `bin/ipfs` is missing. If your host has unreliable DNS or blocks the default download domains, you can provide comma-separated full URLs or base URLs:
```sh
PUBSUB_PROVIDER_KUBO_DOWNLOAD_URLS=https://dist.ipfs.tech/kubo,https://github.com/ipfs/kubo/releases/download
PUBSUB_PROVIDER_KUBO_DOWNLOAD_ATTEMPTS=5
PUBSUB_PROVIDER_KUBO_DOWNLOAD_TIMEOUT_MS=600000
```
If you also run `bitsocial-seeder`, keep this provider on swarm port `4002` or another non-`4001` port.
## Upgrading
If you previously ran `latest`, force Compose to recreate the container from the pinned image:
```sh
git pull
docker compose down
docker compose pull
docker compose up -d --force-recreate
```
Verify the fixed image is running:
```sh
docker inspect pubsub-provider --format 'image={{.Image}} restarts={{.RestartCount}}'
docker exec pubsub-provider /app/bin/ipfs version
curl http://127.0.0.1/commit-hash
```
The logs should not contain `downloading ipfs`. If they do, the container is still running an old local image or an old checkout.
## Local Development
Requires Node.js 24 or newer.
```sh
npm install
npm start -- [--ipfs-gateway-use-subdomains] [--shutdown-key ]
```
Run tests:
```sh
npm test
```
## Cloudflare Note
Pubsub subscriptions use long-lived HTTP connections. Cloudflare can time out those keepalive connections unless response buffering/streaming behavior is configured appropriately. If pubsub fallback is the reason you run this provider, prefer direct DNS or infrastructure that supports long-lived streaming responses.
## License
GPL-3.0-or-later. See [LICENSE](https://github.com/bitsocialnet/pubsub-provider/blob/HEAD/LICENSE).
### bitsocial-indexer
Repository: bitsocialnet/bitsocial-indexer
Source: https://github.com/bitsocialnet/bitsocial-indexer#readme
Description: A neutral, self hostable crawler + search/index API + web UI for the
# bitsocial-indexer
A neutral, self-hostable **crawler + search/index API + web UI** for the
[Bitsocial](https://bitsocial.net) network.
It connects to a [`bitsocial-cli`](https://github.com/bitsocialnet/bitsocial-cli)
daemon over PKC RPC, indexes the communities **you** configure into a local
SQLite database, and exposes them through a REST + full-text-search API and an
optional server-rendered web UI.
> **It ships empty.** Out of the box the indexer knows about **zero**
> communities and shows nothing — the operator decides what to index. Point it
> at a list of communities and it becomes a search engine / archive for exactly
> those.
This is the engine. A concrete deployment — choosing which communities to
index, re-skinning the UI, adding ads or analytics — is layered on top as a
separate project (see [Running your own instance](#running-your-own-instance)).
---
## Architecture
```
Bitsocial network (IPFS / IPNS / pubsub)
│
bitsocial-cli daemon (PKC RPC, ws://localhost:9138)
│ @pkcprotocol/pkc-js
┌──────────┴───────────────────────────────────┐
│ server/ — crawler + API (one Node service) │
│ crawler ──▶ SQLite + FTS5 ──▶ Fastify API │
└──────────┬───────────────────────────────────┘
│ REST + search (the integration seam)
┌────────┴────────┐
│ │
webui/ any external client
(Next.js, (e.g. a Bitsocial app's
bitsocial.net in-app /search board just
skin) calls the API)
```
The **API is the product surface.** The bundled `webui` is one consumer; a
Bitsocial client adding in-app search is another — it just calls the same
endpoints, no shared frontend code.
| Part | Stack |
|------|-------|
| `server/` | Node 22, TypeScript (ESM), [Fastify](https://fastify.dev) 5, [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) + FTS5, [`@pkcprotocol/pkc-js`](https://github.com/pkcprotocol/pkc-js) |
| `webui/` | Next.js 15 (App Router, SSR for SEO), React 19, Bitsocial brand tokens |
## Quickstart
```bash
# 1. API server (http://localhost:4000)
cd server
npm install
npm run seed # optional: load demo communities + posts so the UI isn't empty
npm run dev
# 2. Web UI (http://localhost:3000) — in another terminal
cd webui
npm install
npm run dev
```
Without `npm run seed`, the server starts with **no communities** and the UI
shows its empty / onboarding state — which is the real default. Configure
communities (below) to index live content.
## Configuration
All config is environment variables (see [`server/.env.example`](https://github.com/bitsocialnet/bitsocial-indexer/blob/HEAD/server/.env.example)).
### `server/`
| Var | Default | Meaning |
|-----|---------|---------|
| `COMMUNITIES` | _(empty)_ | Comma-separated community addresses to index, e.g. `art.bso,tech.bso` |
| `COMMUNITIES_SOURCE` | _(empty)_ | URL/path to a JSON list of community addresses (e.g. a client's directory). Overrides/augments `COMMUNITIES`. |
| `PKC_RPC_URL` | `ws://localhost:9138` | The `bitsocial-cli` daemon RPC endpoint |
| `DB_PATH` | `./data/indexer.db` | SQLite file (`:memory:` for ephemeral) |
| `CRAWL_INTERVAL_MS` | `60000` | Per-community delay before the next refresh |
| `CRAWL_CONCURRENCY` | `4` | Maximum communities crawled at once |
| `CRAWL_TIMEOUT_MS` | `300000` | Hard timeout for one community crawl; a timeout resets the RPC client |
| `ALLOWED_ORIGINS` | `*` | CORS allow-list, comma-separated (`*` = any origin — fine for a public read-only API). An entry may contain `*` as a wildcard, e.g. `https://*.seedit.localhost` matches every branch-scoped dev origin. `CORS_ORIGIN` is accepted as a legacy fallback. |
| `BLOCKLIST_SOURCE` | _(empty)_ | Path to a JSON file of CIDs to take down (operator blocklist, see below). |
| `DIRECTORY_DEFAULTS_SOURCE` | _(empty)_ | URL/path to a client's `*-directories-defaults.json`, which states `features.safeForWork` per directory code (see below). |
| `NSFW_OVERRIDES_SOURCE` | _(empty)_ | Path to a JSON file of operator NSFW verdicts per community (see below). |
If neither `COMMUNITIES` nor `COMMUNITIES_SOURCE` is set, the crawler stays
idle and the indexer serves nothing. That is intentional.
#### Takedowns (`BLOCKLIST_SOURCE`)
An archive keeps serving content after it disappears from the source network,
so upstream moderation can no longer reach it — takedown requests (DMCA,
illegal content) need an operator-side mechanism. Point `BLOCKLIST_SOURCE` at
a JSON file where each entry is a bare CID string or
`{ "cid": "…", "scope": "comment" | "thread", "reason": "…" }` (`scope`
defaults to `comment`; `thread` takes down a post **and all its replies** by
the post's CID). Add a CID to the file and it is redacted within a minute —
the file is re-read whenever it changes, no restart needed; remove the entry
and the stored content is served again (the redaction never destroys the
archived data). Blocklisted comments leave listings and search but stay in
threads as redacted tombstones, marked `takedown: 1` (plus the optional
`takedown_reason`) on the API so UIs can distinguish them from upstream
moderation, and they stay redacted across re-crawls. The bundled web UI shows
them as `[removed — takedown request]` and documents the policy on its
`/legal` page (see `CONTACT_EMAIL` below).
#### NSFW communities
A community declares its own status through the protocol:
`community.features.safeForWork` is an **optional** boolean, so it has three
states — `true`, `false`, and never set. `safeForWork === false` is the
owner-declared way of saying "this community is NSFW".
Each indexed community gets both the raw declaration (`safe_for_work`: `1`, `0`
or `null`) and a resolved `nsfw` flag on `GET /api/communities` (and
`/api/communities/:address`). The resolution takes four signals, **highest
precedence first**:
1. **Operator override** — `NSFW_OVERRIDES_SOURCE`, a JSON file where each entry
is a bare address or `{ "address": "…", "nsfw": false, "reason": "…" }`.
`nsfw` defaults to `true`, and an explicit `false` clears the flag, so a bad
verdict is correctable. Like the blocklist, the file is re-read whenever it
changes — no restart needed.
2. **`community.features.safeForWork`** — the owner's own declaration, read off
the community on every crawl. `false` → NSFW, `true` → not, unset → the next
signal decides.
3. **The directory the address is listed under** — `DIRECTORY_DEFAULTS_SOURCE`
(below), for the many communities whose owner never set the feature.
4. **Inference from content** — any indexed comment in the community carrying
the protocol's `nsfw` flag means the community accepts NSFW content.
An unset `safeForWork` is never read as either verdict: it falls through, and
only the last signal (inference) is allowed to answer with a plain boolean.
##### Directory verdicts (`DIRECTORY_DEFAULTS_SOURCE`)
Bitsocial clients state a *directory's* SFW status once, by directory code, in
their `-directories-defaults.json` — this is how 5chan knows `/f/` and
`/b/` are NSFW while `/3/` and `/a/` are not. The sibling
`--directory.json` files hold that directory's candidate
addresses. Point `DIRECTORY_DEFAULTS_SOURCE` at the defaults file and the
crawler reads both, giving every address listed under a directory that
directory's `features.safeForWork`:
```
DIRECTORY_DEFAULTS_SOURCE=https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-directories-defaults.json
```
Sibling file names follow the convention that repo documents, so one setting
reaches every directory. A directory that states no `safeForWork`, or an address
in no directory at all, contributes nothing and falls through to inference. Both
files are read once, when the crawler schedules its communities.
`GET /api/search?nsfw=` filters on the result: `false` (**the default**) drops
anything NSFW — the comment is flagged, or its community is — and `true`
includes it. Listings (`/api/posts`) and the sitemap are not filtered.
### `webui/`
| Var | Default | Meaning |
|-----|---------|---------|
| `INDEXER_API` | `http://localhost:4000` | Where the UI reads the API from (server-side fetch) |
| `SITE_NAME` | `Bitsocial` | Instance name in the header / page titles |
| `SITE_BADGE` | `Indexer` | Small pill next to the name (empty to hide) |
| `SITE_URL` | `http://localhost:3000` | Public origin of the web UI — canonical URLs, OpenGraph tags, `robots.txt`, sitemaps |
| `THEME` | `default` | UI skin: `default` (Bitsocial dark) or `5chan` (classic imageboard look) |
| `BRAND_TEXT` | _(empty)_ | Optional footer attribution line, e.g. `A Bitsocial Forge product`. Unset = nothing rendered |
| `BRAND_URL` | _(empty)_ | Makes `BRAND_TEXT` a link |
| `CONTACT_EMAIL` | _(empty)_ | Contact address for content-removal / takedown requests, shown on the `/legal` archive-policy page. Unset = the page says requests are handled by the instance operator |
| `SHOW_NSFW` | `false` | Whether this instance's search returns NSFW results. The UI sends the value on every query, so an archive of boards that are NSFW by design opts in with `SHOW_NSFW=true` rather than silently inheriting the API's safe default |
The web UI serves its own `robots.txt` and a `sitemap.xml` **sitemap index**
(one child sitemap per community, capped at the 5,000 most recent posts each,
enumerated through the paginated `/api/posts` listing and cached for an hour).
## API
CORS-enabled so browser clients can call it directly.
| Endpoint | Description |
|----------|-------------|
| `GET /api/health` | Status + index counts |
| `GET /api/communities` | Indexed communities + post counts + resolved `nsfw` flag + declared `safe_for_work` |
| `GET /api/posts` | Browse posts — `?community=&sort=new\|top\|replies\|old&time=hour..all&page=&limit=&replies=true` |
| `GET /api/posts/:cid` | A thread: original post + threaded replies |
| `GET /api/search` | Full-text search — `?q=&community=&sort=&time=&page=&limit=&replies=&nsfw=`, plus the advanced filters below (NSFW excluded by default). A `q` that is a comment CID is looked up by CID instead (see below) |
| `GET /sitemap.xml`, `/robots.txt` | SEO |
### Advanced search filters
`/api/search` also accepts the five old.reddit-style filters a client parses out
of its search box, so they no longer have to be passed through as literal query
text. Each is optional, and they AND together — with each other and with `q`,
`community`, `time` and `nsfw` — so a query only ever narrows:
| Param | Prefix | Matches |
|-------|--------|---------|
| `author` | `author:lena.bso` | Exactly (case-insensitively) the author's address **or** display name. Never a prefix or substring: an author is an identity, so `lena` must not stand in for `lena-imposter.bso` |
| `site` | `site:example.com` | The link's parsed **host**, subdomains included (`www.example.com` counts). Never a substring of the URL, so `https://evil.com/?r=example.com` is not a match |
| `url` | `url:ink-study` | A substring of the whole link — the path / slug / query search that `site` deliberately is not |
| `selftext` | `selftext:tokenizer` | Words in the post body, through the same full-text index `q` uses |
| `self` | `self:yes` / `self:no` | `yes` = text posts only, `no` = link posts only. Three-state: **absent means no opinion** and keeps both |
`q` may be empty when at least one filter is set: `?author=lena.bso` is a
complete query, answered newest-first since there is no relevance to rank.
`?community=…` alone is not — narrowing parameters need something to narrow, and
`/api/posts` is the listing endpoint. Only `/api/search` takes these; `/api/posts`
and the sitemap are unchanged.
### Searching by CID
A comment CID pasted into a search box is one opaque token the full-text index
can never match, so when `q` is exactly one CID — CIDv0 (`Qm…`) or CIDv1
(`bafy…`), recognised by parsing it rather than by pattern — `/api/search` looks
it up by `comments.cid` instead and returns the comment as an ordinary result
(`total: 1`); a client that already renders search results needs no change.
Only the exact `cid` matches: a CID that appears solely as a thread or parent
reference names a comment that was never indexed. Every other parameter still
narrows — `community=` must be the comment's own, `nsfw=false` still hides an
NSFW comment (a pasted CID is not a way around an instance's safe default) —
and, like `/api/posts/:cid`, a removed, deleted or taken-down comment comes back
as its redacted tombstone, though only for the bare lookup: with `author`,
`site`, `url`, `self` or `selftext` set, tombstones stay hidden as in text
search, since a yes/no answer over redacted content would leak it. A CID mixed
with other words is an ordinary text search.
## Running your own instance
`bitsocial-indexer` is a **tool, not a hosted service** — there is no central
instance. To run one:
1. Deploy this engine (Docker, a VPS, etc.).
2. Set `COMMUNITIES` / `COMMUNITIES_SOURCE` to the communities you want.
3. Optionally re-skin `webui` (override the theme tokens in
[`webui/app/globals.css`](https://github.com/bitsocialnet/bitsocial-indexer/blob/HEAD/webui/app/globals.css)) and add your own branding,
ads, or analytics in your own deployment repo.
Because this engine is **GPL-3.0-or-later** (copyleft on *distribution*, not on
running a network service), you can run a modified, private, monetised instance
without publishing your changes — the same way Etherscan is a closed service
built on open Ethereum.
## License
[GPL-3.0-or-later](https://github.com/bitsocialnet/bitsocial-indexer/blob/HEAD/LICENSE). Brand assets belong to Bitsocial Forge.
### bitsocial-previewer
Repository: bitsocialnet/bitsocial-previewer
Source: https://github.com/bitsocialnet/bitsocial-previewer#readme
Description: Link preview + redirect server for Bitsocial clients. When someone shares a
# bitsocial-previewer
Link-preview + redirect server for Bitsocial clients. When someone shares a
post link on Twitter/Telegram/Discord/etc., this server renders the
OpenGraph/Twitter card for that post, then redirects real browsers into the app.
It is **multi-tenant**: one running instance serves many clients and many mirror
domains, picking the right profile from the request's `Host` header — so a single
deployment (and a single TLS setup) covers every client and every mirror.
```
┌──────────── s.5chan.app/biz/thread/
share link ───────► │ bitsocial-previewer ──► OG/Twitter card (for scrapers)
(no #/ hash) └──────────── 302-ish JS redirect ──► 5chan.app/#/biz/thread/
```
## How it works
1. 5chan copies **path-based** share links (no `#/` hash) so the path
reaches the server: `https://s.5chan.app//thread/`.
2. The server matches the path to a client profile + route, fetches the post by
cid via `@pkcprotocol/pkc-js` (`getComment`), and builds the card.
3. Browsers get redirected into the app's hash route; scrapers read the tags.
`` is usually a short **directory code** (e.g. `biz`). We pass it straight
through to the app (which resolves it); the cid is authoritative for the preview,
so the previewer never needs to resolve codes itself.
### Recognised share-link formats (5chan)
| Incoming on `s.5chan.app` | Preview | Redirects to |
|---|---|---|
| `//thread/` | rich (title/text/image) | `5chan.app/#//thread/` |
| `//catalog` | generic board card | `5chan.app/#//catalog` |
| `/` | generic board card | `5chan.app/#/` |
| `/p//c/` (legacy) | rich | `5chan.app/#//thread/` |
Unresolvable cid → still redirects to the app with a generic card (short cache),
so a share link never dead-ends.
## Layout
| File | Purpose |
|---|---|
| [`lib/clients.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/lib/clients.js) | **The per-client design.** Profiles (hostnames, app URL, routes) + host→profile + route matching. |
| [`lib/html.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/lib/html.js) | OG/Twitter tag rendering + redirect (HTML-escaped, XSS-safe). Pure. |
| [`lib/pkc.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/lib/pkc.js) | `@pkcprotocol/pkc-js` init + cached `getComment`. |
| [`lib/media.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/lib/media.js) | Comment media URL + external-link `og:image` scraping. |
| [`start.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/start.js) | Express wiring. |
| [`config.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/config.js) | Env-driven config (port, bind host, pkc options). |
## Run locally
```bash
npm install
npm start # listens on 127.0.0.1:3924, public-gateway fallback
# in another shell:
curl -s 'http://localhost:3924/biz/thread/' | grep -i og:
```
```bash
npm test # routing + HTML unit tests (no network/install needed for the pure logic)
```
## Deploy (Caddy)
Deployed under `/opt/bitsocial-previewer` like the box's other services. Config
(gateways, port) is inline in [`docker-compose.yml`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/docker-compose.yml). The
previewer resolves shared comments through the public IPFS gateways the 5chan app
uses — a fetch is sub-second once a gateway has the content, and a hard timeout
falls back to a generic card so a request never hangs. (A box's local IPFS node
generally can't resolve arbitrary shared comment cids: thinly peered, and it
doesn't pin individual comment blocks.)
```bash
# on the box, in /opt/bitsocial-previewer (source synced or git-cloned):
docker compose up -d --build # builds + runs on 127.0.0.1:3924
docker compose logs -f --tail=50
```
CI also publishes `ghcr.io/bitsocialnet/bitsocial-previewer:latest`
([workflow](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/.github/workflows/docker.yml)); once that package is made public you
can `docker compose pull && docker compose up -d` instead of building on the box.
Point the local Caddy at it with [`deploy/Caddyfile.snippet`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/deploy/Caddyfile.snippet),
then do the Cloudflare DNS change in [`deploy/cloudflare.md`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/deploy/cloudflare.md).
## Add a client or a mirror
- **New mirror domain for 5chan:** add it to the `5chan` profile's `hostnames`
in [`lib/clients.js`](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/lib/clients.js), add a Caddy block + a DNS A record.
- **New client (e.g. Seedit):** fill in the `seedit` profile template
(hostnames, `appBaseUrl`, route formats) and enable it.
## License
[GPL-3.0-or-later](https://github.com/bitsocialnet/bitsocial-previewer/blob/HEAD/LICENSE) — matching the Bitsocial core stack it builds on
(`@pkcprotocol/pkc-js`, `bitsocial-react-hooks`, 5chan, Seedit).
### challenge-composer
Repository: bitsocialnet/challenge-composer
Source: https://github.com/bitsocialnet/challenge-composer#readme
Description: Live app: **
A standalone, offline-first web app for **visualizing and editing** the `challenges` array of a PKC community's `settings` (pkc-js format). Inspired by the challenges section of seedit's community-settings view.
- Ships as **one self-contained `dist/index.html`** with every JS/CSS chunk inlined by `vite-plugin-singlefile`. Drop it anywhere — USB stick, email attachment, static host — and open from `file://`.
- Zero outward HTTP at runtime. Everything works from the bundled app and user-supplied data.
- Fully typed, ESM, Node ≥22, Vitest for tests.
## Requirements
- Node **22+**
- npm 10+
## Getting started
```sh
npm ci # install pinned exact versions
npm run build # type-check + emit dist/index.html (everything inlined)
xdg-open dist/index.html # or just double-click it
```
Supporting scripts: `npm test` (Vitest + happy-dom), `npm run typecheck`, `npm run preview` (serves `dist/` locally for a smoke test).
The current `package.json` version is injected into the bundle via Vite's `define` as `__APP_VERSION__` and rendered next to the header title, so whoever opens `dist/index.html` can see which release it came from.
## Release flow
Versioning is automated. Conventional commits (`feat:`, `fix:`, `perf:`, `build:`, `revert:`) on `master` trigger a release:
1. The **CI** workflow (`.github/workflows/ci.yml`) runs typecheck, tests, and build on every push/PR.
2. The **Release and deploy** workflow (`.github/workflows/deploy.yml`) runs on successful CI completion on `master`: it invokes `release-it` (config in `config/.release-it.json`) which bumps the version, updates `CHANGELOG.md`, commits `chore(release): X.Y.Z [skip ci]`, tags `vX.Y.Z`, and cuts a GitHub release. It then rebuilds `dist/index.html` with the new version embedded and publishes it to GitHub Pages.
The repo ships a commit-msg hook (`.githooks/commit-msg`, installed by `scripts/install-git-hooks.mjs`) that runs `commitlint` on the message so non-conventional commits are caught before they reach master. `git cz` via `commitizen` is wired up as an interactive alternative.
State (drafts, share links) lives in `localStorage` of whichever origin you open it from.
## What it edits
A JSON array of `CommunityChallengeSetting`, the same shape pkc-js accepts in `community.settings.challenges`. Each entry:
```jsonc
{
"name": "text-math", // pkc-js built-in challenge identifier, OR
"path": "./my-challenge.js", // path to a custom challenge module (one of the two)
"description": "Solve to post.",
"pendingApproval": true, // mods must approve after the user solves
"options": { // all values must be strings
"difficulty": "2"
},
"exclude": [ // any matching group lets the author bypass
{ "role": ["moderator", "admin", "owner"] },
{ "postCount": 10, "firstCommentTimestamp": 604800, "rateLimit": 3, "rateLimitChallengeSuccess": true },
{ "publicationType": { "reply": true } }
]
}
```
### Challenges shipped with pkc-js
A challenge name is "built-in" iff it is a key of `PKC.challenges` (= `pkcJsChallenges`) in `@pkcprotocol/pkc-js` — these resolve by `name` without any install step on the community node. The list is extracted at build time by the `pkcBuiltinNamesPlugin` Vite plugin (see `vite.config.ts`) and exposed to the app as `PKC_BUILTIN_CHALLENGE_NAMES` in `src/lib/knownChallenges.ts`, so adding or removing a built-in in pkc-js propagates on next rebuild without any manual edits here.
Anything not in that list — `captcha-canvas-v3`, `evm-contract-call`, `mintpass`, … — is an **external** challenge that the community operator must install on their node separately, e.g. `bitsocial challenge install @bitsocial/captcha-canvas-challenge`. The **Export CLI** button uses the same `PKC.challenges` list (via `isBuiltinChallenge`) to decide which names need a `challenge install` line prepended.
### Validation
`CommunityChallengeSettingSchema` is imported directly from `@pkcprotocol/pkc-js` at runtime (via a Vite alias around its internal subpath) — no local mirror, so the schema cannot drift from upstream. See `src/pkc-schema.ts` and the `@pkc/*` aliases in `vite.config.ts`.
## Features
- **Seedit-style editor**: add/remove/reorder challenges, edit name/path/options/description/pendingApproval, manage exclude rules (roles, post/reply counts, account age, rate limit, publication type, author addresses).
- **Presets dropdown**: ships with `5chan board defaults`, `captcha only`, and `empty`.
- **Paste JSONC** dialog, **file upload** (`.json` / `.jsonc`), and **download** as `challenges.jsonc`.
- **Export CLI**: emits a `bitsocial challenge install …` + `bitsocial community edit …` shell script tailored to the current settings, flagging unknown challenge names with a TODO for the operator.
- **Live JSON preview** with inline zod validation errors.
- **LocalStorage draft** auto-saves every change.
- **Share URL**: compresses the current settings into a URL fragment (`#s=…`). Browsers never send fragments to servers, so the blob stays client-side — but anyone holding the URL can decode it verbatim. A warning dialog makes this explicit before you copy.
## Security notes
- The Share URL contains your full settings in lz-string-compressed but not encrypted form. Treat it like pasting the raw JSONC into a public place.
- The `?raw` preset imports are bundled at build time. The runtime bundle performs no `fetch()` calls to third-party hosts.
## Architecture
```
src/
├── pkc-schema.ts # re-exports CommunityChallengeSettingSchema via vite alias
├── types/challenges.ts # type-only re-exports from @pkcprotocol/pkc-js
├── state/ # useReducer store + localStorage/hash hydration
├── lib/ # jsonc, share (lz-string), cliExport, known-challenges catalog
├── presets/ # bundled .jsonc presets + registry
└── components/ # Header, ChallengesEditor, ChallengeCard, ChallengeRow,
# OptionsEditor, ExcludeRulesEditor, JsonPreview,
# ImportDialog, ShareDialog, ExportCliDialog
```
## TODO
- **Scenario simulator.** For each challenge in the current settings, simulate which publications and author profiles would be challenged vs. excluded, across a library of mock authors (brand-new account, moderator, high-karma user, rate-limited user, wallet holder, banned author address, …). Should exercise every branch of `ChallengeExcludeSchema` — role / postCount / replyCount / postScore / replyScore / firstCommentTimestamp / rateLimit / rateLimitChallengeSuccess / publicationType / address / community / challenges.
- Inline zod validation errors attached to the specific offending field (currently shown in the JSON preview panel only).
- Drag-and-drop reordering for challenges (the reducer has `MOVE_CHALLENGE` and Move up/down buttons; drag handles are not yet wired up).
### Bitsocial Telegram Bots
Repository: bitsocialnet/bitsocial-telegram-bots
Source: https://github.com/bitsocialnet/bitsocial-telegram-bots#readme
Description: Telegram feed bots for Bitsocial clients. Each bot monitors a specific client's community list and forwards new posts to Telegram channels/groups.
# Bitsocial Telegram Bots
Telegram feed bots for [Bitsocial](https://github.com/bitsocialnet) clients. Each bot monitors a specific client's community list and forwards new posts to Telegram channels/groups.
## Available Bots
### 5chan Feed
Monitors all [5chan](https://github.com/bitsocialnet/5chan) directories (boards) from [`5chan-directories/`](https://github.com/bitsocialnet/lists/tree/master/5chan-directories) and posts new content to Telegram. Each post includes buttons to view it on 5chan and Seedit.
### Seedit Feed *(planned)*
Will monitor [Seedit](https://github.com/bitsocialnet/seedit) communities and post new content to Telegram.
## Setup
1. **Clone the repository:**
```bash
git clone https://github.com/bitsocialnet/bitsocial-telegram-bots.git
cd bitsocial-telegram-bots
```
2. **Install dependencies:**
```bash
yarn install
```
3. **Create a `.env` file** in the root directory:
```env
# Required: Telegram Bot Token from @BotFather
BOT_TOKEN=your_telegram_bot_token_here
# Which bot to run (defaults to 5chan-feed)
BOT_NAME=5chan-feed
# Required: At least one destination must be set
FEED_BOT_CHAT=-1001234567890
FEED_BOT_GROUP=-1001234567891
```
4. **Start the bot:**
```bash
yarn start
```
Or run a specific bot:
```bash
yarn start:5chan-feed
```
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `BOT_TOKEN` | Yes | Telegram bot token from [@BotFather](https://t.me/botfather) |
| `BOT_NAME` | No | Which bot config to use (default: `5chan-feed`) |
| `FEED_BOT_CHAT` | Yes* | Primary Telegram chat/channel ID |
| `FEED_BOT_GROUP` | No | Secondary Telegram group ID |
\* At least one of `FEED_BOT_CHAT` or `FEED_BOT_GROUP` must be set.
## How It Works
1. The bot fetches the community list for the configured client (e.g. `5chan-directories.json` for 5chan)
2. It cycles through each community, checking for new posts via Plebbit
3. New posts are formatted and sent to the configured Telegram destinations with inline buttons linking back to the client
4. Processed post CIDs are tracked in `history.json` to avoid duplicates
5. Cycles repeat every 30 seconds
## Adding a New Bot
To add a bot for a different Bitsocial client:
1. Add a new config in `src/bot-configs.ts` with the client's list URL, community parser, and URL templates
2. Add a corresponding `start:` script in `package.json`
3. Deploy with a separate `.env` pointing to a different `BOT_TOKEN` and `BOT_NAME`
## License
GPL-3.0-or-later
### bitsocial-github-alerts
Repository: bitsocialnet/bitsocial-github-alerts
Source: https://github.com/bitsocialnet/bitsocial-github-alerts#readme
Description: A Telegram bot that posts compact GitHub notifications — pushes, releases, issues, pull requests, and more — to any chat, group, or forum topic. Run by the Bitsocial team for its repositories, but anyone can self host it.
# bitsocial-github-alerts
A Telegram bot that posts compact GitHub notifications — pushes, releases, issues, pull requests, and more — to any chat, group, or forum topic. Run by the [Bitsocial](https://github.com/bitsocialnet) team for its repositories, but anyone can self-host it.
This is a fork of [mhkafadar/notifine](https://github.com/mhkafadar/notifine), stripped down to GitHub + Telegram only, with compact message formatting and added support for GitHub release notifications. All credit for the original architecture goes to the notifine authors. This repository carries no license of its own because upstream notifine has none; licensing follows upstream.
## Features
- **Compact messages** — pushes show at most 5 commits (first line of each commit message, truncated to 72 chars) plus an "… and N more" line
- **Release notifications** — one-liner when a release is published, with tag link, release name, pre-release marker, and the first line of the release notes
- **Supported events**: push (incl. branch create/delete and force-push), release, issues, pull requests, comments (issue/PR review/commit), check runs, workflow runs, wiki edits, ping
- **Branch filtering** — `?branch=` / `?exclude_branch=` glob patterns on the webhook URL
- **Forum topics** — run `/start` inside a Telegram topic to receive notifications there
- Built with Rust (actix-web + teloxide + diesel/Postgres)
## Usage
1. Open a chat with the bot (or add it to a group) and send `/start`
2. The bot replies with a webhook URL of the form `https://github.bitsocial.net/github/`
3. In your GitHub repo, open **Settings → Webhooks → Add webhook**, paste the URL, set content type to `application/json`, and pick the events you want
### Branch filtering
```
# Only main
https://github.bitsocial.net/github/?branch=main
# Multiple branches and wildcards
https://github.bitsocial.net/github/?branch=main,release/*
# Exclude noisy branches (exclusions take precedence)
https://github.bitsocial.net/github/?exclude_branch=feature/*,dependabot/*
```
Applies to push, pull request, workflow run, and create/delete events.
## Self-hosting
### Environment variables
| Variable | Required | Description |
| --- | --- | --- |
| `DATABASE_URL` | yes | Postgres connection string |
| `WEBHOOK_BASE_URL` | yes | Public base URL GitHub uses to reach the server, e.g. `https://github.bitsocial.net` |
| `GITHUB_TELOXIDE_TOKEN` | yes | Telegram bot token from [@BotFather](https://t.me/BotFather) |
| `PORT` | no | HTTP listen port (default `8080`) |
| `ADMIN_LOGS` | no | `ACTIVE` to send admin logs to Telegram (default `NOT_ACTIVE`) |
| `TELEGRAM_ADMIN_CHAT_ID` | no | Chat id that receives admin logs |
| `ADMIN_LOG_LEVEL` | no | 0–255 verbosity threshold for admin logs (default `50`) |
Database migrations are embedded and run automatically at startup.
### Docker Compose (production)
A production compose file is provided in [`deploy/docker-compose.yml`](https://github.com/bitsocialnet/bitsocial-github-alerts/blob/HEAD/deploy/docker-compose.yml). It runs the prebuilt image `ghcr.io/bitsocialnet/bitsocial-github-alerts:latest` next to a Postgres 17 container and binds the app to `127.0.0.1:8090` (put a reverse proxy such as Caddy or nginx in front).
```bash
mkdir bitsocial-github-alerts && cd bitsocial-github-alerts
curl -fsSLO https://raw.githubusercontent.com/bitsocialnet/bitsocial-github-alerts/main/deploy/docker-compose.yml
cat > .env <<'ENV'
GITHUB_TELOXIDE_TOKEN=
WEBHOOK_BASE_URL=https://github.example.com
DATABASE_PASSWORD=
ENV
docker compose up -d
```
`DATABASE_URL` is derived from `DATABASE_PASSWORD` inside the compose file; the other variables come from `.env`.
### Local development
```bash
cp .env.example .env # fill in values
docker compose up -d bitsocial-github-alerts-db
cargo run
```
## Docker image
Images are published to GHCR on every push to `main` and on version tags:
```
ghcr.io/bitsocialnet/bitsocial-github-alerts:latest
```
## Credits
Forked from [mhkafadar/notifine](https://github.com/mhkafadar/notifine). Report bugs for this fork by [creating an issue](https://github.com/bitsocialnet/bitsocial-github-alerts/issues/new).
### 5chan Board Manager
Repository: bitsocialnet/5chan-board-manager
Source: https://github.com/bitsocialnet/5chan-board-manager#readme
Description: A CLI tool that implements 4chan style thread auto archiving and purging for 5chan boards.
# 5chan Board Manager
A CLI tool that implements 4chan-style thread auto-archiving and purging for 5chan boards.
### Feature 1: Thread limit / auto-archive
- After each board update, determine thread positions in active sort
- Filter out pinned threads (they're exempt)
- Count non-pinned threads; any beyond position `per_page × pages` → archive via `createCommentModeration({ commentModeration: { archived: true } })`
- Archived threads are read-only (pkc-js already enforces this)
### Feature 2: Bump limit
- Track reply counts for active threads
- When a thread reaches `bump_limit` replies → archive it via `createCommentModeration({ commentModeration: { archived: true } })`
### Feature 3: Delayed purge
- Track when threads were archived
- After `archive_purge_seconds` has elapsed since archiving → purge via `createCommentModeration({ commentModeration: { purged: true } })`
- Purging is time-based rather than activity-based, so it also runs on a periodic sweep (`PURGE_SWEEP_INTERVAL_SECONDS`, default 60s) instead of only when a `community.update` event arrives. Without the sweep, a board that went quiet after archiving would keep expired threads in the archive until unrelated activity woke the manager.
### Feature 4: Author-deleted comment purging
- On each update, scan comments and replies for `deleted === true`
- Purge via `createCommentModeration({ commentModeration: { purged: true } })`
- Duplicate purge moderations (if the board hasn't processed prior purge yet) are harmless no-ops
### Config Directory Layout
Config and state are stored per-board under `~/.config/5chan/`, managed via `5chan board add/edit/remove` or manual editing:
```
~/.config/5chan/
├── global.json # shared settings (rpcUrl, defaults)
└── boards/
├── random.bso/
│ ├── config.json # { "address": "random.bso" }
│ ├── state.json # auto-created (signers, archivedThreads)
│ └── state.json.lock # transient lock file
├── tech.bso/
│ └── config.json # { "address": "tech.bso", "bumpLimit": 500 }
├── flash.bso/
│ └── config.json # { "address": "flash.bso", "perPage": 30, "pages": 1 }
└── custom.bso/
└── config.json # { "address": "custom.bso", "moderationReasons": { ... } }
```
**global.json** (optional):
```json
{
"rpcUrl": "ws://localhost:9138",
"defaults": {
"perPage": 15,
"pages": 10,
"bumpLimit": 300,
"archivePurgeSeconds": 172800,
"moderationReasons": {
"archiveCapacity": "5chan board manager: thread archived — exceeded board capacity",
"archiveBumpLimit": "5chan board manager: thread archived — reached bump limit",
"purgeArchived": "5chan board manager: thread purged — archive retention expired",
"purgeDeleted": "5chan board manager: content purged — author-deleted"
}
}
}
```
**Minimal config:** a single directory `boards/my-board.bso/config.json` containing `{ "address": "my-board.bso" }`
All fields except each board's `address` are optional:
- `rpcUrl` — falls back to `PKC_RPC_WS_URL` env var, then `ws://localhost:9138`
- `defaults` — applied to all boards unless overridden per-board
- Per-board fields (`perPage`, `pages`, `bumpLimit`, `archivePurgeSeconds`, `moderationReasons`) override `defaults`
- `moderationReasons` — optional object with `archiveCapacity`, `archiveBumpLimit`, `purgeArchived`, `purgeDeleted` string fields. Per-board values override defaults per-field (not the whole object). These reason strings are passed to `createCommentModeration()` so PKC clients can display why a thread was archived or purged.
- Board directory names must match the address field in `config.json`
## Installation
Docker is the only supported install path. The published image
(`ghcr.io/bitsocialnet/5chan-board-manager:latest`) is built and tested with the
bitsocial-cli version the default preset assumes, including the
`@bitsocial/spam-blocker-challenge` that the preset references. Running 5chan
outside Docker — or against a bitsocial-cli you manage yourself — is possible
but unsupported; you will have to keep the challenge and auth-key wiring in
sync by hand (see [Standalone](#standalone-bitsocial-cli-already-running) below).
### Docker Compose (recommended)
#### Full stack (with bitsocial-cli)
If you **don't** already have [bitsocial-cli](https://github.com/bitsocialnet/bitsocial-cli) running, use the full stack compose file which boots both bitsocial-cli (PKC RPC server) and 5chan together.:
```bash
wget -O docker-compose.yml https://raw.githubusercontent.com/bitsocialnet/5chan-board-manager/master/docker-compose.example.yml
docker compose up -d
# Install the spam-blocker challenge referenced by the default preset
# (skip this only if you also remove spam-blocker entries from your preset
# or always use --skip-apply-defaults):
docker compose exec bitsocial bitsocial challenge install @bitsocial/spam-blocker-challenge
# Optional: install this before enabling the wordfilter preset example
docker compose exec bitsocial bitsocial challenge install @bitsocial/wordfilter-challenge@0.3.0
# Now add boards via 5chan board add (see Config Directory Layout above)
```
See [`docker-compose.example.yml`](https://github.com/bitsocialnet/5chan-board-manager/blob/HEAD/docker-compose.example.yml) for the full configuration.
#### Quick usage (Docker, full stack)
Use this flow to create a new board with `bitsocial-cli` and immediately add it to 5chan.
> **Note:** The compose file ships with a pre-configured RPC auth key so both containers can connect out of the box. For production, replace it with your own random string:
> ```bash
> sed -i "s/TFCh0joRU60KwlfVaprP2uenw7NCdAwsBCF5UDoVg/$(openssl rand -base64 32 | tr -d '/+=')/g" docker-compose.yml
> ```
> **Note:** The container starts gracefully even with no boards configured — it waits for boards to be added and picks them up automatically via config hot-reload.
```bash
wget -O docker-compose.yml https://raw.githubusercontent.com/bitsocialnet/5chan-board-manager/master/docker-compose.example.yml
docker compose up -d
# Install the spam-blocker challenge referenced by the default preset
docker compose exec bitsocial bitsocial challenge install @bitsocial/spam-blocker-challenge
# Optional: install this before enabling the wordfilter preset example
docker compose exec bitsocial bitsocial challenge install @bitsocial/wordfilter-challenge@0.3.0
# Create a community (copy the created address from output)
docker compose exec bitsocial bitsocial community create \
--title "My Board title" \
--description "My Board description"
# Add the created community to 5chan board manager
docker compose exec -it 5chan 5chan board add
# Verify it was added
docker compose exec 5chan 5chan board list
# You can load the board now by its address in 5chan or any other web ui
```
#### Standalone (bitsocial-cli already running)
If you **already** have bitsocial-cli running separately (on the host, in another compose stack, etc.), use the standalone compose file which only runs 5chan:
```bash
cp docker-compose.standalone.example.yml docker-compose.yml
# Edit PKC_RPC_WS_URL in docker-compose.yml — replace YOUR-AUTH-KEY with
# your bitsocial-cli auth key (find it with: docker logs 2>&1 | grep "secret auth key")
docker compose up -d
```
Set `PKC_RPC_WS_URL` to the address of your existing instance, **including the auth key** as a path segment:
- **bitsocial-cli on the host (no container):** Use `ws://host.docker.internal:9138/YOUR-AUTH-KEY`. The example compose file includes `extra_hosts: ["host.docker.internal:host-gateway"]` so this works on Linux, macOS, and Windows.
- **bitsocial-cli in another Docker container/network:** Use the container or service name, e.g. `ws://bitsocial:9138/YOUR-AUTH-KEY`, and make sure both containers share the same Docker network.
**You must install the spam-blocker challenge on your bitsocial-cli instance**, because the default preset (`src/presets/community-defaults.jsonc`) references it and `5chan board add --apply-defaults` will be rejected by the PKC RPC server if the challenge is missing:
```bash
bitsocial challenge install @bitsocial/spam-blocker-challenge
```
If you do not want this dependency, remove both `@bitsocial/spam-blocker-challenge` entries from your preset (they are marked optional in the file) or use `--skip-apply-defaults`.
The bundled preset also contains a disabled wordfilter example. If you enable
it, install the challenge on the same bitsocial-cli instance first:
```bash
bitsocial challenge install @bitsocial/wordfilter-challenge@0.3.0
```
See [`docker-compose.standalone.example.yml`](https://github.com/bitsocialnet/5chan-board-manager/blob/HEAD/docker-compose.standalone.example.yml) for the configuration.
### Running commands inside Docker
Use `docker compose exec` to run additional `5chan` CLI commands inside the running container:
```bash
# Add a board
docker compose exec 5chan 5chan board add random.bso
# List configured boards
docker compose exec 5chan 5chan board list
# Edit a board's config
docker compose exec 5chan 5chan board edit random.bso --bump-limit 500
# Open a board's config in $EDITOR for interactive editing
docker compose exec 5chan 5chan board edit random.bso -i
# Reset a board field to global default
docker compose exec 5chan 5chan board edit random.bso --reset per-page
# Reset moderation reasons to defaults
docker compose exec 5chan 5chan board edit random.bso --reset moderation-reasons
# Set global defaults for all boards
docker compose exec 5chan 5chan defaults set --per-page 20 --bump-limit 500
# Open global defaults in $EDITOR for interactive editing
docker compose exec 5chan 5chan defaults set -i
# Remove a board
docker compose exec 5chan 5chan board remove random.bso
```
The container auto-reloads when files in the config directory change, so boards added/edited via `5chan board add/edit` take effect immediately without restarting.
### Build locally
```bash
docker build -t 5chan-board-manager .
docker run -d -v /path/to/data:/data 5chan-board-manager
```
### Data paths
| Container path | Description |
|---|---|
| `/data/5chan/global.json` | Global config (optional — rpcUrl, defaults) |
| `/data/5chan/boards//config.json` | Per-board config (created by `5chan board add`) |
| `/data/5chan/boards//state.json` | Per-board state (auto-created: signers, archived threads) |
### Board creation and defaults preset
If you are not using the Docker quick usage flow above, create the board with `bitsocial community create` first, then add it to 5chan with `5chan board add`.
> **Media rendering:** Keep `settings.fetchThumbnailUrls` enabled for 5chan boards. Bitsocial publications store media links, not uploaded media files, so clients need the thumbnail metadata fetched by the community node to know image and video dimensions before rendering. Without it, clients must fall back to generic containers that can size media poorly or cause layout shifts.
>
> The bundled preset enables this setting for new boards. By default, the community node fetches media links directly, which exposes the node's IP address to those hosts. Operators who need to hide the node's IP should also configure `settings.fetchThumbnailUrlsProxyUrl`.
Run `5chan board add --help` for full details on preset defaults flags (`--apply-defaults`, `--skip-apply-defaults`, `--interactive-apply-defaults`). In interactive terminals, defaults are shown with an `[A]ccept / [M]odify / [S]kip` prompt; choosing Modify opens the preset in `$EDITOR` as an annotated JSONC file with `//` comments explaining each field.
Preset JSONC is validated with Zod. Both plain JSON and JSONC (with `//` comments) are accepted as preset files.
`boardSettings` must follow pkc-js `CommunityEditOptions`:
https://github.com/pkcprotocol/pkc-js?tab=readme-ov-file#communityeditcommunityeditoptions
Bundled preset JSONC defaults:
[`src/presets/community-defaults.jsonc`](https://github.com/bitsocialnet/5chan-board-manager/blob/HEAD/src/presets/community-defaults.jsonc)
`boardSettings` is merged into `community.edit()` with "missing only" semantics (only absent values are applied). `boardManagerSettings` is used as default values for `board add` config fields, and explicit CLI flags override these defaults.
The bundled preset file is `src/presets/community-defaults.jsonc`.
### Optional wordfilters
Wordfilters replace configured text in a publishing client before the
publication is signed. The community node then rejects any publication that
still contains a filtered source word. Consequently, enable wordfilters only
after the clients used by the board support the `wordfilter/v1` contract. An
older client cannot publish the unfiltered text; it receives the configured
challenge error instead.
The bundled preset contains a commented example with 4chan's classic filters,
`soy` -> `onions`, `tbh` -> `desu` and `smh` -> `baka`, applied to post and
reply content, comment-edit content, and post titles. To enable it for a new
board:
1. Install `@bitsocial/wordfilter-challenge@0.3.0` on the bitsocial-cli instance
that hosts the community.
2. Run `5chan board add ADDRESS --interactive-apply-defaults`.
3. Choose **Modify**, uncomment the wordfilter challenge object, review the
rule, then save the preset.
The active challenge object has this shape when configuring a custom preset or
editing an existing community's `settings.challenges` array:
```json
{
"name": "@bitsocial/wordfilter-challenge",
"description": "Replaces configured words before publications are signed.",
"options": {
"wordfilter/v1/rules": "[{\"src\":\"soy\",\"dst\":\"onions\"},{\"src\":\"tbh\",\"dst\":\"desu\"},{\"src\":\"smh\",\"dst\":\"baka\"}]",
"wordfilter/v1/fieldNames": "[\"comment.content\",\"comment.title\",\"commentEdit.content\"]",
"error": "This board replaces certain words. Please retry after refreshing the board."
},
"publicOptions": [
"wordfilter/v1/rules",
"wordfilter/v1/fieldNames",
"error"
]
}
```
The two contract options must remain in `publicOptions`: publishing clients
need them to produce text the community will accept. Field paths start with
the publication type (`comment.content`, `commentEdit.content`,
`vote.author.displayName`, ...), as required by wordfilter-challenge 0.3.0;
bare paths such as `content` are rejected when the configuration is saved, and
clients written against the 0.2.0 bare-path format do not apply prefixed paths,
so their posts are rejected until they are updated. Rules are literal and
case-insensitive substring matches, not regular expressions and not
whole-word matches. This example matches `soy`, `Soy`, and `SOY`, and also
the `soy` inside `soybean` (which becomes `onionsbean`, exactly as on 4chan),
but not `s o y` or Unicode lookalikes.
Invalid rules within one wordfilter challenge are rejected when the community
configuration is saved. Each challenge allows at most 64 rules; source and
destination strings are limited to 128 characters; and replacements that could
loop or reintroduce another source in that challenge are rejected. Conflicts
between separate wordfilter challenges cannot be validated in isolation; they
surface during client application or as a publication rejection. See the
[`@bitsocial/wordfilter-challenge` contract](https://github.com/bitsocialnet/wordfilter-challenge#the-wordfilterv1-contract)
for the complete validation and client behavior.
## Commands
* [`5chan board add ADDRESS`](#5chan-board-add-address)
* [`5chan board edit ADDRESS`](#5chan-board-edit-address)
* [`5chan board list`](#5chan-board-list)
* [`5chan board remove ADDRESS`](#5chan-board-remove-address)
* [`5chan defaults set`](#5chan-defaults-set)
* [`5chan help [COMMAND]`](#5chan-help-command)
* [`5chan logs`](#5chan-logs)
* [`5chan start`](#5chan-start)
## `5chan board add ADDRESS`
Add one or more boards to the config
```
USAGE
$ 5chan board add ADDRESS... [--rpc-url ] [--per-page ] [--pages ] [--bump-limit
] [--archive-purge-seconds ] [--apply-defaults] [--skip-apply-defaults] [--interactive-apply-defaults]
[--defaults-preset ]
ARGUMENTS
ADDRESS... Board address(es) to add (one or more, space-separated)
FLAGS
--apply-defaults Apply preset defaults silently (no prompts)
--archive-purge-seconds= Seconds after archiving before purge
--bump-limit= Bump limit for threads
--defaults-preset= Path to a custom preset JSON file
--interactive-apply-defaults Interactively review and modify preset defaults before applying
--pages= Number of pages
--per-page= Posts per page
--rpc-url= [default: ws://localhost:9138, env: PKC_RPC_WS_URL] PKC RPC WebSocket URL (for
validation)
--skip-apply-defaults Skip applying preset defaults
DESCRIPTION
Add one or more boards to the config
Multiple addresses may be supplied space-separated; the same defaults decision
and preset are applied to each. All addresses are validated and checked for
conflicts up front, so nothing is written if any address is invalid or already
present.
Preset defaults behavior:
--apply-defaults Apply all preset defaults silently (no prompts)
--skip-apply-defaults Skip preset defaults silently
--interactive-apply-defaults Review defaults, accept all, modify in $EDITOR, or skip (requires TTY)
Interactive TTY (no flags) Same as --interactive-apply-defaults: shows [A]ccept / [M]odify / [S]kip
Non-interactive (no flags) Errors; requires --apply-defaults or --skip-apply-defaults
When choosing [M]odify, the preset opens in your editor ($VISUAL > $EDITOR > vi/notepad).
Modified presets are validated before applying; invalid changes fail the command.
Note: "board add" only accepts 5chan settings flags (pagination, bump limits, archiving).
To set board settings (title, description, rules, etc.), use a WebUI or bitsocial-cli:
https://github.com/bitsocialnet/bitsocial-cli#bitsocial-community-edit-address
EXAMPLES
$ 5chan board add random.bso
$ 5chan board add random.bso tech.bso flash.bso
$ 5chan board add tech.bso --bump-limit 500
$ 5chan board add flash.bso --per-page 30 --pages 1
$ 5chan board add my-board.bso --rpc-url ws://custom-host:9138
$ 5chan board add my-board.bso --apply-defaults
$ 5chan board add my-board.bso other-board.bso --apply-defaults
$ 5chan board add my-board.bso --skip-apply-defaults
$ 5chan board add my-board.bso --interactive-apply-defaults
$ 5chan board add my-board.bso --apply-defaults --defaults-preset ./my-preset.json
```
_See code: [src/commands/board/add.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/board/add.ts)_
## `5chan board edit ADDRESS`
Edit 5chan settings for an existing board
```
USAGE
$ 5chan board edit ADDRESS [-i | --per-page | --pages | --bump-limit |
--archive-purge-seconds | --reset ]
ARGUMENTS
ADDRESS Board address to edit
FLAGS
-i, --interactive Open the board config in $EDITOR for interactive editing
--archive-purge-seconds= Seconds after archiving before purge
--bump-limit= Bump limit for threads
--pages= Number of pages
--per-page= Posts per page
--reset= Comma-separated fields to reset to defaults (per-page, pages, bump-limit,
archive-purge-seconds, moderation-reasons)
DESCRIPTION
Edit 5chan settings for an existing board
This command configures how 5chan manages the board (pagination, bump limits, archiving).
Use --interactive (-i) to open the board config in $EDITOR for direct viewing/editing.
To edit board settings (title, description, rules, etc.), use a WebUI or bitsocial-cli:
https://github.com/bitsocialnet/bitsocial-cli#bitsocial-community-edit-address
EXAMPLES
$ 5chan board edit tech.bso --bump-limit 500
$ 5chan board edit flash.bso --per-page 30 --pages 1
$ 5chan board edit random.bso --reset per-page,bump-limit
$ 5chan board edit random.bso --per-page 20 --reset bump-limit
$ 5chan board edit random.bso --reset moderation-reasons
$ 5chan board edit random.bso --interactive
$ 5chan board edit random.bso -i
```
_See code: [src/commands/board/edit.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/board/edit.ts)_
## `5chan board list`
List all board addresses
```
USAGE
$ 5chan board list
DESCRIPTION
List all board addresses
EXAMPLES
$ 5chan board list
```
_See code: [src/commands/board/list.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/board/list.ts)_
## `5chan board remove ADDRESS`
Remove a board from the config
```
USAGE
$ 5chan board remove ADDRESS
ARGUMENTS
ADDRESS Community address to remove
DESCRIPTION
Remove a board from the config
EXAMPLES
$ 5chan board remove random.bso
```
_See code: [src/commands/board/remove.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/board/remove.ts)_
## `5chan defaults set`
Set global default settings for all boards
```
USAGE
$ 5chan defaults set [-i | --per-page | --pages | --bump-limit | --archive-purge-seconds
| --reset ]
FLAGS
-i, --interactive Open defaults in $EDITOR for interactive editing
--archive-purge-seconds= Seconds after archiving before purge
--bump-limit= Bump limit for threads
--pages= Number of pages
--per-page= Posts per page
--reset= Comma-separated fields to remove from defaults (per-page, pages, bump-limit,
archive-purge-seconds, moderation-reasons)
DESCRIPTION
Set global default settings for all boards
Defaults in global.json apply to every board unless overridden per-board.
Use --interactive (-i) to open the defaults object in $EDITOR for direct editing.
EXAMPLES
$ 5chan defaults set --per-page 20
$ 5chan defaults set --bump-limit 500 --pages 10
$ 5chan defaults set --reset per-page,bump-limit
$ 5chan defaults set --per-page 20 --reset bump-limit
$ 5chan defaults set --interactive
$ 5chan defaults set -i
```
_See code: [src/commands/defaults/set.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/defaults/set.ts)_
## `5chan help [COMMAND]`
Display help for 5chan.
```
USAGE
$ 5chan help [COMMAND...] [-n]
ARGUMENTS
[COMMAND...] Command to show help for.
FLAGS
-n, --nested-commands Include all nested commands in the output.
DESCRIPTION
Display help for 5chan.
```
## `5chan logs`
View the latest 5chan daemon log file. By default dumps the full log and exits. Use --follow to stream new output in real-time (like tail -f).
```
USAGE
$ 5chan logs [-f] [-n ] [--since ] [--until ] [--logPath ] [--stdout |
--stderr]
FLAGS
-f, --follow Follow log output in real-time (like tail -f)
-n, --tail= [default: all] Number of log entries to show from the end. Use "all" to show everything.
--logPath= Specify the directory containing log files
--since= Show logs since timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s,
42m, 2h, 1d)
--stderr Show only stderr log entries (output of pkc-logger library)
--stdout Show only stdout log entries
--until= Show logs before timestamp (ISO 8601, e.g. 2026-01-02T13:23:37Z) or relative time (e.g. 30s,
42m, 2h, 1d)
DESCRIPTION
View the latest 5chan daemon log file. By default dumps the full log and exits. Use --follow to stream new output in
real-time (like tail -f).
EXAMPLES
$ 5chan logs
$ 5chan logs -f
$ 5chan logs -n 50
$ 5chan logs --since 5m
$ 5chan logs --since 2026-01-02T13:23:37Z --until 2026-01-02T14:00:00Z
$ 5chan logs --since 1h -f
$ 5chan logs --stdout
$ 5chan logs --stderr
$ 5chan logs --stdout -f
```
_See code: [src/commands/logs.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/logs.ts)_
## `5chan start`
Start board managers for all configured boards
```
USAGE
$ 5chan start [-c ] [--log-path ]
FLAGS
-c, --config-dir= Path to config directory (overrides default)
--log-path= [default: /home/runner/.local/state/5chan] Directory to store daemon log files
DESCRIPTION
Start board managers for all configured boards
Board managers enforce imageboard-style thread lifecycle rules on each board:
- Archive threads that exceed board capacity (perPage × pages)
- Archive threads that reach the bump limit
- Purge archived threads after the retention period expires
- Purge author-deleted threads and replies
The config directory is watched for changes; boards are hot-reloaded
(added, removed, or restarted) without requiring a full restart.
Daemon output is written to a rotated log file (default: /home/runner/.local/state/5chan).
View it with `5chan logs`. stderr is suppressed on the terminal; real
uncaught errors still reach the terminal.
EXAMPLES
$ 5chan start
$ 5chan start --config-dir /path/to/config
$ 5chan start --log-path /var/log/5chan
```
_See code: [src/commands/start.ts](https://github.com/bitsocialnet/5chan-board-manager/blob/v0.2.27/src/commands/start.ts)_
## Config Hot-Reload
`5chan start` watches the config directory (`boards/` and `global.json`) using chokidar with a 200ms debounce, plus a periodic reconcile (every 2s) that guarantees changes are picked up even if the OS watcher drops events (e.g. inotify queue overflow). Changes that arrive while a reload is already running are queued and applied right after it. When any config file changes:
1. Loads and validates the new config
2. Diffs old vs new boards
3. Stops board managers for removed boards
4. Restarts board managers for boards with changed config
5. Starts board managers for added boards
6. Logs the delta: `config reloaded: +N added, -N removed, ~N changed, M running`
This means you can add, edit, or remove boards while the board manager is running — either by editing config files directly or by running `5chan board add/edit/remove` in another terminal. When global config changes (rpcUrl, defaults), all running boards are restarted.
## File Locking
Each board manager acquires a PID-based lock file (`{statePath}.lock`) to prevent concurrent board managers on the same board. On startup:
1. Attempts to create lock file exclusively (`wx` flag)
2. If lock exists, reads the PID and checks if it's still alive (`process.kill(pid, 0)`)
3. If alive — throws `Another board manager (PID N) is already running`
4. If stale — removes the old lock and retries
The lock is released when the board manager stops.
## Purge Sweep
Archiving is driven by `community.update` events, because it depends on board activity (new threads pushing old ones past capacity, replies hitting the bump limit). Purging is different: once a thread is archived, only the clock decides when its retention expires.
So the board manager also runs a periodic sweep that purges archived threads whose `archivePurgeSeconds` has elapsed, independent of update events. A board that goes quiet right after archiving would otherwise keep expired threads in the archive indefinitely, since nothing would trigger the update handler.
| Env var | Default | Purpose |
|---|---|---|
| `PURGE_SWEEP_INTERVAL_SECONDS` | `60` | How often to check for archived threads past their retention window. Set to `0` to disable the sweep and fall back to purging on update events only. |
The sweep does no network fetching — it reads the archived-thread timestamps already held in the state file, and only publishes when something is genuinely due. It is serialized against the update handler, so a thread is never purged twice concurrently.
## Author-Deleted Comment Purging
The board manager detects comments and replies that were deleted by their author (where `comment.deleted === true`) and purges them via `createCommentModeration({ commentModeration: { purged: true } })`. Once purged, the comment is removed from the board and won't appear in future listings. If a purge hasn't been processed yet, the next cycle may re-publish a redundant purge moderation, which is a harmless no-op.
## Auto Mod Signer Management
On startup for each board (using the internally-created PKC instance):
1. Check state JSON for a signer private key for this board address
2. If none exists, create one via `pkc.createSigner()` and save to state JSON
3. Check board roles via `community.roles` for the signer's address
4. If not a mod, auto-add via `community.edit()` (works because we run on `LocalCommunity` or `RpcLocalCommunity` — we own the board)
Logged via `pkc-logger` when creating signer or adding mod role.
## Address Change Handling
When bitsocial-cli changes a board's address (e.g., from a hash like `12D3KooW...` to a named address like `random.bso`), the board manager detects the change automatically via the pkc-js `update` event and migrates all associated files:
1. Signer key is moved to the new address in the state file
2. Board directory is renamed from `boards/{oldAddress}/` to `boards/{newAddress}/` (with updated `address` field in `config.json`)
3. Lock file is re-acquired for the new address
4. Internal maps are updated so moderation continues uninterrupted
The mod signer carries over to the new address — no need to re-assign the moderator role. If the migration fails (e.g., lock conflict on the new address), the board manager logs the error and continues operating under the old address.
## State Persistence
State is stored as `state.json` inside each board's directory (`boards/{address}/state.json`), alongside the board's `config.json`.
```json
{
"signers": {
"": { "privateKey": "..." }
},
"archivedThreads": {
"": { "archivedTimestamp": 1234567890 }
}
}
```
- **`signers`**: maps board address → mod signer private key (auto-created if missing)
- **`archivedThreads`**: maps comment CID → archive metadata (entries removed on purge)
- State writes use atomic temp-then-rename to prevent corruption
- Loaded on startup, written on archive, entries removed on purge
## Cold Start
The script may start long after the board has been running. On first run, many threads may need locking/purging at once. No rate limiting is needed — same-node publishing has no pubsub overhead. The first cycle may be heavier; steady-state handles a few threads per update.
## Idempotency
Before archiving, checks the thread's `archived` property. Skips if already archived (pkc-js throws on duplicate moderation actions).
## Logging
Uses `pkc-logger` (same logger as the pkc-js ecosystem). Key events logged:
- Board manager start/stop
- Threads archived (with CID and reason: capacity vs bump limit)
- Threads purged
- Author-deleted comments purged
- Config hot-reload events
- Mod role auto-added
- Errors
**Daemon log file.** When `5chan start` runs, stdout and stderr are captured to a
rotated log file under `$XDG_STATE_HOME/5chan/log/` (inside Docker: `/data/5chan/log/`).
Each entry is prefixed with `[ISO-timestamp] [stdout|stderr]`. At most 5 files are
retained; the oldest is deleted on each restart. Each file is capped at 20MB.
stderr is suppressed on the terminal — only real uncaught errors reach it. Use
`5chan logs` (or `5chan logs -f`) to view daemon output:
```bash
docker compose exec 5chan 5chan logs # dump latest log
docker compose exec 5chan 5chan logs -f # stream live
docker compose exec 5chan 5chan logs --stderr -f # only pkc-logger output
```
**Docker:** debug logging is **off** by default. The example compose files set
`DEBUG=bitsocial:5chan-board-manager*` on the `5chan` service so daemon output
flows into the log file. Because stderr is no longer teed to the terminal,
`docker logs` will be quieter than before — use `5chan logs` to see the full
debug stream. To silence the log file, drop the variable from your compose
file (or override it to empty):
```yaml
environment:
DEBUG: ""
```
To broaden the captured namespaces (e.g. to include pkc-js events), widen `DEBUG`:
```yaml
environment:
DEBUG: "bitsocial:5chan-board-manager*,pkc*,pkc-js*"
```
## Heartbeat & Health Check
Each board manager runs a periodic heartbeat that:
- Logs a line per tick at trace level (`[board ] heartbeat — last update: (s ago)`) so you can confirm the daemon is alive even when a board is idle. Visible with the default `DEBUG='bitsocial:5chan-board-manager*'`.
- Touches a shared heartbeat file (default `$XDG_STATE_HOME/5chan/heartbeat`, i.e. `/data/5chan/heartbeat` in Docker) for the Docker healthcheck to consume.
- If `now - lastUpdateAt` exceeds `HEARTBEAT_STALE_UPDATE_SECONDS`, an error-level `[board ] no update events for s` line is logged each tick. The process is **not** restarted automatically — operators should monitor the log or rely on the file-mtime healthcheck below.
| Env var | Default | Purpose |
|---|---|---|
| `HEARTBEAT_INTERVAL_SECONDS` | `300` | Tick cadence. |
| `HEARTBEAT_STALE_UPDATE_SECONDS` | `1800` | If no `community.update` event has fired within this window, a stale-update warning is logged each tick. |
| `HEARTBEAT_FILE` | `/heartbeat` | Override the heartbeat file path (e.g. for tests). |
The example compose files include a healthcheck that reports `unhealthy` if the heartbeat file's mtime is older than 10 minutes — useful for `docker ps` and external watchdogs. Note this only catches "process completely dead" (the file's mtime stops advancing only when the interval timer stops firing); if the RPC connection is stuck but the process is alive, the heartbeat file keeps getting touched, so watch the log for the stale-update warning instead.
## 4chan Board Behavior Reference
### Board capacity
Total thread capacity = `per_page × pages`. Both are **configurable per board**.
| Setting | Range | Description |
|---------|-------|-------------|
| `per_page` | 15–30 | Threads per index page (e.g., /b/ = 15, /v/ = 20, /f/ = 30) |
| `pages` | 1–10 | Number of index pages (e.g., /f/ = 1, most boards = 10) |
Capacity examples: /b/ = 150, /v/ = 200, /f/ = 30.
### Thread lifecycle
1. **New thread created** → sits at top of page 1
2. **Bumped by replies** → moves back to top of page 1
3. **Sinks gradually** as newer threads get replies
4. **Falls off last page** → archived (read-only, ~48h)
5. **Purged** → permanently deleted from 4chan's servers
### Bumping
A reply moves the thread to the top of page 1. This is equivalent to PKC's **"active sort"**, which orders threads by `lastReplyTimestamp`.
### Bump limit
Configurable per board (300–500+). After N replies, new replies no longer bump the thread, but it still accepts replies until it falls off the last page.
Examples: /b/ = 300, /3/ = 310, /v/ = 500.
### Pinned (sticky) threads
Sit at top of page 1, exempt from thread limit and archiving. "Pinned" and "sticky" are the same thing.
### Archive vs purge
- **Archived** = locked/read-only, still visible for ~48 hours
- **Purged** = permanently deleted from 4chan's servers
- Not all boards have archives (`is_archived` flag in API)
### Third-party archives
External services (archive.4plebs.org, desuarchive.org) independently scrape and preserve threads before purge.
### Other per-board settings from 4chan API
`image_limit`, `max_filesize`, `max_comment_chars`, `cooldowns`, `spoilers`, `country_flags`, `user_ids`, `forced_anon`, etc.
## Testing
Unit tests run under Vitest:
```bash
npm test
```
End-to-end tests run the real board manager against a real PKC RPC server:
```bash
npm run test:e2e
```
The e2e suite spawns its own Kubo (IPFS) daemon on random ports and an in-process PKC RPC server on port `19138`. All state is written to temp directories that are removed at teardown. No `bitsocial-cli` or docker-compose stack needs to be running — Kubo is provided by the `kubo` npm package installed as a devDependency.
To point the suite at an externally-running RPC instead (e.g. a long-lived docker-compose stack), set `PKC_RPC_WS_URL` before invoking the script — when set, global-setup skips spawning and uses the provided URL verbatim:
```bash
PKC_RPC_WS_URL=ws://localhost:9138/YOUR-AUTH-KEY npm run test:e2e
```
## Differences from 4chan
| Behavior | 4chan | This module |
|----------|------|-------------|
| **Bump limit** | Threads past bump limit still accept replies — they just stop rising in the catalog | Threads are **archived** (no more replies) because pkc-js has no "stop bumping without archiving" mechanism |
| **Sage** | Replying with `sage` in the email field prevents the thread from being bumped | Not supported — PKC has no equivalent mechanism, so all replies bump the thread |
| **Image limit** | Per-thread image limit (e.g., 150 images on /b/) after which no more images can be posted | Not implemented — pkc-js has its own file-size constraints but no per-thread image count limit |
## PKC-js Implementation
### Architecture
External module using pkc-js's public API:
- No pkc-js core modifications needed
- Uses `pkc.createCommentModeration()` for both archiving and purging
- Listens to board `update` events (via `community.on('update', ...)`) to detect new posts
- Gets thread positions from board post feeds (`community.posts.pageCids.active`), or falls back to sorting preloaded pages by `lastReplyTimestamp` descending (then `postNumber`)
### Configurable settings
Uses 4chan field names for interoperability.
| Setting | Default | 4chan range | Description |
|---------|---------|-------------|-------------|
| `per_page` | 15 | 15–30 | Threads per index page |
| `pages` | 10 | 1–10 | Number of index pages |
| `bump_limit` | 300 | 300–500 | Max replies before thread is archived |
| `archive_purge_seconds` | 172800 (48h) | ~48h | Seconds before archived posts are purged (no 4chan equivalent, 4chan uses ~48h) |
| `moderationReasons` | (see below) | — | Reason strings passed to `createCommentModeration()`. Fields: `archiveCapacity`, `archiveBumpLimit`, `purgeArchived`, `purgeDeleted` |
**Max active threads** = `per_page × pages` (default: 150)
### API note
Cannot do `community.posts.getPage("active")`. Must either:
1. Use `community.posts.pageCids.active` to get the CID, then fetch that page
2. Or fall back to sorting preloaded pages by `lastReplyTimestamp` descending, then `postNumber` (approximates active sort without needing `pageCids.active`)
### Board record size constraint
The entire board IPFS record is capped at 1MB (`MAX_FILE_SIZE_BYTES_FOR_COMMUNITY_IPFS`). `community.posts.pages.hot` is preloaded into the record with whatever space remains after the rest of the record (title, description, roles, challenges, etc.).
- If the preloaded page has **no `nextCid`**, it contains all posts — no pagination needed
- If `nextCid` **is present**, additional pages must be fetched via `community.posts.getPage({ cid: nextCid })`
- `community.posts.pageCids.active` provides the CID of the first active-sorted page, which is the sort order the board manager needs
Reference: `pkc-js/src/community/community-client-manager.ts`, `pkc-js/src/runtime/node/community/local-community.ts`
### Module flow
```
1. Create PKC instance internally from the provided pkcRpcUrl
2. Load state JSON; get or create signer for this board via `pkc.createSigner()`
3. Get board (`LocalCommunity` or `RpcLocalCommunity`)
4. Check board roles via `community.roles`; if missing, call `community.edit()` to add as mod
5. Acquire file lock to prevent concurrent board managers on same board
6. Call `community.update()`
7. On each 'update' event:
a. Determine thread source (three scenarios):
1. pageCids.active exists → fetch via getPage(), paginate via nextCid
2. Only pages.hot exists → use preloaded page, sort by lastReplyTimestamp desc then postNumber
3. Neither exists → no posts, return early
b. Walk through pages to build full ordered list of threads
c. Filter out pinned threads
d. For each non-pinned thread beyond position (per_page * pages):
- Skip if already archived
- createCommentModeration({ archived: true }) and publish
- Record archivedTimestamp in state file
e. For each thread with replyCount >= bump_limit:
- Skip if already archived
- createCommentModeration({ archived: true }) and publish
- Record archivedTimestamp in state file
f. For each archived thread where (now - archivedAt) > archive_purge_seconds:
- createCommentModeration({ purged: true }) and publish
- Remove from state file
g. For each author-deleted comment/reply:
- createCommentModeration({ purged: true }) and publish
8. Every PURGE_SWEEP_INTERVAL_SECONDS, independent of update events (skipped when set to 0):
- Run step (f) again, so time-based purging still happens on a quiet board
```
### Key pkc-js APIs used
| API | Purpose |
|-----|---------|
| `pkc.createCommentModeration()` | Archive and purge threads |
| `commentModeration.publish()` | Publish the moderation action |
| `community.posts.pageCids.active` | Get active sort page CID |
| `community.posts.pages.hot` | Preloaded first page (for calculating active sort) |
| `community.on('update', ...)` | Listen for new posts/updates |
| `page.nextCid` | Paginate through multi-page feeds |
### Key pkc-js source files (reference only, not modified)
| File | Relevant code |
|------|--------------|
| `src/pkc/pkc.ts` | `createCommentModeration()` definition |
| `src/publications/comment-moderation/schema.ts` | ModeratorOptionsSchema with `archived`, `purged` fields |
| `src/runtime/node/community/local-community.ts` | Existing archived check that blocks replies |
| `src/runtime/node/community/db-handler.ts` | `queryPostsWithActiveScore()` — active sort CTE |
| `src/pages/util.ts` | Sort type definitions and scoring functions |