@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/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/index.js +853 -441
- package/dist/index.js.map +1 -1
- package/package.json +23 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26025
|
-
|
|
26026
|
-
|
|
26027
|
-
|
|
26028
|
-
|
|
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
|
|
26113
|
+
let res;
|
|
26046
26114
|
try {
|
|
26047
|
-
|
|
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
|
|
26056
|
-
if (!
|
|
26057
|
-
|
|
26058
|
-
|
|
26059
|
-
|
|
26060
|
-
|
|
26061
|
-
|
|
26062
|
-
|
|
26063
|
-
|
|
26064
|
-
|
|
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
|
-
|
|
26070
|
-
|
|
26071
|
-
|
|
26072
|
-
const
|
|
26073
|
-
|
|
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
|
-
|
|
26076
|
-
|
|
26077
|
-
|
|
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
|
-
|
|
26091
|
-
|
|
26092
|
-
|
|
26093
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26179
|
+
log(` Warning: could not decrypt registration_data: ${err.message}`);
|
|
26114
26180
|
}
|
|
26115
26181
|
}
|
|
26116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26183
|
-
if (encryptedRtsToken) {
|
|
26184
|
-
|
|
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
|
-
|
|
26249
|
+
log(` Token expires in ${expiresIn}s (refresh after ${(expiresIn / 2 / 60).toFixed(0)} min)`);
|
|
26199
26250
|
}
|
|
26200
|
-
|
|
26201
|
-
|
|
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
|
-
|
|
26276
|
+
log(` Warning: device token registration failed (${tokenRes.status}): ${tokenErrText.slice(0, 200)}`);
|
|
26230
26277
|
} else {
|
|
26231
|
-
|
|
26278
|
+
log(" Device token registered.");
|
|
26232
26279
|
}
|
|
26233
|
-
|
|
26280
|
+
log("\nStep 5: Registering signing key + fetching server key...");
|
|
26234
26281
|
if (process.env.EXPRESS_DEBUG) {
|
|
26235
|
-
|
|
26236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26363
|
+
log(` Warning: CTS RTS key registration failed (${ctsRtsRes.status}): ${errText.slice(0, 200)}`);
|
|
26343
26364
|
} else {
|
|
26344
|
-
|
|
26365
|
+
log(` RTS key registered in CTS: ${rtsPublicKeyId}`);
|
|
26345
26366
|
}
|
|
26346
26367
|
if (!etsRtsRes.ok) {
|
|
26347
26368
|
const errText = await etsRtsRes.text().catch(() => "");
|
|
26348
|
-
|
|
26369
|
+
log(` Warning: ETS RTS key registration failed (${etsRtsRes.status}): ${errText.slice(0, 200)}`);
|
|
26349
26370
|
} else {
|
|
26350
|
-
|
|
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
|
-
|
|
26394
|
+
log(` Warning: CTS fallback RTS key registration failed (${ctsEncRes.status}): ${errText.slice(0, 200)}`);
|
|
26382
26395
|
} else {
|
|
26383
|
-
|
|
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
|
-
|
|
26400
|
+
log(` Warning: ETS fallback RTS key registration failed (${etsEncRes.status}): ${errText.slice(0, 200)}`);
|
|
26388
26401
|
} else {
|
|
26389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26454
|
+
log(` Warning: CTS encryption key registration failed (${ctsCtsKeyRes.status}): ${errText.slice(0, 200)}`);
|
|
26450
26455
|
} else {
|
|
26451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26504
|
+
log(` Warning: apigw activation failed (${activationRes.status}): ${actErrText.slice(0, 200)}`);
|
|
26500
26505
|
} else {
|
|
26501
|
-
|
|
26506
|
+
log(" Apigw activated.");
|
|
26502
26507
|
}
|
|
26503
|
-
|
|
26508
|
+
log(`
|
|
26504
26509
|
User HUID: ${userHuid || "unknown"}`);
|
|
26505
|
-
|
|
26506
|
-
|
|
26507
|
-
|
|
26508
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
27613
|
-
|
|
27614
|
-
|
|
27615
|
-
|
|
27616
|
-
|
|
27617
|
-
|
|
27618
|
-
|
|
27619
|
-
|
|
27620
|
-
|
|
27621
|
-
|
|
27622
|
-
|
|
27623
|
-
|
|
27624
|
-
|
|
27625
|
-
|
|
27626
|
-
|
|
27627
|
-
|
|
27628
|
-
|
|
27629
|
-
|
|
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
|
-
|
|
27635
|
-
|
|
27636
|
-
|
|
27637
|
-
}
|
|
27638
|
-
}
|
|
27639
|
-
|
|
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/
|
|
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
|
|
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 =
|
|
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(
|
|
27704
|
-
const ciphertext =
|
|
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 ===
|
|
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
|
-
|
|
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 (
|
|
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
|
|
27925
|
-
const result = await
|
|
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
|
|
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,
|
|
28070
|
-
const msgCiphertext = payloadRaw.slice(
|
|
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 =
|
|
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
|
|
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 () =>
|
|
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
|
|
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.
|
|
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());
|