@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/LICENSE +21 -0
- package/README.md +419 -0
- package/bin/cli.js +827 -0
- package/bin/server.js +109 -0
- package/package.json +66 -0
- package/src/auth/ed25519.js +91 -0
- package/src/auth/jwt.js +100 -0
- package/src/auth/utils.js +46 -0
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
|
+
};
|
package/src/auth/jwt.js
ADDED
|
@@ -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
|
+
}
|