@goodea/echolet 0.1.0 → 0.1.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.
- package/dist/cli.js +136 -89
- package/dist/tui.js +0 -0
- package/package.json +3 -3
- package/web-dist/client/bundle.js +5 -5
- package/web-dist/client/styles.css +453 -0
- package/web-dist/server.js +353 -82
- package/dist/cli.red-24474.js +0 -37801
- package/web-dist/client/audiophile_tube_ui_1789836391373.jpg +0 -0
- package/web-dist/client/cyberdeck_spectrum_ui_1789835241900.jpg +0 -0
- package/web-dist/client/echolet_obsidian_clean_1789834739350.jpg +0 -0
- package/web-dist/client/echolet_split_horizon_1789834765974.jpg +0 -0
- package/web-dist/client/echolet_tactical_terminal_1789834714340.jpg +0 -0
- package/web-dist/client/gallery.html +0 -541
- package/web-dist/client/glassmorphic_radio_ui_1789835283668.jpg +0 -0
- package/web-dist/client/nixie_tube_messenger_1789836184230.jpg +0 -0
- package/web-dist/client/op1_minimal_skin_1789836795427.jpg +0 -0
- package/web-dist/client/soviet_tube_radio_1789836251332.jpg +0 -0
- package/web-dist/client/stealth_zerotrace_ui_1789835261306.jpg +0 -0
- package/web-dist/client/steampunk_brass_ui_1789836339337.jpg +0 -0
- package/web-dist/client/tube_oscilloscope_ui_1789836294852.jpg +0 -0
- package/web-dist/client/vintage_radiola_ui_1789836216660.jpg +0 -0
package/web-dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ const require = createRequire(import.meta.url);
|
|
|
4
4
|
|
|
5
5
|
// ../web/src/server/server.ts
|
|
6
6
|
import http from "node:http";
|
|
7
|
-
import { readFile
|
|
7
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
8
8
|
import { existsSync as existsSync2 } from "node:fs";
|
|
9
9
|
import { resolve as resolve2, extname, dirname as dirname2 } from "node:path";
|
|
10
10
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
@@ -40,19 +40,37 @@ var CliBridge = class {
|
|
|
40
40
|
async execute(argv, stdinInput) {
|
|
41
41
|
return new Promise((res) => {
|
|
42
42
|
const child = spawn(process.execPath, [this.options.cliPath, ...argv], {
|
|
43
|
-
env: process.env,
|
|
43
|
+
env: this.options.environment ?? process.env,
|
|
44
44
|
stdio: ["pipe", "pipe", "pipe"]
|
|
45
45
|
});
|
|
46
46
|
let stdout = "";
|
|
47
47
|
let stderr = "";
|
|
48
|
+
let settled = false;
|
|
49
|
+
const finish = (outcome) => {
|
|
50
|
+
if (settled) return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
res(outcome);
|
|
54
|
+
};
|
|
55
|
+
const timeout = setTimeout(() => {
|
|
56
|
+
child.kill("SIGKILL");
|
|
57
|
+
finish({
|
|
58
|
+
ok: false,
|
|
59
|
+
code: "CLI_TIMEOUT",
|
|
60
|
+
exitCode: 1,
|
|
61
|
+
data: { error: "CLI command timed out" }
|
|
62
|
+
});
|
|
63
|
+
}, this.options.commandTimeoutMs ?? 15e3);
|
|
48
64
|
child.stdout.on("data", (chunk) => {
|
|
49
65
|
stdout += chunk.toString("utf8");
|
|
50
66
|
});
|
|
51
67
|
child.stderr.on("data", (chunk) => {
|
|
52
68
|
stderr += chunk.toString("utf8");
|
|
53
69
|
});
|
|
70
|
+
child.stdin.on("error", () => {
|
|
71
|
+
});
|
|
54
72
|
child.on("error", (err) => {
|
|
55
|
-
|
|
73
|
+
finish({
|
|
56
74
|
ok: false,
|
|
57
75
|
code: "SPAWN_ERROR",
|
|
58
76
|
exitCode: 1,
|
|
@@ -65,7 +83,7 @@ var CliBridge = class {
|
|
|
65
83
|
const trimmed = stdout.trim();
|
|
66
84
|
if (trimmed.startsWith("{")) {
|
|
67
85
|
const parsed = JSON.parse(trimmed);
|
|
68
|
-
|
|
86
|
+
finish({
|
|
69
87
|
ok: parsed.ok ?? code === 0,
|
|
70
88
|
code: parsed.error?.code ?? (code === 0 ? "ok" : "ERROR"),
|
|
71
89
|
exitCode: code,
|
|
@@ -75,7 +93,7 @@ var CliBridge = class {
|
|
|
75
93
|
}
|
|
76
94
|
} catch {
|
|
77
95
|
}
|
|
78
|
-
|
|
96
|
+
finish({
|
|
79
97
|
ok: code === 0,
|
|
80
98
|
code: code === 0 ? "ok" : "NON_ZERO_EXIT",
|
|
81
99
|
exitCode: code,
|
|
@@ -137,6 +155,203 @@ var CliBridge = class {
|
|
|
137
155
|
"--json"
|
|
138
156
|
]);
|
|
139
157
|
}
|
|
158
|
+
async validateContact(cardPath) {
|
|
159
|
+
return this.execute([
|
|
160
|
+
"contact",
|
|
161
|
+
"import",
|
|
162
|
+
"--from",
|
|
163
|
+
cardPath,
|
|
164
|
+
"--profile",
|
|
165
|
+
this.options.profileDir,
|
|
166
|
+
"--json"
|
|
167
|
+
], "no\n");
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// ../web/src/server/contactCards.ts
|
|
172
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
173
|
+
import { tmpdir } from "node:os";
|
|
174
|
+
import { join } from "node:path";
|
|
175
|
+
function objectValue(value) {
|
|
176
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
177
|
+
}
|
|
178
|
+
function parseContactCardJson(input) {
|
|
179
|
+
let value = input;
|
|
180
|
+
if (typeof value === "string") {
|
|
181
|
+
try {
|
|
182
|
+
value = JSON.parse(value);
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, code: "INVALID_CONTACT_CARD", message: "Contact card is not valid JSON" };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const card = objectValue(value);
|
|
188
|
+
const signalBundle = objectValue(card?.signal_bundle);
|
|
189
|
+
const deviceRecord = objectValue(signalBundle?.device_record);
|
|
190
|
+
const identityId = deviceRecord?.identity_id;
|
|
191
|
+
const deviceId = deviceRecord?.device_id;
|
|
192
|
+
if (card?.type !== "echolet_contact_card" || card.version !== 1 || typeof identityId !== "string" || !identityId || typeof deviceId !== "string" || !deviceId) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
code: "INVALID_CONTACT_CARD",
|
|
196
|
+
message: "Contact card is missing public identity fields"
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
data: { card, preview: { identityId, deviceId } }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function errorDetail(outcome) {
|
|
205
|
+
const data = objectValue(outcome.data);
|
|
206
|
+
const error = objectValue(data?.error);
|
|
207
|
+
return typeof error?.detail === "string" ? error.detail : null;
|
|
208
|
+
}
|
|
209
|
+
function operationFailure(outcome) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
code: outcome.code,
|
|
213
|
+
message: "Contact card operation failed",
|
|
214
|
+
outcome
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function ioFailure() {
|
|
218
|
+
return { ok: false, code: "PERSISTENCE_FAILURE", message: "Temporary contact card storage failed" };
|
|
219
|
+
}
|
|
220
|
+
async function exportContactCard(bridge2, temporaryRoot = tmpdir()) {
|
|
221
|
+
let directory;
|
|
222
|
+
try {
|
|
223
|
+
directory = await mkdtemp(join(temporaryRoot, "echolet-web-export-"));
|
|
224
|
+
const outputPath = join(directory, "contact-card.json");
|
|
225
|
+
const outcome = await bridge2.exportContact(outputPath);
|
|
226
|
+
if (!outcome.ok) return operationFailure(outcome);
|
|
227
|
+
const contents = await readFile(outputPath, "utf8");
|
|
228
|
+
return parseContactCardJson(contents);
|
|
229
|
+
} catch {
|
|
230
|
+
return ioFailure();
|
|
231
|
+
} finally {
|
|
232
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => void 0);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function importContactCardJson(bridge2, cardJson, temporaryRoot = tmpdir()) {
|
|
236
|
+
const parsed = parseContactCardJson(cardJson);
|
|
237
|
+
if (!parsed.ok) return parsed;
|
|
238
|
+
let directory;
|
|
239
|
+
try {
|
|
240
|
+
directory = await mkdtemp(join(temporaryRoot, "echolet-web-import-"));
|
|
241
|
+
const inputPath = join(directory, "contact-card.json");
|
|
242
|
+
await writeFile(inputPath, JSON.stringify(parsed.data.card), { encoding: "utf8", flag: "wx", mode: 384 });
|
|
243
|
+
const outcome = await bridge2.importContact(inputPath);
|
|
244
|
+
if (!outcome.ok) return operationFailure(outcome);
|
|
245
|
+
return parsed;
|
|
246
|
+
} catch {
|
|
247
|
+
return ioFailure();
|
|
248
|
+
} finally {
|
|
249
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => void 0);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function validateContactCardJson(bridge2, cardJson, temporaryRoot = tmpdir()) {
|
|
253
|
+
const parsed = parseContactCardJson(cardJson);
|
|
254
|
+
if (!parsed.ok) return parsed;
|
|
255
|
+
let directory;
|
|
256
|
+
try {
|
|
257
|
+
directory = await mkdtemp(join(temporaryRoot, "echolet-web-validate-"));
|
|
258
|
+
const inputPath = join(directory, "contact-card.json");
|
|
259
|
+
await writeFile(inputPath, JSON.stringify(parsed.data.card), { encoding: "utf8", flag: "wx", mode: 384 });
|
|
260
|
+
const outcome = await bridge2.validateContact(inputPath);
|
|
261
|
+
const declinedAfterVerification = outcome.code === "CONTACT_NOT_CONFIRMED" || outcome.code === "TRUST_FAILURE" && errorDetail(outcome) === "CONTACT_NOT_CONFIRMED";
|
|
262
|
+
return declinedAfterVerification ? parsed : operationFailure(outcome);
|
|
263
|
+
} catch {
|
|
264
|
+
return ioFailure();
|
|
265
|
+
} finally {
|
|
266
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => void 0);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ../web/src/server/stationApi.ts
|
|
271
|
+
function createStatusPayload(label, snapshot) {
|
|
272
|
+
return { label, ...snapshot };
|
|
273
|
+
}
|
|
274
|
+
function validationFailureHttpStatus(code) {
|
|
275
|
+
if (code === "INVALID_CONTACT_CARD" || code === "TRUST_FAILURE") return 400;
|
|
276
|
+
if (code === "CLI_TIMEOUT") return 504;
|
|
277
|
+
return 500;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ../web/src/server/stationStatus.ts
|
|
281
|
+
function recordValue(value) {
|
|
282
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
283
|
+
}
|
|
284
|
+
var StationStatus = class {
|
|
285
|
+
constructor(options) {
|
|
286
|
+
this.options = options;
|
|
287
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
288
|
+
this.now = options.now ?? Date.now;
|
|
289
|
+
this.statusUpdatedAt = new Date(this.now()).toISOString();
|
|
290
|
+
}
|
|
291
|
+
fetchImpl;
|
|
292
|
+
now;
|
|
293
|
+
profile = null;
|
|
294
|
+
profileState = "unknown";
|
|
295
|
+
relay = "unknown";
|
|
296
|
+
relayReachability = "unknown";
|
|
297
|
+
pingMs = null;
|
|
298
|
+
statusUpdatedAt;
|
|
299
|
+
profileRefreshGeneration = 0;
|
|
300
|
+
relayRefreshGeneration = 0;
|
|
301
|
+
async refreshProfile() {
|
|
302
|
+
const generation = ++this.profileRefreshGeneration;
|
|
303
|
+
const outcome = await this.options.doctor();
|
|
304
|
+
const profile = outcome.ok ? recordValue(outcome.data) : null;
|
|
305
|
+
if (generation !== this.profileRefreshGeneration) return;
|
|
306
|
+
this.profile = profile;
|
|
307
|
+
this.profileState = profile ? "verified" : outcome.code === "SPAWN_ERROR" || outcome.code === "CLI_TIMEOUT" ? "unknown" : "unverified";
|
|
308
|
+
this.touch();
|
|
309
|
+
}
|
|
310
|
+
async refreshRelayReachability() {
|
|
311
|
+
const generation = ++this.relayRefreshGeneration;
|
|
312
|
+
const startedAt = this.now();
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
const timeout = setTimeout(() => controller.abort(), this.options.healthTimeoutMs ?? 3e3);
|
|
315
|
+
let relayReachability = "unreachable";
|
|
316
|
+
let pingMs = null;
|
|
317
|
+
try {
|
|
318
|
+
const response = await this.fetchImpl(`${this.options.relayUrl}/health`, { signal: controller.signal });
|
|
319
|
+
if (response.ok) {
|
|
320
|
+
relayReachability = "reachable";
|
|
321
|
+
pingMs = Math.max(0, this.now() - startedAt);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
} finally {
|
|
325
|
+
clearTimeout(timeout);
|
|
326
|
+
}
|
|
327
|
+
if (generation !== this.relayRefreshGeneration) return;
|
|
328
|
+
this.relayReachability = relayReachability;
|
|
329
|
+
this.pingMs = pingMs;
|
|
330
|
+
this.touch();
|
|
331
|
+
}
|
|
332
|
+
recordPoll(outcome) {
|
|
333
|
+
if (outcome.ok) this.relay = "connected";
|
|
334
|
+
else if (outcome.code === "RELAY_UNAVAILABLE" || outcome.code === "CLI_TIMEOUT" || outcome.code === "SPAWN_ERROR") {
|
|
335
|
+
this.relay = "disconnected";
|
|
336
|
+
} else {
|
|
337
|
+
this.relay = "unknown";
|
|
338
|
+
}
|
|
339
|
+
this.touch();
|
|
340
|
+
}
|
|
341
|
+
snapshot() {
|
|
342
|
+
return {
|
|
343
|
+
profile: this.profile,
|
|
344
|
+
profileState: this.profileState,
|
|
345
|
+
relay: this.relay,
|
|
346
|
+
relayReachability: this.relayReachability,
|
|
347
|
+
relayUrl: this.options.relayUrl,
|
|
348
|
+
pingMs: this.pingMs,
|
|
349
|
+
statusUpdatedAt: this.statusUpdatedAt
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
touch() {
|
|
353
|
+
this.statusUpdatedAt = new Date(this.now()).toISOString();
|
|
354
|
+
}
|
|
140
355
|
};
|
|
141
356
|
|
|
142
357
|
// ../web/src/server/server.ts
|
|
@@ -182,6 +397,10 @@ var bridge = new CliBridge({
|
|
|
182
397
|
storeKeyEnv: config.storeKeyEnv,
|
|
183
398
|
relayUrl: config.relayUrl
|
|
184
399
|
});
|
|
400
|
+
var stationStatus = new StationStatus({
|
|
401
|
+
doctor: () => bridge.doctor(),
|
|
402
|
+
relayUrl: config.relayUrl
|
|
403
|
+
});
|
|
185
404
|
var telemetryLogs = [];
|
|
186
405
|
function logTelemetry(type, message) {
|
|
187
406
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -206,24 +425,16 @@ data: ${JSON.stringify(data)}
|
|
|
206
425
|
client.write(payload);
|
|
207
426
|
}
|
|
208
427
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const ping = Date.now() - start;
|
|
220
|
-
lastPingMs = ping;
|
|
221
|
-
return ping;
|
|
222
|
-
}
|
|
223
|
-
} catch {
|
|
224
|
-
lastPingMs = null;
|
|
225
|
-
}
|
|
226
|
-
return null;
|
|
428
|
+
function statusPayload() {
|
|
429
|
+
return createStatusPayload(config.label, stationStatus.snapshot());
|
|
430
|
+
}
|
|
431
|
+
function broadcastStatus() {
|
|
432
|
+
broadcastSSE("status", statusPayload());
|
|
433
|
+
}
|
|
434
|
+
function numericField(value, key) {
|
|
435
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
436
|
+
const field = value[key];
|
|
437
|
+
return typeof field === "number" && Number.isFinite(field) ? field : null;
|
|
227
438
|
}
|
|
228
439
|
var polling = false;
|
|
229
440
|
async function pollLoop() {
|
|
@@ -231,8 +442,10 @@ async function pollLoop() {
|
|
|
231
442
|
polling = true;
|
|
232
443
|
try {
|
|
233
444
|
const outcome = await bridge.poll();
|
|
445
|
+
stationStatus.recordPoll(outcome);
|
|
446
|
+
broadcastStatus();
|
|
234
447
|
if (outcome.ok && outcome.data) {
|
|
235
|
-
const received = outcome.data
|
|
448
|
+
const received = numericField(outcome.data, "received") ?? 0;
|
|
236
449
|
if (received > 0) {
|
|
237
450
|
logTelemetry("success", `[Inbound] ${received} new encrypted message(s) downloaded from relay`);
|
|
238
451
|
broadcastSSE("new_message", { received });
|
|
@@ -240,36 +453,41 @@ async function pollLoop() {
|
|
|
240
453
|
} else if (!outcome.ok) {
|
|
241
454
|
logTelemetry("warn", `[Poll Warning] ${outcome.code}`);
|
|
242
455
|
}
|
|
243
|
-
} catch (
|
|
244
|
-
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const message = error instanceof Error ? error.message : "Unknown poll error";
|
|
458
|
+
logTelemetry("error", `[Poll Error] ${message}`);
|
|
245
459
|
} finally {
|
|
246
460
|
polling = false;
|
|
247
461
|
}
|
|
248
462
|
}
|
|
249
463
|
setInterval(pollLoop, 2500);
|
|
250
464
|
setInterval(async () => {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
465
|
+
await stationStatus.refreshRelayReachability();
|
|
466
|
+
const snapshot = statusPayload();
|
|
467
|
+
if (snapshot.pingMs !== null) {
|
|
468
|
+
broadcastSSE("ping", { ping: snapshot.pingMs, status: "reachable" });
|
|
254
469
|
} else {
|
|
255
470
|
broadcastSSE("ping", { ping: null, status: "unreachable" });
|
|
256
471
|
}
|
|
472
|
+
broadcastStatus();
|
|
257
473
|
}, 5e3);
|
|
258
474
|
(async () => {
|
|
259
475
|
logTelemetry("crypto", `Initializing Echolet Web Node for [${config.label}]`);
|
|
260
476
|
logTelemetry("info", `Profile store: ${config.profileDir}`);
|
|
261
477
|
logTelemetry("info", `Target relay: ${config.relayUrl}`);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
478
|
+
await stationStatus.refreshRelayReachability();
|
|
479
|
+
const snapshot = statusPayload();
|
|
480
|
+
if (snapshot.pingMs !== null) {
|
|
481
|
+
logTelemetry("success", `Relay reachable: ${snapshot.pingMs}ms latency`);
|
|
265
482
|
} else {
|
|
266
483
|
logTelemetry("warn", `Relay unreachable or checking...`);
|
|
267
484
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
logTelemetry("
|
|
485
|
+
await stationStatus.refreshProfile();
|
|
486
|
+
const profile = statusPayload().profile;
|
|
487
|
+
const identityId = typeof profile?.identity_id === "string" ? profile.identity_id : "";
|
|
488
|
+
if (profile) {
|
|
489
|
+
logTelemetry("crypto", `Identity verified: ${identityId.substring(0, 16)}...`);
|
|
490
|
+
logTelemetry("info", `Pinned contacts count: ${numericField(profile, "contact_count") ?? 0}`);
|
|
273
491
|
}
|
|
274
492
|
})();
|
|
275
493
|
var MIME_TYPES = {
|
|
@@ -281,20 +499,57 @@ var MIME_TYPES = {
|
|
|
281
499
|
".png": "image/png",
|
|
282
500
|
".jpg": "image/jpeg"
|
|
283
501
|
};
|
|
502
|
+
var MAX_JSON_BODY_BYTES = 128 * 1024;
|
|
503
|
+
var RequestBodyError = class extends Error {
|
|
504
|
+
constructor(statusCode, code, message) {
|
|
505
|
+
super(message);
|
|
506
|
+
this.statusCode = statusCode;
|
|
507
|
+
this.code = code;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
284
510
|
async function readBody(req) {
|
|
285
511
|
return new Promise((res, rej) => {
|
|
286
512
|
let body = "";
|
|
287
|
-
|
|
513
|
+
let bytes = 0;
|
|
514
|
+
let settled = false;
|
|
515
|
+
req.on("data", (chunk) => {
|
|
516
|
+
if (settled) return;
|
|
517
|
+
bytes += chunk.length;
|
|
518
|
+
if (bytes > MAX_JSON_BODY_BYTES) {
|
|
519
|
+
settled = true;
|
|
520
|
+
req.resume();
|
|
521
|
+
rej(new RequestBodyError(413, "PAYLOAD_TOO_LARGE", "JSON body exceeds 128 KiB"));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
body += chunk.toString("utf8");
|
|
525
|
+
});
|
|
288
526
|
req.on("end", () => {
|
|
527
|
+
if (settled) return;
|
|
289
528
|
try {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
529
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
530
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
531
|
+
rej(new RequestBodyError(400, "INVALID_JSON_BODY", "JSON body must be an object"));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
res(parsed);
|
|
535
|
+
} catch {
|
|
536
|
+
rej(new RequestBodyError(400, "INVALID_JSON_BODY", "JSON body is malformed"));
|
|
293
537
|
}
|
|
294
538
|
});
|
|
295
539
|
req.on("error", rej);
|
|
296
540
|
});
|
|
297
541
|
}
|
|
542
|
+
function writeJson(res, statusCode, value) {
|
|
543
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
544
|
+
res.end(JSON.stringify(value));
|
|
545
|
+
}
|
|
546
|
+
function writeRequestError(res, error) {
|
|
547
|
+
if (error instanceof RequestBodyError) {
|
|
548
|
+
writeJson(res, error.statusCode, { ok: false, code: error.code, error: error.message });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
writeJson(res, 500, { ok: false, code: "INTERNAL_ERROR", error: "Request failed" });
|
|
552
|
+
}
|
|
298
553
|
var server = http.createServer(async (req, res) => {
|
|
299
554
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
300
555
|
const pathname = url.pathname;
|
|
@@ -312,25 +567,24 @@ var server = http.createServer(async (req, res) => {
|
|
|
312
567
|
"Cache-Control": "no-cache",
|
|
313
568
|
Connection: "keep-alive"
|
|
314
569
|
});
|
|
315
|
-
res.write(
|
|
570
|
+
res.write(`event: status
|
|
571
|
+
data: ${JSON.stringify(statusPayload())}
|
|
572
|
+
|
|
573
|
+
`);
|
|
316
574
|
sseClients.add(res);
|
|
317
575
|
req.on("close", () => sseClients.delete(res));
|
|
318
576
|
return;
|
|
319
577
|
}
|
|
320
578
|
if (pathname === "/api/status" && req.method === "GET") {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
res
|
|
326
|
-
res.end(JSON.stringify({
|
|
579
|
+
await Promise.all([
|
|
580
|
+
stationStatus.refreshProfile(),
|
|
581
|
+
stationStatus.refreshRelayReachability()
|
|
582
|
+
]);
|
|
583
|
+
writeJson(res, 200, {
|
|
327
584
|
ok: true,
|
|
328
|
-
|
|
329
|
-
profile: cachedProfile,
|
|
330
|
-
relayUrl: config.relayUrl,
|
|
331
|
-
pingMs: lastPingMs,
|
|
585
|
+
...statusPayload(),
|
|
332
586
|
telemetry: telemetryLogs.slice(-50)
|
|
333
|
-
})
|
|
587
|
+
});
|
|
334
588
|
return;
|
|
335
589
|
}
|
|
336
590
|
if (pathname === "/api/history" && req.method === "GET") {
|
|
@@ -348,13 +602,15 @@ var server = http.createServer(async (req, res) => {
|
|
|
348
602
|
if (pathname === "/api/send" && req.method === "POST") {
|
|
349
603
|
try {
|
|
350
604
|
const body = await readBody(req);
|
|
351
|
-
|
|
605
|
+
const to = typeof body.to === "string" ? body.to : "";
|
|
606
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
607
|
+
if (!to || !text) {
|
|
352
608
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
353
609
|
res.end(JSON.stringify({ ok: false, error: "Missing 'to' or 'text'" }));
|
|
354
610
|
return;
|
|
355
611
|
}
|
|
356
|
-
logTelemetry("crypto", `Encrypting message via Double Ratchet for recipient ${
|
|
357
|
-
const outcome = await bridge.send(
|
|
612
|
+
logTelemetry("crypto", `Encrypting message via Double Ratchet for recipient ${to.substring(0, 12)}...`);
|
|
613
|
+
const outcome = await bridge.send(to, text);
|
|
358
614
|
if (outcome.ok) {
|
|
359
615
|
logTelemetry("success", `[Outbound] Envelope delivered to relay (status: ${outcome.data?.status})`);
|
|
360
616
|
broadcastSSE("outbound_sent", outcome.data);
|
|
@@ -382,46 +638,61 @@ var server = http.createServer(async (req, res) => {
|
|
|
382
638
|
return;
|
|
383
639
|
}
|
|
384
640
|
if (pathname === "/api/contacts/export" && req.method === "GET") {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const cardJson = await readFile(tmpOut, "utf8");
|
|
389
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
390
|
-
res.end(cardJson);
|
|
641
|
+
const result = await exportContactCard(bridge);
|
|
642
|
+
if (result.ok) {
|
|
643
|
+
writeJson(res, 200, result.data.card);
|
|
391
644
|
return;
|
|
392
645
|
}
|
|
393
|
-
|
|
394
|
-
res.
|
|
646
|
+
logTelemetry("error", `Contact export failed: ${result.code}`);
|
|
647
|
+
writeJson(res, 500, { ok: false, code: result.code, error: result.message });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (pathname === "/api/contacts/validate" && req.method === "POST") {
|
|
651
|
+
try {
|
|
652
|
+
const body = await readBody(req);
|
|
653
|
+
const result = await validateContactCardJson(bridge, body.cardJson);
|
|
654
|
+
if (!result.ok) {
|
|
655
|
+
writeJson(res, validationFailureHttpStatus(result.code), {
|
|
656
|
+
ok: false,
|
|
657
|
+
code: result.code,
|
|
658
|
+
error: result.message
|
|
659
|
+
});
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
writeJson(res, 200, { ok: true, data: { preview: result.data.preview } });
|
|
663
|
+
} catch (error) {
|
|
664
|
+
writeRequestError(res, error);
|
|
665
|
+
}
|
|
395
666
|
return;
|
|
396
667
|
}
|
|
397
668
|
if (pathname === "/api/contacts/import" && req.method === "POST") {
|
|
398
669
|
try {
|
|
399
670
|
const body = await readBody(req);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
await writeFile(cardPath, typeof body.cardJson === "string" ? body.cardJson : JSON.stringify(body.cardJson), "utf8");
|
|
671
|
+
if (body.cardJson === void 0) {
|
|
672
|
+
writeJson(res, 400, { ok: false, code: "MISSING_CONTACT_CARD", error: "Missing cardJson" });
|
|
673
|
+
return;
|
|
404
674
|
}
|
|
405
|
-
if (
|
|
406
|
-
res
|
|
407
|
-
res.end(JSON.stringify({ ok: false, error: "Missing cardPath or cardJson" }));
|
|
675
|
+
if (body.confirmed !== true) {
|
|
676
|
+
writeJson(res, 409, { ok: false, code: "CONFIRMATION_REQUIRED", error: "Confirm the validated contact card before import" });
|
|
408
677
|
return;
|
|
409
678
|
}
|
|
410
679
|
logTelemetry("crypto", `Verifying contact card cryptographic signatures...`);
|
|
411
|
-
const
|
|
412
|
-
if (
|
|
680
|
+
const result = await importContactCardJson(bridge, body.cardJson);
|
|
681
|
+
if (result.ok) {
|
|
413
682
|
logTelemetry("success", `Contact trusted & added to secure address book`);
|
|
414
|
-
|
|
415
|
-
if (doc.ok) cachedProfile = doc.data;
|
|
683
|
+
await stationStatus.refreshProfile();
|
|
416
684
|
broadcastSSE("contact_added", {});
|
|
685
|
+
broadcastStatus();
|
|
417
686
|
} else {
|
|
418
|
-
logTelemetry("error", `Contact import failed: ${
|
|
687
|
+
logTelemetry("error", `Contact import failed: ${result.code}`);
|
|
419
688
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
689
|
+
writeJson(
|
|
690
|
+
res,
|
|
691
|
+
result.ok ? 200 : result.code === "INVALID_CONTACT_CARD" || result.code === "TRUST_FAILURE" ? 400 : 500,
|
|
692
|
+
result.ok ? { ok: true, code: "ok", data: { preview: result.data.preview } } : { ok: false, code: result.code, error: result.message }
|
|
693
|
+
);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
writeRequestError(res, error);
|
|
425
696
|
}
|
|
426
697
|
return;
|
|
427
698
|
}
|
|
@@ -434,7 +705,7 @@ var server = http.createServer(async (req, res) => {
|
|
|
434
705
|
const ext = extname(target).toLowerCase();
|
|
435
706
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
436
707
|
try {
|
|
437
|
-
const content = await
|
|
708
|
+
const content = await readFile2(target);
|
|
438
709
|
res.writeHead(200, { "Content-Type": contentType });
|
|
439
710
|
res.end(content);
|
|
440
711
|
return;
|