@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.
@@ -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, writeFile } from "node:fs/promises";
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
- res({
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
- res({
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
- res({
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
- var cachedProfile = null;
210
- var lastPingMs = null;
211
- async function checkRelayPing() {
212
- const start = Date.now();
213
- try {
214
- const controller = new AbortController();
215
- const timeout = setTimeout(() => controller.abort(), 3e3);
216
- const res = await fetch(`${config.relayUrl}/health`, { signal: controller.signal });
217
- clearTimeout(timeout);
218
- if (res.ok) {
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.received ?? 0;
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 (err) {
244
- logTelemetry("error", `[Poll Error] ${err.message}`);
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
- const ping = await checkRelayPing();
252
- if (ping !== null) {
253
- broadcastSSE("ping", { ping, status: "healthy" });
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
- const ping = await checkRelayPing();
263
- if (ping !== null) {
264
- logTelemetry("success", `Connected to Relay: ${ping}ms latency`);
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
- const doc = await bridge.doctor();
269
- if (doc.ok) {
270
- cachedProfile = doc.data;
271
- logTelemetry("crypto", `Identity verified: ${doc.data.identity_id?.substring(0, 16)}...`);
272
- logTelemetry("info", `Pinned contacts count: ${doc.data.contact_count ?? 0}`);
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
- req.on("data", (chunk) => body += chunk);
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
- res(body ? JSON.parse(body) : {});
291
- } catch (err) {
292
- rej(err);
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("\n");
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
- if (!cachedProfile) {
322
- const doc = await bridge.doctor();
323
- if (doc.ok) cachedProfile = doc.data;
324
- }
325
- res.writeHead(200, { "Content-Type": "application/json" });
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
- label: config.label,
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
- if (!body.to || !body.text) {
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 ${body.to.substring(0, 12)}...`);
357
- const outcome = await bridge.send(body.to, body.text);
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 tmpOut = resolve2(config.profileDir, "../export-temp.json");
386
- const outcome = await bridge.exportContact(tmpOut);
387
- if (outcome.ok && existsSync2(tmpOut)) {
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
- res.writeHead(500, { "Content-Type": "application/json" });
394
- res.end(JSON.stringify(outcome));
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
- let cardPath = body.cardPath;
401
- if (body.cardJson) {
402
- cardPath = resolve2(config.profileDir, "../import-temp.json");
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 (!cardPath) {
406
- res.writeHead(400, { "Content-Type": "application/json" });
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 outcome = await bridge.importContact(cardPath);
412
- if (outcome.ok) {
680
+ const result = await importContactCardJson(bridge, body.cardJson);
681
+ if (result.ok) {
413
682
  logTelemetry("success", `Contact trusted & added to secure address book`);
414
- const doc = await bridge.doctor();
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: ${outcome.code}`);
687
+ logTelemetry("error", `Contact import failed: ${result.code}`);
419
688
  }
420
- res.writeHead(outcome.ok ? 200 : 500, { "Content-Type": "application/json" });
421
- res.end(JSON.stringify(outcome));
422
- } catch (err) {
423
- res.writeHead(500, { "Content-Type": "application/json" });
424
- res.end(JSON.stringify({ ok: false, error: err.message }));
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 readFile(target);
708
+ const content = await readFile2(target);
438
709
  res.writeHead(200, { "Content-Type": contentType });
439
710
  res.end(content);
440
711
  return;