@ih8e/express-cli 0.1.4 → 0.1.6

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/index.js CHANGED
@@ -49,6 +49,219 @@ var init_esm_shims = __esm({
49
49
  }
50
50
  });
51
51
 
52
+ // src/types/express.ts
53
+ var init_express = __esm({
54
+ "src/types/express.ts"() {
55
+ "use strict";
56
+ init_esm_shims();
57
+ }
58
+ });
59
+
60
+ // src/types/api.ts
61
+ var init_api = __esm({
62
+ "src/types/api.ts"() {
63
+ "use strict";
64
+ init_esm_shims();
65
+ }
66
+ });
67
+
68
+ // src/types/config.ts
69
+ import { z } from "zod";
70
+ var configSchema, envSchema;
71
+ var init_config = __esm({
72
+ "src/types/config.ts"() {
73
+ "use strict";
74
+ init_esm_shims();
75
+ configSchema = z.object({
76
+ host: z.string().min(1, "host is required \u2014 set EXPRESS_HOST or configure via `express config set host <host>`"),
77
+ protocol: z.enum(["https", "http"]).default("https"),
78
+ token: z.string().optional(),
79
+ locale: z.string().default("ru"),
80
+ platform: z.string().default("web"),
81
+ platform_package_id: z.string().default("ru.alfabank"),
82
+ app_version: z.string().default("3.66.47"),
83
+ output: z.enum(["table", "json"]).default("table")
84
+ });
85
+ envSchema = z.object({
86
+ EXPRESS_HOST: z.string().optional(),
87
+ EXPRESS_TOKEN: z.string().optional(),
88
+ EXPRESS_LOCALE: z.string().optional(),
89
+ EXPRESS_OUTPUT: z.enum(["table", "json"]).optional()
90
+ });
91
+ }
92
+ });
93
+
94
+ // src/types/index.ts
95
+ var init_types = __esm({
96
+ "src/types/index.ts"() {
97
+ "use strict";
98
+ init_esm_shims();
99
+ init_express();
100
+ init_api();
101
+ init_config();
102
+ }
103
+ });
104
+
105
+ // src/config/store.ts
106
+ import Conf from "conf";
107
+ function getStoredConfig() {
108
+ return store.get("config") ?? {};
109
+ }
110
+ function setStoredConfig(partial) {
111
+ const current = getStoredConfig();
112
+ store.set("config", { ...current, ...partial });
113
+ }
114
+ function getAuthToken() {
115
+ return store.get("authToken") ?? null;
116
+ }
117
+ function setAuthToken(token) {
118
+ if (token === null) {
119
+ store.delete("authToken");
120
+ } else {
121
+ store.set("authToken", token);
122
+ }
123
+ }
124
+ function getRtsAuthToken() {
125
+ return store.get("rtsAuthToken") ?? null;
126
+ }
127
+ function setRtsAuthToken(token) {
128
+ if (token === null) {
129
+ store.delete("rtsAuthToken");
130
+ } else {
131
+ store.set("rtsAuthToken", token);
132
+ }
133
+ }
134
+ function getRefreshToken() {
135
+ return store.get("refreshToken") ?? null;
136
+ }
137
+ function setRefreshToken(token) {
138
+ if (token === null) {
139
+ store.delete("refreshToken");
140
+ } else {
141
+ store.set("refreshToken", token);
142
+ }
143
+ }
144
+ function getTokenExpiresAt() {
145
+ return store.get("tokenExpiresAt") ?? null;
146
+ }
147
+ function setTokenExpiresAt(expiresAt) {
148
+ if (expiresAt === null) {
149
+ store.delete("tokenExpiresAt");
150
+ } else {
151
+ store.set("tokenExpiresAt", expiresAt);
152
+ }
153
+ }
154
+ function calcTokenExpiresAt(expiresIn) {
155
+ return Date.now() + Math.floor(expiresIn / 2) * 1e3;
156
+ }
157
+ function isTokenExpiringSoon() {
158
+ const expiresAt = getTokenExpiresAt();
159
+ if (!expiresAt) return true;
160
+ return Date.now() >= expiresAt;
161
+ }
162
+ function getEtsAuthToken() {
163
+ return store.get("etsAuthToken") ?? null;
164
+ }
165
+ function setEtsAuthToken(token) {
166
+ if (token === null) {
167
+ store.delete("etsAuthToken");
168
+ } else {
169
+ store.set("etsAuthToken", token);
170
+ }
171
+ }
172
+ function getApigwKeysRaw() {
173
+ return store.get("apigwKeys") ?? null;
174
+ }
175
+ function setApigwKeysRaw(data) {
176
+ if (data === null) {
177
+ store.delete("apigwKeys");
178
+ } else {
179
+ store.set("apigwKeys", data);
180
+ }
181
+ }
182
+ function clearAll() {
183
+ store.clear();
184
+ }
185
+ var store;
186
+ var init_store = __esm({
187
+ "src/config/store.ts"() {
188
+ "use strict";
189
+ init_esm_shims();
190
+ store = new Conf({
191
+ projectName: "express-cli",
192
+ defaults: {
193
+ config: {},
194
+ authToken: null,
195
+ refreshToken: null,
196
+ rtsAuthToken: null,
197
+ apigwKeys: null,
198
+ tokenExpiresAt: null,
199
+ etsAuthToken: null
200
+ }
201
+ });
202
+ }
203
+ });
204
+
205
+ // src/config/loader.ts
206
+ var loader_exports = {};
207
+ __export(loader_exports, {
208
+ getBaseUrl: () => getBaseUrl,
209
+ getEtsBaseUrl: () => getEtsBaseUrl,
210
+ getWebOrigin: () => getWebOrigin,
211
+ loadConfig: () => loadConfig
212
+ });
213
+ import { ZodError } from "zod";
214
+ function loadConfig(cliOverrides = {}) {
215
+ const stored = getStoredConfig();
216
+ const env2 = {};
217
+ if (process.env.EXPRESS_HOST) env2.host = process.env.EXPRESS_HOST;
218
+ if (process.env.EXPRESS_TOKEN) env2.token = process.env.EXPRESS_TOKEN;
219
+ if (process.env.EXPRESS_LOCALE) env2.locale = process.env.EXPRESS_LOCALE;
220
+ if (process.env.EXPRESS_OUTPUT) env2.output = process.env.EXPRESS_OUTPUT;
221
+ const merged = {
222
+ ...stored,
223
+ ...env2,
224
+ ...cliOverrides
225
+ };
226
+ if (!merged.token) {
227
+ const storedToken = getAuthToken();
228
+ if (storedToken) merged.token = storedToken;
229
+ }
230
+ try {
231
+ return configSchema.parse(merged);
232
+ } catch (err) {
233
+ if (err instanceof ZodError) {
234
+ const details = err.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
235
+ throw new Error(`Configuration error:
236
+ ${details}
237
+
238
+ Run: express-cli config set host <hostname>`);
239
+ }
240
+ throw err;
241
+ }
242
+ }
243
+ function getBaseUrl(config) {
244
+ return `${config.protocol}://${config.host}`;
245
+ }
246
+ function getDomain(ctsHost) {
247
+ const parts = ctsHost.split(".");
248
+ return parts.length > 2 ? parts.slice(1).join(".") : ctsHost;
249
+ }
250
+ function getEtsBaseUrl(config) {
251
+ return `https://ets.${getDomain(config.host)}`;
252
+ }
253
+ function getWebOrigin(config) {
254
+ return `https://${getDomain(config.host)}`;
255
+ }
256
+ var init_loader = __esm({
257
+ "src/config/loader.ts"() {
258
+ "use strict";
259
+ init_esm_shims();
260
+ init_types();
261
+ init_store();
262
+ }
263
+ });
264
+
52
265
  // node_modules/react/cjs/react.production.js
53
266
  var require_react_production = __commonJS({
54
267
  "node_modules/react/cjs/react.production.js"(exports) {
@@ -25062,179 +25275,11 @@ init_esm_shims();
25062
25275
 
25063
25276
  // src/config/index.ts
25064
25277
  init_esm_shims();
25065
-
25066
- // src/config/loader.ts
25067
- init_esm_shims();
25068
- import { ZodError } from "zod";
25069
-
25070
- // src/types/index.ts
25071
- init_esm_shims();
25072
-
25073
- // src/types/express.ts
25074
- init_esm_shims();
25075
-
25076
- // src/types/api.ts
25077
- init_esm_shims();
25078
-
25079
- // src/types/config.ts
25080
- init_esm_shims();
25081
- import { z } from "zod";
25082
- var configSchema = z.object({
25083
- host: z.string().min(1, "host is required \u2014 set EXPRESS_HOST or configure via `express config set host <host>`"),
25084
- protocol: z.enum(["https", "http"]).default("https"),
25085
- token: z.string().optional(),
25086
- locale: z.string().default("ru"),
25087
- platform: z.string().default("web"),
25088
- platform_package_id: z.string().default("ru.alfabank"),
25089
- app_version: z.string().default("3.66.47"),
25090
- output: z.enum(["table", "json"]).default("table")
25091
- });
25092
- var envSchema = z.object({
25093
- EXPRESS_HOST: z.string().optional(),
25094
- EXPRESS_TOKEN: z.string().optional(),
25095
- EXPRESS_LOCALE: z.string().optional(),
25096
- EXPRESS_OUTPUT: z.enum(["table", "json"]).optional()
25097
- });
25098
-
25099
- // src/config/store.ts
25100
- init_esm_shims();
25101
- import Conf from "conf";
25102
- var store = new Conf({
25103
- projectName: "express-cli",
25104
- defaults: {
25105
- config: {},
25106
- authToken: null,
25107
- refreshToken: null,
25108
- rtsAuthToken: null,
25109
- apigwKeys: null,
25110
- tokenExpiresAt: null,
25111
- etsAuthToken: null
25112
- }
25113
- });
25114
- function getStoredConfig() {
25115
- return store.get("config") ?? {};
25116
- }
25117
- function setStoredConfig(partial) {
25118
- const current = getStoredConfig();
25119
- store.set("config", { ...current, ...partial });
25120
- }
25121
- function getAuthToken() {
25122
- return store.get("authToken") ?? null;
25123
- }
25124
- function setAuthToken(token) {
25125
- if (token === null) {
25126
- store.delete("authToken");
25127
- } else {
25128
- store.set("authToken", token);
25129
- }
25130
- }
25131
- function getRtsAuthToken() {
25132
- return store.get("rtsAuthToken") ?? null;
25133
- }
25134
- function setRtsAuthToken(token) {
25135
- if (token === null) {
25136
- store.delete("rtsAuthToken");
25137
- } else {
25138
- store.set("rtsAuthToken", token);
25139
- }
25140
- }
25141
- function getRefreshToken() {
25142
- return store.get("refreshToken") ?? null;
25143
- }
25144
- function setRefreshToken(token) {
25145
- if (token === null) {
25146
- store.delete("refreshToken");
25147
- } else {
25148
- store.set("refreshToken", token);
25149
- }
25150
- }
25151
- function getTokenExpiresAt() {
25152
- return store.get("tokenExpiresAt") ?? null;
25153
- }
25154
- function setTokenExpiresAt(expiresAt) {
25155
- if (expiresAt === null) {
25156
- store.delete("tokenExpiresAt");
25157
- } else {
25158
- store.set("tokenExpiresAt", expiresAt);
25159
- }
25160
- }
25161
- function calcTokenExpiresAt(expiresIn) {
25162
- return Date.now() + Math.floor(expiresIn / 2) * 1e3;
25163
- }
25164
- function isTokenExpiringSoon() {
25165
- const expiresAt = getTokenExpiresAt();
25166
- if (!expiresAt) return true;
25167
- return Date.now() >= expiresAt;
25168
- }
25169
- function getEtsAuthToken() {
25170
- return store.get("etsAuthToken") ?? null;
25171
- }
25172
- function setEtsAuthToken(token) {
25173
- if (token === null) {
25174
- store.delete("etsAuthToken");
25175
- } else {
25176
- store.set("etsAuthToken", token);
25177
- }
25178
- }
25179
- function getApigwKeysRaw() {
25180
- return store.get("apigwKeys") ?? null;
25181
- }
25182
- function setApigwKeysRaw(data) {
25183
- if (data === null) {
25184
- store.delete("apigwKeys");
25185
- } else {
25186
- store.set("apigwKeys", data);
25187
- }
25188
- }
25189
- function clearAll() {
25190
- store.clear();
25191
- }
25192
-
25193
- // src/config/loader.ts
25194
- function loadConfig(cliOverrides = {}) {
25195
- const stored = getStoredConfig();
25196
- const env2 = {};
25197
- if (process.env.EXPRESS_HOST) env2.host = process.env.EXPRESS_HOST;
25198
- if (process.env.EXPRESS_TOKEN) env2.token = process.env.EXPRESS_TOKEN;
25199
- if (process.env.EXPRESS_LOCALE) env2.locale = process.env.EXPRESS_LOCALE;
25200
- if (process.env.EXPRESS_OUTPUT) env2.output = process.env.EXPRESS_OUTPUT;
25201
- const merged = {
25202
- ...stored,
25203
- ...env2,
25204
- ...cliOverrides
25205
- };
25206
- if (!merged.token) {
25207
- const storedToken = getAuthToken();
25208
- if (storedToken) merged.token = storedToken;
25209
- }
25210
- try {
25211
- return configSchema.parse(merged);
25212
- } catch (err) {
25213
- if (err instanceof ZodError) {
25214
- const details = err.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
25215
- throw new Error(`Configuration error:
25216
- ${details}
25217
-
25218
- Run: express-cli config set host <hostname>`);
25219
- }
25220
- throw err;
25221
- }
25222
- }
25223
- function getBaseUrl(config) {
25224
- return `${config.protocol}://${config.host}`;
25225
- }
25226
- function getDomain(ctsHost) {
25227
- const parts = ctsHost.split(".");
25228
- return parts.length > 2 ? parts.slice(1).join(".") : ctsHost;
25229
- }
25230
- function getEtsBaseUrl(config) {
25231
- return `https://ets.${getDomain(config.host)}`;
25232
- }
25233
- function getWebOrigin(config) {
25234
- return `https://${getDomain(config.host)}`;
25235
- }
25278
+ init_loader();
25279
+ init_store();
25236
25280
 
25237
25281
  // src/auth/import.ts
25282
+ init_loader();
25238
25283
  async function importToken(token, cliOverrides = {}) {
25239
25284
  const config = loadConfig(cliOverrides);
25240
25285
  const baseUrl = getBaseUrl(config);
@@ -25275,6 +25320,7 @@ init_esm_shims();
25275
25320
 
25276
25321
  // src/auth/keys.ts
25277
25322
  init_esm_shims();
25323
+ init_store();
25278
25324
  import { ed25519 } from "@noble/curves/ed25519.js";
25279
25325
  import { randomBytes } from "crypto";
25280
25326
  import nacl from "tweetnacl";
@@ -25561,6 +25607,8 @@ ${signingString}`);
25561
25607
  }
25562
25608
 
25563
25609
  // src/auth/device-login.ts
25610
+ init_store();
25611
+ init_loader();
25564
25612
  import nacl2 from "tweetnacl";
25565
25613
  import { randomUUID } from "crypto";
25566
25614
  async function fetchCurrentAccountCtsKey(baseUrl, token, userHuid, webOrigin) {
@@ -25958,6 +26006,30 @@ Minting a new one would break your other devices. Import the shared key instead:
25958
26006
 
25959
26007
  // src/auth/qr-login.ts
25960
26008
  init_esm_shims();
26009
+
26010
+ // src/auth/qr-browser.ts
26011
+ init_esm_shims();
26012
+ import { execFile } from "child_process";
26013
+ import QRCode from "qrcode";
26014
+ async function openQrInBrowser(payload, registrationId) {
26015
+ let pngDataUrl;
26016
+ try {
26017
+ pngDataUrl = await QRCode.toDataURL(payload, { scale: 8, margin: 2 });
26018
+ } catch {
26019
+ return;
26020
+ }
26021
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>eXpress QR</title><style>body{font-family:sans-serif;background:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0}h2{color:#1a1a1a;margin-bottom:16px}img{width:280px;height:280px;image-rendering:pixelated;border:1px solid #eee;border-radius:8px}p{color:#999;font-size:11px;margin-top:12px;font-family:monospace}</style></head><body><h2>Scan with eXpress</h2><img src="${pngDataUrl}" alt="QR"><p>${registrationId}</p></body></html>`;
26022
+ const htmlDataUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
26023
+ try {
26024
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
26025
+ execFile(opener, [htmlDataUrl]);
26026
+ } catch {
26027
+ }
26028
+ }
26029
+
26030
+ // src/auth/qr-login.ts
26031
+ init_store();
26032
+ init_loader();
25961
26033
  import { randomUUID as randomUUID2, randomBytes as randomBytes2 } from "crypto";
25962
26034
  import qrcode from "qrcode-terminal";
25963
26035
  import nacl3 from "tweetnacl";
@@ -25992,16 +26064,20 @@ function commonHeaders(webOrigin) {
25992
26064
  "sec-ch-ua-platform": '"macOS"'
25993
26065
  };
25994
26066
  }
25995
- async function qrLogin(cliOverrides = {}) {
26067
+ function buildQrMaterial(cliOverrides = {}) {
25996
26068
  const config = loadConfig(cliOverrides);
25997
- const etsBaseUrl = getEtsBaseUrl(config);
25998
- const webOrigin = getWebOrigin(config);
25999
26069
  const qrSigningKey = generateSigningKeyPair();
26000
26070
  const registrationId = qrSigningKey.keyId;
26001
26071
  const registrationToken = Buffer.from(randomBytes2(64)).toString("base64");
26002
26072
  const signPubKey = publicKeyToBase64(qrSigningKey.publicKey);
26003
26073
  const udid = randomUUID2();
26004
26074
  const encryptionKey = randomBytes2(32);
26075
+ const qrPayload = JSON.stringify({
26076
+ registration_id: registrationId,
26077
+ registration_token: registrationToken,
26078
+ registration_key: Buffer.from(encryptionKey).toString("base64"),
26079
+ version: 1
26080
+ });
26005
26081
  const qrBody = JSON.stringify({
26006
26082
  registration_id: registrationId,
26007
26083
  registration_token: registrationToken,
@@ -26021,63 +26097,53 @@ async function qrLogin(cliOverrides = {}) {
26021
26097
  platform: "web",
26022
26098
  platform_package_id: "com.pyligrim.alphach"
26023
26099
  });
26024
- const qrPayload = JSON.stringify({
26025
- registration_id: registrationId,
26026
- registration_token: registrationToken,
26027
- registration_key: Buffer.from(encryptionKey).toString("base64"),
26028
- version: 1
26029
- });
26030
- console.log("Step 1/7: Scan this QR code with your eXpress app:\n");
26031
- qrcode.generate(qrPayload, { small: true }, (qr) => {
26032
- console.log(qr);
26033
- });
26034
- console.log(`
26035
- registration_id: ${registrationId}`);
26036
- console.log(" Waiting for scan (server long-polling)...\n");
26100
+ return { registrationId, encryptionKey, qrSigningKey, udid, qrPayload, qrBody, config };
26101
+ }
26102
+ async function pollForQrScan(mat) {
26103
+ const etsBaseUrl = getEtsBaseUrl(mat.config);
26104
+ const webOrigin = getWebOrigin(mat.config);
26037
26105
  const etsUrl = `${etsBaseUrl}/api/v1/authentication/qr/mobile_to_web/request`;
26038
26106
  const qrHeaders = signQrRequest({
26039
26107
  method: "POST",
26040
26108
  url: etsUrl,
26041
- body: qrBody,
26042
- registrationId,
26043
- privateKey: qrSigningKey.privateKey
26109
+ body: mat.qrBody,
26110
+ registrationId: mat.registrationId,
26111
+ privateKey: mat.qrSigningKey.privateKey
26044
26112
  });
26045
- let qrRes;
26113
+ let res;
26046
26114
  try {
26047
- qrRes = await fetch(etsUrl, {
26115
+ res = await fetch(etsUrl, {
26048
26116
  method: "POST",
26049
26117
  headers: { ...commonHeaders(webOrigin), ...qrHeaders },
26050
- body: qrBody
26118
+ body: mat.qrBody
26051
26119
  });
26052
26120
  } catch (err) {
26053
26121
  throw new Error(`QR request network error: ${err.message}`);
26054
26122
  }
26055
- const qrText = await qrRes.text();
26056
- if (!qrRes.ok) {
26057
- console.log(` Response (${qrRes.status}): ${qrText.slice(0, 500)}`);
26058
- throw new Error(`QR request failed (${qrRes.status}): ${qrText.slice(0, 500)}`);
26059
- }
26060
- let qrData;
26061
- try {
26062
- qrData = JSON.parse(qrText);
26063
- } catch {
26064
- throw new Error(`Invalid QR response: ${qrText.slice(0, 500)}`);
26065
- }
26066
- if (process.env.EXPRESS_DEBUG) {
26067
- console.log(` [DEBUG] QR full response: ${qrText.slice(0, 1e3)}`);
26123
+ const text = await res.text();
26124
+ if (!res.ok) throw new Error(`QR request failed (${res.status}): ${text.slice(0, 500)}`);
26125
+ const data = JSON.parse(text);
26126
+ if (process.env.EXPRESS_DEBUG) process.stderr.write(`[qr] full response: ${text.slice(0, 1e3)}
26127
+ `);
26128
+ const result = extractResult2(data);
26129
+ const ctsRegistrationToken = result.cts_registration_token ?? "";
26130
+ const rtsRegistrationToken = result.rts_registration_token ?? "";
26131
+ const registrationData = result.registration_data ?? "";
26132
+ if (!ctsRegistrationToken && !rtsRegistrationToken) {
26133
+ throw new Error(`No tokens in QR response: ${text.slice(0, 500)}`);
26068
26134
  }
26069
- const qrResult = extractResult2(qrData);
26070
- const ctsRegistrationToken = qrResult.cts_registration_token ?? "";
26071
- const rtsRegistrationToken = qrResult.rts_registration_token ?? "";
26072
- const registrationData = qrResult.registration_data ?? "";
26073
- console.log(" QR scanned! Got tokens from server.");
26135
+ return { ctsRegistrationToken, rtsRegistrationToken, registrationData };
26136
+ }
26137
+ async function completeQrRegistration(mat, poll, log = console.log) {
26138
+ const { config, registrationId, encryptionKey, qrSigningKey } = mat;
26139
+ const { ctsRegistrationToken, rtsRegistrationToken, registrationData } = poll;
26140
+ const etsBaseUrl = getEtsBaseUrl(config);
26141
+ const webOrigin = getWebOrigin(config);
26142
+ log(" QR scanned! Got tokens from server.");
26074
26143
  if (process.env.EXPRESS_DEBUG) {
26075
- console.log(` [DEBUG] registration_data length: ${registrationData.length}`);
26076
- console.log(` [DEBUG] registration_data raw: ${registrationData.slice(0, 100)}...`);
26077
- console.log(` [DEBUG] encryptionKey (registration_key) hex: ${Buffer.from(encryptionKey).toString("hex")}`);
26078
- }
26079
- if (!ctsRegistrationToken && !rtsRegistrationToken) {
26080
- throw new Error(`No tokens in QR response: ${qrText.slice(0, 500)}`);
26144
+ log(` [DEBUG] registration_data length: ${registrationData.length}`);
26145
+ log(` [DEBUG] registration_data raw: ${registrationData.slice(0, 100)}...`);
26146
+ log(` [DEBUG] encryptionKey (registration_key) hex: ${Buffer.from(encryptionKey).toString("hex")}`);
26081
26147
  }
26082
26148
  let rtsPrivateKey = null;
26083
26149
  let rtsPublicKeyId = "";
@@ -26087,14 +26153,14 @@ async function qrLogin(cliOverrides = {}) {
26087
26153
  try {
26088
26154
  const raw = Uint8Array.from(Buffer.from(registrationData, "base64"));
26089
26155
  if (process.env.EXPRESS_DEBUG) {
26090
- console.log(` [DEBUG] registration_data decoded length: ${raw.length}`);
26091
- console.log(` [DEBUG] first 40 bytes hex: ${Buffer.from(raw.slice(0, 40)).toString("hex")}`);
26092
- console.log(` [DEBUG] encryptionKey hex: ${Buffer.from(encryptionKey).toString("hex")}`);
26093
- console.log(` [DEBUG] encryptionKey length: ${encryptionKey.length}`);
26156
+ log(` [DEBUG] registration_data decoded length: ${raw.length}`);
26157
+ log(` [DEBUG] first 40 bytes hex: ${Buffer.from(raw.slice(0, 40)).toString("hex")}`);
26158
+ log(` [DEBUG] encryptionKey hex: ${Buffer.from(encryptionKey).toString("hex")}`);
26159
+ log(` [DEBUG] encryptionKey length: ${encryptionKey.length}`);
26094
26160
  }
26095
26161
  const decrypted = decryptRegistrationData(registrationData, encryptionKey);
26096
26162
  if (process.env.EXPRESS_DEBUG) {
26097
- console.log(" Decrypted registration_data:", JSON.stringify(decrypted).slice(0, 500));
26163
+ log(" Decrypted registration_data: " + JSON.stringify(decrypted).slice(0, 500));
26098
26164
  }
26099
26165
  if (decrypted && typeof decrypted === "object") {
26100
26166
  const data = decrypted;
@@ -26110,10 +26176,10 @@ async function qrLogin(cliOverrides = {}) {
26110
26176
  }
26111
26177
  }
26112
26178
  } catch (err) {
26113
- console.log(` Warning: could not decrypt registration_data: ${err.message}`);
26179
+ log(` Warning: could not decrypt registration_data: ${err.message}`);
26114
26180
  }
26115
26181
  }
26116
- console.log("\nStep 2/7: Confirming with ETS...");
26182
+ log("\nStep 2: Confirming with ETS...");
26117
26183
  const confirmUrl = `${etsBaseUrl}/api/v1/authentication/register_confirm/qr`;
26118
26184
  const confirmBody = JSON.stringify({
26119
26185
  registration_id: registrationId,
@@ -26132,23 +26198,17 @@ async function qrLogin(cliOverrides = {}) {
26132
26198
  body: confirmBody
26133
26199
  });
26134
26200
  const confirmText = await confirmRes.text();
26135
- if (!confirmRes.ok) {
26136
- throw new Error(`ETS register_confirm failed (${confirmRes.status}): ${confirmText.slice(0, 500)}`);
26137
- }
26201
+ if (!confirmRes.ok) throw new Error(`ETS register_confirm failed (${confirmRes.status}): ${confirmText.slice(0, 500)}`);
26138
26202
  const confirmData = extractResult2(JSON.parse(confirmText));
26139
26203
  const userHuid = confirmData.user_huid ?? "";
26140
26204
  const etsAuthToken = confirmData.auth_token ?? "";
26141
- console.log(` ETS confirmed. User: ${userHuid || "unknown"}`);
26205
+ log(` ETS confirmed. User: ${userHuid || "unknown"}`);
26142
26206
  if (etsAuthToken) {
26143
26207
  setEtsAuthToken(etsAuthToken);
26144
- if (process.env.EXPRESS_DEBUG) {
26145
- console.log(` [DEBUG] ETS auth_token saved (${etsAuthToken.length} chars)`);
26146
- }
26147
- }
26148
- if (!ctsRegistrationToken) {
26149
- throw new Error("No cts_registration_token \u2014 cannot confirm with CTS");
26208
+ if (process.env.EXPRESS_DEBUG) log(` [DEBUG] ETS auth_token saved (${etsAuthToken.length} chars)`);
26150
26209
  }
26151
- console.log("\nStep 3/7: Confirming with CTS (AD integration)...");
26210
+ if (!ctsRegistrationToken) throw new Error("No cts_registration_token \u2014 cannot confirm with CTS");
26211
+ log("\nStep 3: Confirming with CTS (AD integration)...");
26152
26212
  const ctsUrl = `${getBaseUrl(config)}/api/v1/ad_integration/register_confirm/qr`;
26153
26213
  const adConfirmBody = JSON.stringify({
26154
26214
  rts_registration_id: registrationId,
@@ -26168,9 +26228,7 @@ async function qrLogin(cliOverrides = {}) {
26168
26228
  body: adConfirmBody
26169
26229
  });
26170
26230
  const adText = await adRes.text();
26171
- if (!adRes.ok) {
26172
- throw new Error(`AD integration confirm failed (${adRes.status}): ${adText.slice(0, 500)}`);
26173
- }
26231
+ if (!adRes.ok) throw new Error(`AD integration confirm failed (${adRes.status}): ${adText.slice(0, 500)}`);
26174
26232
  const adData = extractResult2(JSON.parse(adText));
26175
26233
  const accessToken = adData.access_token;
26176
26234
  const refreshToken2 = adData.refresh_token;
@@ -26179,26 +26237,19 @@ async function qrLogin(cliOverrides = {}) {
26179
26237
  const encryptedRtsToken = adData.encrypted_rts_token;
26180
26238
  if (process.env.EXPRESS_DEBUG) {
26181
26239
  const adDataRaw = JSON.parse(adText);
26182
- console.log(` [DEBUG] AD confirm full result keys: ${JSON.stringify(Object.keys(adDataRaw.result || adDataRaw))}`);
26183
- if (encryptedRtsToken) {
26184
- console.log(` [DEBUG] encrypted_rts_token found: ${encryptedRtsToken.slice(0, 60)}...`);
26185
- } else {
26186
- console.log(` [DEBUG] encrypted_rts_token NOT found in response`);
26187
- }
26188
- }
26189
- if (!accessToken) {
26190
- throw new Error(`No access_token in AD confirm response: ${adText.slice(0, 500)}`);
26240
+ log(` [DEBUG] AD confirm full result keys: ${JSON.stringify(Object.keys(adDataRaw.result || adDataRaw))}`);
26241
+ if (encryptedRtsToken) log(` [DEBUG] encrypted_rts_token found: ${encryptedRtsToken.slice(0, 60)}...`);
26242
+ else log(` [DEBUG] encrypted_rts_token NOT found in response`);
26191
26243
  }
26244
+ if (!accessToken) throw new Error(`No access_token in AD confirm response: ${adText.slice(0, 500)}`);
26192
26245
  setAuthToken(accessToken);
26193
- if (refreshToken2) {
26194
- setRefreshToken(refreshToken2);
26195
- }
26246
+ if (refreshToken2) setRefreshToken(refreshToken2);
26196
26247
  if (typeof expiresIn === "number") {
26197
26248
  setTokenExpiresAt(calcTokenExpiresAt(expiresIn));
26198
- console.log(` Token expires in ${expiresIn}s (refresh after ${(expiresIn / 2 / 60).toFixed(0)} min)`);
26249
+ log(` Token expires in ${expiresIn}s (refresh after ${(expiresIn / 2 / 60).toFixed(0)} min)`);
26199
26250
  }
26200
- console.log(` CTS confirmed. Access token: ${accessToken.slice(0, 40)}...`);
26201
- console.log("\nStep 4/7: Registering device token...");
26251
+ log(` CTS confirmed. Access token: ${accessToken.slice(0, 40)}...`);
26252
+ log("\nStep 4: Registering device token...");
26202
26253
  const tokenUrl = `${getBaseUrl(config)}/api/v1/ad_integration/token`;
26203
26254
  const tokenBody = JSON.stringify({
26204
26255
  app_version: config.app_version,
@@ -26217,23 +26268,19 @@ async function qrLogin(cliOverrides = {}) {
26217
26268
  });
26218
26269
  const tokenRes = await fetch(tokenUrl, {
26219
26270
  method: "PUT",
26220
- headers: {
26221
- ...commonHeaders(webOrigin),
26222
- Authorization: `Bearer ${accessToken}`,
26223
- "Content-Type": "application/json"
26224
- },
26271
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
26225
26272
  body: tokenBody
26226
26273
  });
26227
26274
  if (!tokenRes.ok) {
26228
26275
  const tokenErrText = await tokenRes.text().catch(() => "");
26229
- console.log(` Warning: device token registration failed (${tokenRes.status}): ${tokenErrText.slice(0, 200)}`);
26276
+ log(` Warning: device token registration failed (${tokenRes.status}): ${tokenErrText.slice(0, 200)}`);
26230
26277
  } else {
26231
- console.log(" Device token registered.");
26278
+ log(" Device token registered.");
26232
26279
  }
26233
- console.log("\nStep 5/7: Registering signing key + fetching server key...");
26280
+ log("\nStep 5: Registering signing key + fetching server key...");
26234
26281
  if (process.env.EXPRESS_DEBUG) {
26235
- console.log(` [DEBUG] serverId: ${serverId}`);
26236
- console.log(` [DEBUG] userHuid: ${userHuid}`);
26282
+ log(` [DEBUG] serverId: ${serverId}`);
26283
+ log(` [DEBUG] userHuid: ${userHuid}`);
26237
26284
  }
26238
26285
  const apigwSigningKey = generateSigningKeyPair();
26239
26286
  const apigwKeyPublicBase64 = publicKeyToBase64(apigwSigningKey.publicKey);
@@ -26247,56 +26294,42 @@ async function qrLogin(cliOverrides = {}) {
26247
26294
  const [kdcSignRes, etsKdcSignRes, etsKdcStartRes] = await Promise.all([
26248
26295
  fetch(kdcSignUrl, {
26249
26296
  method: "POST",
26250
- headers: {
26251
- ...commonHeaders(webOrigin),
26252
- Authorization: `Bearer ${accessToken}`,
26253
- "Content-Type": "application/json"
26254
- },
26297
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
26255
26298
  body: kdcSignBody
26256
26299
  }),
26257
26300
  fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
26258
26301
  method: "POST",
26259
- headers: {
26260
- ...commonHeaders(webOrigin),
26261
- Authorization: `Bearer ${etsAuthToken}`,
26262
- "Content-Type": "application/json"
26263
- },
26302
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${etsAuthToken}`, "Content-Type": "application/json" },
26264
26303
  body: kdcSignBody
26265
26304
  }),
26266
- fetch(`${etsBaseUrl}/api/v1/kdc/start`, {
26267
- headers: {
26268
- ...commonHeaders(webOrigin)
26269
- }
26270
- })
26305
+ fetch(`${etsBaseUrl}/api/v1/kdc/start`, { headers: { ...commonHeaders(webOrigin) } })
26271
26306
  ]);
26272
26307
  if (!kdcSignRes.ok) {
26273
26308
  const kdcErrText = await kdcSignRes.text().catch(() => "");
26274
- console.log(` Warning: CTS KDC signing key registration failed (${kdcSignRes.status}): ${kdcErrText.slice(0, 200)}`);
26309
+ log(` Warning: CTS KDC signing key registration failed (${kdcSignRes.status}): ${kdcErrText.slice(0, 200)}`);
26275
26310
  } else {
26276
26311
  const kdcSignData = await kdcSignRes.json().catch(() => null);
26277
- console.log(` Signing key registered in CTS: ${apigwSigningKey.keyId}`, kdcSignData ? JSON.stringify(kdcSignData).slice(0, 200) : "");
26312
+ log(` Signing key registered in CTS: ${apigwSigningKey.keyId}` + (kdcSignData ? " " + JSON.stringify(kdcSignData).slice(0, 200) : ""));
26278
26313
  }
26279
26314
  if (!etsKdcSignRes.ok) {
26280
26315
  const etsErrText = await etsKdcSignRes.text().catch(() => "");
26281
- console.log(` Warning: ETS KDC signing key registration failed (${etsKdcSignRes.status}): ${etsErrText.slice(0, 200)}`);
26316
+ log(` Warning: ETS KDC signing key registration failed (${etsKdcSignRes.status}): ${etsErrText.slice(0, 200)}`);
26282
26317
  } else {
26283
26318
  const etsSignData = await etsKdcSignRes.json().catch(() => null);
26284
- console.log(` Signing key registered in ETS: ${apigwSigningKey.keyId}`, etsSignData ? JSON.stringify(etsSignData).slice(0, 200) : "");
26319
+ log(` Signing key registered in ETS: ${apigwSigningKey.keyId}` + (etsSignData ? " " + JSON.stringify(etsSignData).slice(0, 200) : ""));
26285
26320
  }
26286
26321
  let serverPublicKey = new Uint8Array(0);
26287
26322
  let serverPublicKeyId = "";
26288
26323
  if (etsKdcStartRes.ok) {
26289
26324
  const kdcStartText = await etsKdcStartRes.text();
26290
- if (process.env.EXPRESS_DEBUG) {
26291
- console.log(` [DEBUG] ETS KDC start response: ${kdcStartText.slice(0, 500)}`);
26292
- }
26325
+ if (process.env.EXPRESS_DEBUG) log(` [DEBUG] ETS KDC start response: ${kdcStartText.slice(0, 500)}`);
26293
26326
  try {
26294
26327
  const kdcStartData = JSON.parse(kdcStartText);
26295
26328
  const keyBody = kdcStartData.result ?? kdcStartText;
26296
26329
  serverPublicKey = new Uint8Array(Buffer.from(keyBody, "base64"));
26297
26330
  serverPublicKeyId = "kdc-start-ets";
26298
26331
  const rawB64 = Buffer.from(serverPublicKey).toString("base64");
26299
- console.log(` ETS server public key from /kdc/start: ${rawB64} (curve25519, used directly)`);
26332
+ log(` ETS server public key from /kdc/start: ${rawB64} (curve25519, used directly)`);
26300
26333
  } catch {
26301
26334
  try {
26302
26335
  serverPublicKey = new Uint8Array(Buffer.from(kdcStartText, "base64"));
@@ -26306,11 +26339,7 @@ async function qrLogin(cliOverrides = {}) {
26306
26339
  }
26307
26340
  if (!serverPublicKey.length) {
26308
26341
  if (process.env.EXPRESS_DEBUG && !etsKdcStartRes.ok) {
26309
- console.log(` [DEBUG] ETS KDC start status: ${etsKdcStartRes.status}`);
26310
- try {
26311
- console.log(` [DEBUG] ETS KDC start body: ${(await etsKdcStartRes.text()).slice(0, 500)}`);
26312
- } catch {
26313
- }
26342
+ log(` [DEBUG] ETS KDC start status: ${etsKdcStartRes.status}`);
26314
26343
  }
26315
26344
  throw new Error("Could not fetch server public key from ETS /kdc/start");
26316
26345
  }
@@ -26320,34 +26349,26 @@ async function qrLogin(cliOverrides = {}) {
26320
26349
  const [ctsRtsRes, etsRtsRes] = await Promise.all([
26321
26350
  fetch(kdcSignUrl, {
26322
26351
  method: "POST",
26323
- headers: {
26324
- ...commonHeaders(webOrigin),
26325
- Authorization: `Bearer ${accessToken}`,
26326
- "Content-Type": "application/json"
26327
- },
26352
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
26328
26353
  body: rtsKeyBody
26329
26354
  }),
26330
26355
  fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
26331
26356
  method: "POST",
26332
- headers: {
26333
- ...commonHeaders(webOrigin),
26334
- Authorization: `Bearer ${etsAuthToken}`,
26335
- "Content-Type": "application/json"
26336
- },
26357
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${etsAuthToken}`, "Content-Type": "application/json" },
26337
26358
  body: rtsKeyBody
26338
26359
  })
26339
26360
  ]);
26340
26361
  if (!ctsRtsRes.ok) {
26341
26362
  const errText = await ctsRtsRes.text().catch(() => "");
26342
- console.log(` Warning: CTS RTS key registration failed (${ctsRtsRes.status}): ${errText.slice(0, 200)}`);
26363
+ log(` Warning: CTS RTS key registration failed (${ctsRtsRes.status}): ${errText.slice(0, 200)}`);
26343
26364
  } else {
26344
- console.log(` RTS key registered in CTS: ${rtsPublicKeyId}`);
26365
+ log(` RTS key registered in CTS: ${rtsPublicKeyId}`);
26345
26366
  }
26346
26367
  if (!etsRtsRes.ok) {
26347
26368
  const errText = await etsRtsRes.text().catch(() => "");
26348
- console.log(` Warning: ETS RTS key registration failed (${etsRtsRes.status}): ${errText.slice(0, 200)}`);
26369
+ log(` Warning: ETS RTS key registration failed (${etsRtsRes.status}): ${errText.slice(0, 200)}`);
26349
26370
  } else {
26350
- console.log(` RTS key registered in ETS: ${rtsPublicKeyId}`);
26371
+ log(` RTS key registered in ETS: ${rtsPublicKeyId}`);
26351
26372
  }
26352
26373
  }
26353
26374
  if (!rtsPrivateKey) {
@@ -26359,34 +26380,26 @@ async function qrLogin(cliOverrides = {}) {
26359
26380
  const [ctsEncRes, etsEncRes] = await Promise.all([
26360
26381
  fetch(kdcSignUrl, {
26361
26382
  method: "POST",
26362
- headers: {
26363
- ...commonHeaders(webOrigin),
26364
- Authorization: `Bearer ${accessToken}`,
26365
- "Content-Type": "application/json"
26366
- },
26383
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
26367
26384
  body: rtsFallbackBody
26368
26385
  }),
26369
26386
  fetch(`${etsBaseUrl}/api/v2/kdc/keys/${userHuid}`, {
26370
26387
  method: "POST",
26371
- headers: {
26372
- ...commonHeaders(webOrigin),
26373
- Authorization: `Bearer ${etsAuthToken}`,
26374
- "Content-Type": "application/json"
26375
- },
26388
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${etsAuthToken}`, "Content-Type": "application/json" },
26376
26389
  body: rtsFallbackBody
26377
26390
  })
26378
26391
  ]);
26379
26392
  if (!ctsEncRes.ok) {
26380
26393
  const errText = await ctsEncRes.text().catch(() => "");
26381
- console.log(` Warning: CTS fallback RTS key registration failed (${ctsEncRes.status}): ${errText.slice(0, 200)}`);
26394
+ log(` Warning: CTS fallback RTS key registration failed (${ctsEncRes.status}): ${errText.slice(0, 200)}`);
26382
26395
  } else {
26383
- console.log(` Fallback encryption key registered in CTS: ${rtsPublicKeyId}`);
26396
+ log(` Fallback encryption key registered in CTS: ${rtsPublicKeyId}`);
26384
26397
  }
26385
26398
  if (!etsEncRes.ok) {
26386
26399
  const errText = await etsEncRes.text().catch(() => "");
26387
- console.log(` Warning: ETS fallback RTS key registration failed (${etsEncRes.status}): ${errText.slice(0, 200)}`);
26400
+ log(` Warning: ETS fallback RTS key registration failed (${etsEncRes.status}): ${errText.slice(0, 200)}`);
26388
26401
  } else {
26389
- console.log(` Fallback encryption key registered in ETS: ${rtsPublicKeyId}`);
26402
+ log(` Fallback encryption key registered in ETS: ${rtsPublicKeyId}`);
26390
26403
  }
26391
26404
  }
26392
26405
  const rtsPublicKey = nacl3.box.keyPair.fromSecretKey(rtsPrivateKey).publicKey;
@@ -26395,18 +26408,14 @@ async function qrLogin(cliOverrides = {}) {
26395
26408
  try {
26396
26409
  rtsAuthToken = decryptRtsToken(encryptedRtsToken, serverPublicKey, rtsPrivateKey);
26397
26410
  setRtsAuthToken(rtsAuthToken);
26398
- if (process.env.EXPRESS_DEBUG) {
26399
- console.log(` [DEBUG] Decrypted RTS auth token: ${rtsAuthToken.slice(0, 60)}...`);
26400
- }
26401
- console.log(` RTS auth token decrypted from encrypted_rts_token`);
26411
+ if (process.env.EXPRESS_DEBUG) log(` [DEBUG] Decrypted RTS auth token: ${rtsAuthToken.slice(0, 60)}...`);
26412
+ log(` RTS auth token decrypted from encrypted_rts_token`);
26402
26413
  } catch (err) {
26403
- console.log(` Warning: could not decrypt encrypted_rts_token: ${err.message}`);
26414
+ log(` Warning: could not decrypt encrypted_rts_token: ${err.message}`);
26404
26415
  }
26405
26416
  }
26406
26417
  const rtsIdFromToken = extractRtsKeyIdFromToken(accessToken);
26407
- if (rtsIdFromToken && process.env.EXPRESS_DEBUG) {
26408
- console.log(` [DEBUG] rts_id from CTS token: ${rtsIdFromToken}`);
26409
- }
26418
+ if (rtsIdFromToken && process.env.EXPRESS_DEBUG) log(` [DEBUG] rts_id from CTS token: ${rtsIdFromToken}`);
26410
26419
  const existingCts = loadApigwKeys()?.ctsKey;
26411
26420
  let ctsKey;
26412
26421
  if (qrCtsPrivateKey && qrCtsKeyId) {
@@ -26415,10 +26424,10 @@ async function qrLogin(cliOverrides = {}) {
26415
26424
  privateKey: qrCtsPrivateKey,
26416
26425
  publicKey: nacl3.box.keyPair.fromSecretKey(qrCtsPrivateKey).publicKey
26417
26426
  };
26418
- console.log(` Using CTS key from QR handshake: ${qrCtsKeyId.slice(0, 8)}... (shared account key)`);
26427
+ log(` Using CTS key from QR handshake: ${qrCtsKeyId.slice(0, 8)}... (shared account key)`);
26419
26428
  } else if (existingCts) {
26420
26429
  ctsKey = existingCts;
26421
- console.log(` Reusing existing CTS key: ${existingCts.keyId.slice(0, 8)}... (not re-registering)`);
26430
+ log(` Reusing existing CTS key: ${existingCts.keyId.slice(0, 8)}... (not re-registering)`);
26422
26431
  } else {
26423
26432
  const currentCts = await fetchCurrentAccountCtsKey2(getBaseUrl(config), accessToken, userHuid, webOrigin);
26424
26433
  if (currentCts) {
@@ -26426,29 +26435,25 @@ async function qrLogin(cliOverrides = {}) {
26426
26435
  `Account already has a shared CTS key (${currentCts}) that this CLI doesn't hold.
26427
26436
  Minting a new one would break your other devices (they can't fetch its private key).
26428
26437
  Instead, extract the key from a logged-in web client (IndexedDB authState \u2192 encryptionKeys \u2192 user.privateKeys.cts) and run:
26429
- express auth import-cts <private_key_b64> ${currentCts}
26438
+ express-cli auth import-cts <private_key_b64> ${currentCts}
26430
26439
  Then re-run login, or just use 'auth refresh' for tokens.`
26431
26440
  );
26432
26441
  }
26433
- console.log(" No existing account CTS key found \u2014 minting a new one (first device).");
26442
+ log(" No existing account CTS key found \u2014 minting a new one (first device).");
26434
26443
  const ctsKeyPair = nacl3.box.keyPair();
26435
26444
  const ctsKeyId = crypto.randomUUID();
26436
26445
  const ctsKeyPubB64 = Buffer.from(ctsKeyPair.publicKey).toString("base64");
26437
26446
  const ctsKeyBody = JSON.stringify({ key: ctsKeyPubB64, kind: "cts", algo: "xsalsa20", id: ctsKeyId });
26438
26447
  const ctsCtsKeyRes = await fetch(kdcSignUrl, {
26439
26448
  method: "POST",
26440
- headers: {
26441
- ...commonHeaders(webOrigin),
26442
- Authorization: `Bearer ${accessToken}`,
26443
- "Content-Type": "application/json"
26444
- },
26449
+ headers: { ...commonHeaders(webOrigin), Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
26445
26450
  body: ctsKeyBody
26446
26451
  });
26447
26452
  if (!ctsCtsKeyRes.ok) {
26448
26453
  const errText = await ctsCtsKeyRes.text().catch(() => "");
26449
- console.log(` Warning: CTS encryption key registration failed (${ctsCtsKeyRes.status}): ${errText.slice(0, 200)}`);
26454
+ log(` Warning: CTS encryption key registration failed (${ctsCtsKeyRes.status}): ${errText.slice(0, 200)}`);
26450
26455
  } else {
26451
- console.log(` CTS encryption key registered: ${ctsKeyId.slice(0, 8)}...`);
26456
+ log(` CTS encryption key registered: ${ctsKeyId.slice(0, 8)}...`);
26452
26457
  }
26453
26458
  ctsKey = {
26454
26459
  keyId: ctsKeyId,
@@ -26468,7 +26473,7 @@ Then re-run login, or just use 'auth refresh' for tokens.`
26468
26473
  serverPublicKeyId
26469
26474
  };
26470
26475
  saveApigwKeys(apigwKeys);
26471
- console.log("\nStep 6/7: Activating apigw via ETS...");
26476
+ log("\nStep 6: Activating apigw via ETS...");
26472
26477
  const activationUrl = `${etsBaseUrl}/api/v1/apigw/api/v1/authentication/activation`;
26473
26478
  const activationBody = JSON.stringify({
26474
26479
  app_version: config.app_version,
@@ -26496,20 +26501,39 @@ Then re-run login, or just use 'auth refresh' for tokens.`
26496
26501
  });
26497
26502
  if (!activationRes.ok) {
26498
26503
  const actErrText = await activationRes.text().catch(() => "");
26499
- console.log(` Warning: apigw activation failed (${activationRes.status}): ${actErrText.slice(0, 200)}`);
26504
+ log(` Warning: apigw activation failed (${activationRes.status}): ${actErrText.slice(0, 200)}`);
26500
26505
  } else {
26501
- console.log(" Apigw activated.");
26506
+ log(" Apigw activated.");
26502
26507
  }
26503
- console.log(`
26508
+ log(`
26504
26509
  User HUID: ${userHuid || "unknown"}`);
26505
- console.log(` Signing key: ${apigwSigningKey.keyId.slice(0, 8)}...`);
26506
- console.log(` Encryption key: ${rtsPublicKeyId.slice(0, 8)}...`);
26507
- console.log(` Server key: ${serverPublicKeyId.slice(0, 8)}...`);
26508
- console.log("\nQR login complete! You are now authenticated.");
26510
+ log(` Signing key: ${apigwSigningKey.keyId.slice(0, 8)}...`);
26511
+ log(` Encryption key: ${rtsPublicKeyId.slice(0, 8)}...`);
26512
+ log(` Server key: ${serverPublicKeyId.slice(0, 8)}...`);
26513
+ log("\nQR login complete! You are now authenticated.");
26509
26514
  }
26515
+ async function qrLogin(cliOverrides = {}) {
26516
+ const mat = buildQrMaterial(cliOverrides);
26517
+ console.log("Step 1/6: Scan this QR code with your eXpress app:\n");
26518
+ qrcode.generate(mat.qrPayload, { small: true }, (qr) => {
26519
+ console.log(qr);
26520
+ });
26521
+ console.log(`
26522
+ registration_id: ${mat.registrationId}`);
26523
+ console.log(" Waiting for scan (server long-polling)...\n");
26524
+ openQrInBrowser(mat.qrPayload, mat.registrationId).catch(() => {
26525
+ });
26526
+ const pollResult = await pollForQrScan(mat);
26527
+ await completeQrRegistration(mat, pollResult);
26528
+ }
26529
+
26530
+ // src/cli/auth.ts
26531
+ init_store();
26510
26532
 
26511
26533
  // src/auth/token-refresh.ts
26512
26534
  init_esm_shims();
26535
+ init_store();
26536
+ init_loader();
26513
26537
  var refreshPromise = null;
26514
26538
  async function refreshToken(cliOverrides = {}) {
26515
26539
  if (refreshPromise) return refreshPromise;
@@ -26657,6 +26681,7 @@ import { Command as Command2 } from "commander";
26657
26681
 
26658
26682
  // src/api/client.ts
26659
26683
  init_esm_shims();
26684
+ init_store();
26660
26685
  var ApiClient = class {
26661
26686
  config;
26662
26687
  baseUrl;
@@ -26723,8 +26748,9 @@ var ApiClient = class {
26723
26748
  const retryRes = await fetch(url, { ...options, headers: newHeaders });
26724
26749
  return this.handleResponse(retryRes);
26725
26750
  }
26751
+ throw new Error("Token expired and refresh failed. Please re-authenticate with `express auth qr`.");
26726
26752
  }
26727
- throw new Error("Token expired and refresh failed. Please re-authenticate with `express auth qr`.");
26753
+ throw new Error(`API error 401: ${res.statusText}${text ? ` \u2014 ${text.slice(0, 200)}` : ""}`);
26728
26754
  }
26729
26755
  return this.handleResponse(res);
26730
26756
  }
@@ -27126,8 +27152,97 @@ async function fetchChatListViaWebSocket(params) {
27126
27152
  });
27127
27153
  });
27128
27154
  }
27155
+ async function createPersonalChatViaWebSocket(params) {
27156
+ const { host, ctsToken, encryptionKeyId, myHuid, targetHuid, timeoutMs = 15e3 } = params;
27157
+ const instanceId = randomUUID3();
27158
+ const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encryptionKeyId}&version=6&background=false&instance_id=${instanceId}`;
27159
+ return new Promise((resolve, reject) => {
27160
+ let settled = false;
27161
+ const done = (fn) => {
27162
+ if (!settled) {
27163
+ settled = true;
27164
+ fn();
27165
+ }
27166
+ };
27167
+ const timer = setTimeout(() => {
27168
+ try {
27169
+ ws2.close();
27170
+ } catch {
27171
+ }
27172
+ done(() => reject(new Error("WebSocket timeout: no chat_new response")));
27173
+ }, timeoutMs);
27174
+ const hostParts = host.split(".");
27175
+ const webOrigin = `https://${hostParts.length > 2 ? hostParts.slice(1).join(".") : host}`;
27176
+ const ws2 = new WebSocket(wsUrl, {
27177
+ headers: {
27178
+ Origin: webOrigin,
27179
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
27180
+ }
27181
+ });
27182
+ const authRef = 0;
27183
+ const createRef = 1;
27184
+ const send = (msg) => {
27185
+ ws2.send(JSON.stringify(msg));
27186
+ };
27187
+ ws2.addEventListener("open", () => {
27188
+ send({ topic: "phoenix", event: "authenticate", payload: { token: ctsToken }, ref: authRef });
27189
+ });
27190
+ ws2.addEventListener("message", (event) => {
27191
+ let msg;
27192
+ try {
27193
+ msg = JSON.parse(event.data);
27194
+ } catch {
27195
+ return;
27196
+ }
27197
+ if (msg.ref === authRef && msg.event === "phx_reply") {
27198
+ if (msg.payload.status !== "ok") {
27199
+ clearTimeout(timer);
27200
+ ws2.close();
27201
+ done(() => reject(new Error(`WS auth failed: ${JSON.stringify(msg.payload.response)}`)));
27202
+ return;
27203
+ }
27204
+ send({
27205
+ topic: "system",
27206
+ event: "chat_new",
27207
+ payload: {
27208
+ chat_type: "chat",
27209
+ members: [targetHuid, myHuid],
27210
+ name: "personal chat",
27211
+ description: "",
27212
+ threads_enabled: false
27213
+ },
27214
+ ref: createRef
27215
+ });
27216
+ return;
27217
+ }
27218
+ if (msg.ref === createRef && msg.event === "phx_reply") {
27219
+ clearTimeout(timer);
27220
+ ws2.close();
27221
+ if (msg.payload.status !== "ok") {
27222
+ done(() => reject(new Error(`chat_new failed: ${JSON.stringify(msg.payload.response)}`)));
27223
+ return;
27224
+ }
27225
+ const chatId = msg.payload.response?.chat_new?.payload?.group_chat_id;
27226
+ if (!chatId) {
27227
+ done(() => reject(new Error(`chat_new: no group_chat_id in response`)));
27228
+ return;
27229
+ }
27230
+ done(() => resolve(chatId));
27231
+ }
27232
+ });
27233
+ ws2.addEventListener("error", (err) => {
27234
+ clearTimeout(timer);
27235
+ done(() => reject(new Error(`WS error: ${String(err)}`)));
27236
+ });
27237
+ ws2.addEventListener("close", (event) => {
27238
+ clearTimeout(timer);
27239
+ done(() => reject(new Error(`WS closed before chat_new reply (code=${event.code})`)));
27240
+ });
27241
+ });
27242
+ }
27129
27243
 
27130
27244
  // src/api/chats.ts
27245
+ init_store();
27131
27246
  var ChatsApi = class {
27132
27247
  constructor(client) {
27133
27248
  this.client = client;
@@ -27174,6 +27289,16 @@ var ChatsApi = class {
27174
27289
  });
27175
27290
  return data?.open_chats?.[0] ?? null;
27176
27291
  }
27292
+ async createDm(targetHuid) {
27293
+ const config = loadConfig();
27294
+ const host = new URL(getBaseUrl(config)).hostname;
27295
+ const ctsToken = getAuthToken();
27296
+ const keys = loadApigwKeys();
27297
+ const encryptionKeyId = (keys?.ctsKey ?? keys?.encryptionKey)?.keyId;
27298
+ if (!ctsToken || !encryptionKeyId) throw new Error("Not authenticated");
27299
+ const myHuid = (await new UserApi(this.client).getSelfProfile()).user_huid;
27300
+ return createPersonalChatViaWebSocket({ host, ctsToken, encryptionKeyId, myHuid, targetHuid });
27301
+ }
27177
27302
  };
27178
27303
 
27179
27304
  // src/api/phonebook.ts
@@ -27221,8 +27346,7 @@ var PhonebookApi = class {
27221
27346
  );
27222
27347
  if (!data || typeof data !== "object") return [];
27223
27348
  const obj = data;
27224
- const result = obj.result;
27225
- const phonebook = result?.phonebook ?? [];
27349
+ const phonebook = obj.phonebook ?? [];
27226
27350
  const huids = [
27227
27351
  ...new Set(
27228
27352
  phonebook.flatMap((entry) => entry.contacts ?? []).map((c) => c.user_huid).filter(Boolean)
@@ -27602,50 +27726,100 @@ function createChatsCommand() {
27602
27726
  init_esm_shims();
27603
27727
  import { Command as Command8 } from "commander";
27604
27728
 
27605
- // src/api/messaging.ts
27729
+ // src/api/messaging-ws.ts
27730
+ init_esm_shims();
27731
+ import WebSocket2 from "ws";
27732
+ import { randomBytes as randomBytes3, randomUUID as randomUUID4, createHash } from "crypto";
27733
+ import { readFile } from "fs/promises";
27734
+ import { basename } from "path";
27735
+ import nacl4 from "tweetnacl";
27736
+ import sodium2 from "libsodium-wrappers-sumo";
27737
+ import sharp from "sharp";
27738
+
27739
+ // src/api/file-service.ts
27606
27740
  init_esm_shims();
27607
- var MessagingApi = class {
27741
+ var UPLOAD_PART_SIZE = 6291507;
27742
+ var IMAGE_MIME_TYPES = {
27743
+ ".png": "image/png",
27744
+ ".jpg": "image/jpeg",
27745
+ ".jpeg": "image/jpeg",
27746
+ ".gif": "image/gif",
27747
+ ".webp": "image/webp"
27748
+ };
27749
+ function detectImageMimeType(fileName) {
27750
+ const ext = fileName.includes(".") ? "." + fileName.split(".").pop().toLowerCase() : "";
27751
+ return IMAGE_MIME_TYPES[ext] ?? null;
27752
+ }
27753
+ var FileServiceApi = class {
27608
27754
  constructor(client) {
27609
27755
  this.client = client;
27610
27756
  }
27611
27757
  client;
27612
- async sendMessage(params) {
27613
- const payload = {
27614
- group_chat_id: params.groupChatId,
27615
- notification: {
27616
- status: "ok",
27617
- body: params.body,
27618
- ...params.metadata && { metadata: params.metadata },
27619
- ...params.mentions && { mentions: params.mentions },
27620
- ...params.stealthMode && {
27621
- opts: { stealth_mode: true }
27622
- }
27623
- }
27624
- };
27625
- return this.client.post("/api/v4/botx/notifications/direct", payload);
27626
- }
27627
- async sendFile(params) {
27628
- const payload = {
27629
- group_chat_id: params.groupChatId,
27630
- notification: {
27631
- status: "ok",
27632
- body: params.caption ?? ""
27758
+ /** Uploads an already-encrypted content blob plus an already-encrypted JPEG preview via the resumable protocol. */
27759
+ async uploadWithPreview(init, content, preview) {
27760
+ const resumableId = await this.initUpload(
27761
+ init,
27762
+ `content=${content.length};preview=${preview.data.length},${preview.mimeType}`
27763
+ );
27764
+ await this.uploadPart(resumableId, "content", content);
27765
+ const finalRes = await this.uploadPart(resumableId, "preview", preview.data);
27766
+ const body = await finalRes.json();
27767
+ return body.result;
27768
+ }
27769
+ async initUpload(init, shapes) {
27770
+ const res = await this.client.rawRequest("/api/v2/file_service/resumable", {
27771
+ method: "POST",
27772
+ headers: {
27773
+ "Content-Type": "application/json",
27774
+ "upload-part-size": String(UPLOAD_PART_SIZE),
27775
+ "upload-shapes": shapes
27633
27776
  },
27634
- file: {
27635
- file_name: params.fileName,
27636
- data: params.fileData
27637
- }
27638
- };
27639
- return this.client.post("/api/v4/botx/notifications/direct", payload);
27777
+ body: JSON.stringify(init)
27778
+ });
27779
+ if (!res.ok) {
27780
+ throw new Error(`file upload init failed: ${res.status} ${await res.text()}`);
27781
+ }
27782
+ const resumableId = res.headers.get("upload-resumable-id");
27783
+ if (!resumableId) throw new Error("file upload init: missing upload-resumable-id header");
27784
+ return resumableId;
27785
+ }
27786
+ async uploadPart(resumableId, shape, data) {
27787
+ const res = await this.client.rawRequest(`/api/v2/file_service/resumable/${resumableId}`, {
27788
+ method: "POST",
27789
+ headers: {
27790
+ "Content-Type": "application/octet-stream",
27791
+ "upload-shape": shape,
27792
+ "upload-part-size": String(UPLOAD_PART_SIZE),
27793
+ "upload-range": `bytes=0-${data.length - 1}`
27794
+ },
27795
+ body: data
27796
+ });
27797
+ if (!res.ok) {
27798
+ throw new Error(`file upload part (${shape}) failed: ${res.status} ${await res.text()}`);
27799
+ }
27800
+ return res;
27640
27801
  }
27641
27802
  };
27642
27803
 
27643
- // src/api/messaging-ws.ts
27804
+ // src/api/file-crypto.ts
27644
27805
  init_esm_shims();
27645
- import WebSocket2 from "ws";
27646
- import { randomBytes as randomBytes3, randomUUID as randomUUID4 } from "crypto";
27647
- import nacl4 from "tweetnacl";
27648
27806
  import sodium from "libsodium-wrappers-sumo";
27807
+ var FILE_CHUNK_SIZE = 2097152;
27808
+ async function encryptFileStream(data, key) {
27809
+ await sodium.ready;
27810
+ const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
27811
+ const parts = [header];
27812
+ for (let offset = 0; offset < data.length; offset += FILE_CHUNK_SIZE) {
27813
+ const chunk = data.subarray(offset, offset + FILE_CHUNK_SIZE);
27814
+ const isLast = offset + FILE_CHUNK_SIZE >= data.length;
27815
+ const tag = isLast ? sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL : sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE;
27816
+ parts.push(sodium.crypto_secretstream_xchacha20poly1305_push(state, chunk, null, tag));
27817
+ }
27818
+ return Buffer.concat(parts);
27819
+ }
27820
+
27821
+ // src/api/messaging-ws.ts
27822
+ init_store();
27649
27823
  function buildTextPayload(text, fromHuid, chatId) {
27650
27824
  return JSON.stringify({
27651
27825
  type: "text",
@@ -27660,12 +27834,49 @@ function buildTextPayload(text, fromHuid, chatId) {
27660
27834
  body: text
27661
27835
  });
27662
27836
  }
27837
+ function buildImagePayload(params) {
27838
+ return JSON.stringify({
27839
+ type: "image",
27840
+ msg_id: randomUUID4(),
27841
+ from: params.fromHuid,
27842
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
27843
+ group_chat_id: params.chatId,
27844
+ lat: 0,
27845
+ lng: 0,
27846
+ stealth_forwarding: false,
27847
+ payload: {
27848
+ file: params.contentPath,
27849
+ file_name: params.fileName,
27850
+ file_size: params.fileSize,
27851
+ file_hash: params.fileHash,
27852
+ file_mime_type: params.mimeType,
27853
+ chunk_size: FILE_CHUNK_SIZE,
27854
+ file_encryption_algo: "stream",
27855
+ file_preview: params.previewPath,
27856
+ file_preview_width: params.previewWidth,
27857
+ file_preview_height: params.previewHeight,
27858
+ blur_preview_file: params.blurPreviewDataUri,
27859
+ file_id: params.fileId
27860
+ },
27861
+ body: params.caption
27862
+ });
27863
+ }
27663
27864
  function payloadAad(chatId, syncId) {
27664
27865
  return new Uint8Array(Buffer.from(`${chatId}:${syncId}`));
27665
27866
  }
27867
+ function wrapKeyForRecipients(symmetricKey, publicKeys, ctsKey) {
27868
+ return publicKeys.map((kdcKey) => {
27869
+ const recipientPubKey = new Uint8Array(Buffer.from(kdcKey.body, "base64"));
27870
+ const nonce = randomBytes3(nacl4.box.nonceLength);
27871
+ const ciphertext = nacl4.box(symmetricKey, nonce, recipientPubKey, ctsKey.privateKey);
27872
+ if (!ciphertext) throw new Error(`Failed to encrypt key for ${kdcKey.id}`);
27873
+ const combined = Buffer.concat([nonce, Buffer.from(ciphertext)]);
27874
+ return { key_id: kdcKey.id, key: combined.toString("base64"), algo: "xsalsa20:xchacha20_aead_ietf" };
27875
+ });
27876
+ }
27666
27877
  async function sendMessageViaWebSocket(params) {
27667
27878
  const { client, chatId, body, timeoutMs = 2e4 } = params;
27668
- await sodium.ready;
27879
+ await sodium2.ready;
27669
27880
  const apigwKeys = loadApigwKeys();
27670
27881
  if (!apigwKeys) throw new Error("No apigw keys. Run 'express auth login' first.");
27671
27882
  const config = loadConfig();
@@ -27681,27 +27892,11 @@ async function sendMessageViaWebSocket(params) {
27681
27892
  const publicKeys = kdcKeys.filter((k) => k.kind === "cts");
27682
27893
  const symmetricKey = randomBytes3(32);
27683
27894
  const syncId = randomUUID4();
27684
- const encryptedKeys = publicKeys.map((kdcKey) => {
27685
- const recipientPubKey = new Uint8Array(Buffer.from(kdcKey.body, "base64"));
27686
- const nonce = randomBytes3(nacl4.box.nonceLength);
27687
- const ciphertext2 = nacl4.box(
27688
- symmetricKey,
27689
- nonce,
27690
- recipientPubKey,
27691
- ctsKey.privateKey
27692
- );
27693
- if (!ciphertext2) throw new Error(`Failed to encrypt key for ${kdcKey.id}`);
27694
- const combined = Buffer.concat([nonce, Buffer.from(ciphertext2)]);
27695
- return {
27696
- key_id: kdcKey.id,
27697
- key: combined.toString("base64"),
27698
- algo: "xsalsa20:xchacha20_aead_ietf"
27699
- };
27700
- });
27895
+ const encryptedKeys = wrapKeyForRecipients(symmetricKey, publicKeys, ctsKey);
27701
27896
  const selfHuid = (await new UserApi(client).getSelfProfile()).user_huid;
27702
27897
  const plaintext = new Uint8Array(Buffer.from(buildTextPayload(body, selfHuid, chatId)));
27703
- const msgNonce = randomBytes3(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
27704
- const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
27898
+ const msgNonce = randomBytes3(sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
27899
+ const ciphertext = sodium2.crypto_aead_xchacha20poly1305_ietf_encrypt(
27705
27900
  plaintext,
27706
27901
  payloadAad(chatId, syncId),
27707
27902
  null,
@@ -27732,6 +27927,114 @@ async function sendMessageViaWebSocket(params) {
27732
27927
  timeoutMs
27733
27928
  });
27734
27929
  }
27930
+ async function buildPreview(imageBytes) {
27931
+ const data = await sharp(imageBytes).resize({ width: 300, height: 300, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 90 }).toBuffer();
27932
+ const blurData = data;
27933
+ const { width, height } = await sharp(data).metadata();
27934
+ return { data, width: width ?? 0, height: height ?? 0, blurData };
27935
+ }
27936
+ async function sendImageViaWebSocket(params) {
27937
+ const { client, chatId, filePath, caption = "", timeoutMs = 3e4 } = params;
27938
+ const fileName = basename(filePath);
27939
+ const mimeType = detectImageMimeType(fileName);
27940
+ if (!mimeType) {
27941
+ throw new Error(
27942
+ `Unsupported file type for '${fileName}'. Only images are currently supported: .png, .jpg, .jpeg, .gif, .webp`
27943
+ );
27944
+ }
27945
+ await sodium2.ready;
27946
+ const apigwKeys = loadApigwKeys();
27947
+ if (!apigwKeys) throw new Error("No apigw keys. Run 'express auth login' first.");
27948
+ const config = loadConfig();
27949
+ const host = new URL(getBaseUrl(config)).hostname;
27950
+ const webOrigin = getWebOrigin(config);
27951
+ const ctsToken = getAuthToken();
27952
+ const ctsKey = apigwKeys.ctsKey ?? apigwKeys.encryptionKey;
27953
+ const encKeyId = ctsKey.keyId;
27954
+ const participantKeyIds = await getChatKeyIds(host, webOrigin, ctsToken, encKeyId, chatId);
27955
+ const kdcKeys = await client.get(
27956
+ `/api/v1/kdc/keys/?ids=${participantKeyIds.join(",")}`
27957
+ ) ?? [];
27958
+ const publicKeys = kdcKeys.filter((k) => k.kind === "cts");
27959
+ const originalBytes = await readFile(filePath);
27960
+ const fileHash = createHash("sha256").update(originalBytes).digest("base64");
27961
+ const preview = await buildPreview(originalBytes);
27962
+ const blurPreviewDataUri = `data:image/jpeg;base64,${preview.blurData.toString("base64")}`;
27963
+ const fileKey = randomBytes3(32);
27964
+ const fileKeys = wrapKeyForRecipients(fileKey, publicKeys, ctsKey);
27965
+ const syncId = randomUUID4();
27966
+ const encryptedContent = await encryptFileStream(originalBytes, fileKey);
27967
+ const encryptedPreview = await encryptFileStream(preview.data, fileKey);
27968
+ const uploaded = await new FileServiceApi(client).uploadWithPreview(
27969
+ {
27970
+ subject: "groupchat_file",
27971
+ subject_id: chatId,
27972
+ visible: true,
27973
+ file_name: fileName,
27974
+ mime_type: mimeType,
27975
+ meta: {
27976
+ sync_id: syncId,
27977
+ kind: "media",
27978
+ chunk_size: FILE_CHUNK_SIZE,
27979
+ file_encryption_algo: "stream",
27980
+ sender_key_id: encKeyId,
27981
+ keys: fileKeys,
27982
+ file_hash: fileHash
27983
+ }
27984
+ },
27985
+ encryptedContent,
27986
+ { data: encryptedPreview, mimeType: "image/jpeg" }
27987
+ );
27988
+ const selfHuid = (await new UserApi(client).getSelfProfile()).user_huid;
27989
+ const plaintext = new Uint8Array(Buffer.from(buildImagePayload({
27990
+ fromHuid: selfHuid,
27991
+ chatId,
27992
+ caption,
27993
+ fileName,
27994
+ fileSize: originalBytes.length,
27995
+ fileHash,
27996
+ mimeType,
27997
+ contentPath: uploaded.content,
27998
+ previewPath: uploaded.content_shapes.preview ?? "",
27999
+ previewWidth: preview.width,
28000
+ previewHeight: preview.height,
28001
+ blurPreviewDataUri,
28002
+ fileId: uploaded.id
28003
+ })));
28004
+ const msgKey = randomBytes3(32);
28005
+ const encryptedKeys = wrapKeyForRecipients(msgKey, publicKeys, ctsKey);
28006
+ const msgNonce = randomBytes3(sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
28007
+ const ciphertext = sodium2.crypto_aead_xchacha20poly1305_ietf_encrypt(
28008
+ plaintext,
28009
+ payloadAad(chatId, syncId),
28010
+ null,
28011
+ new Uint8Array(msgNonce),
28012
+ new Uint8Array(msgKey)
28013
+ );
28014
+ const encryptedPayload = Buffer.concat([msgNonce, Buffer.from(ciphertext)]).toString("base64");
28015
+ const signingKey = apigwKeys.signingKey;
28016
+ const signBytes = signEd25519(
28017
+ signingKey.privateKey,
28018
+ new Uint8Array(Buffer.from(encryptedPayload, "utf8"))
28019
+ );
28020
+ const signature = {
28021
+ sign: Buffer.from(signBytes).toString("base64"),
28022
+ sign_key_id: signingKey.keyId,
28023
+ sign_algo: "ed25519"
28024
+ };
28025
+ return sendMessageNew({
28026
+ host,
28027
+ webOrigin,
28028
+ ctsToken,
28029
+ encKeyId,
28030
+ chatId,
28031
+ syncId,
28032
+ encryptedKeys,
28033
+ encryptedPayload,
28034
+ signature,
28035
+ timeoutMs
28036
+ });
28037
+ }
27735
28038
  async function getChatKeyIds(host, webOrigin, ctsToken, encKeyId, chatId) {
27736
28039
  const wsUrl = `wss://${host}/socket/user/websocket?vsn=1.0.0&auto_join=true&key_id=${encKeyId}&version=6&background=false&voex_unencrypted=true&instance_id=${randomUUID4()}`;
27737
28040
  return new Promise((resolve, reject) => {
@@ -27875,18 +28178,31 @@ async function resolveChatId(client, chatIdOrName) {
27875
28178
  const chats = await listChatsWithNames(client);
27876
28179
  const lower = chatIdOrName.toLowerCase();
27877
28180
  const matches = chats.filter((c) => (c.name ?? "").toLowerCase().includes(lower));
27878
- if (matches.length === 0) throw new Error(`No chat found matching "${chatIdOrName}"`);
28181
+ if (matches.length === 1) return matches[0].group_chat_id;
27879
28182
  if (matches.length > 1) {
27880
28183
  const names = matches.map((c) => ` ${c.name} (${c.group_chat_id})`).join("\n");
27881
28184
  throw new Error(`Multiple chats match "${chatIdOrName}":
27882
28185
  ${names}
27883
28186
  Use the full chat ID.`);
27884
28187
  }
27885
- return matches[0].group_chat_id;
28188
+ const results = await new PhonebookApi(client).searchUsers(chatIdOrName, 5);
28189
+ if (results.length === 0) throw new Error(`No chat or contact found matching "${chatIdOrName}"`);
28190
+ if (results.length > 1) {
28191
+ const names = results.map((p) => ` ${p.name} (${p.user_huid})`).join("\n");
28192
+ throw new Error(`No existing DM with "${chatIdOrName}", found multiple contacts:
28193
+ ${names}
28194
+ Be more specific.`);
28195
+ }
28196
+ const person = results[0];
28197
+ process.stderr.write(`No DM with "${person.name}" \u2014 creating one...
28198
+ `);
28199
+ const chatId = await new ChatsApi(client).createDm(person.user_huid);
28200
+ process.stderr.write(`DM created: ${chatId}
28201
+ `);
28202
+ return chatId;
27886
28203
  }
27887
28204
 
27888
28205
  // src/cli/send.ts
27889
- import { readFileSync } from "fs";
27890
28206
  function createSendCommand() {
27891
28207
  const cmd = new Command8("send");
27892
28208
  cmd.description("Send messages and files");
@@ -27901,33 +28217,11 @@ function createSendCommand() {
27901
28217
  process.exit(1);
27902
28218
  }
27903
28219
  });
27904
- cmd.command("file <chat-id> <file-path>").description("Send a file to a chat").option("--caption <caption>", "File caption").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (chatId, filePath, opts) => {
28220
+ cmd.command("file <chat-id-or-name> <file-path>").description("Send a file to a chat (chat ID or partial name). Currently images only: .png, .jpg, .jpeg, .gif, .webp").option("--caption <caption>", "File caption").option("--host <host>", "eXpress host").option("-o, --output <format>", "Output format", "json").action(async (chatIdOrName, filePath, opts) => {
27905
28221
  try {
27906
- const data = readFileSync(filePath);
27907
- const fileName = filePath.split("/").pop() ?? "file";
27908
- const mimeMap = {
27909
- ".png": "image/png",
27910
- ".jpg": "image/jpeg",
27911
- ".jpeg": "image/jpeg",
27912
- ".gif": "image/gif",
27913
- ".pdf": "application/pdf",
27914
- ".txt": "text/plain",
27915
- ".json": "application/json",
27916
- ".csv": "text/csv",
27917
- ".zip": "application/zip"
27918
- };
27919
- const ext = fileName.includes(".") ? "." + fileName.split(".").pop().toLowerCase() : "";
27920
- const mime = mimeMap[ext] ?? "application/octet-stream";
27921
- const base64 = data.toString("base64");
27922
- const dataUri = `data:${mime};base64,${base64}`;
27923
28222
  const client = new ApiClient(opts.host ? { host: opts.host } : void 0);
27924
- const api = new MessagingApi(client);
27925
- const result = await api.sendFile({
27926
- groupChatId: chatId,
27927
- fileName,
27928
- fileData: dataUri,
27929
- caption: opts.caption
27930
- });
28223
+ const chatId = await resolveChatId(client, chatIdOrName);
28224
+ const result = await sendImageViaWebSocket({ client, chatId, filePath, caption: opts.caption });
27931
28225
  console.log(formatOutput(result, opts.output));
27932
28226
  } catch (err) {
27933
28227
  console.error(`Error: ${err.message}`);
@@ -28048,11 +28342,12 @@ import { Command as Command11 } from "commander";
28048
28342
  init_esm_shims();
28049
28343
  import WebSocket3 from "ws";
28050
28344
  import { randomUUID as randomUUID5 } from "crypto";
28345
+ init_store();
28051
28346
 
28052
28347
  // src/api/decrypt.ts
28053
28348
  init_esm_shims();
28054
28349
  import nacl5 from "tweetnacl";
28055
- import sodium2 from "libsodium-wrappers-sumo";
28350
+ import sodium3 from "libsodium-wrappers-sumo";
28056
28351
  function decryptMessage(msg, ctsPrivateKey, myKeyId, keyMap, apigwKeys) {
28057
28352
  const encKey = msg.key;
28058
28353
  if (encKey.key_id !== myKeyId) {
@@ -28066,10 +28361,10 @@ function decryptMessage(msg, ctsPrivateKey, myKeyId, keyMap, apigwKeys) {
28066
28361
  const symmetricKey = nacl5.box.open(ciphertext, nonce, senderPubKey, ctsPrivateKey);
28067
28362
  if (!symmetricKey) throw new Error("nacl.box.open failed \u2014 wrong key pair");
28068
28363
  const payloadRaw = Uint8Array.from(Buffer.from(msg.payload, "base64"));
28069
- const msgNonce = payloadRaw.slice(0, sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
28070
- const msgCiphertext = payloadRaw.slice(sodium2.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
28364
+ const msgNonce = payloadRaw.slice(0, sodium3.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
28365
+ const msgCiphertext = payloadRaw.slice(sodium3.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
28071
28366
  const aad = new Uint8Array(Buffer.from(`${msg.group_chat_id}:${msg.sync_id}`));
28072
- const plaintext = sodium2.crypto_aead_xchacha20poly1305_ietf_decrypt(null, msgCiphertext, aad, msgNonce, symmetricKey);
28367
+ const plaintext = sodium3.crypto_aead_xchacha20poly1305_ietf_decrypt(null, msgCiphertext, aad, msgNonce, symmetricKey);
28073
28368
  return JSON.parse(new TextDecoder().decode(plaintext));
28074
28369
  }
28075
28370
  function toDecrypted(msg, payload) {
@@ -28100,7 +28395,7 @@ function getSenderPublicKey(msg, keyMap, apigwKeys) {
28100
28395
  return null;
28101
28396
  }
28102
28397
  async function decryptMessages(events, apigwKeys, client = new ApiClient()) {
28103
- await sodium2.ready;
28398
+ await sodium3.ready;
28104
28399
  const ctsKey = apigwKeys.ctsKey ?? apigwKeys.encryptionKey;
28105
28400
  const messages = events.filter((e) => e.event_type === "message_new" && e.payload && e.key);
28106
28401
  if (messages.length === 0) return [];
@@ -28249,6 +28544,7 @@ init_esm_shims();
28249
28544
  import WebSocket4 from "ws";
28250
28545
  import { EventEmitter } from "events";
28251
28546
  import { randomUUID as randomUUID6 } from "crypto";
28547
+ init_store();
28252
28548
  var HEARTBEAT_MS = 25e3;
28253
28549
  var REQUEST_TIMEOUT_MS = 15e3;
28254
28550
  var MAX_RECONNECT_DELAY_MS = 3e4;
@@ -38447,9 +38743,23 @@ init_esm_shims();
38447
38743
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
38448
38744
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
38449
38745
  import { z as z2 } from "zod";
38746
+ import { writeFileSync as writeFileSync3, readFileSync as readFileSync2, unlinkSync } from "fs";
38747
+ import { tmpdir } from "os";
38748
+ import { join } from "path";
38749
+ init_store();
38750
+ import qrcode2 from "qrcode-terminal";
38751
+ var AUTH_INSTRUCTION = "Run 'express-cli auth qr' (or 'npx @ih8e/express-cli auth qr') in a terminal to log in.";
38450
38752
  var ok = (data) => ({
38451
38753
  content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }]
38452
38754
  });
38755
+ var notAuthenticated = () => ok({ error: "not_authenticated", instruction: AUTH_INSTRUCTION });
38756
+ async function ensureAuth() {
38757
+ const exp = getTokenExpiresAt();
38758
+ if (exp && Date.now() > exp - 5 * 60 * 1e3) {
38759
+ await refreshToken();
38760
+ }
38761
+ return !!getAuthToken();
38762
+ }
38453
38763
  var Inbox = class {
38454
38764
  buf = [];
38455
38765
  seq = 0;
@@ -38535,6 +38845,7 @@ async function runMcpServer() {
38535
38845
  description: "List chats (DMs, groups, channels) with names and full chat IDs. DM names are resolved to the person's full name.",
38536
38846
  inputSchema: { type: z2.enum(["all", "dm", "group", "channel"]).optional().describe("Filter by chat type (default all)") }
38537
38847
  }, async ({ type }) => {
38848
+ if (!await ensureAuth()) return notAuthenticated();
38538
38849
  const chats = await listChatsWithNames(new ApiClient());
38539
38850
  const kind = { dm: "chat", group: "group_chat", channel: "channel" };
38540
38851
  const filtered = !type || type === "all" ? chats : chats.filter((c) => c.chat_type === kind[type]);
@@ -38545,6 +38856,7 @@ async function runMcpServer() {
38545
38856
  description: "Find chats whose name (person or group) contains the query. Returns name + full chat_id to use with other tools.",
38546
38857
  inputSchema: { query: z2.string().describe("Part of the chat or person name") }
38547
38858
  }, async ({ query }) => {
38859
+ if (!await ensureAuth()) return notAuthenticated();
38548
38860
  const chats = await listChatsWithNames(new ApiClient());
38549
38861
  const q = query.toLowerCase();
38550
38862
  return ok(chats.filter((c) => (c.name ?? "").toLowerCase().includes(q)).map((c) => ({ name: c.name, chat_id: c.group_chat_id, type: c.chat_type })));
@@ -38557,6 +38869,7 @@ async function runMcpServer() {
38557
38869
  limit: z2.number().int().min(1).max(200).optional().describe("How many recent messages (default 20)")
38558
38870
  }
38559
38871
  }, async ({ chat, limit }) => {
38872
+ if (!await ensureAuth()) return notAuthenticated();
38560
38873
  const client = new ApiClient();
38561
38874
  const chatId = await resolveChatId(client, chat);
38562
38875
  const msgs = await readMessages({ chatId, limit: limit ?? 20 });
@@ -38568,9 +38881,10 @@ async function runMcpServer() {
38568
38881
  });
38569
38882
  server.registerTool("send_message", {
38570
38883
  title: "Send a message",
38571
- description: "Send a text message to a chat, identified by name (partial ok) or chat_id. Returns the sync_id.",
38884
+ description: "Send a text message to a chat, identified by name (partial ok) or chat_id. If no DM exists with that person, one is created automatically. Returns the sync_id.",
38572
38885
  inputSchema: { chat: z2.string().describe("Chat name or full chat_id"), text: z2.string().describe("Message text") }
38573
38886
  }, async ({ chat, text }) => {
38887
+ if (!await ensureAuth()) return notAuthenticated();
38574
38888
  const client = new ApiClient();
38575
38889
  const chatId = await resolveChatId(client, chat);
38576
38890
  const res = await sendMessageViaWebSocket({ client, chatId, body: text });
@@ -38581,6 +38895,7 @@ async function runMcpServer() {
38581
38895
  description: "Global company phonebook search across all employees by name.",
38582
38896
  inputSchema: { query: z2.string().describe("Name to search"), limit: z2.number().int().min(1).max(50).optional() }
38583
38897
  }, async ({ query, limit }) => {
38898
+ if (!await ensureAuth()) return notAuthenticated();
38584
38899
  const profiles = await new PhonebookApi(new ApiClient()).searchUsers(query, limit ?? 20);
38585
38900
  return ok(profiles.map((p) => ({ name: p.name, huid: p.user_huid, email: p.email, position: p.company_position, department: p.department })));
38586
38901
  });
@@ -38588,26 +38903,123 @@ async function runMcpServer() {
38588
38903
  title: "My profile",
38589
38904
  description: "Get the authenticated user's own profile.",
38590
38905
  inputSchema: {}
38591
- }, async () => ok(await new UserApi(new ApiClient()).getSelfProfile()));
38906
+ }, async () => {
38907
+ if (!await ensureAuth()) return notAuthenticated();
38908
+ return ok(await new UserApi(new ApiClient()).getSelfProfile());
38909
+ });
38592
38910
  server.registerTool("wait_for_messages", {
38593
38911
  title: "Wait for incoming messages",
38594
38912
  description: "Block until new incoming messages arrive (from any chat/discussion), then return them. Returns messages received since the previous call to this tool; if none are pending, waits up to timeout_seconds. Your own sent messages are not included. Use this to react to new messages instead of polling.",
38595
38913
  inputSchema: { timeout_seconds: z2.number().int().min(1).max(120).optional().describe("Max seconds to wait when nothing is pending (default 30)") }
38596
38914
  }, async ({ timeout_seconds }) => {
38597
- if (!session) return ok({ error: "Session unavailable \u2014 run 'express auth qr' to authenticate." });
38915
+ if (!session) return ok({ error: "Session unavailable.", instruction: AUTH_INSTRUCTION });
38598
38916
  const items = await inbox.take((timeout_seconds ?? 30) * 1e3);
38599
38917
  return ok({ connected: sessionReady, count: items.length, messages: await enrich(items) });
38600
38918
  });
38601
38919
  server.registerTool("status", {
38602
38920
  title: "Auth status",
38603
- description: "Check authentication and access-token status.",
38921
+ description: "Check authentication and access-token status. Call this first if other tools return not_authenticated.",
38604
38922
  inputSchema: {}
38605
38923
  }, async () => {
38606
38924
  const exp = getTokenExpiresAt();
38607
38925
  return ok({
38608
38926
  authenticated: !!getAuthToken(),
38609
- token_expires_in_seconds: exp ? Math.max(0, Math.floor((exp - Date.now()) / 1e3)) : null
38927
+ token_expires_in_seconds: exp ? Math.max(0, Math.floor((exp - Date.now()) / 1e3)) : null,
38928
+ login_instruction: AUTH_INSTRUCTION
38929
+ });
38930
+ });
38931
+ const QR_MATERIAL_FILE = join(tmpdir(), "express-qr-material.json");
38932
+ let pendingQrMaterial = null;
38933
+ let pendingQrPoll = null;
38934
+ server.registerTool("auth_qr_start", {
38935
+ title: "Start QR login \u2014 step 1 of 2",
38936
+ description: "Step 1: generate a QR code for eXpress login. The QR code is included in the response \u2014 display it to the user as-is. After displaying the QR you MUST immediately call the MCP tool `auth_qr_poll` (step 2) \u2014 do NOT wait for the user to confirm scanning first; auth_qr_poll waits automatically. Do NOT run auth_qr_poll as a shell command \u2014 it is an MCP tool. Use when status reports not_authenticated.",
38937
+ inputSchema: {}
38938
+ }, async () => {
38939
+ const material = buildQrMaterial();
38940
+ let qrAscii = "";
38941
+ qrcode2.generate(material.qrPayload, { small: true }, (qr) => {
38942
+ qrAscii = qr;
38943
+ });
38944
+ writeFileSync3(join(tmpdir(), "express-qr.txt"), qrAscii, "utf8");
38945
+ process.stderr.write("\n" + qrAscii + "\n");
38946
+ openQrInBrowser(material.qrPayload, material.registrationId).catch(() => {
38610
38947
  });
38948
+ const persistent = {
38949
+ registrationId: material.registrationId,
38950
+ encryptionKey: Buffer.from(material.encryptionKey).toString("base64"),
38951
+ qrSigningKey: {
38952
+ keyId: material.qrSigningKey.keyId,
38953
+ privateKey: Buffer.from(material.qrSigningKey.privateKey).toString("base64"),
38954
+ publicKey: Buffer.from(material.qrSigningKey.publicKey).toString("base64")
38955
+ },
38956
+ udid: material.udid,
38957
+ qrPayload: material.qrPayload,
38958
+ qrBody: material.qrBody
38959
+ };
38960
+ writeFileSync3(QR_MATERIAL_FILE, JSON.stringify(persistent), "utf8");
38961
+ pendingQrMaterial = material;
38962
+ pendingQrPoll = pollForQrScan(material).catch((err) => {
38963
+ process.stderr.write(`[mcp] QR poll error: ${err.message}
38964
+ `);
38965
+ pendingQrMaterial = null;
38966
+ pendingQrPoll = null;
38967
+ throw err;
38968
+ });
38969
+ return ok(
38970
+ `${qrAscii.trim()}
38971
+
38972
+ Scan with the eXpress mobile app.
38973
+
38974
+ [AGENT: call auth_qr_poll immediately \u2014 do not wait for user input]`
38975
+ );
38976
+ });
38977
+ server.registerTool("auth_qr_poll", {
38978
+ title: "Complete QR login \u2014 step 2 of 2",
38979
+ description: "Step 2: wait for the phone scan and complete QR login. Call this immediately after auth_qr_start \u2014 do NOT wait for user confirmation. Waits up to 90 s for the server handshake, then saves tokens and keys. IMPORTANT: call this as an MCP tool, not as a shell command.",
38980
+ inputSchema: {}
38981
+ }, async () => {
38982
+ let material = pendingQrMaterial;
38983
+ let poll = pendingQrPoll;
38984
+ if (!material || !poll) {
38985
+ let saved = null;
38986
+ try {
38987
+ saved = JSON.parse(readFileSync2(QR_MATERIAL_FILE, "utf8"));
38988
+ } catch {
38989
+ }
38990
+ if (!saved) return ok("No pending QR session. Call auth_qr_start first.");
38991
+ const sk = saved.qrSigningKey;
38992
+ const hydrated = {
38993
+ registrationId: saved.registrationId,
38994
+ encryptionKey: Buffer.from(saved.encryptionKey, "base64"),
38995
+ qrSigningKey: {
38996
+ keyId: sk.keyId,
38997
+ privateKey: Buffer.from(sk.privateKey, "base64"),
38998
+ publicKey: Buffer.from(sk.publicKey, "base64")
38999
+ },
39000
+ udid: saved.udid,
39001
+ qrPayload: saved.qrPayload,
39002
+ qrBody: saved.qrBody,
39003
+ config: (await Promise.resolve().then(() => (init_loader(), loader_exports))).loadConfig()
39004
+ };
39005
+ material = hydrated;
39006
+ poll = pollForQrScan(material);
39007
+ }
39008
+ pendingQrMaterial = null;
39009
+ pendingQrPoll = null;
39010
+ try {
39011
+ unlinkSync(QR_MATERIAL_FILE);
39012
+ } catch {
39013
+ }
39014
+ try {
39015
+ const pollResult = await poll;
39016
+ await completeQrRegistration(material, pollResult, (msg) => {
39017
+ process.stderr.write(msg + "\n");
39018
+ });
39019
+ return ok("Login successful. You are now authenticated.");
39020
+ } catch (err) {
39021
+ return ok(`Login failed: ${err.message}`);
39022
+ }
38611
39023
  });
38612
39024
  await server.connect(new StdioServerTransport());
38613
39025
  }
@@ -38629,7 +39041,7 @@ function createMcpCommand() {
38629
39041
  // src/cli/root.ts
38630
39042
  function createRootCommand() {
38631
39043
  const program2 = new Command15();
38632
- program2.name("express-cli").description("CLI client for eXpress Chat").version("0.1.4");
39044
+ program2.name("express-cli").description("CLI client for eXpress Chat").version("0.1.6");
38633
39045
  program2.addCommand(createAuthCommand());
38634
39046
  program2.addCommand(createApiCommand());
38635
39047
  program2.addCommand(createConfigCommand());