@massalabs/gossip-sdk 0.0.1

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.
Files changed (69) hide show
  1. package/README.md +484 -0
  2. package/package.json +41 -0
  3. package/src/api/messageProtocol/index.ts +53 -0
  4. package/src/api/messageProtocol/mock.ts +13 -0
  5. package/src/api/messageProtocol/rest.ts +209 -0
  6. package/src/api/messageProtocol/types.ts +70 -0
  7. package/src/config/protocol.ts +97 -0
  8. package/src/config/sdk.ts +131 -0
  9. package/src/contacts.ts +210 -0
  10. package/src/core/SdkEventEmitter.ts +91 -0
  11. package/src/core/SdkPolling.ts +134 -0
  12. package/src/core/index.ts +9 -0
  13. package/src/crypto/bip39.ts +84 -0
  14. package/src/crypto/encryption.ts +77 -0
  15. package/src/db.ts +465 -0
  16. package/src/gossipSdk.ts +994 -0
  17. package/src/index.ts +211 -0
  18. package/src/services/announcement.ts +653 -0
  19. package/src/services/auth.ts +95 -0
  20. package/src/services/discussion.ts +380 -0
  21. package/src/services/message.ts +1055 -0
  22. package/src/services/refresh.ts +234 -0
  23. package/src/sw.ts +17 -0
  24. package/src/types/events.ts +108 -0
  25. package/src/types.ts +70 -0
  26. package/src/utils/base64.ts +39 -0
  27. package/src/utils/contacts.ts +161 -0
  28. package/src/utils/discussions.ts +55 -0
  29. package/src/utils/logs.ts +86 -0
  30. package/src/utils/messageSerialization.ts +257 -0
  31. package/src/utils/queue.ts +106 -0
  32. package/src/utils/type.ts +7 -0
  33. package/src/utils/userId.ts +114 -0
  34. package/src/utils/validation.ts +144 -0
  35. package/src/utils.ts +47 -0
  36. package/src/wasm/encryption.ts +108 -0
  37. package/src/wasm/index.ts +20 -0
  38. package/src/wasm/loader.ts +123 -0
  39. package/src/wasm/session.ts +276 -0
  40. package/src/wasm/userKeys.ts +31 -0
  41. package/test/config/protocol.spec.ts +31 -0
  42. package/test/config/sdk.spec.ts +163 -0
  43. package/test/db/helpers.spec.ts +142 -0
  44. package/test/db/operations.spec.ts +128 -0
  45. package/test/db/states.spec.ts +535 -0
  46. package/test/integration/discussion-flow.spec.ts +422 -0
  47. package/test/integration/messaging-flow.spec.ts +708 -0
  48. package/test/integration/sdk-lifecycle.spec.ts +325 -0
  49. package/test/mocks/index.ts +9 -0
  50. package/test/mocks/mockMessageProtocol.ts +100 -0
  51. package/test/services/auth.spec.ts +311 -0
  52. package/test/services/discussion.spec.ts +279 -0
  53. package/test/services/message-deduplication.spec.ts +299 -0
  54. package/test/services/message-startup.spec.ts +331 -0
  55. package/test/services/message.spec.ts +817 -0
  56. package/test/services/refresh.spec.ts +199 -0
  57. package/test/services/session-status.spec.ts +349 -0
  58. package/test/session/wasm.spec.ts +227 -0
  59. package/test/setup.ts +52 -0
  60. package/test/utils/contacts.spec.ts +156 -0
  61. package/test/utils/discussions.spec.ts +66 -0
  62. package/test/utils/queue.spec.ts +52 -0
  63. package/test/utils/serialization.spec.ts +120 -0
  64. package/test/utils/userId.spec.ts +120 -0
  65. package/test/utils/validation.spec.ts +223 -0
  66. package/test/utils.ts +212 -0
  67. package/tsconfig.json +26 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/vitest.config.ts +28 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Logger utility for SDK
3
+ *
4
+ * Provides structured console logging with module and context support.
5
+ * All output goes to the terminal via console methods.
6
+ */
7
+
8
+ export class Logger {
9
+ private module: string;
10
+ private context: string;
11
+
12
+ constructor(module: string, context: string = '') {
13
+ this.module = module;
14
+ this.context = context;
15
+ }
16
+
17
+ private getSource(): string {
18
+ return this.context ? `${this.module}:${this.context}` : this.module;
19
+ }
20
+
21
+ private formatMainMessage(message: string): string {
22
+ const source = this.getSource();
23
+ return `[${source}] ${message}`;
24
+ }
25
+
26
+ info(message: string, extra?: unknown): void {
27
+ const main = this.formatMainMessage(message);
28
+ if (extra !== undefined) {
29
+ console.log(main, extra);
30
+ } else {
31
+ console.log(main);
32
+ }
33
+ }
34
+
35
+ error(messageOrError: string | Error | unknown, extra?: unknown): void {
36
+ const source = this.getSource();
37
+
38
+ if (messageOrError instanceof Error) {
39
+ const main = `[${source}] ${messageOrError.message}`;
40
+ console.error(main, messageOrError, extra);
41
+ } else {
42
+ const message =
43
+ typeof messageOrError === 'string'
44
+ ? messageOrError
45
+ : messageOrError instanceof Error
46
+ ? messageOrError.message
47
+ : JSON.stringify(messageOrError);
48
+ const main = `[${source}] ${message}`;
49
+ if (extra !== undefined) {
50
+ console.error(main, extra);
51
+ } else {
52
+ console.error(main);
53
+ }
54
+ }
55
+ }
56
+
57
+ debug(message: string, extra?: unknown): void {
58
+ const main = this.formatMainMessage(message);
59
+ if (extra !== undefined) {
60
+ console.debug(main, extra);
61
+ } else {
62
+ console.debug(main);
63
+ }
64
+ }
65
+
66
+ warn(message: string, extra?: unknown): void {
67
+ const main = this.formatMainMessage(message);
68
+ if (extra !== undefined) {
69
+ console.warn(main, extra);
70
+ } else {
71
+ console.warn(main);
72
+ }
73
+ }
74
+
75
+ // Chainable context builder
76
+ withContext(newContext: string): Logger {
77
+ const fullContext = this.context
78
+ ? `${this.context}:${newContext}`
79
+ : newContext;
80
+ return new Logger(this.module, fullContext);
81
+ }
82
+
83
+ forMethod(methodName: string): Logger {
84
+ return this.withContext(methodName);
85
+ }
86
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Message Serialization Utilities
3
+ *
4
+ * Functions for serializing and deserializing messages for the protocol.
5
+ * Supports regular text messages, replies, forwards, and keep-alive messages.
6
+ */
7
+
8
+ import { strToBytes, bytesToStr, U32 } from '@massalabs/massa-web3';
9
+ import { MessageType } from '../db';
10
+
11
+ // Message type constants (protocol-level)
12
+ const MESSAGE_TYPE_REGULAR = 0x00;
13
+ const MESSAGE_TYPE_REPLY = 0x01;
14
+ const MESSAGE_TYPE_FORWARD = 0x02;
15
+ export const MESSAGE_TYPE_KEEP_ALIVE = 0x03;
16
+
17
+ // Seeker size: 1 byte length prefix + 32 bytes hash + 1 byte key index
18
+ const SEEKER_SIZE = 34;
19
+
20
+ export interface DeserializedMessage {
21
+ content: string;
22
+ replyTo?: {
23
+ originalContent: string;
24
+ originalSeeker: Uint8Array;
25
+ };
26
+ forwardOf?: {
27
+ originalContent: string;
28
+ originalSeeker: Uint8Array;
29
+ };
30
+ type: MessageType;
31
+ }
32
+
33
+ /**
34
+ * Serialize a keep-alive message
35
+ * Keep-alive messages are used to maintain session activity
36
+ */
37
+ export function serializeKeepAliveMessage(): Uint8Array {
38
+ return new Uint8Array([MESSAGE_TYPE_KEEP_ALIVE]);
39
+ }
40
+
41
+ /**
42
+ * Serialize a regular text message
43
+ *
44
+ * Format: [type: 1 byte][content: variable]
45
+ *
46
+ * @param content - The message content string
47
+ * @returns Serialized message bytes
48
+ */
49
+ export function serializeRegularMessage(content: string): Uint8Array {
50
+ const contentBytes = strToBytes(content);
51
+ const result = new Uint8Array(1 + contentBytes.length);
52
+ result[0] = MESSAGE_TYPE_REGULAR;
53
+ result.set(contentBytes, 1);
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Serialize a reply message
59
+ *
60
+ * Format: [type: 1 byte][originalContentLen: 4 bytes][originalContent][seeker: 34 bytes][newContent]
61
+ *
62
+ * @param newContent - The reply content
63
+ * @param originalContent - The content being replied to
64
+ * @param originalSeeker - The seeker of the original message
65
+ * @returns Serialized reply message bytes
66
+ */
67
+ export function serializeReplyMessage(
68
+ newContent: string,
69
+ originalContent: string,
70
+ originalSeeker: Uint8Array
71
+ ): Uint8Array {
72
+ const newContentBytes = strToBytes(newContent);
73
+ const originalContentBytes = strToBytes(originalContent);
74
+ const originalContentLenBytes = U32.toBytes(
75
+ BigInt(originalContentBytes.length)
76
+ );
77
+ // Calculate total size
78
+ const totalSize =
79
+ 1 + // type
80
+ originalContentLenBytes.length + // length prefix (4 bytes)
81
+ originalContentBytes.length + // original content
82
+ SEEKER_SIZE + // seeker
83
+ newContentBytes.length; // new content
84
+
85
+ const result = new Uint8Array(totalSize);
86
+ let offset = 0;
87
+
88
+ // Type byte
89
+ result[offset++] = MESSAGE_TYPE_REPLY;
90
+
91
+ // Original content length (4 bytes)
92
+ result.set(originalContentLenBytes, offset);
93
+ offset += originalContentLenBytes.length;
94
+
95
+ // Original content
96
+ result.set(originalContentBytes, offset);
97
+ offset += originalContentBytes.length;
98
+
99
+ // Seeker (34 bytes)
100
+ result.set(originalSeeker, offset);
101
+ offset += SEEKER_SIZE;
102
+
103
+ // New content
104
+ result.set(newContentBytes, offset);
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Serialize a forward message
111
+ *
112
+ * Format: [type: 1 byte][forwardContentLen: 4 bytes][forwardContent][seeker: 34 bytes][newContent]
113
+ *
114
+ * @param forwardContent - The content being forwarded
115
+ * @param newContent - Optional new content to add (empty string if none)
116
+ * @param originalSeeker - The seeker of the original message
117
+ * @returns Serialized forward message bytes
118
+ */
119
+ export function serializeForwardMessage(
120
+ forwardContent: string,
121
+ newContent: string,
122
+ originalSeeker: Uint8Array
123
+ ): Uint8Array {
124
+ const newContentBytes = strToBytes(newContent);
125
+ const forwardContentBytes = strToBytes(forwardContent);
126
+ const forwardContentLenBytes = U32.toBytes(
127
+ BigInt(forwardContentBytes.length)
128
+ );
129
+ // Calculate total size
130
+ const totalSize =
131
+ 1 + // type
132
+ forwardContentLenBytes.length + // length prefix (4 bytes)
133
+ forwardContentBytes.length + // forward content
134
+ SEEKER_SIZE + // seeker
135
+ newContentBytes.length; // new content
136
+
137
+ const result = new Uint8Array(totalSize);
138
+ let offset = 0;
139
+
140
+ // Type byte
141
+ result[offset++] = MESSAGE_TYPE_FORWARD;
142
+
143
+ // Forward content length (4 bytes)
144
+ result.set(forwardContentLenBytes, offset);
145
+ offset += forwardContentLenBytes.length;
146
+
147
+ // Forward content
148
+ result.set(forwardContentBytes, offset);
149
+ offset += forwardContentBytes.length;
150
+
151
+ // Seeker (34 bytes)
152
+ result.set(originalSeeker, offset);
153
+ offset += SEEKER_SIZE;
154
+
155
+ // New content
156
+ result.set(newContentBytes, offset);
157
+
158
+ return result;
159
+ }
160
+
161
+ /**
162
+ * Deserialize a message from bytes
163
+ *
164
+ * @param buffer - The serialized message bytes
165
+ * @returns Deserialized message object
166
+ * @throws Error if message format is invalid
167
+ */
168
+ export function deserializeMessage(buffer: Uint8Array): DeserializedMessage {
169
+ if (buffer.length < 1) {
170
+ throw new Error('Empty message buffer');
171
+ }
172
+
173
+ const messageType = buffer[0];
174
+
175
+ switch (messageType) {
176
+ case MESSAGE_TYPE_KEEP_ALIVE:
177
+ return {
178
+ content: '',
179
+ type: MessageType.KEEP_ALIVE,
180
+ };
181
+
182
+ case MESSAGE_TYPE_REGULAR:
183
+ return {
184
+ content: bytesToStr(buffer.slice(1)),
185
+ type: MessageType.TEXT,
186
+ };
187
+
188
+ case MESSAGE_TYPE_REPLY: {
189
+ // Format: [type: 1][originalContentLen: 4][originalContent][seeker: 34][newContent]
190
+ let offset = 1;
191
+
192
+ // Read original content length (4 bytes)
193
+ const originalContentLen = Number(
194
+ U32.fromBytes(buffer.slice(offset, offset + 4))
195
+ );
196
+ offset += 4;
197
+
198
+ // Read original content
199
+ const originalContent = bytesToStr(
200
+ buffer.slice(offset, offset + originalContentLen)
201
+ );
202
+ offset += originalContentLen;
203
+
204
+ // Read seeker (34 bytes)
205
+ const originalSeeker = buffer.slice(offset, offset + SEEKER_SIZE);
206
+ offset += SEEKER_SIZE;
207
+
208
+ // Read new content (rest of buffer)
209
+ const content = bytesToStr(buffer.slice(offset));
210
+
211
+ return {
212
+ content,
213
+ replyTo: {
214
+ originalContent,
215
+ originalSeeker,
216
+ },
217
+ type: MessageType.TEXT,
218
+ };
219
+ }
220
+
221
+ case MESSAGE_TYPE_FORWARD: {
222
+ // Format: [type: 1][forwardContentLen: 4][forwardContent][seeker: 34][newContent]
223
+ let offset = 1;
224
+
225
+ // Read forward content length (4 bytes)
226
+ const forwardContentLen = Number(
227
+ U32.fromBytes(buffer.slice(offset, offset + 4))
228
+ );
229
+ offset += 4;
230
+
231
+ // Read forward content
232
+ const originalContent = bytesToStr(
233
+ buffer.slice(offset, offset + forwardContentLen)
234
+ );
235
+ offset += forwardContentLen;
236
+
237
+ // Read seeker (34 bytes)
238
+ const originalSeeker = buffer.slice(offset, offset + SEEKER_SIZE);
239
+ offset += SEEKER_SIZE;
240
+
241
+ // Read new content (rest of buffer)
242
+ const content = bytesToStr(buffer.slice(offset));
243
+
244
+ return {
245
+ content,
246
+ forwardOf: {
247
+ originalContent,
248
+ originalSeeker,
249
+ },
250
+ type: MessageType.TEXT,
251
+ };
252
+ }
253
+
254
+ default:
255
+ throw new Error(`Unknown message type: ${messageType}`);
256
+ }
257
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Promise Queue
3
+ *
4
+ * Ensures async operations are executed sequentially.
5
+ * Used to serialize session manager operations per contact.
6
+ */
7
+
8
+ type QueuedTask<T> = {
9
+ fn: () => Promise<T>;
10
+ resolve: (value: T) => void;
11
+ reject: (error: unknown) => void;
12
+ };
13
+
14
+ /**
15
+ * A simple promise queue that executes tasks sequentially.
16
+ * Tasks are processed in FIFO order.
17
+ */
18
+ export class PromiseQueue {
19
+ private queue: QueuedTask<unknown>[] = [];
20
+ private processing = false;
21
+
22
+ /**
23
+ * Add a task to the queue. Returns a promise that resolves
24
+ * when the task completes.
25
+ */
26
+ enqueue<T>(fn: () => Promise<T>): Promise<T> {
27
+ return new Promise<T>((resolve, reject) => {
28
+ this.queue.push({
29
+ fn: fn as () => Promise<unknown>,
30
+ resolve: resolve as (value: unknown) => void,
31
+ reject,
32
+ });
33
+ this.processNext();
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Process the next task in the queue.
39
+ */
40
+ private async processNext(): Promise<void> {
41
+ if (this.processing || this.queue.length === 0) {
42
+ return;
43
+ }
44
+
45
+ this.processing = true;
46
+ const task = this.queue.shift()!;
47
+
48
+ try {
49
+ const result = await task.fn();
50
+ task.resolve(result);
51
+ } catch (error) {
52
+ task.reject(error);
53
+ } finally {
54
+ this.processing = false;
55
+ this.processNext();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if the queue is currently processing.
61
+ */
62
+ get isProcessing(): boolean {
63
+ return this.processing;
64
+ }
65
+
66
+ /**
67
+ * Get the number of pending tasks.
68
+ */
69
+ get pendingCount(): number {
70
+ return this.queue.length;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Manages multiple queues keyed by string (e.g., contact ID).
76
+ * Creates queues lazily on first use.
77
+ */
78
+ export class QueueManager {
79
+ private queues = new Map<string, PromiseQueue>();
80
+
81
+ /**
82
+ * Get or create a queue for the given key.
83
+ */
84
+ getQueue(key: string): PromiseQueue {
85
+ let queue = this.queues.get(key);
86
+ if (!queue) {
87
+ queue = new PromiseQueue();
88
+ this.queues.set(key, queue);
89
+ }
90
+ return queue;
91
+ }
92
+
93
+ /**
94
+ * Enqueue a task for a specific key.
95
+ */
96
+ enqueue<T>(key: string, fn: () => Promise<T>): Promise<T> {
97
+ return this.getQueue(key).enqueue(fn);
98
+ }
99
+
100
+ /**
101
+ * Clear all queues.
102
+ */
103
+ clear(): void {
104
+ this.queues.clear();
105
+ }
106
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Common type utilities for the SDK
3
+ */
4
+
5
+ export type Result<T, E = Error> =
6
+ | { success: true; data: T }
7
+ | { success: false; error: E };
@@ -0,0 +1,114 @@
1
+ /**
2
+ * User ID encoding/decoding utilities using Bech32 format
3
+ * Format: gossip1<encoded-32-bytes>
4
+ *
5
+ * Uses @scure/base for reliable, battle-tested Bech32 encoding
6
+ */
7
+
8
+ import { bech32 } from '@scure/base';
9
+ import { generateUserKeys } from '../wasm';
10
+
11
+ const GOSSIP_PREFIX = 'gossip';
12
+ const USER_ID_BYTE_LENGTH = 32;
13
+
14
+ /**
15
+ * Encode a 32-byte user ID to Bech32 format with "gossip" prefix
16
+ * @param userId - 32-byte user ID as Uint8Array
17
+ * @returns Bech32-encoded string (e.g., "gossip1qpzry9x8gf2tvdw0s3jn54khce6mua7l...")
18
+ * @throws Error if userId is not exactly 32 bytes
19
+ */
20
+ export function encodeUserId(userId: Uint8Array): string {
21
+ if (userId.length !== USER_ID_BYTE_LENGTH) {
22
+ throw new Error(
23
+ `User ID must be exactly ${USER_ID_BYTE_LENGTH} bytes, got ${userId.length}`
24
+ );
25
+ }
26
+
27
+ return bech32.encode(GOSSIP_PREFIX, bech32.toWords(userId));
28
+ }
29
+
30
+ /**
31
+ * Decode a Bech32-encoded user ID back to 32 bytes
32
+ * @param encoded - Bech32-encoded string (e.g., "gossip1qpzry9x8gf2tvdw0s3jn54khce6mua7l...")
33
+ * @returns 32-byte user ID as Uint8Array
34
+ * @throws Error if the format is invalid, checksum fails, or decoded length is not 32 bytes
35
+ */
36
+ export function decodeUserId(encoded: string): Uint8Array {
37
+ // Type assertion needed as bech32.decode expects a template literal type
38
+ const { prefix, words } = bech32.decode(encoded as `${string}1${string}`, 90);
39
+
40
+ // Verify prefix
41
+ if (prefix !== GOSSIP_PREFIX) {
42
+ throw new Error(
43
+ `Invalid prefix: expected "${GOSSIP_PREFIX}", got "${prefix}"`
44
+ );
45
+ }
46
+
47
+ // Convert from 5-bit words back to bytes
48
+ const decoded = bech32.fromWords(words);
49
+
50
+ // Verify length
51
+ if (decoded.length !== USER_ID_BYTE_LENGTH) {
52
+ throw new Error(
53
+ `Decoded user ID must be ${USER_ID_BYTE_LENGTH} bytes, got ${decoded.length}`
54
+ );
55
+ }
56
+
57
+ return new Uint8Array(decoded);
58
+ }
59
+
60
+ /**
61
+ * Validate a Bech32-encoded user ID string
62
+ * @param encoded - Bech32-encoded string to validate
63
+ * @returns true if valid, false otherwise
64
+ */
65
+ export function isValidUserId(encoded: string): boolean {
66
+ try {
67
+ decodeUserId(encoded);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format a user ID for display (shortened version)
76
+ * @param userId - Bech32-encoded user ID string
77
+ * @param prefixChars - Number of characters to show after prefix (default: 8)
78
+ * @param suffixChars - Number of characters to show at end (default: 6)
79
+ * @returns Formatted string (e.g., "gossip1qpzry9x8...mua7l")
80
+ */
81
+ export function formatUserId(
82
+ userId: string,
83
+ prefixChars: number = 8,
84
+ suffixChars: number = 6
85
+ ): string {
86
+ if (!userId) return '';
87
+
88
+ // Find separator position
89
+ const sepPos = userId.indexOf('1');
90
+ if (sepPos === -1) return userId;
91
+
92
+ const prefix = userId.substring(0, sepPos + 1); // "gossip1"
93
+ const data = userId.substring(sepPos + 1); // rest of the string
94
+
95
+ if (data.length <= prefixChars + suffixChars) {
96
+ return userId; // Too short to format
97
+ }
98
+
99
+ const start = data.slice(0, prefixChars);
100
+ const end = data.slice(-suffixChars);
101
+
102
+ return `${prefix}${start}...${end}`;
103
+ }
104
+
105
+ /**
106
+ * Generates a random 32-byte user ID
107
+ * @param password - Optional password
108
+ * @returns gossip Bech32 string representing a 32-byte user ID
109
+ */
110
+ export async function generate(password?: string): Promise<string> {
111
+ const identity = await generateUserKeys(password || '');
112
+ const userId = identity.public_keys().derive_id();
113
+ return encodeUserId(userId);
114
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Validation Utilities
3
+ *
4
+ * Functions for validating user input like usernames, passwords, and user IDs.
5
+ */
6
+
7
+ import { type UserProfile, type GossipDatabase } from '../db';
8
+ import { isValidUserId } from './userId';
9
+
10
+ export type ValidationResult =
11
+ | { valid: true; error?: never }
12
+ | { valid: false; error: string };
13
+
14
+ /**
15
+ * Validate a password meets requirements
16
+ *
17
+ * @param value - The password to validate
18
+ * @returns Validation result
19
+ */
20
+ export function validatePassword(value: string): ValidationResult {
21
+ if (!value || value.trim().length === 0) {
22
+ return { valid: false, error: 'Password is required' };
23
+ }
24
+
25
+ if (value.length < 8) {
26
+ return {
27
+ valid: false,
28
+ error: 'Password must be at least 8 characters long',
29
+ };
30
+ }
31
+
32
+ return { valid: true };
33
+ }
34
+
35
+ /**
36
+ * Validate a username format (without checking availability)
37
+ *
38
+ * @param value - The username to validate
39
+ * @returns Validation result
40
+ */
41
+ export function validateUsernameFormat(value: string): ValidationResult {
42
+ const trimmed = value.trim();
43
+
44
+ if (!trimmed) {
45
+ return { valid: false, error: 'Username is required' };
46
+ }
47
+
48
+ // Disallow any whitespace inside the username (single token only)
49
+ if (/\s/.test(trimmed)) {
50
+ return {
51
+ valid: false,
52
+ error: 'Username cannot contain spaces',
53
+ };
54
+ }
55
+
56
+ if (trimmed.length < 3) {
57
+ return {
58
+ valid: false,
59
+ error: 'Username must be at least 3 characters long',
60
+ };
61
+ }
62
+
63
+ return { valid: true };
64
+ }
65
+
66
+ /**
67
+ * Validate a username is available (not already in use)
68
+ *
69
+ * @param value - The username to check
70
+ * @param db - Database instance
71
+ * @returns Validation result
72
+ */
73
+ export async function validateUsernameAvailability(
74
+ value: string,
75
+ db: GossipDatabase
76
+ ): Promise<ValidationResult> {
77
+ try {
78
+ if (!db.isOpen()) {
79
+ await db.open();
80
+ }
81
+
82
+ const existingProfile = await db.userProfile
83
+ .filter(
84
+ (profile: UserProfile) =>
85
+ profile.username.trim().toLowerCase() === value.trim().toLowerCase()
86
+ )
87
+ .first();
88
+
89
+ if (existingProfile) {
90
+ return {
91
+ valid: false,
92
+ error: 'This username is already in use. Please choose another.',
93
+ };
94
+ }
95
+
96
+ return { valid: true };
97
+ } catch (error) {
98
+ return {
99
+ valid: false,
100
+ error:
101
+ error instanceof Error
102
+ ? error.message
103
+ : 'Unable to verify username availability. Please try again.',
104
+ };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate a username format and availability
110
+ *
111
+ * @param value - The username to validate
112
+ * @param db - Database instance
113
+ * @returns Validation result
114
+ */
115
+ export async function validateUsernameFormatAndAvailability(
116
+ value: string,
117
+ db: GossipDatabase
118
+ ): Promise<ValidationResult> {
119
+ const result = validateUsernameFormat(value);
120
+ if (!result.valid) {
121
+ return result;
122
+ }
123
+
124
+ return await validateUsernameAvailability(value, db);
125
+ }
126
+
127
+ /**
128
+ * Validate a user ID format (Bech32 gossip1... format)
129
+ *
130
+ * @param value - The user ID to validate
131
+ * @returns Validation result
132
+ */
133
+ export function validateUserIdFormat(value: string): ValidationResult {
134
+ const userId = value.trim();
135
+
136
+ if (!isValidUserId(userId)) {
137
+ return {
138
+ valid: false,
139
+ error: 'Invalid format — must be a valid user ID',
140
+ };
141
+ }
142
+
143
+ return { valid: true };
144
+ }