Koshi Documentation
Welcome to the Koshi documentation. This guide covers everything from installation to the full API reference. Koshi is a terminal-native, decentralized social network powered by ed25519 cryptography.
π¦ Version: 4.0.0 Β· License: MIT Β· Author: game_ryo Β· Mode: Serverless P2P Mesh (SQLite)
β Recommended: Run Koshi instantly with Docker β no installation or database setup required.
Docker Quick Deploy
# Pull and run the pre-built standalone image (GHCR)
$ docker run -d \
--name koshi \
--user koshi \
-e HOME=/home/koshi \
-p 3000:3000 \
-v $HOME/.config/koshi:/home/koshi/.config/koshi \
ghcr.io/ryopc/koshi:standalone-latest
# Access the Web UI
$ open http://localhost:3000
| Image | Use Case | External DB |
|---|---|---|
ghcr.io/ryopc/koshi:standalone-latest | Personal / Serverless (β Recommended) | No (SQLite) |
ghcr.io/ryopc/koshi:runner-latest | Production Server | Yes (PostgreSQL) |
Installation
Koshi requires Node.js 20+. In serverless mode (default), SQLite is used locally β no external database required. PostgreSQL 15+ is only needed for remote multi-user server deployments.
π Serverless Mode (Default): No setup required. Install the CLI and start posting immediately with local SQLite + P2P mesh.
Global Install (CLI only)
# From npm (requires package to be published)
$ npm install -g @ryopc/koshi
# Or from source
$ git clone https://github.com/ryopc/koshi.git
$ cd koshi
$ npm install
$ npm link
Serverless Mode (No Setup Required)
# Install CLI from source
$ git clone https://github.com/ryopc/koshi.git
$ cd koshi
$ npm install
$ npm link
# Start local serverless node (SQLite + P2P + Web UI)
$ kb serve
π₯οΈ koshi server β serverless P2P node
REST API: http://127.0.0.1:3000
Web UI: http://127.0.0.1:3000
P2P Mesh: active
Server Installation (PostgreSQL-backed)
$ git clone https://github.com/ryopc/koshi.git
$ cd koshi
$ npm install
# Create PostgreSQL database
$ createdb koshi
# Run database migrations
$ DATABASE_URL=postgresql://user:pass@localhost:5432/koshi \
JWT_SECRET=your-secret \
node src/db/migrate.js
# Start the server
$ DATABASE_URL=postgresql://user:pass@localhost:5432/koshi \
JWT_SECRET=your-secret \
node bin/server.js
# .env (for PostgreSQL-backed server only)
DATABASE_URL=postgresql://user:pass@localhost:5432/koshi
JWT_SECRET=your-secret-change-in-production
PORT=3000
NODE_ENV=production
π» CLI Only: To use the CLI with kb connect <url>, no local server setup is needed β connect directly to any remote koshi server or start your own with kb serve.
Quick Start
Koshi v3.0 defaults to serverless P2P mesh mode β no server setup needed. Just install the CLI and start posting:
# Register a new account (generates ed25519 keypair + local SQLite)
$ kb register alice
β Registration successful!
# Post to the koshi board (gossip to P2P mesh)
$ kb post "Hello, koshi! π"
β Post created!
# View your feed
$ kb feed --limit=10
π Koshi Board Feed
# Start local serverless node (Web UI + P2P + WebSocket)
$ kb serve
π₯οΈ koshi server β serverless P2P node
Web UI: http://127.0.0.1:3000
P2P Mesh: active (0 peers)
# Or connect to a remote server
$ kb connect https://koshi.ryopc.org
Configuration
Credentials and runtime settings are stored in ~/.config/koshi/config.json. In serverless mode (default), all data lives in ~/.config/koshi/local.db (SQLite). The CLI reads the config automatically.
π Server Mode: Use kb connect <url> to connect to a remote server, kb connect --reset to return to serverless mode, or kb connect --status to check the current connection.
| Variable | Default | Description |
|---|---|---|
KOSHI_API_URL | https://koshi.ryopc.org | API base URL (overrides serverUrl config) |
KOSHI_WS_URL | wss://koshi.ryopc.org | WebSocket URL (overrides serverUrl config) |
CLI Reference
The kb command is the gateway to Koshi. All commands are terminal-native with colored output via Chalk and loading spinners via Ora.
$ kb --help
π koshi β Terminal-Native Decentralized SNS
Version 4.0.0 β Serverless P2P mesh
Commands:
register Create a new account with ed25519 keypair
login Authenticate using existing keypair
switch Switch active account
whoami Show your profile information
post Create a new post on the koshi board
feed Display your post feed
follow Follow a user
unfollow Unfollow a user
dm Send a direct message
dms View your direct messages
profile View a user profile
search Search users by username
realtime Connect to the real-time event stream
chat Real-time interactive DM chat
edit-profile Update your own profile
serve Start local serverless node (SQLite+P2P+Web UI)
connect Connect to a remote server / switch to local mode
p2p P2P node management (status, sync, peers)
nostr Nostr integration (keys, relays, push, pull)
admin Admin: users, delete-user, grant, revoke
help Show this help message
Server Commands (v3.0)
Koshi v3.0 runs serverless by default using local SQLite + P2P mesh. No server setup is required.
kb serve [--port=3000] [--daemon]
Starts a local serverless node with REST API + WebSocket + Web UI + P2P mesh. All data is stored in local SQLite.
$ kb serve
π₯οΈ koshi server β serverless P2P node
REST API: http://127.0.0.1:3000
Web UI: http://127.0.0.1:3000
P2P Mesh: active (0 peers)
# Access Web UI at http://127.0.0.1:3000 in your browser
# Or connect CLI to it:
$ kb connect http://127.0.0.1:3000
Auth Commands
kb register <username>
Creates a new account. Generates an ed25519 keypair locally, sends the public key to the server, and stores credentials in ~/.config/koshi/config.json.
$ kb register alice
β Generating ed25519 keypair...
β Registration successful!
Username: alice
User ID: e7d00463-38f8-4d85-8231-6762bb988f7d
Token: eyJhbGciOiJIUzI1NiIs...
β You are now logged in.
kb login <username>
Authenticates using an existing keypair stored in ~/.snsrc. Signs a challenge with your secret key to prove identity.
$ kb login alice
β Signing authentication challenge...
β Login successful!
Post Commands
kb post <message>
Creates a new post on the Koshi board. Content is signed with your ed25519 key. Max 2000 characters.
$ kb post "Hello, koshi! π"
β Signing and posting...
β Post created!
ID: 550e8400-e29b-41d4-a716-446655440000
Posted: 7/14/2026, 2:30:00 PM
kb feed [--limit=N]
Displays the post feed. Shows posts from users you follow plus your own. Falls back to global feed when not authenticated.
$ kb feed --limit=5
π Koshi Board Feed (5 posts)
ββββββββββββββββββββββββββββββββββββββββββββ
Alice @alice
7/14/2026, 2:30:00 PM
Hello, koshi! π
Social Commands
kb follow <username>
$ kb follow bob
β Following @bob...
β You are now following @bob!
kb unfollow <username>
$ kb unfollow bob
β Unfollowing @bob...
β Unfollowed @bob.
kb profile [username]
View a user's public profile. Defaults to your own profile when no username is given.
$ kb profile alice
π€ Profile
ββββββββββββββββββββββββββββββββββββββββ
Username: alice
Display Name: Alice
Bio: Building the terminal future
Followers: 42
Following: 12
Joined: 7/14/2026
kb search <query>
Search users by username or display name. Query must be at least 2 characters.
$ kb search ali
π Search Results for "ali"
ββββββββββββββββββββββββββββββββββ
alice β Alice
kb whoami
Show your own profile information.
$ kb whoami
Username: alice
Followers: 42
Following: 12
Joined: 7/14/2026
Multi-Account Management π
Koshi v3.0 supports managing multiple accounts from a single CLI configuration. Each account has its own ed25519 keypair, Nostr keys (optional), and JWT token.
kb register <username>
Create a new account. The new account is automatically added to the multi-account config and set as active. In serverless mode (default), this uses local SQLite.
kb login [username]
Without arguments, shows an interactive list of saved accounts to choose from. With a username, logs in to that specific account.
$ kb login
π Select Account
1. alice β β Current
2. bob β
0. Cancel
Select number (1-2): 2
β Logged in as bob
kb switch [username]
Switch between saved accounts without re-authenticating. Interactive selection if no username is given.
kb accounts
List all saved accounts and their login status.
$ kb accounts
π Accounts (2 total)
1. alice β Logged in β Current account
2. bob Not logged in
kb account remove <username>
Remove a saved account from the local configuration. Shows confirmation prompt before deleting.
$ kb account remove bob
ποΈ Remove @bob from local config? (y/N): y
β @bob has been removed from local config.
Account credentials and settings are stored in ~/.config/koshi/config.json in the following format:
{
"version": "4.0.0",
"activeUsername": "alice",
"serverUrl": null,
"p2p": {
"corestorePath": "~/.config/koshi/corestore",
"autoSync": true
},
"accounts": {
"alice": {
"userId": "uuid",
"username": "alice",
"publicKey": "hex...",
"secretKey": "hex...",
"token": "jwt...",
"nostr": { "nsec": "nsec1...", "npub": "npub1..." }
},
"bob": { ... }
}
}
kb edit-profile --display-name=... --bio=... --avatar-url=...
Update your own profile fields. Only include the flags you want to change.
$ kb edit-profile --display-name="Alice" --bio="Building the terminal future"
β Updating profile...
β Profile updated!
Username: alice
Display Name: Alice
Bio: Building the terminal future
Available flags:
--display-name="Your Name" # Max 64 characters
--bio="About you" # Max 500 characters
--avatar-url="https://..." # Max 512 characters, must be valid URL
Message Commands
kb dm <username> <message>
Send a signed direct message to another user.
$ kb dm bob "Hey, how's it going?"
β Sending DM to @bob...
β DM sent to @bob!
ID: 550e8400-e29b-41d4-a716-446655440001
kb dms [--unread] [--limit=N]
View your DM inbox. Use --unread to show only unread messages.
$ kb dms --unread
π¨ Unread Messages (2 messages)
ββββββββββββββββββββββββββββββββββββββββββββ
β From: Bob @bob
7/14/2026, 2:35:00 PM
Hey Alice! Welcome to koshi π
Realtime Command
kb realtime
Connects to the WebSocket event stream for live updates. Shows new posts, DMs, and user online/offline events in real time.
$ kb realtime
π‘ Connecting to realtime feed...
β Connected! Waiting for new posts...
π New Post
bob 2:40:00 PM
Just joined koshi!
Press Ctrl+C to disconnect.
Interactive Chat Command
kb chat <username>
Starts an interactive real-time DM session with another user. Messages are sent and received via WebSocket in real time, with ed25519 signatures for authenticity.
$ kb chat bob
π¬ Live Chat with bob
Connect to real-time DMs. Type your message and press Enter.
Type /exit or press Ctrl+C to quit.
β Connected!
π’ bob is now online
> Hey Bob! How are you?
Bob: Hey Alice! I'm great, thanks!
>
Features:
- Real-time message delivery via WebSocket
- Messages signed with your ed25519 key
- See when the other user is online/offline
- Type
/exitor press Ctrl+C to quit
Admin Commands
kb admin users
List all registered users with their stats (posts, followers, join date). Admin privileges required.
$ kb admin users
π₯ All Users (3 total)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
alice [ADMIN] β Alice
ID: e7d00463-... | Posts: 42 | Followers: 12 | Joined: 7/14/2026
bob β Bob
ID: f8e11574-... | Posts: 7 | Followers: 3 | Joined: 7/14/2026
kb admin user <id|username>
View detailed information about a specific user, including public key, DM count, and admin status. Accepts either a UUID or a username.
kb admin delete-user <username>
Permanently delete a user account and all associated data (posts, follows, DMs β cascade deleted). Uses interactive confirmation.
$ kb admin delete-user bob
β οΈ WARNING: This will permanently delete the user and all their data.
This action cannot be undone.
Target: @bob
Type the username to confirm: bob
β User @bob has been permanently deleted.
Use --force flag to skip confirmation for scripting.
kb admin grant <username>
Grant admin privileges to a user.
$ kb admin grant bob
β @bob is now an admin!
kb admin revoke <username>
Revoke admin privileges from a user.
$ kb admin revoke bob
β Admin privileges removed from @bob.
CLI Environment Variables
| Variable | Default | Description |
|---|---|---|
KOSHI_API_URL | https://koshi.ryopc.org | API base URL for all requests |
KOSHI_WS_URL | wss://koshi.ryopc.org | WebSocket URL for realtime |
Nostr Integration π
Koshi v3.0 integrates with the Nostr protocol, allowing you to bridge your koshi identity with the broader Nostr network. You can generate Nostr keys, push posts to relays, pull events from the Nostr ecosystem, and maintain a portable identity across both networks.
β¨ New in v3.0: Nostr integration is fully integrated with the serverless P2P mesh. Your koshi account's Nostr keys (nsec/npub) are stored securely in ~/.config/koshi/config.json per account, and posts can be cross-posted to Nostr relays for broader reach.
How it works: Koshi uses nostr-tools v2 with secp256k1 (via @noble/curves) for Schnorr signatures. Each koshi post is published as a Nostr kind 1 (Short Text Note) event with a custom k: koshi tag for filtering. Your koshi ed25519 identity is separate from Nostr secp256k1 keys β they are managed independently but stored together in config.
Key Management
Generate or import Nostr keys (nsec/npub) directly from the CLI. Keys are stored per-account in ~/.config/koshi/config.json.
# Generate a new Nostr keypair (secp256k1)
$ kb nostr key generate
β Nostr keypair generated and saved!
npub: npub1...
nsec: nsec1...
# Import an existing nsec key (also accepts hex secret key)
$ kb nostr key import nsec1...
# Show current keys and relay configuration
$ kb nostr key show
# Display public key (npub) only
$ kb nostr npub
# Show Nostr sync status (last push/pull timestamps)
$ kb nostr status
β οΈ Security: Your nsec (secret key) is stored in plaintext in config.json with file permissions 0600. Never share your nsec. If compromised, generate a new keypair with kb nostr key generate β this will overwrite the old keys.
Pushing Posts to Nostr
Publish your koshi board posts to Nostr relays as kind 1 (Short Text Note) events. Each event includes:
kind: 1β Standard text notecontentβ Your post texttagsβ Includes["k", "koshi"]for filtering +["d", "for deduplication"] created_atβ Original post timestamp- Signed with your Nostr secp256k1 secret key
# Push recent posts (default: 20)
$ kb nostr push --limit=20
β 5/20 posts published to Nostr!
π‘ Relay Results:
β’ wss://relay.damus.io β 5 published
β’ wss://relay.nostr.band β 5 published
β’ wss://nos.lol β 5 failed (timeout)
# Push more posts
$ kb nostr push --limit=100
The command fetches your recent posts from the koshi feed (via /api/posts/feed), converts each to a Nostr event, and publishes to all configured relays. Results show per-relay success/failure counts.
Pulling Events from Nostr
Fetch Nostr events from relays and display them in koshi format. By default, pulls events authored by your Nostr pubkey (filtering by authors).
# Pull recent events (default: 50)
$ kb nostr pull --limit=50
β Found 12 Nostr events!
π‘ Nostr Events (12δ»Ά)
ββββββββββββββββββββββββββββββββββββββββββββ
nostr:a1b2c3d4 ... Alice @alice
Hello from Nostr!
# Pull more events
$ kb nostr pull --limit=200
The pull operation queries relays with two filters:
- Koshi-tagged filter β
{ kinds: [1], "#k": ["koshi"], authors: [your-pubkey], limit }β finds koshi-originated cross-posts - General filter β
{ kinds: [1], authors: [your-pubkey], limit }β fills remaining slots with any of your kind 1 notes
Results are deduplicated by event ID, sorted newest first, and returned as koshi-formatted posts with nostr: true flag.
Nostr Profile Sync (Kind 0)
Koshi can fetch your Nostr profile metadata (kind 0) from relays:
# Get Nostr profile for a pubkey (internal function)
$ kb nostr profile
Name: alice
Display Name: Alice
About: Building the terminal future
Picture: https://example.com/avatar.png
npub: npub1...
This uses the getNostrProfile function which queries kind 0 (Metadata) events from relays. Useful for importing your Nostr profile into koshi.
Relay Management
Add, remove, list, and test Nostr relay connections. Relays are stored per-account.
$ kb nostr relay list
π Nostr Relays (4δ»Ά)
1. wss://relay.damus.io
2. wss://relay.nostr.band
3. wss://nos.lol
4. wss://relay.snort.social
$ kb nostr relay add wss://relay.example.com
β γͺγ¬γΌγθΏ½ε γγΎγγ: wss://relay.example.com
$ kb nostr relay remove wss://relay.example.com
β γͺγ¬γΌγει€γγΎγγ: wss://relay.example.com
$ kb nostr relay test wss://relay.damus.io
β wss://relay.damus.io Latency: 120ms
# Interactive test (pick from configured relays)
$ kb nostr relay test
Default relays: If no relays are configured, koshi uses: wss://relay.damus.io, wss://relay.nostr.band, wss://nos.lol, wss://relay.snort.social. These are added automatically when you generate keys.
Nostr Event Structure (Koshi Posts)
When a koshi post is pushed to Nostr, the event looks like:
{
"id": "a1b2c3d4...", // SHA256 of serialized event
"pubkey": "abcd1234...", // Your Nostr hex pubkey (secp256k1)
"created_at": 1721000000, // Unix timestamp
"kind": 1, // Short Text Note
"tags": [
["k", "koshi"], // Custom tag: marks as koshi-sourced
["d", "550e8400-..."], // Koshi post UUID for deduplication
["koshi_user", "alice"] // Optional: koshi username
],
"content": "Hello, koshi! π",
"sig": "signature..." // Schnorr signature (64 bytes hex)
}
Integration with Nostr Clients
Since koshi posts are standard Nostr kind 1 events with a k: koshi tag, they appear in any Nostr client (Damus, Amethyst, Iris, Snort, etc.). You can:
- Follow your
npubin any Nostr client to see koshi posts - Use Nostr search to find
#koshitagged posts - Reply from Nostr clients β replies appear as regular Nostr events (not synced back to koshi automatically)
- Use your koshi npub as your Nostr identity across the network
Troubleshooting
| Issue | Solution |
|---|---|
Nostr not configured | Run kb nostr key generate first |
No relays configured | Add relays with kb nostr relay add |
Publish timeout | Test relay with kb nostr relay test ; remove slow relays |
Events not appearing | Check relay connectivity; some relays require auth (NIP-42) β not yet supported |
Duplicate posts | Push uses ["d", postId] tag; re-pushing same post updates event on relays |
P2P Sync π
Koshi v3.0 uses peer-to-peer synchronization as the default serverless mode, powered by the Hypercore protocol stack. Your posts and DMs are replicated across connected peers using hyperswarm DHT for discovery, enabling offline-first and decentralized data distribution β no central server required.
β¨ New in v3.0: P2P mesh runs automatically as serverless infrastructure. Uses hypercore (append-only logs with Merkle tree verification), corestore (hypercore factory), and hyperswarm (DHT-based peer discovery with NAT traversal). Started automatically when you run kb serve.
Architecture: Koshi uses hypercore@10 (append-only logs with Merkle tree verification), corestore@7 (hypercore factory managing multiple cores), and hyperswarm (HyperDHT peer discovery with UDP hole-punching). Data is stored in ~/.config/koshi/corestore/ as hypercore feed files. P2P runs in-process with the CLI or as a background daemon.
Network Topology
Koshi's P2P mesh uses two types of hyperswarm topics for different data flows:
| Topic | Purpose | Participants | Data Types |
|---|---|---|---|
koshi:v1:board | Global board gossip | All nodes | Posts, profile announces, announce blocks |
koshi:v1:user:{pubkey} | Per-user DM delivery | Node hosting that pubkey | Encrypted DMs |
Global Board Topic: Every node joins koshi:v1:board as both client and server. This topic carries posts, profile updates, and announce blocks (which advertise each peer's core keys so others can replicate them).
Per-User Topics: For each local account, the node joins koshi:v1:user:{pubkey} to receive direct DMs. DMs are end-to-end encrypted using nacl.box with x25519 keys derived from ed25519 via ed2curve.
Data Model (Hypercore Feeds)
Each node maintains three named hypercore feeds (cores) via corestore:
| Core Name | Block Types | Description |
|---|---|---|
posts | post, announce | Public board posts + announce blocks advertising core keys |
dms | dm | End-to-end encrypted direct messages |
profile | profile | Profile announcements (username, displayName, bio, avatar) |
Block Structures
// Post block (appended to 'posts' core)
{
"v": 3,
"type": "post",
"id": "550e8400-e29b-41d4-a716-446655440000",
"content": "Hello, koshi! π",
"username": "alice",
"authorPubkey": "ed25519_pubkey_hex...",
"displayName": "Alice",
"signature": "ed25519_signature_hex...",
"createdAt": "2026-07-14T14:30:00.000Z"
}
// DM block (appended to 'dms' core) β ENCRYPTED
{
"v": 3,
"type": "dm",
"id": "550e8400-e29b-41d4-a716-446655440001",
"fromUsername": "alice",
"fromPubkey": "ed25519_pubkey_hex...",
"toUsername": "bob",
"toPubkey": "ed25519_pubkey_hex...",
"encrypted": "base64_ciphertext...",
"nonce": "base64_nonce...",
"signature": "ed25519_signature_hex...",
"createdAt": "2026-07-14T14:35:00.000Z"
}
// Profile block (appended to 'profile' core)
{
"v": 3,
"type": "profile",
"username": "alice",
"pubkey": "ed25519_pubkey_hex...",
"displayName": "Alice",
"bio": "Building the terminal future",
"avatarUrl": "https://example.com/avatar.png",
"updatedAt": "2026-07-14T14:30:00.000Z"
}
// Announce block (appended to 'posts' core) β carries peer core keys
{
"v": 3,
"type": "announce",
"username": "alice",
"pubkey": "ed25519_pubkey_hex...",
"cores": {
"posts": "hypercore_key_hex...",
"dms": "hypercore_key_hex...",
"profile": "hypercore_key_hex..."
},
"createdAt": "2026-07-14T14:30:00.000Z"
}
Sync Mechanism
- Connection: Peer connects via hyperswarm, sends a JSON "hello" line with identity + core keys
- Replication: Both sides start
corestore.replicate()(live mode) over the connection - Announce processing: Received announce blocks register new peer cores for replication
- Refresh: Aggregated reads pull new blocks from all known cores (own + peers)
- Append tracking: Local cores emit
appendevents to trigger incremental refresh
Reads are aggregated: Every node merges blocks from its own cores with blocks from all peer cores it has discovered. Deduplication is by block ID for posts/DMs, and by username for profiles.
DM Encryption (End-to-End)
Direct messages are encrypted using NaCl box (x25519-xsalsa20-poly1305):
- Sender's ed25519 secret key β x25519 via
ed2curve.convertSecretKey() - Recipient's ed25519 public key β x25519 via
ed2curve.convertPublicKey() nacl.box(message, nonce, recipientX25519Pubkey, senderX25519Seckey)- Only sender and recipient can decrypt (sender retains plaintext locally)
- Encrypted blocks replicate across the mesh but are unreadable to other peers
Starting a P2P Node
Launch a P2P node to start syncing with peers. Runs as a background daemon by default.
# Start P2P node in background (daemon mode β default)
$ kb p2p start
β P2P node started in background!
PID: 12345
Log file: ~/.config/koshi/p2p.log
Stop with: kb p2p start --stop
# Start in foreground (debug mode β blocks terminal)
$ kb p2p start --foreground
β P2P node started!
Status: Running
Posts: 42
DMs: 12
Peers: 3
# Check background node status
$ kb p2p start --status
β P2P node running (PID: 12345)
# Stop background node
$ kb p2p start --stop
β P2P node stopped (PID: 12345)
# Shorthand for status
$ kb p2p status
Status: Running
Posts: 42
DMs: 12
Peers: 3
Username: alice
Discovery: 0123456789abcdef... (board topic discovery key)
Storage: ~/.config/koshi/corestore
Data Sync
View synced data from the P2P mesh:
# View synced posts (default: 20)
$ kb p2p sync --limit=20
π‘ P2P Synced Posts (15 δ»Ά)
ββββββββββββββββββββββββββββββββββββββββββββ
alice 2:30:00 PM
Hello, koshi! π
# View synced DMs (default: 20)
$ kb p2p dms --limit=20
βοΈ P2P Synced DMs (3 δ»Ά)
ββββββββββββββββββββββββββββββββββββββββββββ
alice β bob 2:35:00 PM
Hey Bob!
# Stop the P2P node (in-process only; use 'kb p2p start --stop' for daemon)
$ kb p2p stop
β P2P node stopped.
P2P API Endpoints
When running kb serve, the local server exposes P2P endpoints for the Web UI:
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/p2p/status | No | Get P2P node status (ready, posts, DMs, peers) |
| GET | /api/p2p/peers | Yes | List connected peers (username, pubkey) |
| GET | /api/p2p/posts | Yes | Get P2P-synced posts (aggregated) |
| POST | /api/p2p/sync | Yes | Trigger manual refresh of aggregated data |
| POST | /api/p2p/start | Yes | Start P2P node |
| POST | /api/p2p/stop | Yes | Stop P2P node |
Configuration
P2P settings are stored in ~/.config/koshi/config.json under the p2p key:
{
"p2p": {
"corestorePath": "~/.config/koshi/corestore", // Hypercore storage directory
"autoSync": true, // Auto-start P2P on CLI startup
"port": 0 // 0 = random port for hyperswarm
}
}
In v3.1.5+, P2P starts automatically when you run kb serve (via autoStart()). Set "autoSync": false to disable automatic startup.
Note: P2P requires Node.js (native modules for better-sqlite3, hypercore, hyperswarm). It does NOT run in browsers. For browser access, use kb serve + Web UI which bridges P2P data via REST API.
Troubleshooting
| Issue | Solution |
|---|---|
0 peers connected | Normal initially; peers discover via DHT. Check firewall/UDP. Try kb p2p start --foreground to see connection logs. |
Posts not syncing | Run kb p2p sync to force refresh. Check kb p2p status for peer count. |
DMs not decrypting | Only sender/recipient can decrypt. Ensure you're logged into the correct account. |
Corestore lock error | Only one P2P node per corestore path. Stop other instances (kb p2p start --stop). |
NAT traversal fails | hyperswarm uses UDP hole-punching. Some symmetric NATs may block. Try relay peers. |
High memory/CPU | Large corestore directories. Periodic cleanup not yet implemented; consider fresh corestore. |
Advanced: Internal Architecture
Corestore Replication Flow:
1. Connection established via hyperswarm (topic: koshi:v1:board)
2. Both sides send JSON "hello" line: { username, pubkey, cores: { posts, dms, profile } }
3. onPeerHello() opens peer cores via store.get({ key: peerCoreKey })
4. conn.pipe(store.replicate()).pipe(conn) β live bidirectional replication
5. refresh() reads new blocks from all cores, updates aggregated caches
6. trackCoreAppends() attaches 'append' listeners for incremental refresh
7. announce blocks advertise new peer cores β onPeerHello() β replicate
Announce Blocks: Periodically appended to local posts core (deduped to last 5 blocks). Carries this node's core keys so new peers can discover and replicate them.
Peer Deduplication: Peers tracked by pubkey in _seenPeers Set. Reconnection reuses existing core handles.
Offline-First: All writes append to local cores immediately. Replication happens opportunistically when peers connect. No central coordinator required.
API Reference
Koshi exposes a RESTful JSON API. All endpoints are prefixed with /api.
Base URLs:
Serverless (local): http://localhost:3000/api (when running kb serve)
Remote: https://koshi.ryopc.org/api (kb connect https://... or KOSHI_API_URL env var)
Authentication
Authenticated endpoints require a JWT Bearer token in the Authorization header:
Authorization: Bearer <token>
POST /api/auth/register
Register a new user with an ed25519 public key. Rate limited to 10 req/min.
| Method | Path | Auth | Rate Limit |
|---|---|---|---|
| POST | /api/auth/register | No | 10/min |
Request Body:
{
"username": "alice", // 3-32 chars, alphanumeric + underscores
"publicKey": "abcdef..." // 64-char hex string (32 bytes)
}
Response (201):
{
"userId": "e7d00463-38f8-4d85-8231-6762bb988f7d",
"token": "eyJhbGciOiJIUzI1NiIs..."
}
Errors: 400 (validation), 409 (already registered), 500 (server error)
POST /api/auth/login
Login by signing a challenge with your ed25519 key. The challenge format is koshi:login:{username}.
| Method | Path | Auth | Rate Limit |
|---|---|---|---|
| POST | /api/auth/login | No | 10/min |
Request Body:
{
"username": "alice",
"signature": "abc123..." // 128-char hex signature of challenge
}
Response (200):
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": "e7d00463-38f8-4d85-8231-6762bb988f7d"
}
User Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/users/:username | No | Get user profile |
| PUT | /api/users/me | Yes | Update own profile |
| GET | /api/users/search/:query | No | Search users (min 2 chars) |
| GET | /api/users/:id/followers | No | Get user's followers |
| GET | /api/users/:id/following | No | Get user's following |
| POST | /api/users/:id/follow | Yes | Follow a user |
| DELETE | /api/users/:id/follow | Yes | Unfollow a user |
GET /api/users/:username
$ curl https://koshi.ryopc.org/api/users/alice
{
"id": "e7d00463-38f8-4d85-8231-6762bb988f7d",
"username": "alice",
"displayName": "Alice",
"bio": "Building the terminal future",
"avatarUrl": null,
"followersCount": 42,
"followingCount": 12,
"createdAt": "2026-07-14T05:00:00.000Z"
}
PUT /api/users/me
Update profile fields. Only include fields you want to change.
$ curl -X PUT https://koshi.ryopc.org/api/users/me \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Alice",
"bio": "Building the terminal future"
}'
Post Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/posts/feed | Optional | Get post feed (user's follows or global) |
| POST | /api/posts | Yes | Create a new post |
| GET | /api/posts/:id | No | Get a single post |
GET /api/posts/feed
Returns posts from followed users + own posts (authenticated) or global feed (anonymous).
Query params: ?limit=20 (max 100), ?offset=0
POST /api/posts
Create a new post. Content must be signed with your ed25519 key. Rate limited to 10 posts/min.
$ curl -X POST https://koshi.ryopc.org/api/posts \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"content": "Hello, koshi! π",
"signature": "abc123..."
}'
Response (201):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"author": {
"id": "e7d00463-38f8-4d85-8231-6762bb988f7d",
"username": "alice",
"displayName": "Alice"
},
"content": "Hello, koshi! π",
"signature": "abc123...",
"createdAt": "2026-07-14T05:00:00.000Z"
}
Admin Endpoints
Admin endpoints require authentication AND admin privileges. Admin status is determined by the is_admin flag in the database or the ADMIN_USERNAME environment variable.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/admin/users | Admin | List all users with stats |
| GET | /api/admin/users/:id | Admin | Get detailed user info β accepts UUID or username |
| DELETE | /api/admin/users/:id | Admin | Permanently delete a user β accepts UUID or username |
| PUT | /api/admin/users/:id/admin | Admin | Grant/revoke admin privileges β accepts UUID or username |
DELETE /api/admin/users/:id
Permanently deletes a user and all associated data (posts, follows, DMs) via database CASCADE. Accepts either a UUID or a username as the :id parameter.
$ curl -X DELETE https://koshi.ryopc.org/api/admin/users/bob \
-H "Authorization: Bearer <admin-token>"
# Or using a UUID:
$ curl -X DELETE https://koshi.ryopc.org/api/admin/users/f8e11574-38f8-4d85-8231-6762bb988f7d \
-H "Authorization: Bearer <admin-token>"
{
"success": true,
"deletedUser": {
"id": "f8e11574-...",
"username": "bob"
}
}
π‘ Tip: All admin user endpoints accept both a UUID or a username in the :id path parameter. The server automatically detects which one you're using.
PUT /api/admin/users/:id/admin
Toggle admin status for a user. Accepts either a UUID or a username as the :id parameter. Body: { "isAdmin": true } or { "isAdmin": false }.
$ curl -X PUT https://koshi.ryopc.org/api/admin/users/bob/admin \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"isAdmin": true}'
{
"success": true,
"user": {
"id": "...",
"username": "bob",
"isAdmin": true
}
}
DM Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/dms | Yes | Get DM inbox |
| POST | /api/dms/:userId | Yes | Send a DM to a user |
| PUT | /api/dms/:id/read | Yes | Mark DM as read |
| GET | /api/dms/unread/count | Yes | Count unread DMs |
GET /api/dms
Query params: ?limit=50, ?offset=0, ?unread=true
POST /api/dms/:userId
Send a signed direct message. Content max 5000 characters.
$ curl -X POST https://koshi.ryopc.org/api/dms/TARGET_USER_ID \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"content": "Hey there!",
"signature": "abc123..."
}'
Group Endpoints (E2EE)
All groups are end-to-end encrypted (E2EE). Server only relays signed messages and verifies signatures β never decrypts content. Requires PostgreSQL mode.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/groups | Yes | List joined groups |
| POST | /api/groups | Yes | Create a group |
| GET | /api/groups/:id | Yes | Get group details + members |
| PATCH | /api/groups/:id | Yes (admin) | Update group info |
| DELETE | /api/groups/:id | Yes (admin) | Delete group |
| POST | /api/groups/:id/members | Yes (admin) | Invite members |
| DELETE | /api/groups/:id/members/:userId | Yes (admin/self) | Remove member / leave |
| GET | /api/groups/:id/messages | Yes | Get message history |
| POST | /api/groups/:id/messages | Yes | Send a group message |
| PUT | /api/groups/:id/messages/:msgId/read | Yes | Mark group message as read |
POST /api/groups
Create a new E2EE group. At least one member (yourself) is required. Max 100 members.
$ curl -X POST https://koshi.ryopc.org/api/groups \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "Team Pito",
"description": "Project discussion",
"memberUserIds": ["uuid1", "uuid2"],
"avatarUrl": "https://..."
}'
GET /api/groups/:id/messages
Query params: ?limit=50 (max 100), ?offset=0, ?before=
POST /api/groups/:id/messages
Send an encrypted group message. messageType: text | image | file | system. Group E2E uses Sender Keys β server verifies signature only.
Device Endpoints (X3DH Key Management)
Register and manage X3DH devices for end-to-end encryption. All keys are base64-encoded X25519 public keys.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/devices | Yes | List own devices |
| POST | /api/devices | Yes | Register a device |
| DELETE | /api/devices/:deviceId | Yes | Remove a device |
| GET | /api/devices/user/:userId | Yes | Get user's device list (public keys) |
POST /api/devices
Register a new device with X3DH keys. oneTimePreKeys array (max 100) contains X25519 public keys.
$ curl -X POST https://koshi.ryopc.org/api/devices \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"deviceName": "iPhone 15",
"identityKey": "base64-X25519-public-key",
"signedPreKey": "base64-X25519-public-key",
"preKeySignature": "base64-ed25519-signature",
"oneTimePreKeys": ["base64-key1", "base64-key2", "..."]
}'
Key Bundle Endpoints (X3DH)
Fetch X3DH key bundles for initiating E2E sessions. Consumes one one-time prekey per request.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/keys/bundle/:userId | Yes | Get key bundle for target user |
GET /api/keys/bundle/:userId
Optional query: ?deviceId=. Returns identityKey, signedPreKey, preKeySignature, and one consumed one-time prekey.
GET /api/dms
Query params: ?limit=50, ?offset=0, ?unread=true
POST /api/dms/:userId
Send a signed direct message. Content max 5000 characters.
$ curl -X POST https://koshi.ryopc.org/api/dms/TARGET_USER_ID \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"content": "Hey there!",
"signature": "abc123..."
}'
Health Check & Config
| Method | Path | Description |
|---|---|---|
| GET | /api/health | Server health + version + mode |
| GET | /api/config | Runtime config (serverUrl, apiBase, p2p status) |
$ curl http://localhost:3000/api/health
{
"status": "ok",
"service": "koshi-standalone",
"version": "4.0.0",
"mode": "serverless-p2p",
"timestamp": "2026-10-14T..."
}
Cryptography
Koshi uses ed25519 elliptic curve cryptography for all identity and signing operations. This is the same curve used by the Nostr protocol.
Key Generation
When you register, a new ed25519 keypair is generated locally using tweetnacl. Keys are hex-encoded for storage:
import nacl from 'tweetnacl';
function generateKeypair() {
const keypair = nacl.sign.keyPair();
return {
publicKey: bytesToHex(keypair.publicKey), // 64 hex chars (32 bytes)
secretKey: bytesToHex(keypair.secretKey), // 128 hex chars (64 bytes)
};
}
Key formats:
Public Key: 64-character hex string (32 bytes)
Secret Key: 128-character hex string (64 bytes: seed + public key)
Signature: 128-character hex string (64 bytes)
Signing & Verification
Messages (posts, DMs) are signed with your secret key using @noble/ed25519. Verification uses your public key.
Signing (CLI side)
import * as ed from '@noble/ed25519';
async function signMessage(message, secretKey) {
const skBytes = hexToBytes(secretKey);
const msgBytes = new TextEncoder().encode(message);
const signature = await ed.sign(msgBytes, skBytes);
return bytesToHex(signature);
}
Verification (Server side)
async function verifySignature(message, signature, publicKey) {
const msgBytes = new TextEncoder().encode(message);
const sigBytes = hexToBytes(signature);
const pkBytes = hexToBytes(publicKey);
return await ed.verify(sigBytes, msgBytes, pkBytes);
}
Authentication Flow
The authentication flow works as follows:
- Registration: Client generates keypair, sends public key + username to server. Server stores the public key and issues a JWT.
- Login: Client signs the challenge string
"koshi:login:{username}"with their secret key. Server verifies the signature against the stored public key and issues a JWT. - Subsequent requests: JWT token is sent in the
Authorization: Bearer {token}header for all authenticated endpoints.
JWT Tokens
JWTs are signed using HS256 (HMAC with SHA-256) and expire after 24 hours.
{
// Token payload
userId: "e7d00463-38f8-4d85-8231-6762bb988f7d",
username: "alice",
iat: 1784006345,
exp: 1784092745 // 24 hours
}
β Important: Protect your secret key and JWT secret. The JWT secret (JWT_SECRET) should be a cryptographically random string (32+ bytes). Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Key Derivation
You can derive the public key from a secret key using derivePublicKey(secretKey). This works for both 32-byte seeds and 64-byte full secret keys.
WebSocket Reference
Koshi supports real-time communication via WebSocket. Connect with your JWT token for authentication.
Connection
# Connect with JWT token
ws://host:port/ws?token=<your-jwt-token>
# Production example
wss://koshi.ryopc.org/ws?token=eyJhbGciOiJIUzI1NiIs...
Server Events (received from server)
| Event | Payload | Description |
|---|---|---|
connected | { userId, username, message } | Initial connection confirmation |
post_created | { author, content, timestamp } | New post on the board |
dm_received | { from, content, timestamp } | New DM received (targeted to recipient) |
dm_sent | { from, content, timestamp } | DM sent confirmation (to sender) |
dm_read | { dmId, readAt } | DM read receipt |
group_created | { group, members } | Group created (to all members) |
group_updated | { groupId, changes } | Group info updated |
group_deleted | { groupId } | Group deleted |
group_member_added | { groupId, member, addedBy } | Member added to group |
group_member_removed | { groupId, userId, removedBy } | Member removed from group |
group_message | { message, groupId } | Group message received |
group_message_read | { groupId, messageId, userId, readAt } | Group message read receipt |
typing_start | { conversationId, conversationType, userId, username } | Typing indicator started |
typing_stop | { conversationId, conversationType, userId, username } | Typing indicator stopped |
key_bundle_response | { userId, bundle } | X3DH key bundle response |
user_online | { userId, username } | User came online |
user_offline | { userId } | User went offline |
follow_notification | { follower, following } | Someone followed you |
pong | { timestamp } | Heartbeat response |
error | { message } | Error message |
Client Actions (sent to server)
| Type | Payload | Description |
|---|---|---|
ping | {} | Heartbeat ping |
post:create | { content, signature } | Create a new post |
dm:send | { recipientId, content, signature } | Send a DM |
group:create | { name, description?, memberUserIds[], avatarUrl? } | Create a group |
group:update | { groupId, name?, description?, avatarUrl? } | Update group (admin) |
group:delete | { groupId } | Delete group (admin) |
group:invite | { groupId, userIds[] } | Invite members (admin) |
group:leave | { groupId, userId? } | Leave/remove member |
group:message | { groupId, content, signature, messageType?, replyTo? } | Send group message |
group:read | { groupId, messageId } | Mark group message read |
typing:start | { conversationId, conversationType: 'dm'|'group' } | Typing indicator start |
typing:stop | { conversationId, conversationType: 'dm'|'group' } | Typing indicator stop |
key_bundle:request | { targetUserId } | Request X3DH key bundle |
follow | { userId } | Follow a user |
unfollow | { userId } | Unfollow a user |
Example: Create post via WebSocket
// Send
{
"type": "post:create",
"payload": {
"content": "Hello from WebSocket!",
"signature": "abc123..."
}
}
// Receive confirmation
{
"type": "post:created",
"payload": {
"id": "550e8400-...",
"author": { "id": "...", "username": "alice" },
"content": "Hello from WebSocket!",
"timestamp": "2026-07-14T05:00:00.000Z"
}
}
Database Schema
Koshi v3.0 uses SQLite in serverless mode (default) and PostgreSQL 15 for remote server deployments. Both use the same schema with UUID primary keys and timestamps. SQLite stores data in ~/.config/koshi/local.db.
Tables
users
Stores user identities with ed25519 public keys.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK, default uuid_generate_v4() |
username | VARCHAR(32) | UNIQUE, NOT NULL |
public_key | TEXT | UNIQUE, NOT NULL |
display_name | VARCHAR(64) | Nullable |
bio | TEXT | Nullable |
avatar_url | VARCHAR(512) | Nullable |
is_admin | BOOLEAN | NOT NULL, default FALSE |
created_at | TIMESTAMPTZ | NOT NULL, default NOW() |
updated_at | TIMESTAMPTZ | NOT NULL, auto-updated |
follows
Tracks user follow relationships.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
follower_id | UUID | FK β users(id), ON DELETE CASCADE |
following_id | UUID | FK β users(id), ON DELETE CASCADE |
created_at | TIMESTAMPTZ | NOT NULL |
UNIQUE (follower_id, following_id) | ||
kb_posts
Stores signed posts on the Koshi board.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
author_id | UUID | FK β users(id) |
content | TEXT | CHECK 1-2000 characters |
signature | TEXT | NOT NULL |
created_at | TIMESTAMPTZ | NOT NULL, indexed DESC |
dms
Stores signed direct messages between users.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
sender_id | UUID | FK β users(id) |
recipient_id | UUID | FK β users(id) |
content | TEXT | CHECK 1-5000 characters |
signature | TEXT | NOT NULL |
is_read | BOOLEAN | DEFAULT FALSE |
created_at | TIMESTAMPTZ | NOT NULL |
groups
E2EE group chats. All groups are end-to-end encrypted (is_e2ee = TRUE).
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK, default uuid_generate_v4() |
name | VARCHAR(128) | NOT NULL |
description | TEXT | Nullable |
avatar_url | VARCHAR(512) | Nullable |
created_by | UUID | FK β users(id), ON DELETE CASCADE |
is_e2ee | BOOLEAN | NOT NULL, DEFAULT TRUE |
created_at | TIMESTAMPTZ | NOT NULL |
updated_at | TIMESTAMPTZ | NOT NULL, auto-updated |
group_members
Group membership with roles (admin/member).
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
group_id | UUID | FK β groups(id), ON DELETE CASCADE |
user_id | UUID | FK β users(id), ON DELETE CASCADE |
role | VARCHAR(16) | NOT NULL, DEFAULT 'member', CHECK IN ('admin','member') |
joined_at | TIMESTAMPTZ | NOT NULL |
UNIQUE (group_id, user_id) | ||
group_messages
E2EE group messages. Content is encrypted (Sender Keys). Server only verifies signature.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
group_id | UUID | FK β groups(id), ON DELETE CASCADE |
sender_id | UUID | FK β users(id), ON DELETE CASCADE |
content | TEXT | CHECK 1-5000 characters |
signature | TEXT | NOT NULL |
message_type | VARCHAR(16) | DEFAULT 'text', CHECK IN ('text','image','file','system') |
reply_to | UUID | FK β group_messages(id), ON DELETE SET NULL |
created_at | TIMESTAMPTZ | NOT NULL |
message_read_receipts
Unified read receipts for both DMs and group messages.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
message_id | UUID | NOT NULL |
message_type | VARCHAR(8) | NOT NULL, CHECK IN ('dm','group') |
user_id | UUID | FK β users(id), ON DELETE CASCADE |
read_at | TIMESTAMPTZ | NOT NULL |
UNIQUE (message_id, message_type, user_id) | ||
user_devices
X3DH device registration for end-to-end encryption. Keys are base64-encoded.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
user_id | UUID | FK β users(id), ON DELETE CASCADE |
device_name | VARCHAR(64) | Nullable |
identity_key | TEXT | NOT NULL (base64 X25519) |
signed_pre_key | TEXT | NOT NULL (base64 X25519) |
pre_key_signature | TEXT | NOT NULL (base64 ed25519) |
one_time_pre_keys | TEXT[] | NOT NULL DEFAULT '{}' (base64 X25519 array) |
created_at | TIMESTAMPTZ | NOT NULL |
last_seen_at | TIMESTAMPTZ | Nullable |
group_session_keys
Sender Keys for group E2E encryption.
| Column | Type | Constraints |
|---|---|---|
id | UUID | PK |
group_id | UUID | FK β groups(id), ON DELETE CASCADE |
sender_id | UUID | FK β users(id), ON DELETE CASCADE |
sender_key | TEXT | NOT NULL (base64 Sender Key) |
created_at | TIMESTAMPTZ | NOT NULL |
UNIQUE (group_id, sender_id) | ||
Indexes exist on username, public_key, follower/following IDs, post timestamps, DM recipient inbox queries, group membership, and message pagination.
Deployment
Render.com + Neon.tech (Recommended)
1. Set up Neon.tech Database
- Go to Neon.tech and create an account
- Create a new project (PostgreSQL 15)
- Get your connection string from Connection Details
- For production, use the pooled connection string (
?pgbouncer=true)
2. Deploy on Render.com
- Push the repo to GitHub
- Go to Render.com and connect your GitHub repo
- Render will auto-detect
render.yaml(Blueprint) and create the service - In Render dashboard, add environment variables:
DATABASE_URLβ your Neon.tech connection stringJWT_SECRETβ generate with:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
- Render will auto-deploy on every push to
main
Auto-migration: Koshi runs database migrations automatically on every server startup. Tables are created before the HTTP server starts listening, so no manual migration step is needed.
Server Environment Variables
Serverless mode (default): No environment variables required β SQLite is auto-created.
PostgreSQL-backed server: Requires DATABASE_URL and JWT_SECRET.
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | β | PostgreSQL connection string |
JWT_SECRET | β | Secret key for JWT signing (32+ bytes) |
PORT | β | Server port (default: 3000) |
NODE_ENV | β | development or production |
LOG_LEVEL | β | debug, info, warn, error |
ADMIN_USERNAME | β | Bootstrap the first admin user (e.g., alice) |
Docker
β Recommended: Standalone Docker image β runs the full serverless stack (SQLite + P2P mesh + REST API + WebSocket + Web UI) with zero setup. Just pull and run.
Standalone (Serverless β SQLite, No External DB) β Recommended
# Pull pre-built image from GitHub Container Registry (recommended)
$ docker run -d \
--name koshi \
--user koshi \
-e HOME=/home/koshi \
-p 3000:3000 \
-v $HOME/.config/koshi:/home/koshi/.config/koshi \
ghcr.io/ryopc/koshi:standalone-latest
# Or build from source
$ docker build --target standalone -t koshi:standalone .
# Run the container (data persists in volume)
$ docker run -d \
--name koshi \
--user koshi \
-e HOME=/home/koshi \
-p 3000:3000 \
-v $HOME/.config/koshi:/home/koshi/.config/koshi \
koshi:standalone
PostgreSQL-backed Full Server
# Pull the pre-built image from GHCR
$ docker pull ghcr.io/ryopc/koshi:runner-latest
# Run the container (quote DATABASE_URL β the & in query params breaks bash)
$ docker run -d \
--name koshi-api \
-p 3000:3000 \
-e 'DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require' \
-e 'JWT_SECRET=your-secret' \
-e 'ADMIN_USERNAME=alice' \
ghcr.io/ryopc/koshi:runner-latest
π‘ Tip: The standalone Docker image includes everything β SQLite, P2P mesh, REST API, WebSocket, and Web UI. No external database is needed.
Docker Compose
version: '3.8'
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: koshi
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
api:
build:
context: .
target: runner
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/koshi
JWT_SECRET: change-this-in-production
depends_on:
- db
volumes:
pgdata:
Security
- Ed25519 Signatures β All posts and DMs are signed for authenticity. No passwords.
- JWT Auth β Tokens expire after 24 hours. Use
JWT_SECRETenvironment variable. - Parameterized Queries β All SQL uses parameterized queries ($1, $2) to prevent SQL injection.
- Input Validation β All endpoints validate input format, length, and types.
- Helmet β Security headers (CSP, HSTS, X-Frame-Options, etc.) are enabled.
- No Hardcoded Secrets β Everything via environment variables.
- CORS β Configured for CLI client origins.
Rate Limiting
Koshi uses express-rate-limit to protect endpoints:
| Limiter | Rate | Applied To |
|---|---|---|
authLimiter | 10 requests/min per IP | Register & Login |
apiLimiter | 100 requests/min per IP | All API routes |
postLimiter | 10 posts/min per user | Post creation |
Development
Setup
$ git clone https://github.com/ryopc/koshi.git
$ cd koshi
$ npm install
$ npm run dev # Auto-reload with nodemon
$ npm run server # Run standalone SQLite server (serverless)
$ npm test # Run tests
$ npm run lint # Run linter
Project Structure
koshi/
βββ bin/
β βββ cli.js # CLI/TUI entry point (kb command)
β βββ server.js # PostgreSQL-backed server (bin/server.js)
βββ src/
β βββ local/ # Serverless mode (SQLite + P2P bridge)
β β βββ db.js # SQLite database engine
β β βββ api.js # Local API (SQLite)
β β βββ server.js # Local Express + WS server (kb serve)
β β βββ p2p-bridge.js # P2P β SQLite sync bridge
β βββ api/
β β βββ auth.js # Auth routes (register/login)
β β βββ users.js # User management routes
β β βββ posts.js # Posts routes (koshi board)
β β βββ dms.js # Direct messages routes
β β βββ admin.js # Admin routes (users, delete, grant)
β β βββ p2p.js # P2P API routes
β βββ auth/
β β βββ ed25519.js # Ed25519 crypto utilities
β β βββ jwt.js # JWT token utilities
β β βββ utils.js # Hex encoding utilities
β βββ db/
β β βββ schema.sql # PostgreSQL schema
β β βββ migrate.js # Migration script
β β βββ pool.js # Database connection pool
β βββ config/
β β βββ config.js # Multi-account + Nostr + P2P config
β βββ p2p/
β β βββ index.js # P2P mesh (hypercore + hyperswarm)
β βββ nostr/
β β βββ index.js # Nostr protocol integration
β βββ ws/
β β βββ index.js # WebSocket server
β β βββ handlers.js # WebSocket message handlers
β βββ middleware/
β β βββ auth.js # Auth middleware
β β βββ rateLimit.js # Rate limiting
β βββ index.js # Express app setup
βββ client/ # React Web UI (Vite + React 18)
βββ web/ # Built static files for serving
βββ Dockerfile
βββ render.yaml # Render Blueprint
βββ package.json