@mooncompany/uplink-chat 0.5.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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Middleware Module - Security headers, CSRF, rate limiting
3
+ */
4
+
5
+ import rateLimit from 'express-rate-limit';
6
+ import cors from 'cors';
7
+ import { timingSafeEqual } from 'crypto';
8
+ import { ALLOWED_ORIGINS, WEBHOOK_TOKEN, GATEWAY_TOKEN as STATIC_GATEWAY_TOKEN } from './config.js';
9
+ import { isPrivateIP } from './utils.js';
10
+ import { loadConfig } from './runtime-config.js';
11
+ import { createLogger } from './logger.js';
12
+
13
+ const log = createLogger('middleware');
14
+
15
+ // Re-export isPrivateIP for backward compatibility
16
+ export { isPrivateIP };
17
+
18
+ // ===========================================
19
+ // Rate Limiting
20
+ // ===========================================
21
+ // Skip rate limiting for localhost/loopback — only limit external IPs
22
+ const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
23
+
24
+ export const apiLimiter = rateLimit({
25
+ windowMs: 15 * 60 * 1000, // 15 minutes
26
+ max: 500, // Limit each IP to 500 requests per window (increased for active chatting)
27
+ message: { error: true, message: 'Too many requests, please try again later.', code: 'RATE_LIMITED' },
28
+ standardHeaders: true,
29
+ legacyHeaders: false,
30
+ skip: (req) => isLoopback(req.ip),
31
+ });
32
+
33
+ export const strictLimiter = rateLimit({
34
+ windowMs: 15 * 60 * 1000, // 15 minutes
35
+ max: 30, // Limit each IP to 30 requests per window (was 10, too aggressive for onboarding)
36
+ message: { error: true, message: 'Too many requests, please try again later.', code: 'RATE_LIMITED' },
37
+ standardHeaders: true,
38
+ legacyHeaders: false,
39
+ skip: (req) => isLoopback(req.ip),
40
+ });
41
+
42
+ // ===========================================
43
+ // SSRF Prevention
44
+ // ===========================================
45
+ // isPrivateIP is now consolidated in server/utils.js - import from there
46
+
47
+ // ===========================================
48
+ // CSRF Protection
49
+ // ===========================================
50
+ export function csrfProtection(req, res, next) {
51
+ if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
52
+ return next();
53
+ }
54
+
55
+ const origin = req.get('Origin');
56
+ const referer = req.get('Referer');
57
+ const host = req.get('Host');
58
+
59
+ if (!origin && !referer) {
60
+ return next();
61
+ }
62
+
63
+ if (origin) {
64
+ try {
65
+ const originUrl = new URL(origin);
66
+ const hostWithoutPort = host.split(':')[0];
67
+ if (originUrl.hostname !== hostWithoutPort &&
68
+ originUrl.hostname !== 'localhost' &&
69
+ originUrl.hostname !== '127.0.0.1' &&
70
+ !originUrl.hostname.endsWith('.ts.net')) {
71
+ log.warn(`CSRF blocked: origin ${origin} doesn't match host ${host}`);
72
+ return res.status(403).json({ error: 'CSRF validation failed' });
73
+ }
74
+ } catch (e) {
75
+ return res.status(403).json({ error: 'Invalid origin header' });
76
+ }
77
+ }
78
+
79
+ next();
80
+ }
81
+
82
+ // ===========================================
83
+ // Security Headers
84
+ // ===========================================
85
+ export function securityHeaders(req, res, next) {
86
+ const nonce = res.locals.nonce;
87
+
88
+ // Allow embedding from localhost and production RedOS
89
+ // Using frame-ancestors instead of X-Frame-Options for more control
90
+ const allowedFrameAncestors = [
91
+ "'self'",
92
+ "http://localhost:3000", // RedOS dev server
93
+ "http://127.0.0.1:3000",
94
+ "https://redos.moonco.pro", // RedOS production
95
+ ].join(' ');
96
+
97
+ res.setHeader('X-Content-Type-Options', 'nosniff');
98
+ res.setHeader('X-XSS-Protection', '1; mode=block');
99
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
100
+
101
+ // Build CSP with nonce if available
102
+ const scriptSrc = nonce
103
+ ? `script-src 'self' 'nonce-${nonce}' 'unsafe-hashes' https://cdnjs.cloudflare.com`
104
+ : `script-src 'self' 'unsafe-hashes' https://cdnjs.cloudflare.com`;
105
+
106
+ const cspDirectives = [
107
+ "default-src 'self'",
108
+ scriptSrc,
109
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
110
+ "font-src 'self' https://fonts.gstatic.com",
111
+ "img-src 'self' data: blob:",
112
+ "connect-src 'self' data: blob: ws: wss: https://api.elevenlabs.io https://fonts.googleapis.com https://fonts.gstatic.com https://static.cloudflareinsights.com",
113
+ "media-src 'self' blob:",
114
+ `frame-ancestors ${allowedFrameAncestors}`
115
+ ];
116
+
117
+ res.setHeader('Content-Security-Policy', cspDirectives.join('; '));
118
+ next();
119
+ }
120
+
121
+ // ===========================================
122
+ // CORS Configuration
123
+ // ===========================================
124
+ export const corsMiddleware = cors({
125
+ origin: (origin, callback) => {
126
+ if (!origin) return callback(null, true);
127
+ if (ALLOWED_ORIGINS.includes(origin)) {
128
+ return callback(null, true);
129
+ }
130
+ // Auto-allow Tailscale HTTPS origins (*.ts.net)
131
+ try {
132
+ const url = new URL(origin);
133
+ if (url.hostname.endsWith('.ts.net')) {
134
+ return callback(null, true);
135
+ }
136
+ } catch {}
137
+ callback(new Error('CORS not allowed'));
138
+ },
139
+ methods: ['GET', 'POST', 'DELETE'], // M-25: Added DELETE for cross-origin delete routes
140
+ credentials: true,
141
+ maxAge: 86400
142
+ });
143
+
144
+ // ===========================================
145
+ // Bearer Token Verification (Sync & API auth)
146
+ // ===========================================
147
+ export async function verifyBearerToken(req, res, next) {
148
+ // Get token dynamically (includes auto-discovered values from OpenClaw config)
149
+ let expectedToken = STATIC_GATEWAY_TOKEN;
150
+ if (!expectedToken) {
151
+ try {
152
+ const config = await loadConfig();
153
+ expectedToken = config.gatewayToken;
154
+ } catch {
155
+ // Fall through to the check below
156
+ }
157
+ }
158
+
159
+ if (!expectedToken) {
160
+ return res.status(503).json({ error: true, message: 'Authentication not configured', code: 'AUTH_NOT_CONFIGURED' });
161
+ }
162
+ const authHeader = req.headers.authorization || '';
163
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
164
+ if (!token) {
165
+ return res.status(401).json({ error: true, message: 'Missing Authorization header', code: 'UNAUTHORIZED' });
166
+ }
167
+ const providedBuf = Buffer.from(token);
168
+ const expectedBuf = Buffer.from(expectedToken);
169
+ if (providedBuf.length !== expectedBuf.length || !timingSafeEqual(providedBuf, expectedBuf)) {
170
+ return res.status(401).json({ error: true, message: 'Invalid token', code: 'INVALID_TOKEN' });
171
+ }
172
+ next();
173
+ }
174
+
175
+ // ===========================================
176
+ // Webhook Token Verification
177
+ // ===========================================
178
+ export function verifyWebhookToken(req, res, next) {
179
+ if (!WEBHOOK_TOKEN) {
180
+ return res.status(503).json({
181
+ error: 'Webhooks disabled. Set WEBHOOK_TOKEN in .env to enable.'
182
+ });
183
+ }
184
+ const token = req.headers['x-webhook-token'] || req.query.token || '';
185
+
186
+ // Timing-safe comparison to prevent timing attacks
187
+ const providedBuf = Buffer.from(token);
188
+ const expectedBuf = Buffer.from(WEBHOOK_TOKEN);
189
+ if (providedBuf.length !== expectedBuf.length) {
190
+ return res.status(401).json({ error: 'Invalid webhook token' });
191
+ }
192
+ if (!timingSafeEqual(providedBuf, expectedBuf)) {
193
+ return res.status(401).json({ error: 'Invalid webhook token' });
194
+ }
195
+ next();
196
+ }
197
+
198
+ // ===========================================
199
+ // UTF-8 JSON Response
200
+ // ===========================================
201
+ export function jsonUtf8(req, res, next) {
202
+ const originalJson = res.json.bind(res);
203
+ res.json = (data) => {
204
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
205
+ return originalJson(data);
206
+ };
207
+ next();
208
+ }
209
+
210
+ // ===========================================
211
+ // No Cache Headers
212
+ // ===========================================
213
+ export function noCache(req, res, next) {
214
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
215
+ res.set('Pragma', 'no-cache');
216
+ res.set('Expires', '0');
217
+ next();
218
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * OpenClaw Gateway Auto-Discovery
3
+ *
4
+ * Reads the local OpenClaw config to auto-detect gateway URL and token.
5
+ * Eliminates manual onboarding for same-machine installs.
6
+ *
7
+ * Discovery order:
8
+ * 1. Environment variables (GATEWAY_URL, GATEWAY_TOKEN) — highest priority
9
+ * 2. OPENCLAW_STATE_DIR env var (explicit path override)
10
+ * 3. Native OS config (~/.openclaw/openclaw.json)
11
+ * 4. WSL config (Windows only — checks \\wsl.localhost\<distro>\home\<user>\.openclaw\)
12
+ * 5. Default fallback (http://127.0.0.1:18789, no token)
13
+ */
14
+
15
+ import fs from 'fs/promises';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import { execSync } from 'child_process';
19
+ import { createLogger } from './logger.js';
20
+
21
+ const log = createLogger('Discovery');
22
+
23
+ // OpenClaw config locations (in priority order)
24
+ const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
25
+ const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_STATE_DIR, 'openclaw.json');
26
+
27
+ // Default OpenClaw gateway port
28
+ const DEFAULT_GATEWAY_PORT = 18789;
29
+
30
+ // WSL distros to skip (not real Linux installs)
31
+ const WSL_SKIP_DISTROS = new Set(['docker-desktop', 'docker-desktop-data']);
32
+
33
+ /**
34
+ * Attempt to read and parse an OpenClaw config at a given path
35
+ * @param {string} configPath - Path to openclaw.json
36
+ * @returns {{ config: Object, path: string }|null} Parsed config + path, or null
37
+ */
38
+ async function tryReadConfig(configPath) {
39
+ try {
40
+ const data = await fs.readFile(configPath, 'utf8');
41
+ return { config: JSON.parse(data), path: configPath };
42
+ } catch (err) {
43
+ if (err.code !== 'ENOENT') {
44
+ log.warn(`Failed to read config at ${configPath}:`, err.message);
45
+ }
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Get list of WSL distro names (Windows only)
52
+ * @returns {string[]} Distro names
53
+ */
54
+ function getWSLDistros() {
55
+ if (os.platform() !== 'win32') return [];
56
+
57
+ try {
58
+ // wsl --list --quiet outputs distro names, one per line
59
+ // Output is UTF-16LE on some Windows versions, handle encoding
60
+ const output = execSync('wsl --list --quiet', {
61
+ encoding: 'utf8',
62
+ timeout: 5000,
63
+ stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+
66
+ return output
67
+ .replace(/\0/g, '') // Strip null bytes from UTF-16LE artifacts
68
+ .split(/\r?\n/)
69
+ .map(line => line.trim())
70
+ .filter(line => line && !WSL_SKIP_DISTROS.has(line.toLowerCase()));
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get the WSL user's home directory for a given distro
78
+ * @param {string} distro - WSL distro name
79
+ * @returns {string|null} Home directory path or null
80
+ */
81
+ function getWSLHomePath(distro) {
82
+ try {
83
+ const output = execSync(`wsl -d ${distro} -- echo $HOME`, {
84
+ encoding: 'utf8',
85
+ timeout: 5000,
86
+ stdio: ['pipe', 'pipe', 'pipe'],
87
+ });
88
+ const home = output.trim().replace(/\0/g, '');
89
+ if (!home) return null;
90
+
91
+ // Convert /home/user to \\wsl.localhost\distro\home\user
92
+ // Try both \\wsl.localhost\ (Win11+) and \\wsl$\ (older)
93
+ return { wslPath: home, distro };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Build Windows UNC paths to access a WSL file
101
+ * @param {string} distro - WSL distro name
102
+ * @param {string} wslPath - Linux path (e.g., /home/user)
103
+ * @returns {string[]} Array of UNC paths to try
104
+ */
105
+ function buildWSLUNCPaths(distro, wslPath) {
106
+ // Convert forward slashes to backslashes for Windows UNC
107
+ const winSubPath = wslPath.replace(/\//g, '\\');
108
+ return [
109
+ `\\\\wsl.localhost\\${distro}${winSubPath}`, // Win11+ preferred
110
+ `\\\\wsl$\\${distro}${winSubPath}`, // Older Windows 10
111
+ ];
112
+ }
113
+
114
+ /**
115
+ * Search for OpenClaw config in WSL distros
116
+ * @returns {{ config: Object, path: string, source: string }|null}
117
+ */
118
+ async function findWSLConfig() {
119
+ const distros = getWSLDistros();
120
+ if (distros.length === 0) return null;
121
+
122
+ log.debug(`Found WSL distros: ${distros.join(', ')}`);
123
+
124
+ for (const distro of distros) {
125
+ const homeInfo = getWSLHomePath(distro);
126
+ if (!homeInfo) continue;
127
+
128
+ const openclawWslPath = `${homeInfo.wslPath}/.openclaw/openclaw.json`;
129
+ const uncPaths = buildWSLUNCPaths(distro, openclawWslPath);
130
+
131
+ for (const uncPath of uncPaths) {
132
+ const result = await tryReadConfig(uncPath);
133
+ if (result) {
134
+ log.info(`Found OpenClaw config in WSL (${distro}) at ${uncPath}`);
135
+ return { ...result, source: `wsl:${distro}` };
136
+ }
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Read the OpenClaw config, searching native paths first, then WSL
145
+ * @returns {{ config: Object, path: string, source: string }|null}
146
+ */
147
+ async function readOpenClawConfig() {
148
+ // 1. Try native path (or OPENCLAW_STATE_DIR override)
149
+ const nativeResult = await tryReadConfig(OPENCLAW_CONFIG_PATH);
150
+ if (nativeResult) {
151
+ return { ...nativeResult, source: 'native' };
152
+ }
153
+
154
+ // 2. On Windows, try WSL paths
155
+ if (os.platform() === 'win32') {
156
+ const wslResult = await findWSLConfig();
157
+ if (wslResult) return wslResult;
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Extract gateway connection details from OpenClaw config
165
+ * @param {Object} config - Parsed openclaw.json
166
+ * @returns {{ url: string, token: string|null, source: string }}
167
+ */
168
+ function extractGatewayDetails(config) {
169
+ const gateway = config?.gateway || {};
170
+ const port = gateway.port || DEFAULT_GATEWAY_PORT;
171
+ const url = `http://127.0.0.1:${port}`;
172
+
173
+ // Extract auth token
174
+ let token = null;
175
+ if (gateway.auth) {
176
+ if (gateway.auth.mode === 'token' && gateway.auth.token) {
177
+ token = gateway.auth.token;
178
+ } else if (gateway.auth.mode === 'password' && gateway.auth.password) {
179
+ token = gateway.auth.password;
180
+ }
181
+ }
182
+
183
+ return { url, token };
184
+ }
185
+
186
+ /**
187
+ * Verify gateway is reachable at the given URL
188
+ * @param {string} url - Gateway URL to check
189
+ * @param {string|null} token - Auth token (optional)
190
+ * @returns {Promise<boolean>} True if gateway responds
191
+ */
192
+ async function verifyGateway(url, token) {
193
+ try {
194
+ const headers = {};
195
+ if (token) {
196
+ headers['Authorization'] = `Bearer ${token}`;
197
+ }
198
+
199
+ const controller = new AbortController();
200
+ const timeout = setTimeout(() => controller.abort(), 3000);
201
+
202
+ const response = await fetch(`${url}/health`, {
203
+ method: 'GET',
204
+ headers,
205
+ signal: controller.signal,
206
+ });
207
+
208
+ clearTimeout(timeout);
209
+ return response.ok;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Auto-discover the OpenClaw gateway
217
+ *
218
+ * Returns discovered gateway details, or null if nothing found.
219
+ * Does NOT override environment variables — those always take priority.
220
+ *
221
+ * @returns {Promise<{ url: string, token: string|null, source: string, verified: boolean }|null>}
222
+ */
223
+ export async function discoverGateway() {
224
+ // If env vars are set, don't auto-discover (they take priority in runtime-config)
225
+ if (process.env.GATEWAY_URL && process.env.GATEWAY_TOKEN) {
226
+ return {
227
+ url: process.env.GATEWAY_URL,
228
+ token: process.env.GATEWAY_TOKEN,
229
+ source: 'environment',
230
+ verified: false, // Caller can verify if needed
231
+ };
232
+ }
233
+
234
+ // Try reading OpenClaw config (native path first, then WSL on Windows)
235
+ const configResult = await readOpenClawConfig();
236
+ if (!configResult) {
237
+ log.info('No OpenClaw config found (checked native + WSL) — manual setup required');
238
+ return null;
239
+ }
240
+
241
+ const details = extractGatewayDetails(configResult.config);
242
+ details.source = configResult.source;
243
+ details.configPath = configResult.path;
244
+ log.info(`Found OpenClaw config (${configResult.source}) — gateway at ${details.url}`);
245
+
246
+ // Verify the gateway is actually running
247
+ const verified = await verifyGateway(details.url, details.token);
248
+ if (verified) {
249
+ log.info(`✓ Gateway verified at ${details.url}`);
250
+ } else {
251
+ log.warn(`Gateway configured at ${details.url} but not responding — may not be running yet`);
252
+ }
253
+
254
+ return {
255
+ ...details,
256
+ verified,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Get the OpenClaw config path (for display/debugging)
262
+ */
263
+ export function getConfigPath() {
264
+ return OPENCLAW_CONFIG_PATH;
265
+ }
266
+
267
+ /**
268
+ * Get the resolved OpenClaw state directory
269
+ * This may be a WSL UNC path on Windows if that's where the config was found.
270
+ * Other modules (satellite, status) should use this for session file access.
271
+ *
272
+ * @returns {Promise<string>} Resolved state directory path
273
+ */
274
+ let _resolvedStateDir = null;
275
+
276
+ export async function getOpenClawStateDir() {
277
+ if (_resolvedStateDir) return _resolvedStateDir;
278
+
279
+ // If explicitly set, use that
280
+ if (process.env.OPENCLAW_STATE_DIR) {
281
+ _resolvedStateDir = process.env.OPENCLAW_STATE_DIR;
282
+ return _resolvedStateDir;
283
+ }
284
+
285
+ // Check native path first
286
+ const nativePath = path.join(os.homedir(), '.openclaw');
287
+ const nativeResult = await tryReadConfig(path.join(nativePath, 'openclaw.json'));
288
+ if (nativeResult) {
289
+ _resolvedStateDir = nativePath;
290
+ return _resolvedStateDir;
291
+ }
292
+
293
+ // On Windows, check WSL
294
+ if (os.platform() === 'win32') {
295
+ const wslResult = await findWSLConfig();
296
+ if (wslResult) {
297
+ // Derive state dir from config path (remove /openclaw.json)
298
+ _resolvedStateDir = path.dirname(wslResult.path);
299
+ return _resolvedStateDir;
300
+ }
301
+ }
302
+
303
+ // Fallback to native path
304
+ _resolvedStateDir = nativePath;
305
+ return _resolvedStateDir;
306
+ }
307
+
308
+ export default { discoverGateway, getConfigPath, getOpenClawStateDir };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Premium Module — Feature gating for Uplink Premium
3
+ *
4
+ * Manages license activation, premium status, and feature access.
5
+ * License keys are validated locally using HMAC-SHA256 (no phone home).
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { validateLicense } from './license.js';
12
+ import { createLogger } from '../logger.js';
13
+
14
+ const log = createLogger('Premium');
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ let premiumActive = false;
18
+ let activatedAt = null;
19
+
20
+ // When true, premium features show "Coming Soon" instead of purchase prompts.
21
+ // Flip to false when LemonSqueezy is approved and you're ready to sell.
22
+ const PREMIUM_COMING_SOON = true;
23
+
24
+ // Features gated behind premium
25
+ const PREMIUM_FEATURES = [
26
+ 'voice', // TTS + STT
27
+ 'themes', // All themes + custom generator (free gets Midnight only)
28
+ 'agents', // Agent management panel
29
+ 'splitview', // Split view mode
30
+ ];
31
+
32
+ // Free users get Midnight only
33
+ const FREE_THEMES = ['midnight'];
34
+ const ALL_THEMES = ['midnight', 'daylight', 'ember', 'forest', 'phantom'];
35
+
36
+ /**
37
+ * Initialize premium from stored config
38
+ * Reads config.json synchronously at startup (runs once)
39
+ */
40
+ export function initPremium(licenseKey) {
41
+ // If no key passed, try reading from config.json directly
42
+ if (!licenseKey) {
43
+ try {
44
+ const configPath = path.join(__dirname, '..', '..', 'config.json');
45
+ const data = fs.readFileSync(configPath, 'utf8');
46
+ const config = JSON.parse(data);
47
+ licenseKey = config.licenseKey;
48
+ } catch {
49
+ // No config file or no key — that's fine
50
+ }
51
+ }
52
+
53
+ if (!licenseKey) {
54
+ premiumActive = false;
55
+ log.info('Running in free mode');
56
+ return false;
57
+ }
58
+
59
+ const result = validateLicense(licenseKey);
60
+ if (result.valid) {
61
+ premiumActive = true;
62
+ activatedAt = new Date().toISOString();
63
+ log.info('✓ Uplink Premium active');
64
+ return true;
65
+ } else {
66
+ premiumActive = false;
67
+ log.warn('Stored license key is invalid — running in free mode');
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check if premium is active
74
+ * @returns {boolean}
75
+ */
76
+ export function isPremium() {
77
+ return premiumActive;
78
+ }
79
+
80
+ /**
81
+ * Check if a specific feature is available
82
+ * @param {string} feature - Feature name
83
+ * @returns {boolean}
84
+ */
85
+ export function hasFeature(feature) {
86
+ if (premiumActive) return true;
87
+ // Free features are anything NOT in PREMIUM_FEATURES
88
+ return !PREMIUM_FEATURES.includes(feature);
89
+ }
90
+
91
+ /**
92
+ * Attempt to activate a license key
93
+ * @param {string} key - License key to validate
94
+ * @returns {{ success: boolean, error?: string }}
95
+ */
96
+ export function activateLicense(key) {
97
+ const result = validateLicense(key);
98
+ if (result.valid) {
99
+ premiumActive = true;
100
+ activatedAt = new Date().toISOString();
101
+ log.info('✓ License activated');
102
+ return { success: true };
103
+ }
104
+ return { success: false, error: 'Invalid license key' };
105
+ }
106
+
107
+ /**
108
+ * Deactivate premium (for key removal)
109
+ */
110
+ export function deactivateLicense() {
111
+ premiumActive = false;
112
+ activatedAt = null;
113
+ log.info('License deactivated — running in free mode');
114
+ }
115
+
116
+ /**
117
+ * Get premium status for client
118
+ */
119
+ export function getPremiumStatus() {
120
+ return {
121
+ active: premiumActive,
122
+ activatedAt,
123
+ comingSoon: PREMIUM_COMING_SOON,
124
+ features: PREMIUM_FEATURES.reduce((acc, f) => {
125
+ acc[f] = premiumActive;
126
+ return acc;
127
+ }, {}),
128
+ themes: premiumActive ? ALL_THEMES : FREE_THEMES,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Express middleware: require premium for a route
134
+ * Returns 403 with upgrade prompt if not premium
135
+ */
136
+ export function requirePremium(featureName) {
137
+ return (req, res, next) => {
138
+ if (premiumActive) return next();
139
+ return res.status(403).json({
140
+ error: true,
141
+ message: `${featureName || 'This feature'} requires Uplink Premium`,
142
+ code: 'PREMIUM_REQUIRED',
143
+ upgrade: true,
144
+ });
145
+ };
146
+ }
147
+
148
+ export default {
149
+ initPremium,
150
+ isPremium,
151
+ hasFeature,
152
+ activateLicense,
153
+ deactivateLicense,
154
+ getPremiumStatus,
155
+ requirePremium,
156
+ };