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
ImageUse CaseExternal DB
ghcr.io/ryopc/koshi:standalone-latestPersonal / Serverless (βœ… Recommended)No (SQLite)
ghcr.io/ryopc/koshi:runner-latestProduction ServerYes (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.

VariableDefaultDescription
KOSHI_API_URLhttps://koshi.ryopc.orgAPI base URL (overrides serverUrl config)
KOSHI_WS_URLwss://koshi.ryopc.orgWebSocket 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:

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

VariableDefaultDescription
KOSHI_API_URLhttps://koshi.ryopc.orgAPI base URL for all requests
KOSHI_WS_URLwss://koshi.ryopc.orgWebSocket 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:

# 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:

  1. Koshi-tagged filter β€” { kinds: [1], "#k": ["koshi"], authors: [your-pubkey], limit } β€” finds koshi-originated cross-posts
  2. 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:

Troubleshooting

IssueSolution
Nostr not configuredRun kb nostr key generate first
No relays configuredAdd relays with kb nostr relay add
Publish timeoutTest relay with kb nostr relay test ; remove slow relays
Events not appearingCheck 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:

TopicPurposeParticipantsData Types
koshi:v1:boardGlobal board gossipAll nodesPosts, profile announces, announce blocks
koshi:v1:user:{pubkey}Per-user DM deliveryNode hosting that pubkeyEncrypted 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 NameBlock TypesDescription
postspost, announcePublic board posts + announce blocks advertising core keys
dmsdmEnd-to-end encrypted direct messages
profileprofileProfile 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

  1. Connection: Peer connects via hyperswarm, sends a JSON "hello" line with identity + core keys
  2. Replication: Both sides start corestore.replicate() (live mode) over the connection
  3. Announce processing: Received announce blocks register new peer cores for replication
  4. Refresh: Aggregated reads pull new blocks from all known cores (own + peers)
  5. Append tracking: Local cores emit append events 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):

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:

MethodPathAuthDescription
GET/api/p2p/statusNoGet P2P node status (ready, posts, DMs, peers)
GET/api/p2p/peersYesList connected peers (username, pubkey)
GET/api/p2p/postsYesGet P2P-synced posts (aggregated)
POST/api/p2p/syncYesTrigger manual refresh of aggregated data
POST/api/p2p/startYesStart P2P node
POST/api/p2p/stopYesStop 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

IssueSolution
0 peers connectedNormal initially; peers discover via DHT. Check firewall/UDP. Try kb p2p start --foreground to see connection logs.
Posts not syncingRun kb p2p sync to force refresh. Check kb p2p status for peer count.
DMs not decryptingOnly sender/recipient can decrypt. Ensure you're logged into the correct account.
Corestore lock errorOnly one P2P node per corestore path. Stop other instances (kb p2p start --stop).
NAT traversal failshyperswarm uses UDP hole-punching. Some symmetric NATs may block. Try relay peers.
High memory/CPULarge 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.

MethodPathAuthRate Limit
POST/api/auth/registerNo10/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}.

MethodPathAuthRate Limit
POST/api/auth/loginNo10/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

MethodPathAuthDescription
GET/api/users/:usernameNoGet user profile
PUT/api/users/meYesUpdate own profile
GET/api/users/search/:queryNoSearch users (min 2 chars)
GET/api/users/:id/followersNoGet user's followers
GET/api/users/:id/followingNoGet user's following
POST/api/users/:id/followYesFollow a user
DELETE/api/users/:id/followYesUnfollow 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

MethodPathAuthDescription
GET/api/posts/feedOptionalGet post feed (user's follows or global)
POST/api/postsYesCreate a new post
GET/api/posts/:idNoGet 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.

MethodPathAuthDescription
GET/api/admin/usersAdminList all users with stats
GET/api/admin/users/:idAdminGet detailed user info β€” accepts UUID or username
DELETE/api/admin/users/:idAdminPermanently delete a user β€” accepts UUID or username
PUT/api/admin/users/:id/adminAdminGrant/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

MethodPathAuthDescription
GET/api/dmsYesGet DM inbox
POST/api/dms/:userIdYesSend a DM to a user
PUT/api/dms/:id/readYesMark DM as read
GET/api/dms/unread/countYesCount 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.

MethodPathAuthDescription
GET/api/groupsYesList joined groups
POST/api/groupsYesCreate a group
GET/api/groups/:idYesGet group details + members
PATCH/api/groups/:idYes (admin)Update group info
DELETE/api/groups/:idYes (admin)Delete group
POST/api/groups/:id/membersYes (admin)Invite members
DELETE/api/groups/:id/members/:userIdYes (admin/self)Remove member / leave
GET/api/groups/:id/messagesYesGet message history
POST/api/groups/:id/messagesYesSend a group message
PUT/api/groups/:id/messages/:msgId/readYesMark 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.

MethodPathAuthDescription
GET/api/devicesYesList own devices
POST/api/devicesYesRegister a device
DELETE/api/devices/:deviceIdYesRemove a device
GET/api/devices/user/:userIdYesGet 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.

MethodPathAuthDescription
GET/api/keys/bundle/:userIdYesGet 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

MethodPathDescription
GET/api/healthServer health + version + mode
GET/api/configRuntime 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:

  1. Registration: Client generates keypair, sends public key + username to server. Server stores the public key and issues a JWT.
  2. 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.
  3. 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)

EventPayloadDescription
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)

TypePayloadDescription
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.

ColumnTypeConstraints
idUUIDPK, default uuid_generate_v4()
usernameVARCHAR(32)UNIQUE, NOT NULL
public_keyTEXTUNIQUE, NOT NULL
display_nameVARCHAR(64)Nullable
bioTEXTNullable
avatar_urlVARCHAR(512)Nullable
is_adminBOOLEANNOT NULL, default FALSE
created_atTIMESTAMPTZNOT NULL, default NOW()
updated_atTIMESTAMPTZNOT NULL, auto-updated

follows

Tracks user follow relationships.

ColumnTypeConstraints
idUUIDPK
follower_idUUIDFK β†’ users(id), ON DELETE CASCADE
following_idUUIDFK β†’ users(id), ON DELETE CASCADE
created_atTIMESTAMPTZNOT NULL
UNIQUE (follower_id, following_id)

kb_posts

Stores signed posts on the Koshi board.

ColumnTypeConstraints
idUUIDPK
author_idUUIDFK β†’ users(id)
contentTEXTCHECK 1-2000 characters
signatureTEXTNOT NULL
created_atTIMESTAMPTZNOT NULL, indexed DESC

dms

Stores signed direct messages between users.

ColumnTypeConstraints
idUUIDPK
sender_idUUIDFK β†’ users(id)
recipient_idUUIDFK β†’ users(id)
contentTEXTCHECK 1-5000 characters
signatureTEXTNOT NULL
is_readBOOLEANDEFAULT FALSE
created_atTIMESTAMPTZNOT NULL

groups

E2EE group chats. All groups are end-to-end encrypted (is_e2ee = TRUE).

ColumnTypeConstraints
idUUIDPK, default uuid_generate_v4()
nameVARCHAR(128)NOT NULL
descriptionTEXTNullable
avatar_urlVARCHAR(512)Nullable
created_byUUIDFK β†’ users(id), ON DELETE CASCADE
is_e2eeBOOLEANNOT NULL, DEFAULT TRUE
created_atTIMESTAMPTZNOT NULL
updated_atTIMESTAMPTZNOT NULL, auto-updated

group_members

Group membership with roles (admin/member).

ColumnTypeConstraints
idUUIDPK
group_idUUIDFK β†’ groups(id), ON DELETE CASCADE
user_idUUIDFK β†’ users(id), ON DELETE CASCADE
roleVARCHAR(16)NOT NULL, DEFAULT 'member', CHECK IN ('admin','member')
joined_atTIMESTAMPTZNOT NULL
UNIQUE (group_id, user_id)

group_messages

E2EE group messages. Content is encrypted (Sender Keys). Server only verifies signature.

ColumnTypeConstraints
idUUIDPK
group_idUUIDFK β†’ groups(id), ON DELETE CASCADE
sender_idUUIDFK β†’ users(id), ON DELETE CASCADE
contentTEXTCHECK 1-5000 characters
signatureTEXTNOT NULL
message_typeVARCHAR(16)DEFAULT 'text', CHECK IN ('text','image','file','system')
reply_toUUIDFK β†’ group_messages(id), ON DELETE SET NULL
created_atTIMESTAMPTZNOT NULL

message_read_receipts

Unified read receipts for both DMs and group messages.

ColumnTypeConstraints
idUUIDPK
message_idUUIDNOT NULL
message_typeVARCHAR(8)NOT NULL, CHECK IN ('dm','group')
user_idUUIDFK β†’ users(id), ON DELETE CASCADE
read_atTIMESTAMPTZNOT NULL
UNIQUE (message_id, message_type, user_id)

user_devices

X3DH device registration for end-to-end encryption. Keys are base64-encoded.

ColumnTypeConstraints
idUUIDPK
user_idUUIDFK β†’ users(id), ON DELETE CASCADE
device_nameVARCHAR(64)Nullable
identity_keyTEXTNOT NULL (base64 X25519)
signed_pre_keyTEXTNOT NULL (base64 X25519)
pre_key_signatureTEXTNOT NULL (base64 ed25519)
one_time_pre_keysTEXT[]NOT NULL DEFAULT '{}' (base64 X25519 array)
created_atTIMESTAMPTZNOT NULL
last_seen_atTIMESTAMPTZNullable

group_session_keys

Sender Keys for group E2E encryption.

ColumnTypeConstraints
idUUIDPK
group_idUUIDFK β†’ groups(id), ON DELETE CASCADE
sender_idUUIDFK β†’ users(id), ON DELETE CASCADE
sender_keyTEXTNOT NULL (base64 Sender Key)
created_atTIMESTAMPTZNOT 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

  1. Go to Neon.tech and create an account
  2. Create a new project (PostgreSQL 15)
  3. Get your connection string from Connection Details
  4. For production, use the pooled connection string (?pgbouncer=true)

2. Deploy on Render.com

  1. Push the repo to GitHub
  2. Go to Render.com and connect your GitHub repo
  3. Render will auto-detect render.yaml (Blueprint) and create the service
  4. In Render dashboard, add environment variables:
    • DATABASE_URL β€” your Neon.tech connection string
    • JWT_SECRET β€” generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  5. 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.

VariableRequiredDescription
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

Rate Limiting

Koshi uses express-rate-limit to protect endpoints:

LimiterRateApplied To
authLimiter10 requests/min per IPRegister & Login
apiLimiter100 requests/min per IPAll API routes
postLimiter10 posts/min per userPost 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