myceliumail 1.0.2 → 1.0.3

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 (79) hide show
  1. package/.context7 +87 -0
  2. package/.eslintrc.json +29 -0
  3. package/COMPLETE.md +51 -0
  4. package/MYCELIUMAIL_STARTER_KIT.md +603 -0
  5. package/NEXT_STEPS.md +96 -0
  6. package/desktop/README.md +102 -0
  7. package/desktop/assets/icon.icns +0 -0
  8. package/desktop/assets/icon.iconset/icon_128x128.png +0 -0
  9. package/desktop/assets/icon.iconset/icon_128x128@2x.png +0 -0
  10. package/desktop/assets/icon.iconset/icon_16x16.png +0 -0
  11. package/desktop/assets/icon.iconset/icon_16x16@2x.png +0 -0
  12. package/desktop/assets/icon.iconset/icon_256x256.png +0 -0
  13. package/desktop/assets/icon.iconset/icon_256x256@2x.png +0 -0
  14. package/desktop/assets/icon.iconset/icon_32x32.png +0 -0
  15. package/desktop/assets/icon.iconset/icon_32x32@2x.png +0 -0
  16. package/desktop/assets/icon.iconset/icon_512x512.png +0 -0
  17. package/desktop/assets/icon.iconset/icon_512x512@2x.png +0 -0
  18. package/desktop/assets/icon.png +0 -0
  19. package/desktop/assets/tray-icon.png +0 -0
  20. package/desktop/main.js +257 -0
  21. package/desktop/package-lock.json +4198 -0
  22. package/desktop/package.json +48 -0
  23. package/desktop/preload.js +11 -0
  24. package/dist/bin/myceliumail.js +2 -0
  25. package/dist/bin/myceliumail.js.map +1 -1
  26. package/dist/commands/key-announce.d.ts +6 -0
  27. package/dist/commands/key-announce.d.ts.map +1 -0
  28. package/dist/commands/key-announce.js +63 -0
  29. package/dist/commands/key-announce.js.map +1 -0
  30. package/docs/20251215_Treebird-Ecosystem_Knowledge-Base_v2.md +292 -0
  31. package/docs/20251215_Treebird-Ecosystem_Project-Instructions_v2.md +176 -0
  32. package/docs/AGENT_DELEGATION_WORKFLOW.md +453 -0
  33. package/docs/AGENT_STARTER_KIT.md +145 -0
  34. package/docs/ANNOUNCEMENT_DRAFTS.md +55 -0
  35. package/docs/DASHBOARD_AGENT_HANDOFF.md +429 -0
  36. package/docs/DASHBOARD_AGENT_PROMPT.md +32 -0
  37. package/docs/DASHBOARD_BUILD_ROADMAP.md +61 -0
  38. package/docs/DEPLOYMENT.md +59 -0
  39. package/docs/LESSONS_LEARNED.md +127 -0
  40. package/docs/MCP_PUBLISHING_ROADMAP.md +113 -0
  41. package/docs/MCP_STARTER_KIT.md +117 -0
  42. package/docs/SSAN_MESSAGES_SUMMARY.md +92 -0
  43. package/docs/STORAGE_ARCHITECTURE.md +114 -0
  44. package/mcp-server/README.md +143 -0
  45. package/mcp-server/assets/icon.png +0 -0
  46. package/mcp-server/myceliumail-mcp-1.0.0.tgz +0 -0
  47. package/mcp-server/package-lock.json +1142 -0
  48. package/mcp-server/package.json +49 -0
  49. package/mcp-server/src/lib/config.ts +55 -0
  50. package/mcp-server/src/lib/crypto.ts +150 -0
  51. package/mcp-server/src/lib/storage.ts +267 -0
  52. package/mcp-server/src/server.ts +387 -0
  53. package/mcp-server/tsconfig.json +26 -0
  54. package/package.json +3 -3
  55. package/src/bin/myceliumail.ts +54 -0
  56. package/src/commands/broadcast.ts +70 -0
  57. package/src/commands/dashboard.ts +19 -0
  58. package/src/commands/inbox.ts +75 -0
  59. package/src/commands/key-announce.ts +70 -0
  60. package/src/commands/key-import.ts +35 -0
  61. package/src/commands/keygen.ts +44 -0
  62. package/src/commands/keys.ts +55 -0
  63. package/src/commands/read.ts +97 -0
  64. package/src/commands/send.ts +89 -0
  65. package/src/commands/watch.ts +101 -0
  66. package/src/dashboard/public/app.js +523 -0
  67. package/src/dashboard/public/index.html +75 -0
  68. package/src/dashboard/public/styles.css +68 -0
  69. package/src/dashboard/routes.ts +128 -0
  70. package/src/dashboard/server.ts +33 -0
  71. package/src/lib/config.ts +104 -0
  72. package/src/lib/crypto.ts +210 -0
  73. package/src/lib/realtime.ts +109 -0
  74. package/src/storage/local.ts +209 -0
  75. package/src/storage/supabase.ts +336 -0
  76. package/src/types/index.ts +53 -0
  77. package/supabase/migrations/000_myceliumail_setup.sql +93 -0
  78. package/supabase/migrations/001_enable_realtime.sql +10 -0
  79. package/tsconfig.json +28 -0
@@ -0,0 +1,128 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { loadConfig } from '../lib/config.js';
3
+ import * as storage from '../storage/supabase.js';
4
+ import { loadKeyPair, decryptMessage, listOwnKeys } from '../lib/crypto.js';
5
+ import type { Message } from '../types/index.js';
6
+
7
+ // Try to decrypt a message using any available key
8
+ function tryDecryptWithAllKeys(msg: Message): Message {
9
+ if (!msg.encrypted || !msg.ciphertext || !msg.nonce || !msg.senderPublicKey) {
10
+ return msg;
11
+ }
12
+
13
+ // Get all available keypairs
14
+ const ownKeys = listOwnKeys();
15
+
16
+ for (const agentId of ownKeys) {
17
+ const keyPair = loadKeyPair(agentId);
18
+ if (!keyPair) continue;
19
+
20
+ try {
21
+ const decryptedText = decryptMessage({
22
+ ciphertext: msg.ciphertext,
23
+ nonce: msg.nonce,
24
+ senderPublicKey: msg.senderPublicKey
25
+ }, keyPair);
26
+
27
+ if (decryptedText) {
28
+ const parsed = JSON.parse(decryptedText);
29
+ return {
30
+ ...msg,
31
+ subject: parsed.subject,
32
+ body: parsed.body,
33
+ decrypted: true,
34
+ decryptedBy: agentId
35
+ } as Message & { decrypted: boolean; decryptedBy: string };
36
+ }
37
+ } catch (e) {
38
+ // Try next key
39
+ }
40
+ }
41
+
42
+ return msg;
43
+ }
44
+
45
+ export async function registerRoutes(fastify: FastifyInstance) {
46
+ const config = loadConfig();
47
+ const agentId = config.agentId;
48
+
49
+ // GET /api/inbox
50
+ fastify.get('/api/inbox', async (request, reply) => {
51
+ const messages = await storage.getInbox(agentId, { limit: 100 });
52
+
53
+ // Decrypt encrypted messages using all available keys
54
+ const decrypted = messages.map(tryDecryptWithAllKeys);
55
+
56
+ return { messages: decrypted, total: decrypted.length };
57
+ });
58
+
59
+ // GET /api/message/:id
60
+ fastify.get('/api/message/:id', async (request, reply) => {
61
+ const { id } = request.params as { id: string };
62
+ const message = await storage.getMessage(id);
63
+
64
+ if (!message) {
65
+ return reply.code(404).send({ error: 'Message not found' });
66
+ }
67
+
68
+ // Try all keys for decryption
69
+ return tryDecryptWithAllKeys(message);
70
+ });
71
+
72
+ // POST /api/message/:id/read
73
+ fastify.post('/api/message/:id/read', async (request, reply) => {
74
+ const { id } = request.params as { id: string };
75
+ const { readerId } = request.body as { readerId?: string };
76
+ await storage.markAsRead(id, readerId || agentId);
77
+ return { success: true };
78
+ });
79
+
80
+ // POST /api/message/:id/archive
81
+ fastify.post('/api/message/:id/archive', async (request, reply) => {
82
+ const { id } = request.params as { id: string };
83
+ await storage.archiveMessage(id);
84
+ return { success: true };
85
+ });
86
+
87
+ // DELETE /api/message/:id
88
+ fastify.delete('/api/message/:id', async (request, reply) => {
89
+ const { id } = request.params as { id: string };
90
+ const deleted = await storage.deleteMessage(id);
91
+ return { success: deleted };
92
+ });
93
+
94
+ // POST /api/send - Send a new message (supports multi-recipient)
95
+ fastify.post('/api/send', async (request, reply) => {
96
+ const { to, subject, body, from, attachments } = request.body as {
97
+ to: string | string[];
98
+ subject: string;
99
+ body: string;
100
+ from?: string;
101
+ attachments?: { name: string; type: string; data: string; size: number }[];
102
+ };
103
+
104
+ const sender = from || agentId;
105
+ const message = await storage.sendMessage(sender, to, subject, body, { attachments });
106
+ return { success: true, message };
107
+ });
108
+
109
+ // GET /api/stats
110
+ fastify.get('/api/stats', async (request, reply) => {
111
+ const messages = await storage.getInbox(agentId);
112
+ const unread = messages.filter(m => !m.readBy?.includes(agentId) && !m.read).length;
113
+ return {
114
+ total: messages.length,
115
+ unread,
116
+ encrypted: messages.filter(m => m.encrypted).length
117
+ };
118
+ });
119
+
120
+ // GET /api/config - Provide config for frontend Realtime
121
+ fastify.get('/api/config', async (request, reply) => {
122
+ return {
123
+ agentId: config.agentId,
124
+ supabaseUrl: config.supabaseUrl,
125
+ supabaseKey: config.supabaseKey
126
+ };
127
+ });
128
+ }
@@ -0,0 +1,33 @@
1
+ import Fastify from 'fastify';
2
+ import fastifyStatic from '@fastify/static';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ export async function startDashboard(port = 3737) {
10
+ const fastify = Fastify({ logger: true });
11
+
12
+ // Serve static files
13
+ await fastify.register(fastifyStatic, {
14
+ root: join(__dirname, 'public'),
15
+ prefix: '/'
16
+ });
17
+
18
+ // Register routes
19
+ // dynamic import to avoid circular dependencies if any, though here it is clean
20
+ const { registerRoutes } = await import('./routes.js');
21
+ await registerRoutes(fastify);
22
+
23
+ // Start server
24
+ try {
25
+ await fastify.listen({ port, host: '127.0.0.1' });
26
+ console.log(`🍄 Dashboard running on http://localhost:${port}`);
27
+ } catch (err) {
28
+ fastify.log.error(err);
29
+ process.exit(1);
30
+ }
31
+
32
+ return fastify;
33
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Myceliumail Configuration
3
+ *
4
+ * Handles loading agent configuration from environment or config file.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { homedir } from 'os';
10
+
11
+ const CONFIG_DIR = join(homedir(), '.myceliumail');
12
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
13
+
14
+ export interface Config {
15
+ agentId: string;
16
+ supabaseUrl?: string;
17
+ supabaseKey?: string;
18
+ storageMode: 'auto' | 'supabase' | 'local';
19
+ }
20
+
21
+ /**
22
+ * Ensure config directory exists
23
+ */
24
+ function ensureConfigDir(): void {
25
+ if (!existsSync(CONFIG_DIR)) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load configuration from file or environment
32
+ */
33
+ export function loadConfig(): Config {
34
+ // Environment variables take precedence
35
+ const envAgentId = process.env.MYCELIUMAIL_AGENT_ID || process.env.MYCELIUMAIL_AGENT;
36
+ const envSupabaseUrl = process.env.SUPABASE_URL;
37
+ const envSupabaseKey = process.env.SUPABASE_ANON_KEY;
38
+ const envStorageMode = process.env.MYCELIUMAIL_STORAGE as 'auto' | 'supabase' | 'local' | undefined;
39
+
40
+ // Try to load from config file
41
+ let fileConfig: Partial<Config> = {};
42
+ if (existsSync(CONFIG_FILE)) {
43
+ try {
44
+ const raw = readFileSync(CONFIG_FILE, 'utf-8');
45
+ const parsed = JSON.parse(raw);
46
+ fileConfig = {
47
+ agentId: parsed.agent_id,
48
+ supabaseUrl: parsed.supabase_url,
49
+ supabaseKey: parsed.supabase_key,
50
+ storageMode: parsed.storage_mode,
51
+ };
52
+ } catch {
53
+ // Invalid config file, ignore
54
+ }
55
+ }
56
+
57
+ // Merge with env taking precedence
58
+ const config: Config = {
59
+ agentId: envAgentId || fileConfig.agentId || 'anonymous',
60
+ supabaseUrl: envSupabaseUrl || fileConfig.supabaseUrl,
61
+ supabaseKey: envSupabaseKey || fileConfig.supabaseKey,
62
+ storageMode: envStorageMode || fileConfig.storageMode || 'auto',
63
+ };
64
+
65
+ return config;
66
+ }
67
+
68
+ /**
69
+ * Save configuration to file
70
+ */
71
+ export function saveConfig(config: Partial<Config>): void {
72
+ ensureConfigDir();
73
+
74
+ // Load existing config
75
+ let existing: Record<string, string> = {};
76
+ if (existsSync(CONFIG_FILE)) {
77
+ try {
78
+ existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
79
+ } catch {
80
+ // Start fresh
81
+ }
82
+ }
83
+
84
+ // Merge
85
+ if (config.agentId) existing.agent_id = config.agentId;
86
+ if (config.supabaseUrl) existing.supabase_url = config.supabaseUrl;
87
+ if (config.supabaseKey) existing.supabase_key = config.supabaseKey;
88
+
89
+ writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));
90
+ }
91
+
92
+ /**
93
+ * Get config directory path
94
+ */
95
+ export function getConfigDir(): string {
96
+ return CONFIG_DIR;
97
+ }
98
+
99
+ /**
100
+ * Check if Supabase is configured
101
+ */
102
+ export function hasSupabaseConfig(config: Config): boolean {
103
+ return !!(config.supabaseUrl && config.supabaseKey);
104
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Myceliumail Crypto Module
3
+ *
4
+ * E2E encryption for agent messaging using TweetNaCl.
5
+ * Uses X25519 for key exchange and XSalsa20-Poly1305 for encryption.
6
+ */
7
+
8
+ import nacl from 'tweetnacl';
9
+ import util from 'tweetnacl-util';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { homedir } from 'os';
13
+
14
+ // Key storage location
15
+ const KEYS_DIR = join(homedir(), '.myceliumail', 'keys');
16
+
17
+ export interface KeyPair {
18
+ publicKey: Uint8Array;
19
+ secretKey: Uint8Array;
20
+ }
21
+
22
+ export interface EncryptedMessage {
23
+ ciphertext: string; // base64
24
+ nonce: string; // base64
25
+ senderPublicKey: string; // base64
26
+ }
27
+
28
+ /**
29
+ * Ensure keys directory exists
30
+ */
31
+ function ensureKeysDir(): void {
32
+ if (!existsSync(KEYS_DIR)) {
33
+ mkdirSync(KEYS_DIR, { recursive: true });
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Generate a new keypair for an agent
39
+ */
40
+ export function generateKeyPair(): KeyPair {
41
+ return nacl.box.keyPair();
42
+ }
43
+
44
+ /**
45
+ * Save keypair to local storage
46
+ */
47
+ export function saveKeyPair(agentId: string, keyPair: KeyPair): void {
48
+ ensureKeysDir();
49
+ const serialized = {
50
+ publicKey: util.encodeBase64(keyPair.publicKey),
51
+ secretKey: util.encodeBase64(keyPair.secretKey),
52
+ };
53
+ const path = join(KEYS_DIR, `${agentId}.key.json`);
54
+ writeFileSync(path, JSON.stringify(serialized, null, 2), { mode: 0o600 });
55
+ }
56
+
57
+ /**
58
+ * Load keypair from local storage
59
+ */
60
+ export function loadKeyPair(agentId: string): KeyPair | null {
61
+ const path = join(KEYS_DIR, `${agentId}.key.json`);
62
+ if (!existsSync(path)) return null;
63
+
64
+ try {
65
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
66
+ return {
67
+ publicKey: util.decodeBase64(data.publicKey),
68
+ secretKey: util.decodeBase64(data.secretKey),
69
+ };
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Check if keypair exists for an agent
77
+ */
78
+ export function hasKeyPair(agentId: string): boolean {
79
+ const path = join(KEYS_DIR, `${agentId}.key.json`);
80
+ return existsSync(path);
81
+ }
82
+
83
+ /**
84
+ * Get public key as base64 string
85
+ */
86
+ export function getPublicKeyBase64(keyPair: KeyPair): string {
87
+ return util.encodeBase64(keyPair.publicKey);
88
+ }
89
+
90
+ /**
91
+ * Encrypt a message for a recipient
92
+ */
93
+ export function encryptMessage(
94
+ message: string,
95
+ recipientPublicKey: Uint8Array,
96
+ senderKeyPair: KeyPair
97
+ ): EncryptedMessage {
98
+ const messageBytes = util.decodeUTF8(message);
99
+ const nonce = nacl.randomBytes(nacl.box.nonceLength);
100
+
101
+ const ciphertext = nacl.box(
102
+ messageBytes,
103
+ nonce,
104
+ recipientPublicKey,
105
+ senderKeyPair.secretKey
106
+ );
107
+
108
+ return {
109
+ ciphertext: util.encodeBase64(ciphertext),
110
+ nonce: util.encodeBase64(nonce),
111
+ senderPublicKey: util.encodeBase64(senderKeyPair.publicKey),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Decrypt a message from a sender
117
+ */
118
+ export function decryptMessage(
119
+ encrypted: EncryptedMessage,
120
+ recipientKeyPair: KeyPair
121
+ ): string | null {
122
+ try {
123
+ const ciphertext = util.decodeBase64(encrypted.ciphertext);
124
+ const nonce = util.decodeBase64(encrypted.nonce);
125
+ const senderPublicKey = util.decodeBase64(encrypted.senderPublicKey);
126
+
127
+ const decrypted = nacl.box.open(
128
+ ciphertext,
129
+ nonce,
130
+ senderPublicKey,
131
+ recipientKeyPair.secretKey
132
+ );
133
+
134
+ if (!decrypted) return null;
135
+ return util.encodeUTF8(decrypted);
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Known keys registry - maps agent IDs to their public keys
143
+ */
144
+ export function loadKnownKeys(): Record<string, string> {
145
+ const path = join(KEYS_DIR, 'known_keys.json');
146
+ if (!existsSync(path)) return {};
147
+ try {
148
+ return JSON.parse(readFileSync(path, 'utf-8'));
149
+ } catch {
150
+ return {};
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Save a known key for an agent
156
+ */
157
+ export function saveKnownKey(agentId: string, publicKeyBase64: string): void {
158
+ ensureKeysDir();
159
+ const keys = loadKnownKeys();
160
+ keys[agentId] = publicKeyBase64;
161
+ writeFileSync(join(KEYS_DIR, 'known_keys.json'), JSON.stringify(keys, null, 2));
162
+ }
163
+
164
+ /**
165
+ * Get known key for an agent
166
+ */
167
+ export function getKnownKey(agentId: string): string | null {
168
+ const keys = loadKnownKeys();
169
+ return keys[agentId] || null;
170
+ }
171
+
172
+ /**
173
+ * Get all known keys
174
+ */
175
+ export function getKnownKeys(): Record<string, string> {
176
+ return loadKnownKeys();
177
+ }
178
+
179
+ /**
180
+ * Delete a known key
181
+ */
182
+ export function deleteKnownKey(agentId: string): boolean {
183
+ const keys = loadKnownKeys();
184
+ if (!(agentId in keys)) return false;
185
+ delete keys[agentId];
186
+ writeFileSync(join(KEYS_DIR, 'known_keys.json'), JSON.stringify(keys, null, 2));
187
+ return true;
188
+ }
189
+
190
+ /**
191
+ * List all own keypairs (for agents we have keys for)
192
+ */
193
+ export function listOwnKeys(): string[] {
194
+ ensureKeysDir();
195
+ try {
196
+ const files = readdirSync(KEYS_DIR);
197
+ return files
198
+ .filter(f => f.endsWith('.key.json'))
199
+ .map(f => f.replace('.key.json', ''));
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Decode a base64 public key to Uint8Array
207
+ */
208
+ export function decodePublicKey(base64: string): Uint8Array {
209
+ return util.decodeBase64(base64);
210
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Supabase Realtime Module
3
+ *
4
+ * Provides real-time subscription to new messages using Supabase Realtime.
5
+ */
6
+
7
+ import { createClient, RealtimeChannel, SupabaseClient } from '@supabase/supabase-js';
8
+ import { loadConfig, hasSupabaseConfig } from './config.js';
9
+
10
+ interface RealtimeMessage {
11
+ id: string;
12
+ from_agent: string;
13
+ to_agent: string;
14
+ subject: string;
15
+ message: string;
16
+ encrypted: boolean;
17
+ read: boolean;
18
+ created_at: string;
19
+ }
20
+
21
+ export interface MessageCallback {
22
+ (message: RealtimeMessage): void;
23
+ }
24
+
25
+ export interface StatusCallback {
26
+ (status: 'SUBSCRIBED' | 'CLOSED' | 'CHANNEL_ERROR' | 'TIMED_OUT', error?: Error): void;
27
+ }
28
+
29
+ let supabaseClient: SupabaseClient | null = null;
30
+
31
+ /**
32
+ * Get or create the Supabase client for Realtime
33
+ */
34
+ function getClient(): SupabaseClient | null {
35
+ if (supabaseClient) return supabaseClient;
36
+
37
+ const config = loadConfig();
38
+
39
+ if (!hasSupabaseConfig(config)) {
40
+ console.error('❌ Supabase not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY.');
41
+ return null;
42
+ }
43
+
44
+ supabaseClient = createClient(config.supabaseUrl!, config.supabaseKey!, {
45
+ realtime: {
46
+ params: {
47
+ eventsPerSecond: 10,
48
+ },
49
+ },
50
+ });
51
+
52
+ return supabaseClient;
53
+ }
54
+
55
+ /**
56
+ * Subscribe to new messages for a specific agent
57
+ */
58
+ export function subscribeToMessages(
59
+ agentId: string,
60
+ onMessage: MessageCallback,
61
+ onStatus?: StatusCallback
62
+ ): RealtimeChannel | null {
63
+ const client = getClient();
64
+ if (!client) return null;
65
+
66
+ const channel = client
67
+ .channel('inbox-watch')
68
+ .on<RealtimeMessage>(
69
+ 'postgres_changes',
70
+ {
71
+ event: 'INSERT',
72
+ schema: 'public',
73
+ table: 'agent_messages',
74
+ filter: `to_agent=eq.${agentId}`,
75
+ },
76
+ (payload) => {
77
+ onMessage(payload.new);
78
+ }
79
+ )
80
+ .subscribe((status, err) => {
81
+ if (onStatus) {
82
+ // Only pass error if it exists and has meaningful content
83
+ const error = err && (err.message || err.toString()) ? err : undefined;
84
+ onStatus(status as 'SUBSCRIBED' | 'CLOSED' | 'CHANNEL_ERROR' | 'TIMED_OUT', error);
85
+ }
86
+ });
87
+
88
+ return channel;
89
+ }
90
+
91
+ /**
92
+ * Unsubscribe from a channel
93
+ */
94
+ export async function unsubscribe(channel: RealtimeChannel): Promise<void> {
95
+ const client = getClient();
96
+ if (client && channel) {
97
+ await client.removeChannel(channel);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Close all realtime connections
103
+ */
104
+ export async function closeConnection(): Promise<void> {
105
+ if (supabaseClient) {
106
+ await supabaseClient.removeAllChannels();
107
+ supabaseClient = null;
108
+ }
109
+ }