@ryopc/koshi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/server.js ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // koshi – Terminal-Native Decentralized SNS
4
+ // Server Entry Point (Express + WebSocket)
5
+ // License: MIT
6
+ // ============================================================================
7
+ // Starts the HTTP server with Express and attaches WebSocket support.
8
+ // Reads configuration from environment variables.
9
+ //
10
+ // Usage:
11
+ // node bin/server.js
12
+ //
13
+ // Environment:
14
+ // PORT - HTTP server port (default: 3000)
15
+ // DATABASE_URL - PostgreSQL connection string (required)
16
+ // JWT_SECRET - Secret key for JWT signing (required)
17
+ // NODE_ENV - 'development' or 'production' (default: 'development')
18
+ // LOG_LEVEL - 'debug', 'info', 'warn', 'error' (default: 'info' in prod, 'debug' in dev)
19
+ // ============================================================================
20
+
21
+ import { createServer } from 'node:http';
22
+ import { app, logger } from '../src/index.js';
23
+ import { initWebSocket, startHeartbeat } from '../src/ws/index.js';
24
+ import { closePool } from '../src/db/pool.js';
25
+ import { getOnlineCount } from '../src/ws/index.js';
26
+
27
+ // Load .env file in development
28
+ try {
29
+ const dotenv = await import('dotenv');
30
+ dotenv.config();
31
+ } catch {
32
+ // dotenv not available; that's fine
33
+ }
34
+
35
+ const PORT = parseInt(process.env.PORT) || 3000;
36
+
37
+ // Validate required environment variables
38
+ const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET'];
39
+ const missing = requiredEnvVars.filter((name) => !process.env[name]);
40
+
41
+ if (missing.length > 0) {
42
+ logger.error(
43
+ { missing },
44
+ `Missing required environment variables: ${missing.join(', ')}`
45
+ );
46
+ console.error(`\nāŒ Missing required environment variables:`);
47
+ missing.forEach((name) => {
48
+ console.error(` - ${name}`);
49
+ });
50
+ console.error(`\n Create a .env file or export these variables.`);
51
+ console.error(` See .env.example for reference.\n`);
52
+ process.exit(1);
53
+ }
54
+
55
+ // Create HTTP server
56
+ const server = createServer(app);
57
+
58
+ // Initialize WebSocket server
59
+ const wss = initWebSocket(server, logger);
60
+ startHeartbeat();
61
+
62
+ // Start listening
63
+ server.listen(PORT, () => {
64
+ logger.info(
65
+ { port: PORT, env: process.env.NODE_ENV || 'development' },
66
+ `šŸš€ koshi server running on port ${PORT}`
67
+ );
68
+ console.log(`\n ╔══════════════════════════════════════════════╗`);
69
+ console.log(` ā•‘ šŸ„ koshi board server v0.2.0 ā•‘`);
70
+ console.log(` ╠══════════════════════════════════════════════╣`);
71
+ console.log(` ā•‘ REST API : http://localhost:${PORT}/api ā•‘`);
72
+ console.log(` ā•‘ WebSocket: ws://localhost:${PORT}/ws ā•‘`);
73
+ console.log(` ā•‘ Health : http://localhost:${PORT}/api/health ā•‘`);
74
+ console.log(` ╠══════════════════════════════════════════════╣`);
75
+ console.log(` ā•‘ Environment: ${(process.env.NODE_ENV || 'development').padEnd(17)}ā•‘`);
76
+ console.log(` ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n`);
77
+ });
78
+
79
+ // Graceful shutdown
80
+ async function shutdown(signal) {
81
+ logger.info({ signal }, 'Shutdown signal received');
82
+
83
+ // Close WebSocket server
84
+ if (wss) {
85
+ wss.close(() => {
86
+ logger.info('WebSocket server closed');
87
+ });
88
+ }
89
+
90
+ // Close HTTP server
91
+ server.close(() => {
92
+ logger.info('HTTP server closed');
93
+ });
94
+
95
+ // Close database pool
96
+ try {
97
+ await closePool();
98
+ } catch (err) {
99
+ logger.error({ err }, 'Error closing database pool');
100
+ }
101
+
102
+ process.exit(0);
103
+ }
104
+
105
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
106
+ process.on('SIGINT', () => shutdown('SIGINT'));
107
+
108
+ // Expose server for testing
109
+ export { server, wss };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@ryopc/koshi",
3
+ "version": "0.2.0",
4
+ "description": "Terminal-native decentralized SNS — koshi board",
5
+ "type": "module",
6
+ "bin": {
7
+ "kb": "bin/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "nodemon --exec node bin/server.js",
11
+ "dev:cli": "nodemon --exec node bin/cli.js",
12
+ "start": "node bin/server.js",
13
+ "test": "node --experimental-vm-modules node_modules/.bin/jest || true",
14
+ "lint": "eslint . --ext .js || true",
15
+ "migrate": "node src/db/migrate.js",
16
+ "postinstall": "echo 'šŸ„ Thank you for installing koshi! Type kb --help to get started.'"
17
+ },
18
+ "dependencies": {
19
+ "@noble/ed25519": "^2.0.0",
20
+ "chalk": "^5.3.0",
21
+ "ora": "^7.0.1",
22
+ "tweetnacl": "^1.0.3",
23
+ "ws": "^8.14.2"
24
+ },
25
+ "devDependencies": {
26
+ "cors": "^2.8.5",
27
+ "dotenv": "^16.3.1",
28
+ "eslint": "^8.50.0",
29
+ "express": "^4.18.2",
30
+ "express-rate-limit": "^7.1.1",
31
+ "helmet": "^7.0.0",
32
+ "jsonwebtoken": "^9.0.2",
33
+ "nodemon": "^3.0.1",
34
+ "pg": "^8.11.3",
35
+ "pino": "^8.14.1",
36
+ "pino-pretty": "^10.2.0",
37
+ "uuid": "^9.0.0"
38
+ },
39
+ "license": "MIT",
40
+ "author": "game_ryo",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/ryopc/koshi"
44
+ },
45
+ "engines": {
46
+ "node": ">=18.0.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "files": [
52
+ "bin/",
53
+ "src/auth/",
54
+ "package.json",
55
+ "README.md",
56
+ "LICENSE"
57
+ ],
58
+ "keywords": [
59
+ "sns",
60
+ "decentralized",
61
+ "terminal",
62
+ "cli",
63
+ "ed25519",
64
+ "nostr"
65
+ ]
66
+ }
@@ -0,0 +1,91 @@
1
+ // ============================================================================
2
+ // koshi – Terminal-Native Decentralized SNS
3
+ // Ed25519 Cryptographic Utilities
4
+ // License: MIT
5
+ // ============================================================================
6
+ // Provides key generation, signing, and verification using the ed25519
7
+ // elliptic curve. Wraps @noble/ed25519 for the heavy lifting (pure JS,
8
+ // no native dependencies) and tweetnacl for compatibility.
9
+ //
10
+ // All keys and signatures are hex-encoded strings for storage and transport.
11
+ // ============================================================================
12
+
13
+ import * as ed from '@noble/ed25519';
14
+ import nacl from 'tweetnacl';
15
+ import { hexToBytes, bytesToHex } from './utils.js';
16
+
17
+ /**
18
+ * Generate a new ed25519 keypair.
19
+ *
20
+ * @returns {{ publicKey: string, secretKey: string }}
21
+ * publicKey - 64-char hex string (32 bytes)
22
+ * secretKey - 128-char hex string (64 bytes, seed + public)
23
+ */
24
+ export function generateKeypair() {
25
+ const keypair = nacl.sign.keyPair();
26
+ return {
27
+ publicKey: bytesToHex(keypair.publicKey),
28
+ secretKey: bytesToHex(keypair.secretKey),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Sign a message with the given secret key.
34
+ *
35
+ * @param {string} message - The message content to sign
36
+ * @param {string} secretKey - Hex-encoded ed25519 secret key (64 bytes = seed+pub or 32 bytes = seed)
37
+ * @returns {Promise<string>} Hex-encoded signature
38
+ */
39
+ export async function signMessage(message, secretKey) {
40
+ const skBytes = hexToBytes(secretKey);
41
+ const msgBytes = new TextEncoder().encode(message);
42
+ const signature = await ed.sign(msgBytes, skBytes);
43
+ return bytesToHex(signature);
44
+ }
45
+
46
+ /**
47
+ * Verify a message signature against a public key.
48
+ *
49
+ * @param {string} message - The original message content
50
+ * @param {string} signature - Hex-encoded signature to verify
51
+ * @param {string} publicKey - Hex-encoded ed25519 public key (32 bytes)
52
+ * @returns {Promise<boolean>} Whether the signature is valid
53
+ */
54
+ export async function verifySignature(message, signature, publicKey) {
55
+ try {
56
+ const msgBytes = new TextEncoder().encode(message);
57
+ const sigBytes = hexToBytes(signature);
58
+ const pkBytes = hexToBytes(publicKey);
59
+ return await ed.verify(sigBytes, msgBytes, pkBytes);
60
+ } catch (err) {
61
+ // If any input is malformed (wrong length, invalid hex, etc.), return false
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Derive the public key from a secret key.
68
+ * Useful when the user only has their secret key stored.
69
+ *
70
+ * @param {string} secretKey - Hex-encoded ed25519 secret key (64 bytes)
71
+ * @returns {string} Hex-encoded public key (32 bytes)
72
+ */
73
+ export function derivePublicKey(secretKey) {
74
+ const skBytes = hexToBytes(secretKey);
75
+ // tweetnacl's secretKey is 64 bytes: first 32 = seed, last 32 = public
76
+ // If given only a 32-byte seed, derive from that
77
+ if (skBytes.length === 32) {
78
+ const kp = nacl.sign.keyPair.fromSeed(skBytes);
79
+ return bytesToHex(kp.publicKey);
80
+ }
81
+ // 64-byte secret key: last 32 bytes are the public key
82
+ const publicKeyBytes = skBytes.slice(32, 64);
83
+ return bytesToHex(publicKeyBytes);
84
+ }
85
+
86
+ export default {
87
+ generateKeypair,
88
+ signMessage,
89
+ verifySignature,
90
+ derivePublicKey,
91
+ };
@@ -0,0 +1,100 @@
1
+ // ============================================================================
2
+ // koshi – Terminal-Native Decentralized SNS
3
+ // JWT Authentication Utilities
4
+ // License: MIT
5
+ // ============================================================================
6
+ // Wraps jsonwebtoken for token generation and verification.
7
+ // Tokens encode the userId and expire after 24 hours.
8
+ // ============================================================================
9
+
10
+ import jwt from 'jsonwebtoken';
11
+
12
+ /**
13
+ * Get the JWT secret from environment or use a development fallback.
14
+ * In production (PandaStack), JWT_SECRET is set via environment config.
15
+ *
16
+ * @returns {string}
17
+ */
18
+ function getSecret() {
19
+ const secret = process.env.JWT_SECRET;
20
+ if (!secret) {
21
+ throw new Error(
22
+ 'JWT_SECRET environment variable is not set. ' +
23
+ 'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
24
+ );
25
+ }
26
+ return secret;
27
+ }
28
+
29
+ /**
30
+ * Sign a JWT token for the given user.
31
+ *
32
+ * @param {object} payload - Token payload (must include userId)
33
+ * @param {string} payload.userId - The user's UUID
34
+ * @param {string} [payload.username] - Optional username for quick lookup
35
+ * @returns {string} Signed JWT token
36
+ */
37
+ export function signToken(payload) {
38
+ const secret = getSecret();
39
+ const token = jwt.sign(
40
+ {
41
+ userId: payload.userId,
42
+ username: payload.username || null,
43
+ iat: Math.floor(Date.now() / 1000),
44
+ },
45
+ secret,
46
+ {
47
+ expiresIn: '24h', // Tokens expire after 24 hours
48
+ algorithm: 'HS256', // HMAC with SHA-256
49
+ }
50
+ );
51
+ return token;
52
+ }
53
+
54
+ /**
55
+ * Verify and decode a JWT token.
56
+ *
57
+ * @param {string} token - JWT token string
58
+ * @returns {{ userId: string, username: string|null, iat: number, exp: number }}
59
+ * @throws {Error} If token is invalid, expired, or malformed
60
+ */
61
+ export function verifyToken(token) {
62
+ const secret = getSecret();
63
+ try {
64
+ const decoded = jwt.verify(token, secret, {
65
+ algorithms: ['HS256'],
66
+ });
67
+ return {
68
+ userId: decoded.userId,
69
+ username: decoded.username || null,
70
+ iat: decoded.iat,
71
+ exp: decoded.exp,
72
+ };
73
+ } catch (err) {
74
+ if (err instanceof jwt.TokenExpiredError) {
75
+ throw new Error('Token has expired. Please log in again.');
76
+ }
77
+ if (err instanceof jwt.JsonWebTokenError) {
78
+ throw new Error('Invalid token. Please log in again.');
79
+ }
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Decode a token without verifying the signature.
86
+ * Useful for extracting the userId from an expired token (e.g., for refresh).
87
+ * NOTE: Do NOT use this for authentication — always use verifyToken().
88
+ *
89
+ * @param {string} token
90
+ * @returns {object|null}
91
+ */
92
+ export function decodeToken(token) {
93
+ try {
94
+ return jwt.decode(token);
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export default { signToken, verifyToken, decodeToken };
@@ -0,0 +1,46 @@
1
+ // ============================================================================
2
+ // koshi – Terminal-Native Decentralized SNS
3
+ // Hex Encoding/Decoding Utilities
4
+ // License: MIT
5
+ // ============================================================================
6
+
7
+ /**
8
+ * Convert a hex string to a Uint8Array (byte array).
9
+ * @param {string} hex - Hex-encoded string
10
+ * @returns {Uint8Array}
11
+ */
12
+ export function hexToBytes(hex) {
13
+ if (typeof hex !== 'string') {
14
+ throw new TypeError('Expected a hex string');
15
+ }
16
+ if (hex.length % 2 !== 0) {
17
+ throw new Error('Hex string must have an even number of characters');
18
+ }
19
+ const bytes = new Uint8Array(hex.length / 2);
20
+ for (let i = 0; i < hex.length; i += 2) {
21
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
22
+ }
23
+ return bytes;
24
+ }
25
+
26
+ /**
27
+ * Convert a Uint8Array (byte array) to a hex string.
28
+ * @param {Uint8Array} bytes - Byte array
29
+ * @returns {string}
30
+ */
31
+ export function bytesToHex(bytes) {
32
+ return Array.from(bytes)
33
+ .map((b) => b.toString(16).padStart(2, '0'))
34
+ .join('');
35
+ }
36
+
37
+ /**
38
+ * Generate a random hex string of the given byte length.
39
+ * @param {number} byteLength - Number of random bytes
40
+ * @returns {string} Hex-encoded random string
41
+ */
42
+ export function randomHex(byteLength = 32) {
43
+ const bytes = new Uint8Array(byteLength);
44
+ crypto.getRandomValues(bytes);
45
+ return bytesToHex(bytes);
46
+ }