@ryopc/koshi 0.2.0 โ†’ 1.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ๐Ÿ„ koshi โ€” Terminal-Native Decentralized SNS
2
2
 
3
- **Version 0.2.0** ยท MIT License ยท by [game_ryo](https://github.com/ryopc)
3
+ **Version 1.1.0** ยท MIT License ยท by [game_ryo](https://github.com/ryopc)
4
4
 
5
5
  > A terminal-native, decentralized social network powered by ed25519 cryptography.
6
6
 
@@ -12,7 +12,10 @@
12
12
  - **๐Ÿ“ Posts** โ€” Create and view posts on the koshi board
13
13
  - **๐Ÿ‘ฅ Follow System** โ€” Follow/unfollow other users
14
14
  - **โœ‰๏ธ Direct Messages** โ€” Signed, private DMs
15
- - **๐Ÿ“ก Real-time** โ€” WebSocket-powered live updates
15
+ - **๐Ÿ’ฌ Real-time Chat** โ€” Interactive DM chat via WebSocket
16
+ - **๐Ÿ“ก Real-time Feed** โ€” WebSocket-powered live updates
17
+ - **โœ๏ธ Profile Editing** โ€” Update your display name, bio, and avatar
18
+ - **๐Ÿ› ๏ธ Admin Controls** โ€” User management, account deletion, admin grants
16
19
  - **๐Ÿ’ป Terminal-native** โ€” Beautiful CLI with chalk colors and spinners
17
20
  - **๐Ÿณ Docker-ready** โ€” Containerized for easy deployment
18
21
  - **โ˜๏ธ Render.com** โ€” One-click deploy to Render.com
@@ -96,9 +99,12 @@ The `kb` command is your gateway to the koshi board.
96
99
  | `kb unfollow <username>` | Unfollow a user |
97
100
  | `kb dm <username> <message>` | Send a direct message |
98
101
  | `kb dms [--unread]` | View your direct messages |
102
+ | `kb chat <username>` | Start an interactive real-time DM chat |
103
+ | `kb edit-profile --display-name=... --bio=...` | Update your own profile |
99
104
  | `kb profile [username]` | View a user profile |
100
105
  | `kb search <query>` | Search users by username |
101
106
  | `kb realtime` | Connect to the real-time event stream |
107
+ | `kb admin <command>` | Admin commands (users, delete-user, grant, revoke) |
102
108
  | `kb help [command]` | Show help |
103
109
 
104
110
  ### Examples
@@ -122,11 +128,26 @@ kb follow bob
122
128
  # Send a DM
123
129
  kb dm bob "Hey, how's it going?"
124
130
 
131
+ # Start an interactive real-time DM chat
132
+ kb chat bob
133
+
134
+ # Edit your profile
135
+ kb edit-profile --display-name="Alice" --bio="Building the terminal future"
136
+
125
137
  # Search for users
126
138
  kb search alice
127
139
 
128
140
  # Live stream
129
141
  kb realtime
142
+
143
+ # Admin: list all users
144
+ kb admin users
145
+
146
+ # Admin: delete a user account (requires confirmation)
147
+ kb admin delete-user bob
148
+
149
+ # Admin: grant admin privileges
150
+ kb admin grant alice
130
151
  ```
131
152
 
132
153
  ### Configuration
@@ -240,6 +261,17 @@ Authorization: Bearer <token>
240
261
  | PUT | `/api/dms/:id/read` | Yes | Mark DM as read |
241
262
  | GET | `/api/dms/unread/count` | Yes | Count unread DMs |
242
263
 
264
+ #### Admin
265
+
266
+ | Method | Path | Auth | Description |
267
+ |--------|------|------|-------------|
268
+ | GET | `/api/admin/users` | Admin | List all users |
269
+ | GET | `/api/admin/users/:id` | Admin | Get user details (with keys, DM count) |
270
+ | DELETE | `/api/admin/users/:id` | Admin | Permanently delete a user account |
271
+ | PUT | `/api/admin/users/:id/admin` | Admin | Grant or revoke admin privileges |
272
+
273
+ > Admin privileges: Set `ADMIN_USERNAME` environment variable, or mark user as admin via `UPDATE users SET is_admin = TRUE WHERE username = '...';`
274
+
243
275
  #### WebSocket
244
276
 
245
277
  Connect: `ws://host:port/ws?token={jwt}`
@@ -339,6 +371,7 @@ volumes:
339
371
 
340
372
  - **Ed25519 Signatures** โ€” All posts and DMs are signed for authenticity
341
373
  - **JWT Auth** โ€” Tokens expire after 24 hours
374
+ - **Admin Auth** โ€” Admin-only endpoints protected by `requireAdmin` middleware
342
375
  - **Rate Limiting** โ€” Auth endpoints limited to 10 req/min per IP
343
376
  - **SQL Injection Prevention** โ€” Parameterized queries throughout
344
377
  - **Input Validation** โ€” All endpoints validate input
@@ -376,7 +409,8 @@ koshi/
376
409
  โ”‚ โ”‚ โ”œโ”€โ”€ auth.js # Auth routes (register/login)
377
410
  โ”‚ โ”‚ โ”œโ”€โ”€ users.js # User management routes
378
411
  โ”‚ โ”‚ โ”œโ”€โ”€ posts.js # Posts routes (koshi board)
379
- โ”‚ โ”‚ โ””โ”€โ”€ dms.js # Direct messages routes
412
+ โ”‚ โ”‚ โ”œโ”€โ”€ dms.js # Direct messages routes
413
+ โ”‚ โ”‚ โ””โ”€โ”€ admin.js # Admin routes (users, delete, grant)
380
414
  โ”‚ โ”œโ”€โ”€ auth/
381
415
  โ”‚ โ”‚ โ”œโ”€โ”€ ed25519.js # Ed25519 crypto utilities
382
416
  โ”‚ โ”‚ โ”œโ”€โ”€ jwt.js # JWT token utilities
package/bin/cli.js CHANGED
@@ -521,6 +521,56 @@ async function cmdProfile(args) {
521
521
  }
522
522
  }
523
523
 
524
+ // ============================================================================
525
+ // Command: edit-profile
526
+ // ============================================================================
527
+ async function cmdEditProfile(args) {
528
+ const config = loadConfig();
529
+ if (!config.token) {
530
+ console.error(chalk.red('โœ– Not logged in.'));
531
+ process.exit(1);
532
+ }
533
+
534
+ // Parse flags
535
+ let displayName, bio, avatarUrl;
536
+ for (const arg of args) {
537
+ if (arg.startsWith('--display-name=')) {
538
+ displayName = arg.slice('--display-name='.length);
539
+ } else if (arg.startsWith('--bio=')) {
540
+ bio = arg.slice('--bio='.length);
541
+ } else if (arg.startsWith('--avatar-url=')) {
542
+ avatarUrl = arg.slice('--avatar-url='.length);
543
+ }
544
+ }
545
+
546
+ if (!displayName && !bio && !avatarUrl) {
547
+ console.error(chalk.red('โœ– Error: Provide at least one field to update.'));
548
+ console.error(chalk.dim(' Usage: kb edit-profile --display-name="My Name" --bio="Hello!" --avatar-url="https://..."'));
549
+ process.exit(1);
550
+ }
551
+
552
+ const spinner = ora('Updating profile...').start();
553
+
554
+ try {
555
+ const body = {};
556
+ if (displayName !== undefined) body.displayName = displayName.trim();
557
+ if (bio !== undefined) body.bio = bio.trim();
558
+ if (avatarUrl !== undefined) body.avatarUrl = avatarUrl;
559
+
560
+ const data = await apiRequest('PUT', '/api/users/me', body, config.token);
561
+
562
+ spinner.succeed(chalk.green('Profile updated!'));
563
+
564
+ console.log(`\n ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
565
+ if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
566
+ if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
567
+ if (data.avatarUrl) console.log(` ${chalk.bold('Avatar:')} ${chalk.dim(data.avatarUrl)}`);
568
+ } catch (err) {
569
+ spinner.fail(chalk.red(`Failed to update profile: ${err.message}`));
570
+ process.exit(1);
571
+ }
572
+ }
573
+
524
574
  // ============================================================================
525
575
  // Command: search
526
576
  // ============================================================================
@@ -600,12 +650,28 @@ function showHelp(command = null) {
600
650
  },
601
651
  profile: {
602
652
  usage: 'kb profile [username]',
603
- desc: 'View a user profile (default: your own)',
653
+ desc: 'View a user profile',
654
+ },
655
+ 'edit-profile': {
656
+ usage: 'kb edit-profile --display-name=... --bio=... --avatar-url=...',
657
+ desc: 'Update your own profile',
604
658
  },
605
659
  search: {
606
660
  usage: 'kb search <query>',
607
661
  desc: 'Search users by username or display name',
608
662
  },
663
+ chat: {
664
+ usage: 'kb chat <username>',
665
+ desc: 'Real-time interactive DM chat',
666
+ },
667
+ realtime: {
668
+ usage: 'kb realtime',
669
+ desc: 'Stream live posts and events',
670
+ },
671
+ admin: {
672
+ usage: 'kb admin <command>',
673
+ desc: 'Admin: users, delete-user, grant, revoke',
674
+ },
609
675
  help: {
610
676
  usage: 'kb help [command]',
611
677
  desc: 'Show this help message',
@@ -623,7 +689,7 @@ function showHelp(command = null) {
623
689
  }
624
690
 
625
691
  console.log(`\n ${chalk.bold.cyan('๐Ÿ„ koshi โ€” Terminal-Native Decentralized SNS')}`);
626
- console.log(` ${chalk.dim('Version 0.2.0')}`);
692
+ console.log(` ${chalk.dim('Version 1.1.0')}`);
627
693
  console.log(`\n ${chalk.bold('Usage:')} kb <command> [options]\n`);
628
694
  console.log(` ${chalk.bold('Commands:')}\n`);
629
695
 
@@ -647,12 +713,336 @@ function showHelp(command = null) {
647
713
  // Command: version
648
714
  // ============================================================================
649
715
  function showVersion() {
650
- console.log('koshi v0.2.0');
716
+ console.log('koshi v1.1.0');
651
717
  console.log('Terminal-native decentralized SNS');
652
718
  console.log('License: MIT');
653
719
  console.log('Author: game_ryo');
654
720
  }
655
721
 
722
+ // ============================================================================
723
+ // Command: admin
724
+ // ============================================================================
725
+ async function cmdAdmin(args) {
726
+ const config = loadConfig();
727
+ if (!config.token) {
728
+ console.error(chalk.red('โœ– Not logged in. Admin commands require authentication.'));
729
+ process.exit(1);
730
+ }
731
+
732
+ const subcommand = args[0];
733
+
734
+ if (!subcommand || subcommand === 'help') {
735
+ console.log(`\n ${chalk.bold.cyan('๐Ÿ› ๏ธ Admin Commands')}`);
736
+ console.log(` ${chalk.dim('โ”€'.repeat(50))}`);
737
+ console.log(` ${chalk.cyan('kb admin users')} List all registered users`);
738
+ console.log(` ${chalk.cyan('kb admin user <id>')} View detailed user info`);
739
+ console.log(` ${chalk.cyan('kb admin delete-user <username>')} Permanently delete a user account`);
740
+ console.log(` ${chalk.cyan('kb admin grant <username>')} Grant admin privileges`);
741
+ console.log(` ${chalk.cyan('kb admin revoke <username>')} Revoke admin privileges`);
742
+ console.log(` ${chalk.dim('โ”€'.repeat(50))}\n`);
743
+ return;
744
+ }
745
+
746
+ switch (subcommand) {
747
+ case 'users':
748
+ await cmdAdminUsers(config);
749
+ break;
750
+ case 'user':
751
+ await cmdAdminUserDetail(config, args.slice(1));
752
+ break;
753
+ case 'delete-user':
754
+ await cmdAdminDeleteUser(config, args.slice(1));
755
+ break;
756
+ case 'grant':
757
+ await cmdAdminSetAdmin(config, args.slice(1), true);
758
+ break;
759
+ case 'revoke':
760
+ await cmdAdminSetAdmin(config, args.slice(1), false);
761
+ break;
762
+ default:
763
+ console.error(chalk.red(`โœ– Unknown admin command: "${subcommand}"`));
764
+ process.exit(1);
765
+ }
766
+ }
767
+
768
+ async function cmdAdminUsers(config) {
769
+ const spinner = ora('Fetching user list...').start();
770
+ try {
771
+ const data = await apiRequest('GET', '/api/admin/users', null, config.token);
772
+ spinner.stop();
773
+
774
+ console.log(`\n ${chalk.bold.cyan(`๐Ÿ‘ฅ All Users (${data.total} total)`)}`);
775
+ console.log(` ${chalk.dim('โ”€'.repeat(70))}`);
776
+
777
+ for (const user of data.users) {
778
+ const adminBadge = user.isAdmin ? chalk.yellow(' [ADMIN]') : '';
779
+ const display = user.displayName || '(no display name)';
780
+ console.log(` ${chalk.bold(user.username)}${adminBadge} ${chalk.dim(`โ€” ${display}`)}`);
781
+ console.log(` ${chalk.dim(`ID: ${user.id} | Posts: ${user.postsCount} | Followers: ${user.followersCount} | Joined: ${new Date(user.createdAt).toLocaleDateString()}`)}`);
782
+ console.log();
783
+ }
784
+ } catch (err) {
785
+ spinner.fail(chalk.red(`Failed to list users: ${err.message}`));
786
+ process.exit(1);
787
+ }
788
+ }
789
+
790
+ async function cmdAdminUserDetail(config, args) {
791
+ const userId = args[0];
792
+ if (!userId) {
793
+ console.error(chalk.red('โœ– Error: User ID is required. Usage: kb admin user <id>'));
794
+ process.exit(1);
795
+ }
796
+
797
+ const spinner = ora('Fetching user details...').start();
798
+ try {
799
+ const data = await apiRequest('GET', `/api/admin/users/${userId}`, null, config.token);
800
+ spinner.stop();
801
+
802
+ console.log(`\n ${chalk.bold.cyan('๐Ÿ” User Details')}`);
803
+ console.log(` ${chalk.dim('โ”€'.repeat(50))}`);
804
+ console.log(` ${chalk.bold('Username:')} ${chalk.cyan(data.username)}`);
805
+ console.log(` ${chalk.bold('ID:')} ${chalk.dim(data.id)}`);
806
+ console.log(` ${chalk.bold('Public Key:')} ${chalk.dim(data.publicKey ? data.publicKey.substring(0, 32) + '...' : 'N/A')}`);
807
+ if (data.displayName) console.log(` ${chalk.bold('Display Name:')} ${data.displayName}`);
808
+ if (data.bio) console.log(` ${chalk.bold('Bio:')} ${data.bio}`);
809
+ console.log(` ${chalk.bold('Admin:')} ${data.isAdmin ? chalk.green('โœ“ Yes') : chalk.dim('No')}`);
810
+ console.log(` ${chalk.bold('Posts:')} ${data.postsCount}`);
811
+ console.log(` ${chalk.bold('DMs:')} ${data.dmsCount}`);
812
+ console.log(` ${chalk.bold('Followers:')} ${data.followersCount}`);
813
+ console.log(` ${chalk.bold('Following:')} ${data.followingCount}`);
814
+ console.log(` ${chalk.bold('Joined:')} ${new Date(data.createdAt).toLocaleDateString()}`);
815
+ } catch (err) {
816
+ spinner.fail(chalk.red(`Failed to fetch user: ${err.message}`));
817
+ process.exit(1);
818
+ }
819
+ }
820
+
821
+ async function cmdAdminDeleteUser(config, args) {
822
+ const username = args.find(a => !a.startsWith('--'));
823
+ const force = args.includes('--force');
824
+
825
+ if (!username) {
826
+ console.error(chalk.red('โœ– Error: Username is required. Usage: kb admin delete-user <username>'));
827
+ process.exit(1);
828
+ }
829
+
830
+ if (!force) {
831
+ // Confirm deletion interactively
832
+ console.log(`\n ${chalk.yellow('โš ๏ธ WARNING: This will permanently delete the user and all their data.')}`);
833
+ console.log(` ${chalk.yellow('This action cannot be undone.')}\n`);
834
+ console.log(` ${chalk.bold('Target:')} @${username}`);
835
+ console.log();
836
+
837
+ const confirmation = await new Promise((resolve) => {
838
+ process.stdout.write(` ${chalk.bold('Type the username to confirm')}: `);
839
+ process.stdin.once('data', (buf) => {
840
+ resolve(buf.toString().trim());
841
+ });
842
+ });
843
+
844
+ if (confirmation !== username) {
845
+ console.log(chalk.yellow('\n โœ– Confirmation does not match. Aborted.'));
846
+ process.exit(1);
847
+ }
848
+ }
849
+
850
+ const spinner = ora(`Deleting user @${username}...`).start();
851
+
852
+ try {
853
+ // Resolve username to ID first
854
+ const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
855
+
856
+ const result = await apiRequest('DELETE', `/api/admin/users/${userData.id}`, {}, config.token);
857
+
858
+ spinner.succeed(chalk.green(`User @${username} has been permanently deleted.`));
859
+ } catch (err) {
860
+ spinner.fail(chalk.red(`Delete failed: ${err.message}`));
861
+ process.exit(1);
862
+ }
863
+ }
864
+
865
+ async function cmdAdminSetAdmin(config, args, makeAdmin) {
866
+ const username = args[0];
867
+ if (!username) {
868
+ const action = makeAdmin ? 'grant' : 'revoke';
869
+ console.error(chalk.red(`โœ– Error: Username is required. Usage: kb admin ${action} <username>`));
870
+ process.exit(1);
871
+ }
872
+
873
+ const actionLabel = makeAdmin ? 'Granting admin to' : 'Revoking admin from';
874
+ const spinner = ora(`${actionLabel} @${username}...`).start();
875
+
876
+ try {
877
+ const userData = await apiRequest('GET', `/api/users/${username}`, null, config.token);
878
+
879
+ const result = await apiRequest('PUT', `/api/admin/users/${userData.id}/admin`, {
880
+ isAdmin: makeAdmin,
881
+ }, config.token);
882
+
883
+ if (makeAdmin) {
884
+ spinner.succeed(chalk.green(`@${username} is now an admin!`));
885
+ } else {
886
+ spinner.succeed(chalk.yellow(`Admin privileges removed from @${username}.`));
887
+ }
888
+ } catch (err) {
889
+ spinner.fail(chalk.red(`Failed: ${err.message}`));
890
+ process.exit(1);
891
+ }
892
+ }
893
+
894
+ // ============================================================================
895
+ // Command: chat (interactive real-time DM mode)
896
+ // ============================================================================
897
+ async function cmdChat(args) {
898
+ const config = loadConfig();
899
+ if (!config.token || !config.secretKey) {
900
+ console.error(chalk.red('โœ– Not logged in. Use "kb login" or "kb register" first.'));
901
+ process.exit(1);
902
+ }
903
+
904
+ const targetUsername = args[0];
905
+ if (!targetUsername) {
906
+ console.error(chalk.red('โœ– Error: Usage: kb chat <username>'));
907
+ process.exit(1);
908
+ }
909
+
910
+ console.log(`\n ${chalk.bold.cyan('๐Ÿ’ฌ Live Chat with')} ${chalk.bold(targetUsername)}`);
911
+ console.log(` ${chalk.dim('Connect to real-time DMs. Type your message and press Enter.')}`);
912
+ console.log(` ${chalk.dim('Type /exit or press Ctrl+C to quit.')}\n`);
913
+
914
+ try {
915
+ // Resolve target user
916
+ const userData = await apiRequest('GET', `/api/users/${targetUsername}`, null, config.token);
917
+ const { default: WebSocket } = await import('ws');
918
+
919
+ // Connect to WebSocket
920
+ const ws = new WebSocket(`${WS_URL}/ws?token=${config.token}`);
921
+
922
+ ws.on('open', () => {
923
+ console.log(chalk.green(' โœ“ Connected!\n'));
924
+ promptForInput();
925
+ });
926
+
927
+ // Handle incoming messages
928
+ ws.on('message', (data) => {
929
+ try {
930
+ const msg = JSON.parse(data.toString());
931
+
932
+ switch (msg.type) {
933
+ case 'connected':
934
+ // Initial connection confirmation
935
+ break;
936
+
937
+ case 'dm_received':
938
+ if (msg.payload.from.username === targetUsername) {
939
+ console.log(`\n ${chalk.bold.green(`${targetUsername}:`)} ${msg.payload.content}`);
940
+ promptForInput();
941
+ }
942
+ break;
943
+
944
+ case 'dm:sent':
945
+ console.log(`\n ${chalk.bold.cyan(`You:`)} ${msg.payload.content}`);
946
+ promptForInput();
947
+ break;
948
+
949
+ case 'error':
950
+ console.log(`\n ${chalk.red(`โœ– Error: ${msg.payload.message}`)}`);
951
+ promptForInput();
952
+ break;
953
+
954
+ case 'user_online':
955
+ if (msg.payload.username === targetUsername) {
956
+ console.log(`\n ${chalk.green('๐ŸŸข')} ${chalk.bold(targetUsername)} ${chalk.dim('is now online')}`);
957
+ }
958
+ break;
959
+
960
+ case 'user_offline':
961
+ if (msg.payload.userId === userData.id) {
962
+ console.log(`\n ${chalk.red('๐Ÿ”ด')} ${chalk.bold(targetUsername)} ${chalk.dim('went offline')}`);
963
+ }
964
+ break;
965
+
966
+ default:
967
+ break;
968
+ }
969
+ } catch {
970
+ // Ignore parse errors
971
+ }
972
+ });
973
+
974
+ ws.on('close', () => {
975
+ console.log(chalk.yellow('\n Disconnected.'));
976
+ process.exit(0);
977
+ });
978
+
979
+ ws.on('error', (err) => {
980
+ console.error(chalk.red(`\n WebSocket error: ${err.message}`));
981
+ process.exit(1);
982
+ });
983
+
984
+ // Handle user input
985
+ let inputBuffer = '';
986
+ function promptForInput() {
987
+ process.stdout.write(chalk.cyan(' > '));
988
+ }
989
+
990
+ process.stdin.setEncoding('utf-8');
991
+ process.stdin.on('data', async (chunk) => {
992
+ inputBuffer += chunk;
993
+
994
+ if (inputBuffer.includes('\n')) {
995
+ const lines = inputBuffer.split('\n');
996
+ inputBuffer = lines.pop(); // Keep incomplete line
997
+
998
+ for (const line of lines) {
999
+ const trimmed = line.trim();
1000
+
1001
+ if (!trimmed) {
1002
+ promptForInput();
1003
+ continue;
1004
+ }
1005
+
1006
+ if (trimmed === '/exit') {
1007
+ console.log(chalk.dim(' Closing connection...'));
1008
+ ws.close();
1009
+ return;
1010
+ }
1011
+
1012
+ // Sign and send the message
1013
+ try {
1014
+ const { signMessage } = await import('../src/auth/ed25519.js');
1015
+ const signature = await signMessage(trimmed, config.secretKey);
1016
+
1017
+ ws.send(JSON.stringify({
1018
+ type: 'dm:send',
1019
+ payload: {
1020
+ recipientId: userData.id,
1021
+ content: trimmed,
1022
+ signature,
1023
+ },
1024
+ }));
1025
+ } catch (err) {
1026
+ console.log(` ${chalk.red(`โœ– Failed to send: ${err.message}`)}`);
1027
+ promptForInput();
1028
+ }
1029
+ }
1030
+ }
1031
+ });
1032
+
1033
+ // Graceful shutdown
1034
+ process.on('SIGINT', () => {
1035
+ console.log(chalk.dim('\n Closing connection...'));
1036
+ ws.close();
1037
+ process.exit(0);
1038
+ });
1039
+
1040
+ } catch (err) {
1041
+ console.error(chalk.red(`Failed to start chat: ${err.message}`));
1042
+ process.exit(1);
1043
+ }
1044
+ }
1045
+
656
1046
  // ============================================================================
657
1047
  // Command: realtime feed (stream mode)
658
1048
  // ============================================================================
@@ -802,6 +1192,18 @@ async function main() {
802
1192
  await cmdSearch(args.slice(1));
803
1193
  break;
804
1194
 
1195
+ case 'edit-profile':
1196
+ await cmdEditProfile(args.slice(1));
1197
+ break;
1198
+
1199
+ case 'chat':
1200
+ await cmdChat(args.slice(1));
1201
+ break;
1202
+
1203
+ case 'admin':
1204
+ await cmdAdmin(args.slice(1));
1205
+ break;
1206
+
805
1207
  case 'realtime':
806
1208
  case 'stream':
807
1209
  await cmdRealtime();
package/bin/server.js CHANGED
@@ -23,6 +23,7 @@ import { app, logger } from '../src/index.js';
23
23
  import { initWebSocket, startHeartbeat } from '../src/ws/index.js';
24
24
  import { closePool } from '../src/db/pool.js';
25
25
  import { getOnlineCount } from '../src/ws/index.js';
26
+ import { runMigration } from '../src/db/migrate.js';
26
27
 
27
28
  // Load .env file in development
28
29
  try {
@@ -52,6 +53,20 @@ if (missing.length > 0) {
52
53
  process.exit(1);
53
54
  }
54
55
 
56
+ // ============================================================================
57
+ // Auto-migration: ensure database schema is up to date on startup
58
+ // ============================================================================
59
+ // This is especially important for Render free-tier instances that hibernate.
60
+ // The preDeployCommand only runs on fresh deploys, not on wake-up, so we
61
+ // run the migration here to guarantee tables exist on every start.
62
+ // ============================================================================
63
+ const migrationResult = await runMigration();
64
+ if (!migrationResult.success) {
65
+ logger.warn({ message: migrationResult.message }, 'Auto-migration did not complete. Server will still start.');
66
+ } else {
67
+ logger.info('Auto-migration complete');
68
+ }
69
+
55
70
  // Create HTTP server
56
71
  const server = createServer(app);
57
72
 
@@ -60,13 +75,15 @@ const wss = initWebSocket(server, logger);
60
75
  startHeartbeat();
61
76
 
62
77
  // Start listening
63
- server.listen(PORT, () => {
78
+ // '0.0.0.0' ใ‚’ๆ˜Ž็คบ็š„ใซๆŒ‡ๅฎšใ—ใฆใ€Render ใฎๅค–้ƒจใ‹ใ‚‰ใฎ้€šไฟกใ‚’ๅ—ใ‘ไป˜ใ‘ใ‚‰ใ‚Œใ‚‹ใ‚ˆใ†ใซใ—ใพใ™ใ€‚
79
+ server.listen(PORT, '0.0.0.0', () => {
64
80
  logger.info(
81
+
65
82
  { port: PORT, env: process.env.NODE_ENV || 'development' },
66
83
  `๐Ÿš€ koshi server running on port ${PORT}`
67
84
  );
68
85
  console.log(`\n โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—`);
69
- console.log(` โ•‘ ๐Ÿ„ koshi board server v0.2.0 โ•‘`);
86
+ console.log(` โ•‘ ๐Ÿ„ koshi board server v1.1.0 โ•‘`);
70
87
  console.log(` โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ`);
71
88
  console.log(` โ•‘ REST API : http://localhost:${PORT}/api โ•‘`);
72
89
  console.log(` โ•‘ WebSocket: ws://localhost:${PORT}/ws โ•‘`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryopc/koshi",
3
- "version": "0.2.0",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal-native decentralized SNS โ€” koshi board",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,13 +34,13 @@
34
34
  "pg": "^8.11.3",
35
35
  "pino": "^8.14.1",
36
36
  "pino-pretty": "^10.2.0",
37
- "uuid": "^9.0.0"
37
+ "uuid": "^14.0.0"
38
38
  },
39
39
  "license": "MIT",
40
40
  "author": "game_ryo",
41
41
  "repository": {
42
42
  "type": "git",
43
- "url": "https://github.com/ryopc/koshi"
43
+ "url": "git+https://github.com/ryopc/koshi.git"
44
44
  },
45
45
  "engines": {
46
46
  "node": ">=18.0.0"
@@ -38,8 +38,11 @@ export function generateKeypair() {
38
38
  */
39
39
  export async function signMessage(message, secretKey) {
40
40
  const skBytes = hexToBytes(secretKey);
41
+ // @noble/ed25519 v2.x expects a 32-byte private key (seed), not the full
42
+ // 64-byte tweetnacl-format key. If given 64 bytes, take only the seed.
43
+ const seedBytes = skBytes.length === 64 ? skBytes.slice(0, 32) : skBytes;
41
44
  const msgBytes = new TextEncoder().encode(message);
42
- const signature = await ed.sign(msgBytes, skBytes);
45
+ const signature = await ed.signAsync(msgBytes, seedBytes);
43
46
  return bytesToHex(signature);
44
47
  }
45
48
 
@@ -56,7 +59,8 @@ export async function verifySignature(message, signature, publicKey) {
56
59
  const msgBytes = new TextEncoder().encode(message);
57
60
  const sigBytes = hexToBytes(signature);
58
61
  const pkBytes = hexToBytes(publicKey);
59
- return await ed.verify(sigBytes, msgBytes, pkBytes);
62
+ // Use verifyAsync to avoid needing etc.sha512Sync
63
+ return await ed.verifyAsync(sigBytes, msgBytes, pkBytes);
60
64
  } catch (err) {
61
65
  // If any input is malformed (wrong length, invalid hex, etc.), return false
62
66
  return false;