A centralized spam detection service that evaluates publications and provides risk scores to help communities filter spam. This public workspace contains the integration packages:
- Challenge Package (
@bitsocial/spam-blocker-challenge) - package for community integration - Shared Package (
@bitsocial/spam-blocker-shared) - shared schemas and response types used by the public integration surface
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.
Repo-specific AI workflow guidance lives in:
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:
corepack enablecorepack yarn install./scripts/install-default-agent-skills.sh
Important:
packages/shareddefines 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.
bitsocial-spam-blocker/
├── package.json # Root workspace config
├── tsconfig.base.json
├── docs/ # Agent playbooks and workflow notes
├── packages/
│ ├── challenge/ # package for community owners
│ │ └── src/
│ │ └── index.ts # ChallengeFileFactory
│ └── shared/ # Shared types
│ └── src/types.ts
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.
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:
// 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;
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: ["challengeRequest", "timestamp"];
}
}Response:
{
riskScore: number; // 0.0 to 1.0
explanation?: string; // Human-readable reasoning for the score
// Pre-generated challenge URL - community can use this if it decides to challenge
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 a pre-generated challengeUrl. If the community decides to challenge based on riskScore, it can immediately send the URL to the user without making a second request. If the challenge is not used, the session auto-purges after 1 hour.
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:
{
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:
{
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"
}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. For IP-based intelligence, only the country code is shared, never the raw IP address.
Iframe logic (OAuth-first):
When OAuth providers are configured, the iframe shows OAuth buttons as the primary challenge:
- 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.
- After first OAuth: If
riskScore × oauthMultiplier < passThreshold→ session completes. Otherwise, "Additional verification needed" view shows remaining providers and optional CAPTCHA. - 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:
- User signs in via OAuth (or solves CAPTCHA fallback)
- Server applies score adjustment and determines if session passes
- If more verification needed, iframe transitions to "need more" view
- Once passed, iframe shows "Verification complete!"
- The user clicks "done" in their bitsocial client (the client provides this button outside the iframe)
- The client sends a
ChallengeAnswerwith an empty string to the community - The community's challenge code calls
/api/v1/challenge/verifyto check if the session is completed
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:
{
sessionId: string;
challengeResponse: string; // Token from the challenge provider
challengeType?: string; // e.g., "turnstile" (default)
}Response:
{
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.
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, marksoauthCompletedand 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
oauthIdentityfield.
GET /api/v1/oauth/status/:sessionId — Polling endpoint used by the iframe to check OAuth status. Returns { completed, oauthCompleted, needsMore, firstProvider, status }.
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.
/evaluate → riskScore
│
├─ < autoAcceptThreshold → auto_accept (pass immediately, no challenge)
├─ ≥ autoRejectThreshold → auto_reject (fail immediately)
└─ between → create session (store riskScore), return challengeUrl
│
▼
Iframe serves OAuth buttons (primary) + optional CAPTCHA fallback link
│
├─ User signs in via OAuth → callback applies score adjustment
│ │
│ ├─ riskScore × 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 × oauthMult × 2ndOauthMult < threshold?
│ │ YES → completed ──────────────────> /verify → success
│ │
│ └─ User completes CAPTCHA
│ → riskScore × oauthMult × captchaMult < threshold?
│ YES → completed ──────────────────> /verify → success
│
└─ User clicks "I don't have a social account" → CAPTCHA fallback
│
├─ riskScore × captchaMultiplier < passThreshold?
│ YES → mark "completed" ──────────────────────────> /verify → success
│
└─ NO → mark captchaCompleted, return { oauthRequired: true }
Iframe redirects back to OAuth view
┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐
│ Bitsocial │ │ Spam Blocker │ │ OAuth / │
│ Client │ │ Server │ │ Turnstile │
└────────┬────────┘ └────────┬─────────┘ └───────┬────────┘
│ │ │
│ 1. ChallengeRequest │ │
│ (to community) │ │
│─────────────────────────> │
│ │ │
│ 2. community calls /evaluate │ │
│ │ │
│ 3. riskScore + │ │
│ sessionId + │ │
│ challengeUrl │ │
│<───────────────────────── │
│ │ │
│ 4. If challenge needed,│ │
│ community sends │ │
│ challengeUrl to │ │
│ client │ │
│ │ │
│ 5. Client loads iframe │ │
│─────────────────────────────────────────────────────>
│ │ │
│ 6. Iframe shows OAuth │ │
│ buttons (primary) │ │
│ + CAPTCHA fallback │ │
│ │ │
│ 7. User signs in via │ │
│ OAuth provider │ │
│ ───────────────────────────────────────────────>
│ │ │
│ 8. OAuth callback │ │
│ applies score │ │
│ adjustment │ │
│ │ │
│ 9a. If score passes │ │
│ → session done │ │
│ 9b. If needs more │ │
│ → show 2nd OAuth │ │
│ or CAPTCHA option │ │
│ ───────────────────────────────────────────────>
│ │ │
│ 10. (If more needed) │ │
│ User completes │ │
│ 2nd OAuth or │ │
│ CAPTCHA │ │
│ → session done │ │
│ │ │
│ 11. Iframe shows │ │
│ "click done" │ │
│<───────────────────────── │
│ │ │
│ 12. User clicks "done" │ │
│ button in client │ │
│ (outside iframe) │ │
│ │ │
│ 13. Client sends │ │
│ ChallengeAnswer │ │
│ with empty string │ │
│─────────────────────────> │
│ │ │
│ 14. community's verify("") │ │
│ calls /verify │ │
│ with sessionId │ │
│ │ │
│ 15. success: true + │ │
│ IP intelligence │ │
│<───────────────────────── │
│ │ │
│ 16. community applies │ │
│ post-challenge │ │
│ filters │ │
│ │ │
│ 17. Publication │ │
│ accepted/rejected │ │
└─────────────────────────┘ │
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, and IP intelligence.
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.
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.previousCommentCidchains 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→ Auto-accept (no challenge)autoAcceptThreshold <= riskScore < oauthSufficientThreshold→ One OAuth is sufficient (oauth_sufficient)oauthSufficientThreshold <= riskScore < autoRejectThreshold→ OAuth + more needed (oauth_plus_more)riskScore >= autoRejectThreshold→ Auto-reject
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)
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. community-level actions (commentEdit, commentModeration, communityEdit) are rejected by the evaluate endpoint since they don't require spam detection.
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:
- The iframe shows OAuth sign-in buttons; user signs in with a provider
- The OAuth callback applies score adjustment (
riskScore × oauthMultiplier) - If the adjusted score is below
challengePassThreshold→ session markedcompleted - If not →
oauthCompletedis set, iframe shows "need more" view with remaining providers and optional CAPTCHA - User completes second OAuth (different provider) or CAPTCHA → combined multiplier applied → session marked
completed - Alternatively, user can use CAPTCHA fallback from the start ("I don't have a social account")
- The user clicks "done" in their bitsocial client
- The client sends a
ChallengeAnswerwith an empty string to the community - The community's challenge code calls
/api/v1/challenge/verifywith thesessionId - The server checks
session.status === "completed"and returns success + IP intelligence
Session expiry: 1 hour from creation
Tables:
Author columns store the full author object from each publication (for example, DecryptedChallengeRequestMessageTypeWithcommunityAuthor.comment.author).
Stores comment publications for analysis and rate limiting.
sessionIdTEXT PRIMARY KEY (foreign key of challengeSessions)authorTEXT NOT NULL -- is actually a JSONcommunityAddressTEXT NOT NULLparentCidTEXT (null for posts, set for replies)contentTEXTlinkTEXTlinkWidthINTEGERlinkHeightINTEGERpostCidTEXTsignatureTEXT NOT NULLtitleTEXTtimestampINTEGER NOT NULLlinkHtmlTagNameTEXTflairTEXTspoilerINTEGER (BOOLEAN 0/1)protocolVersionTEXT NOT NULLnsfwINTEGER (BOOLEAN 0/1)receivedAtINTEGER NOT NULL
Stores vote publications.
sessionIdTEXT PRIMARY KEY (foreign key of challengeSessions)authorTEXT NOT NULL -- is actually a jsoncommunityAddressTEXT NOT NULLcommentCidTEXT NOT NULLsignatureTEXT NOT NULLprotocolVersionTEXT NOT NULLvoteINTEGER NOT NULL (-1, 0 or 1)timestampINTEGER NOT NULLreceivedAtINTEGER NOT NULL
Tracks challenge sessions. Sessions are kept permanently for historical analysis. Internal timestamps (completedAt, expiresAt, receivedChallengeRequestAt, authorAccessedIframeAt) are in milliseconds.
sessionIdTEXT PRIMARY KEY -- UUID v4communityPublicKeyTEXTstatusTEXT DEFAULT 'pending' (pending, completed, failed)completedAtINTEGERexpiresAtINTEGER NOT NULLreceivedChallengeRequestAtINTEGER NOT NULLauthorAccessedIframeAtINTEGER -- when did the author access the iframe?oauthIdentityTEXT -- format: "provider:userId" or JSON array '["provider:userId", ...]'challengeTierTEXT -- 'oauth_sufficient' or 'oauth_plus_more' (determined by score thresholds)captchaCompletedINTEGER DEFAULT 0 -- 1 if CAPTCHA portion completedoauthCompletedINTEGER DEFAULT 0 -- 1 if first OAuth completedriskScoreREAL -- the risk score at evaluation time (used for score adjustment after OAuth/CAPTCHA)
Stores raw IP addresses associated with authors (captured via iframe). One record per challenge.
sessionIdTEXT NOT NULL (foreign key to challengeSessions.sessionId) PRIMARY KEYipAddressTEXT NOT NULL -- ip address string representationisVpnINTEGER (BOOLEAN 0/1)isProxyINTEGER (BOOLEAN 0/1)isTorINTEGER (BOOLEAN 0/1)isDatacenterINTEGER (BOOLEAN 0/1)countryCodeTEXT -- ISO 3166-1 alpha-2 country codetimestampINTEGER NOT NULL -- when did we query the ip provider
Ephemeral table for CSRF protection during OAuth flow. Internal timestamps (createdAt, expiresAt) are in milliseconds.
stateTEXT PRIMARY KEYsessionIdTEXT NOT NULL (foreign key to challengeSessions)providerTEXT NOT NULL -- 'github', 'google', 'twitter', etc.codeVerifierTEXT -- PKCE code verifier (required for google, twitter)createdAtINTEGER NOT NULLexpiresAtINTEGER NOT NULL
Implements pkc-js ChallengeFileFactory:
// 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.
| Option | Default | Description |
|---|---|---|
serverUrl |
https://spamblocker.bitsocial.net/api/v1 |
URL of the BitsocialSpamBlocker server (must be http/https) |
autoAcceptThreshold |
0.2 |
Auto-accept publications below this risk score |
autoRejectThreshold |
0.8 |
Auto-reject publications above this risk 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 throws an error (does not silently accept or reject). This ensures the community owner is notified of issues.
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.
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 keyTURNSTILE_SECRET_KEY: Cloudflare Turnstile secret keyBASE_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: Auto-accept below this score (default: 0.2)OAUTH_SUFFICIENT_THRESHOLD: Scores between autoAccept and this pass with one OAuth (default: 0.4)AUTO_REJECT_THRESHOLD: Auto-reject 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_SECRETGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETTWITTER_CLIENT_ID/TWITTER_CLIENT_SECRETYANDEX_CLIENT_ID/YANDEX_CLIENT_SECRETTIKTOK_CLIENT_ID/TIKTOK_CLIENT_SECRETDISCORD_CLIENT_ID/DISCORD_CLIENT_SECRETREDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET
Risk factor disabling:
DISABLED_RISK_FACTORS: Comma-separated list of risk factor names to disable. Disabled factors getweight=0and their weight is redistributed to remaining factors. Valid values:commentContentTitleRisk,commentUrlRisk,velocityRisk,accountAge,karmaScore,ipRisk,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 tosilentto disable loggingPKC_RPC_URL: PKC RPC URL for community resolutionALLOW_NON_DOMAIN_COMMUNITIES: Set totrueto allow non-domain community addresses
- 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: Always throw 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
- 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
- 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
- Build the public packages with
corepack yarn build - Type-check the public packages with
corepack yarn type-check - Run the challenge-package tests with
corepack yarn test - Integrate the challenge package with a server instance that implements the API contract documented in this README
- Verify end-to-end flow against a local or hosted private server deployment
- 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