@profullstack/qryptchat 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -964,25 +964,48 @@ class QcClient {
964
964
  await this.refresh();
965
965
  return this.session.access_token;
966
966
  }
967
- async request(path, { method = "GET", body, signal, retry = true } = {}) {
967
+ async reauth(usedToken) {
968
+ if (this.session.access_token === usedToken)
969
+ await this.refresh();
970
+ }
971
+ async request(path, { method = "GET", body, signal, retry = true, throttled = 0 } = {}) {
972
+ const used = await this.token();
968
973
  const res = await this.fetch(`${this.base}${path}`, {
969
974
  method,
970
975
  signal,
971
976
  headers: {
972
- Authorization: `Bearer ${await this.token()}`,
977
+ Authorization: `Bearer ${used}`,
973
978
  ...body !== undefined ? { "Content-Type": "application/json" } : {}
974
979
  },
975
980
  body: body !== undefined ? JSON.stringify(body) : undefined
976
981
  });
977
982
  if (res.status === 401 && retry) {
978
- await this.refresh();
979
- return this.request(path, { method, body, signal, retry: false });
983
+ await this.reauth(used);
984
+ return this.request(path, { method, body, signal, retry: false, throttled });
985
+ }
986
+ if (res.status === 429 && throttled < 3) {
987
+ await sleep(retryAfterMs(res, throttled), signal);
988
+ return this.request(path, { method, body, signal, retry, throttled: throttled + 1 });
980
989
  }
981
990
  const data = await res.json().catch(() => ({}));
982
991
  if (!res.ok)
983
992
  throw new QcError(data.error_description || data.error || `HTTP ${res.status}`, res.status);
984
993
  return data;
985
994
  }
995
+ async keyCheck() {
996
+ try {
997
+ const me = this.me?.id;
998
+ const mine = this.session.keys?.keys1024?.publicKey;
999
+ if (!me || !mine)
1000
+ return "unknown";
1001
+ const { public_keys: keys = {} } = await this.request("/api/crypto/public-keys", { method: "POST", body: { user_ids: [me] } });
1002
+ if (!keys[me])
1003
+ return "unknown";
1004
+ return keys[me] === mine ? "ok" : "mismatch";
1005
+ } catch {
1006
+ return "unknown";
1007
+ }
1008
+ }
986
1009
  async conversations() {
987
1010
  const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
988
1011
  return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
@@ -1047,21 +1070,31 @@ class QcClient {
1047
1070
  }
1048
1071
  async events(onEvent, { signal, onStatus = () => {} } = {}) {
1049
1072
  let delay = 1000;
1073
+ let reauthed = false;
1050
1074
  while (!signal?.aborted) {
1051
1075
  onStatus("connecting");
1052
1076
  try {
1077
+ const used = await this.token();
1053
1078
  const res = await this.fetch(`${this.base}/api/events`, {
1054
- headers: { Authorization: `Bearer ${await this.token()}`, Accept: "text/event-stream" },
1079
+ headers: { Authorization: `Bearer ${used}`, Accept: "text/event-stream" },
1055
1080
  signal
1056
1081
  });
1057
1082
  if (res.status === 401) {
1058
- await this.refresh();
1083
+ if (reauthed)
1084
+ throw new QcError("The server refused this session. Run qc login.", 401);
1085
+ reauthed = true;
1086
+ await this.reauth(used);
1087
+ continue;
1088
+ }
1089
+ if (res.status === 429) {
1090
+ await sleep(retryAfterMs(res, 2), signal);
1059
1091
  continue;
1060
1092
  }
1061
1093
  if (!res.ok || !res.body)
1062
1094
  throw new Error(`HTTP ${res.status}`);
1063
1095
  onStatus("live");
1064
1096
  delay = 1000;
1097
+ reauthed = false;
1065
1098
  await readSse(res.body, onEvent);
1066
1099
  } catch (err) {
1067
1100
  if (signal?.aborted)
@@ -1111,6 +1144,10 @@ async function readSse(body, onEvent) {
1111
1144
  }
1112
1145
  }
1113
1146
  }
1147
+ function retryAfterMs(res, attempt = 0) {
1148
+ const s = Number(res.headers?.get?.("retry-after"));
1149
+ return Number.isFinite(s) && s > 0 ? Math.min(s, 60) * 1000 : 2000 * 2 ** attempt;
1150
+ }
1114
1151
  function sleep(ms, signal) {
1115
1152
  return new Promise((resolve) => {
1116
1153
  const t = setTimeout(resolve, ms);
@@ -1417,7 +1454,11 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1417
1454
  }
1418
1455
  case "login": {
1419
1456
  const { session } = await login({ oob: !!flags.oob });
1420
- return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
1457
+ out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
1458
+ if (await new QcClient(session).keyCheck() === "mismatch") {
1459
+ out("Warning: the browser handed over keys that are not your account's current keys, so messages will not decrypt here. Restore your keys in that browser (Settings > Keys) or approve from the device you chat on, then run qc login again.");
1460
+ }
1461
+ return;
1421
1462
  }
1422
1463
  case "logout":
1423
1464
  clearSession();
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  parseArgs2,
4
4
  findChat2,
5
5
  main2
6
- } from "./chunk-7ane8p3w.js";
6
+ } from "./chunk-8m3j5hfk.js";
7
7
  import"./chunk-vfahhydf.js";
8
8
  export {
9
9
  HELP2 as HELP,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import"./chunk-vfahhydf.js";
2
2
  import {
3
3
  findChat2
4
- } from "./chunk-7ane8p3w.js";
4
+ } from "./chunk-8m3j5hfk.js";
5
5
 
6
6
  // src/cli/mcp.js
7
7
  import { createInterface } from "node:readline";
package/dist/tui.js CHANGED
@@ -203,16 +203,17 @@ function saveRecent(list) {
203
203
  writeFileSync(recentFile(), JSON.stringify(list), { mode: 384 });
204
204
  } catch {}
205
205
  }
206
+ var KEY_MISMATCH = "Keys don't match your account, so messages can't decrypt. Run qc login and approve from a browser that can read your chats.";
206
207
  async function runTui(client, { initialChat } = {}) {
207
208
  const state = initialState(client.me);
208
209
  state.recent = loadRecent();
209
210
  const abort = new AbortController;
210
211
  const app = await createApp({ quitKeys: ["ctrl+c"], focusNavigation: false });
211
212
  const redraw = () => app.invalidate();
212
- const note = (msg) => {
213
+ const note = (msg, { sticky = false } = {}) => {
213
214
  state.notice = msg;
214
215
  redraw();
215
- if (msg)
216
+ if (msg && !sticky)
216
217
  setTimeout(() => {
217
218
  if (state.notice === msg) {
218
219
  state.notice = "";
@@ -231,6 +232,7 @@ async function runTui(client, { initialChat } = {}) {
231
232
  note(`Could not load messages: ${err.message}`);
232
233
  }
233
234
  }
235
+ let keysChecked = false;
234
236
  async function loadConversations() {
235
237
  try {
236
238
  const before = state.activeId;
@@ -243,9 +245,10 @@ async function runTui(client, { initialChat } = {}) {
243
245
  state.selected = Math.max(0, state.conversations.findIndex((c) => c.id === before));
244
246
  }
245
247
  redraw();
246
- for (const c of state.conversations.slice(0, 40)) {
247
- if (c.id !== state.activeId && !state.messages[c.id])
248
- await loadMessages(c.id);
248
+ if (!keysChecked) {
249
+ keysChecked = true;
250
+ if (await client.keyCheck() === "mismatch")
251
+ note(KEY_MISMATCH, { sticky: true });
249
252
  }
250
253
  } catch (err) {
251
254
  state.loading = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
5
5
  "type": "module",
6
6
  "bin": {