blue-js-sdk 2.0.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.
Files changed (215) hide show
  1. package/CHANGELOG.md +446 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/ai-path/ADMIN-ELEVATION.md +116 -0
  5. package/ai-path/AI-MANIFESTO.md +185 -0
  6. package/ai-path/BREAKING.md +74 -0
  7. package/ai-path/CHECKLIST.md +619 -0
  8. package/ai-path/CONNECTION-STEPS.md +724 -0
  9. package/ai-path/DECISION-TREE.md +378 -0
  10. package/ai-path/DEPENDENCIES.md +459 -0
  11. package/ai-path/E2E-FLOW.md +1555 -0
  12. package/ai-path/FAILURES.md +403 -0
  13. package/ai-path/GUIDE.md +1217 -0
  14. package/ai-path/README.md +558 -0
  15. package/ai-path/SPLIT-TUNNEL.md +266 -0
  16. package/ai-path/cli.js +535 -0
  17. package/ai-path/connect.js +884 -0
  18. package/ai-path/discover.js +178 -0
  19. package/ai-path/environment.js +266 -0
  20. package/ai-path/errors.js +86 -0
  21. package/ai-path/examples/autonomous-agent.mjs +220 -0
  22. package/ai-path/examples/multi-region.mjs +174 -0
  23. package/ai-path/examples/one-shot.mjs +31 -0
  24. package/ai-path/index.js +60 -0
  25. package/ai-path/pricing.js +136 -0
  26. package/ai-path/recommend.js +413 -0
  27. package/ai-path/run-admin.vbs +25 -0
  28. package/ai-path/setup.js +291 -0
  29. package/ai-path/wallet.js +137 -0
  30. package/app-helpers.js +363 -0
  31. package/app-settings.js +95 -0
  32. package/app-types.js +267 -0
  33. package/audit.js +847 -0
  34. package/batch.js +293 -0
  35. package/bin/setup.js +376 -0
  36. package/chain/authz.js +109 -0
  37. package/chain/broadcast.js +472 -0
  38. package/chain/client.js +160 -0
  39. package/chain/fee-grants.js +305 -0
  40. package/chain/index.js +891 -0
  41. package/chain/lcd.js +313 -0
  42. package/chain/queries.js +547 -0
  43. package/chain/rpc.js +408 -0
  44. package/chain/wallet.js +141 -0
  45. package/cli/config.js +143 -0
  46. package/cli/index.js +463 -0
  47. package/cli/output.js +182 -0
  48. package/cli.js +491 -0
  49. package/client/index.js +251 -0
  50. package/client.js +271 -0
  51. package/config/index.js +255 -0
  52. package/connection/connect.js +849 -0
  53. package/connection/disconnect.js +180 -0
  54. package/connection/discovery.js +321 -0
  55. package/connection/index.js +76 -0
  56. package/connection/proxy.js +148 -0
  57. package/connection/resilience.js +428 -0
  58. package/connection/security.js +232 -0
  59. package/connection/state.js +369 -0
  60. package/connection/tunnel.js +691 -0
  61. package/consumer.js +132 -0
  62. package/cosmjs-setup.js +1884 -0
  63. package/defaults.js +366 -0
  64. package/disk-cache.js +107 -0
  65. package/dist/client.d.ts +108 -0
  66. package/dist/client.d.ts.map +1 -0
  67. package/dist/client.js +400 -0
  68. package/dist/client.js.map +1 -0
  69. package/dist/index.d.ts +8 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +8 -0
  72. package/dist/index.js.map +1 -0
  73. package/errors/index.js +112 -0
  74. package/errors.js +218 -0
  75. package/examples/README.md +64 -0
  76. package/examples/connect-direct.mjs +106 -0
  77. package/examples/connect-plan.mjs +125 -0
  78. package/examples/error-handling.mjs +109 -0
  79. package/examples/query-nodes.mjs +94 -0
  80. package/examples/wallet-basics.mjs +61 -0
  81. package/generated/amino/amino.ts +9 -0
  82. package/generated/cosmos/base/v1beta1/coin.ts +365 -0
  83. package/generated/cosmos_proto/cosmos.ts +323 -0
  84. package/generated/gogoproto/gogo.ts +9 -0
  85. package/generated/google/protobuf/descriptor.ts +7601 -0
  86. package/generated/google/protobuf/duration.ts +208 -0
  87. package/generated/google/protobuf/timestamp.ts +238 -0
  88. package/generated/sentinel/lease/v1/events.ts +924 -0
  89. package/generated/sentinel/lease/v1/lease.ts +292 -0
  90. package/generated/sentinel/lease/v1/msg.ts +949 -0
  91. package/generated/sentinel/lease/v1/params.ts +164 -0
  92. package/generated/sentinel/node/v3/events.ts +881 -0
  93. package/generated/sentinel/node/v3/msg.ts +1002 -0
  94. package/generated/sentinel/node/v3/node.ts +263 -0
  95. package/generated/sentinel/node/v3/params.ts +183 -0
  96. package/generated/sentinel/plan/v3/events.ts +675 -0
  97. package/generated/sentinel/plan/v3/msg.ts +1191 -0
  98. package/generated/sentinel/plan/v3/plan.ts +283 -0
  99. package/generated/sentinel/provider/v2/events.ts +171 -0
  100. package/generated/sentinel/provider/v2/msg.ts +480 -0
  101. package/generated/sentinel/provider/v2/params.ts +131 -0
  102. package/generated/sentinel/provider/v2/provider.ts +246 -0
  103. package/generated/sentinel/session/v3/events.ts +480 -0
  104. package/generated/sentinel/session/v3/msg.ts +616 -0
  105. package/generated/sentinel/session/v3/params.ts +260 -0
  106. package/generated/sentinel/session/v3/proof.ts +180 -0
  107. package/generated/sentinel/session/v3/session.ts +384 -0
  108. package/generated/sentinel/subscription/v3/events.ts +1181 -0
  109. package/generated/sentinel/subscription/v3/msg.ts +1305 -0
  110. package/generated/sentinel/subscription/v3/params.ts +167 -0
  111. package/generated/sentinel/subscription/v3/subscription.ts +315 -0
  112. package/generated/sentinel/types/v1/bandwidth.ts +124 -0
  113. package/generated/sentinel/types/v1/price.ts +149 -0
  114. package/generated/sentinel/types/v1/renewal.ts +87 -0
  115. package/generated/sentinel/types/v1/status.ts +54 -0
  116. package/generated/typeRegistry.ts +27 -0
  117. package/index.js +486 -0
  118. package/node-connect.js +3015 -0
  119. package/operator.js +134 -0
  120. package/package.json +113 -0
  121. package/plan-operations.js +199 -0
  122. package/preflight.js +352 -0
  123. package/pricing/index.js +262 -0
  124. package/proto/amino/amino.proto +84 -0
  125. package/proto/cosmos/base/v1beta1/coin.proto +61 -0
  126. package/proto/cosmos_proto/cosmos.proto +112 -0
  127. package/proto/gogoproto/gogo.proto +145 -0
  128. package/proto/google/api/annotations.proto +31 -0
  129. package/proto/google/api/http.proto +370 -0
  130. package/proto/google/protobuf/any.proto +106 -0
  131. package/proto/google/protobuf/duration.proto +115 -0
  132. package/proto/google/protobuf/timestamp.proto +145 -0
  133. package/proto/sentinel/lease/v1/events.proto +52 -0
  134. package/proto/sentinel/lease/v1/genesis.proto +15 -0
  135. package/proto/sentinel/lease/v1/lease.proto +25 -0
  136. package/proto/sentinel/lease/v1/msg.proto +62 -0
  137. package/proto/sentinel/lease/v1/params.proto +17 -0
  138. package/proto/sentinel/node/v3/events.proto +50 -0
  139. package/proto/sentinel/node/v3/genesis.proto +15 -0
  140. package/proto/sentinel/node/v3/msg.proto +63 -0
  141. package/proto/sentinel/node/v3/node.proto +27 -0
  142. package/proto/sentinel/node/v3/params.proto +21 -0
  143. package/proto/sentinel/node/v3/querier.proto +63 -0
  144. package/proto/sentinel/plan/v3/events.proto +41 -0
  145. package/proto/sentinel/plan/v3/genesis.proto +21 -0
  146. package/proto/sentinel/plan/v3/msg.proto +83 -0
  147. package/proto/sentinel/plan/v3/plan.proto +32 -0
  148. package/proto/sentinel/plan/v3/querier.proto +53 -0
  149. package/proto/sentinel/provider/v2/events.proto +16 -0
  150. package/proto/sentinel/provider/v2/genesis.proto +15 -0
  151. package/proto/sentinel/provider/v2/msg.proto +35 -0
  152. package/proto/sentinel/provider/v2/params.proto +17 -0
  153. package/proto/sentinel/provider/v2/provider.proto +24 -0
  154. package/proto/sentinel/provider/v3/genesis.proto +15 -0
  155. package/proto/sentinel/provider/v3/params.proto +13 -0
  156. package/proto/sentinel/session/v3/events.proto +30 -0
  157. package/proto/sentinel/session/v3/genesis.proto +15 -0
  158. package/proto/sentinel/session/v3/msg.proto +50 -0
  159. package/proto/sentinel/session/v3/params.proto +25 -0
  160. package/proto/sentinel/session/v3/proof.proto +25 -0
  161. package/proto/sentinel/session/v3/querier.proto +100 -0
  162. package/proto/sentinel/session/v3/session.proto +50 -0
  163. package/proto/sentinel/subscription/v2/allocation.proto +21 -0
  164. package/proto/sentinel/subscription/v2/payout.proto +22 -0
  165. package/proto/sentinel/subscription/v3/events.proto +65 -0
  166. package/proto/sentinel/subscription/v3/genesis.proto +17 -0
  167. package/proto/sentinel/subscription/v3/msg.proto +83 -0
  168. package/proto/sentinel/subscription/v3/params.proto +21 -0
  169. package/proto/sentinel/subscription/v3/subscription.proto +33 -0
  170. package/proto/sentinel/types/v1/bandwidth.proto +19 -0
  171. package/proto/sentinel/types/v1/price.proto +21 -0
  172. package/proto/sentinel/types/v1/renewal.proto +21 -0
  173. package/proto/sentinel/types/v1/status.proto +16 -0
  174. package/protocol/encoding.js +341 -0
  175. package/protocol/events.js +361 -0
  176. package/protocol/handshake.js +297 -0
  177. package/protocol/index.js +15 -0
  178. package/protocol/messages.js +346 -0
  179. package/protocol/plans.js +199 -0
  180. package/protocol/v2ray.js +268 -0
  181. package/protocol/v3.js +723 -0
  182. package/protocol/wireguard.js +125 -0
  183. package/security/index.js +132 -0
  184. package/session-manager.js +329 -0
  185. package/session-tracker.js +80 -0
  186. package/setup.js +376 -0
  187. package/speedtest/index.js +528 -0
  188. package/speedtest.js +567 -0
  189. package/src/client.ts +502 -0
  190. package/src/index.ts +20 -0
  191. package/state/index.js +347 -0
  192. package/state.js +516 -0
  193. package/test-all-chain-ops.js +493 -0
  194. package/test-all-logic.js +199 -0
  195. package/test-all-msg-types.js +292 -0
  196. package/test-every-connection.js +208 -0
  197. package/test-feegrant-connect.js +98 -0
  198. package/test-logic.js +148 -0
  199. package/test-mainnet.js +176 -0
  200. package/test-plan-lifecycle.js +335 -0
  201. package/tls-trust.js +132 -0
  202. package/tsconfig.build.json +20 -0
  203. package/tsconfig.json +34 -0
  204. package/types/chain.d.ts +746 -0
  205. package/types/connection.d.ts +425 -0
  206. package/types/errors.d.ts +174 -0
  207. package/types/index.d.ts +1380 -0
  208. package/types/nodes.d.ts +187 -0
  209. package/types/pricing.d.ts +156 -0
  210. package/types/protocol.d.ts +332 -0
  211. package/types/session.d.ts +236 -0
  212. package/types/settings.d.ts +192 -0
  213. package/v3protocol.js +1053 -0
  214. package/wallet/index.js +153 -0
  215. package/wireguard.js +307 -0
package/state/index.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Sentinel dVPN SDK — Local State Persistence
3
+ *
4
+ * Tracks active sessions, V2Ray PIDs, and WireGuard tunnel names across process restarts.
5
+ * Also tracks session history to avoid reusing poisoned (failed handshake) sessions.
6
+ * State is saved to ~/.sentinel-sdk/state.json.
7
+ * Session history is saved to ~/.sentinel-sdk/sessions.json.
8
+ * PID file at ~/.sentinel-sdk/app.pid for server process management.
9
+ *
10
+ * When the process crashes mid-connection:
11
+ * - In-memory state (activeV2RayProc, activeWgTunnel) is lost
12
+ * - The tunnel/proxy may still be running (WG service, v2ray.exe, system proxy)
13
+ * - On next startup, loadState() + recoverOrphans() detects and cleans up
14
+ *
15
+ * Usage:
16
+ * import { saveState, loadState, clearState, recoverOrphans } from './state.js';
17
+ * import { markSessionPoisoned, isSessionPoisoned, getSessionHistory } from './state.js';
18
+ * import { writePidFile, checkPidFile, clearPidFile } from './state.js';
19
+ */
20
+
21
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, renameSync } from 'fs';
22
+ import { execSync, execFileSync } from 'child_process';
23
+ import path from 'path';
24
+ import os from 'os';
25
+
26
+ // ── State file validation (prevents command injection via poisoned state.json) ──
27
+ const STATE_SCHEMA = {
28
+ sessionId: v => v == null || /^\d+$/.test(String(v)),
29
+ serviceType: v => v == null || v === 'wireguard' || v === 'v2ray',
30
+ v2rayPid: v => v == null || (Number.isInteger(Number(v)) && Number(v) > 0),
31
+ socksPort: v => v == null || (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= 65535),
32
+ wgTunnelName: v => v == null || /^[a-zA-Z0-9_-]{1,64}$/.test(v),
33
+ systemProxySet: v => v == null || typeof v === 'boolean',
34
+ nodeAddress: v => v == null || /^sentnode1[a-z0-9]{38}$/.test(v),
35
+ confPath: v => v == null || (typeof v === 'string' && v.length <= 260 && (/^[a-zA-Z]:[\\\/][a-zA-Z0-9_.\-\\\/ ]+$/.test(v) || /^\/[a-zA-Z0-9_.\-\/ ]+$/.test(v))),
36
+ };
37
+
38
+ function validateStateValues(state) {
39
+ for (const [field, validate] of Object.entries(STATE_SCHEMA)) {
40
+ if (state[field] !== undefined && !validate(state[field])) {
41
+ console.warn(`[sentinel-sdk] Corrupted state: invalid ${field} "${state[field]}" — skipping recovery`);
42
+ return false;
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
48
+ const STATE_DIR = path.join(os.homedir(), '.sentinel-sdk');
49
+ const STATE_FILE = path.join(STATE_DIR, 'state.json');
50
+ const SESSIONS_FILE = path.join(STATE_DIR, 'sessions.json');
51
+ const PID_FILE = path.join(STATE_DIR, 'app.pid');
52
+
53
+ /**
54
+ * Save current connection state to disk.
55
+ * Call this after a successful connection.
56
+ * @param {object} state
57
+ * @param {string} state.sessionId - Active session ID
58
+ * @param {string} state.serviceType - 'wireguard' | 'v2ray'
59
+ * @param {number} state.v2rayPid - V2Ray process PID (if v2ray)
60
+ * @param {number} state.socksPort - SOCKS5 port (if v2ray)
61
+ * @param {string} state.wgTunnelName - WireGuard tunnel service name (if wireguard)
62
+ * @param {boolean} state.systemProxySet - Whether Windows system proxy was set
63
+ * @param {string} state.nodeAddress - Connected node address
64
+ * @param {string} state.confPath - WireGuard config file path
65
+ */
66
+ export function saveState(state) {
67
+ try {
68
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
69
+ // Strip unknown fields — only persist STATE_SCHEMA keys + metadata
70
+ const ALLOWED_KEYS = new Set([...Object.keys(STATE_SCHEMA), 'savedAt', 'pid']);
71
+ const cleaned = {};
72
+ for (const [k, v] of Object.entries(state)) {
73
+ if (ALLOWED_KEYS.has(k)) cleaned[k] = v;
74
+ }
75
+ const data = {
76
+ ...cleaned,
77
+ savedAt: new Date().toISOString(),
78
+ pid: process.pid,
79
+ };
80
+ writeFileSync(STATE_FILE + '.tmp', JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
81
+ renameSync(STATE_FILE + '.tmp', STATE_FILE);
82
+ } catch (e) { console.warn('[sentinel-sdk] saveState warning:', e.message); }
83
+ }
84
+
85
+ /**
86
+ * Load saved state from disk.
87
+ * Returns null if no state file exists or it's corrupt.
88
+ */
89
+ export function loadState() {
90
+ try {
91
+ if (!existsSync(STATE_FILE)) return null;
92
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Clear saved state (call after successful disconnect).
100
+ */
101
+ export function clearState() {
102
+ try {
103
+ if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE);
104
+ } catch (e) { console.warn('[sentinel-sdk] clearState warning:', e.message); }
105
+ }
106
+
107
+ /**
108
+ * Detect and clean up orphaned tunnels/processes from a previous crash.
109
+ * Call this at app startup after registerCleanupHandlers().
110
+ *
111
+ * Returns what was cleaned up (for logging).
112
+ */
113
+ export function recoverOrphans() {
114
+ const state = loadState();
115
+ if (!state) return null;
116
+
117
+ // Validate state values before using them in shell commands (prevents command injection via poisoned state.json)
118
+ if (!validateStateValues(state)) {
119
+ clearState();
120
+ return { found: true, cleaned: ['Corrupted state file removed'] };
121
+ }
122
+
123
+ const recovered = { found: true, cleaned: [] };
124
+
125
+ // Check if the process that saved the state is still running
126
+ const savedPid = state.pid;
127
+ let processAlive = false;
128
+ if (savedPid) {
129
+ try {
130
+ process.kill(savedPid, 0); // signal 0 = check existence
131
+ processAlive = true;
132
+ } catch {
133
+ processAlive = false;
134
+ }
135
+ }
136
+
137
+ // If the original process is still running, don't touch anything
138
+ if (processAlive) {
139
+ return { found: true, cleaned: [], note: `Original process ${savedPid} still running` };
140
+ }
141
+
142
+ // Clean up orphaned V2Ray
143
+ if (state.serviceType === 'v2ray' && state.v2rayPid) {
144
+ try {
145
+ if (process.platform === 'win32') {
146
+ execFileSync('taskkill', ['/F', '/PID', String(state.v2rayPid)], { stdio: 'pipe', timeout: 5000 });
147
+ } else {
148
+ process.kill(state.v2rayPid, 'SIGKILL');
149
+ }
150
+ recovered.cleaned.push(`v2ray PID ${state.v2rayPid}`);
151
+ } catch {} // already dead — expected if process exited naturally
152
+ }
153
+
154
+ // Clean up orphaned WireGuard tunnel
155
+ if (state.serviceType === 'wireguard' && state.wgTunnelName) {
156
+ try {
157
+ if (process.platform === 'win32') {
158
+ // Check if WireGuard service exists
159
+ const out = execFileSync('sc', ['query', `WireGuardTunnel$${state.wgTunnelName}`], {
160
+ encoding: 'utf8', timeout: 5000, stdio: 'pipe',
161
+ });
162
+ if (out.includes('RUNNING') || out.includes('STOPPED')) {
163
+ // Find wireguard.exe
164
+ const wgExe = ['C:\\Program Files\\WireGuard\\wireguard.exe', 'C:\\Program Files (x86)\\WireGuard\\wireguard.exe']
165
+ .find(p => existsSync(p));
166
+ if (wgExe) {
167
+ execFileSync(wgExe, ['/uninstalltunnelservice', state.wgTunnelName], { timeout: 15000, stdio: 'pipe' });
168
+ recovered.cleaned.push(`WireGuard tunnel ${state.wgTunnelName}`);
169
+ }
170
+ }
171
+ }
172
+ } catch (e) { console.warn('[sentinel-sdk] WG orphan cleanup warning:', e.message); }
173
+
174
+ // Linux/macOS: use wg-quick to remove stale tunnel
175
+ if (process.platform !== 'win32') {
176
+ try {
177
+ execFileSync('wg-quick', ['down', state.wgTunnelName], { timeout: 10000, stdio: 'pipe' });
178
+ recovered.cleaned.push(`WireGuard tunnel ${state.wgTunnelName} (wg-quick down)`);
179
+ } catch (e) { console.warn('[sentinel-sdk] wg-quick down warning:', e.message); }
180
+ }
181
+ }
182
+
183
+ // Clean up orphaned system proxy
184
+ if (state.systemProxySet) {
185
+ try {
186
+ if (process.platform === 'win32') {
187
+ const REG = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
188
+ execFileSync('reg', ['add', REG, '/v', 'ProxyEnable', '/t', 'REG_DWORD', '/d', '0', '/f'], { stdio: 'pipe' });
189
+ execFileSync('reg', ['delete', REG, '/v', 'ProxyServer', '/f'], { stdio: 'pipe' });
190
+ recovered.cleaned.push('Windows system proxy');
191
+ } else if (process.platform === 'darwin') {
192
+ const services = execFileSync('networksetup', ['-listallnetworkservices'], { encoding: 'utf8', stdio: 'pipe' })
193
+ .split('\n').filter(s => s && !s.startsWith('*') && !s.startsWith('An asterisk'));
194
+ for (const svc of services) {
195
+ try { execFileSync('networksetup', ['-setsocksfirewallproxystate', svc, 'off'], { stdio: 'pipe' }); } catch {} // service may not have proxy enabled
196
+ }
197
+ recovered.cleaned.push('macOS system proxy');
198
+ } else {
199
+ execFileSync('gsettings', ['set', 'org.gnome.system.proxy', 'mode', 'none'], { stdio: 'pipe' });
200
+ recovered.cleaned.push('Linux system proxy (GNOME)');
201
+ }
202
+ } catch (e) { console.warn('[sentinel-sdk] proxy orphan cleanup warning:', e.message); }
203
+ }
204
+
205
+ // Clean up stale config file
206
+ if (state.confPath && existsSync(state.confPath)) {
207
+ try { unlinkSync(state.confPath); } catch (e) { console.warn('[sentinel-sdk] conf cleanup warning:', e.message); }
208
+ }
209
+
210
+ clearState();
211
+ return recovered;
212
+ }
213
+
214
+ // ─── Session Tracking ────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Load session history from disk.
218
+ * Returns { sessions: { [sessionId]: { status, nodeAddress, error?, timestamp } } }
219
+ */
220
+ function loadSessions() {
221
+ try {
222
+ if (!existsSync(SESSIONS_FILE)) return { sessions: {} };
223
+ return JSON.parse(readFileSync(SESSIONS_FILE, 'utf8'));
224
+ } catch {
225
+ return { sessions: {} };
226
+ }
227
+ }
228
+
229
+ function saveSessions(data) {
230
+ try {
231
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
232
+ const tmpFile = SESSIONS_FILE + '.tmp';
233
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
234
+ renameSync(tmpFile, SESSIONS_FILE);
235
+ } catch {} // best-effort session tracking — non-fatal if write fails
236
+ }
237
+
238
+ /**
239
+ * Mark a session as poisoned (handshake failed).
240
+ * findExistingSession callers should skip poisoned sessions.
241
+ * @param {string} sessionId
242
+ * @param {string} nodeAddress
243
+ * @param {string} error - Why it was poisoned
244
+ */
245
+ export function markSessionPoisoned(sessionId, nodeAddress, error) {
246
+ const data = loadSessions();
247
+ data.sessions[String(sessionId)] = {
248
+ status: 'poisoned',
249
+ nodeAddress,
250
+ error: error?.substring(0, 200),
251
+ timestamp: new Date().toISOString(),
252
+ };
253
+ // Prune old entries (keep last 200)
254
+ const entries = Object.entries(data.sessions);
255
+ if (entries.length > 200) {
256
+ const sorted = entries.sort((a, b) => new Date(b[1].timestamp) - new Date(a[1].timestamp));
257
+ data.sessions = Object.fromEntries(sorted.slice(0, 200));
258
+ }
259
+ saveSessions(data);
260
+ }
261
+
262
+ /**
263
+ * Mark a session as successfully connected.
264
+ * @param {string} sessionId
265
+ * @param {string} nodeAddress
266
+ */
267
+ export function markSessionActive(sessionId, nodeAddress) {
268
+ const data = loadSessions();
269
+ data.sessions[String(sessionId)] = {
270
+ status: 'active',
271
+ nodeAddress,
272
+ timestamp: new Date().toISOString(),
273
+ };
274
+ saveSessions(data);
275
+ }
276
+
277
+ /**
278
+ * Check if a session was poisoned (handshake failed previously).
279
+ * @param {string} sessionId
280
+ * @returns {boolean}
281
+ */
282
+ export function isSessionPoisoned(sessionId) {
283
+ const data = loadSessions();
284
+ return data.sessions[String(sessionId)]?.status === 'poisoned';
285
+ }
286
+
287
+ /**
288
+ * Get full session history for debugging.
289
+ * @returns {{ [sessionId]: { status, nodeAddress, error?, timestamp } }}
290
+ */
291
+ export function getSessionHistory() {
292
+ return loadSessions().sessions;
293
+ }
294
+
295
+ // ─── PID File ────────────────────────────────────────────────────────────────
296
+
297
+ /**
298
+ * Write a PID file for the current process.
299
+ * Use at server startup to enable clean restarts.
300
+ * @param {string} [name='app'] - App name (creates ~/.sentinel-sdk/{name}.pid)
301
+ * @returns {{ pidFile: string }}
302
+ */
303
+ export function writePidFile(name = 'app') {
304
+ try {
305
+ mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
306
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
307
+ writeFileSync(pidFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { encoding: 'utf8', mode: 0o600 });
308
+ return { pidFile };
309
+ } catch {
310
+ return { pidFile: null };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Check if a previous instance is running from a PID file.
316
+ * Returns { running: boolean, pid?: number } so the caller can decide what to do.
317
+ * @param {string} [name='app'] - App name
318
+ */
319
+ export function checkPidFile(name = 'app') {
320
+ try {
321
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
322
+ if (!existsSync(pidFile)) return { running: false };
323
+ const data = JSON.parse(readFileSync(pidFile, 'utf8'));
324
+ const pid = data.pid;
325
+ try {
326
+ process.kill(pid, 0); // signal 0 = check existence
327
+ return { running: true, pid, startedAt: data.startedAt };
328
+ } catch {
329
+ // Process is dead — stale PID file
330
+ unlinkSync(pidFile);
331
+ return { running: false, stalePid: pid };
332
+ }
333
+ } catch {
334
+ return { running: false };
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Remove the PID file (call on clean shutdown).
340
+ * @param {string} [name='app'] - App name
341
+ */
342
+ export function clearPidFile(name = 'app') {
343
+ try {
344
+ const pidFile = path.join(STATE_DIR, `${name}.pid`);
345
+ if (existsSync(pidFile)) unlinkSync(pidFile);
346
+ } catch {} // best-effort cleanup — non-fatal
347
+ }