vexp-cli 3.2.4 → 3.3.0
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/README.md +4 -4
- package/dist/agent-config.js +415 -70
- package/dist/agent-consent.js +244 -0
- package/dist/cli.js +197 -95
- package/dist/codex-trust.js +608 -0
- package/dist/doctor.js +1112 -112
- package/dist/forget.js +7 -3
- package/dist/hook-template.js +78 -23
- package/dist/license-activate.js +90 -0
- package/dist/license.js +577 -125
- package/dist/serve.js +3 -2
- package/dist/socket-path.js +25 -1
- package/mcp/mcp-server.cjs +48 -48
- package/package.json +7 -7
package/dist/license.js
CHANGED
|
@@ -16,7 +16,21 @@ function vexpHomeDir() {
|
|
|
16
16
|
return os.homedir();
|
|
17
17
|
}
|
|
18
18
|
const VEXP_LICENSE_PUBLIC_KEY = "MCowBQYDK2VwAyEApwiWYGCyhCaGHAHWn/RTBSGo/1MNmGyZUgSNBQ5YE4g=";
|
|
19
|
-
|
|
19
|
+
let licensePublicKeyDer = VEXP_LICENSE_PUBLIC_KEY;
|
|
20
|
+
/**
|
|
21
|
+
* Tests only: verify tokens against a key the test generated, so the success
|
|
22
|
+
* paths run through real Ed25519 verification without the server's private
|
|
23
|
+
* key. Not reachable from the command line or the environment: only code
|
|
24
|
+
* already running inside this process can call it.
|
|
25
|
+
*/
|
|
26
|
+
export function __setLicensePublicKeyForTests(derBase64) {
|
|
27
|
+
licensePublicKeyDer = derBase64 ?? VEXP_LICENSE_PUBLIC_KEY;
|
|
28
|
+
}
|
|
29
|
+
/** Read at call time, not import time, so a test (or a user who exports it
|
|
30
|
+
* after a long-lived process started) always hits the origin it names. */
|
|
31
|
+
function webOrigin() {
|
|
32
|
+
return process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
|
|
33
|
+
}
|
|
20
34
|
// 14-day grace window after the last successful online refresh before we
|
|
21
35
|
// stop trusting the cached freshToken and fall back to the long JWT only.
|
|
22
36
|
const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
@@ -28,52 +42,193 @@ const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
|
|
|
28
42
|
// fresh.jwt alive quietly never landed and users fell back to the long
|
|
29
43
|
// JWT — or, once that lapsed, to the free tier.
|
|
30
44
|
const VALIDATE_TIMEOUT_MS = 10_000;
|
|
45
|
+
/** Where the user gets a current key: the account page re-mints it. */
|
|
46
|
+
export const ACCOUNT_URL = "https://vexp.dev/account";
|
|
47
|
+
function vexpDir() {
|
|
48
|
+
return path.join(vexpHomeDir(), ".vexp");
|
|
49
|
+
}
|
|
31
50
|
function getLicensePath() {
|
|
32
|
-
return path.join(
|
|
51
|
+
return path.join(vexpDir(), "license.jwt");
|
|
33
52
|
}
|
|
34
53
|
function getFreshTokenPath() {
|
|
35
|
-
return path.join(
|
|
54
|
+
return path.join(vexpDir(), "fresh.jwt");
|
|
36
55
|
}
|
|
37
56
|
function getLastCheckPath() {
|
|
38
|
-
return path.join(
|
|
57
|
+
return path.join(vexpDir(), "last_online_check");
|
|
39
58
|
}
|
|
40
59
|
function getDeviceIdPath() {
|
|
41
|
-
return path.join(
|
|
60
|
+
return path.join(vexpDir(), "device.id");
|
|
42
61
|
}
|
|
43
62
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
63
|
+
* Write a file so that a reader never sees it half-written: the content goes
|
|
64
|
+
* to a temp file next to the target and is renamed over it. The Rust engine,
|
|
65
|
+
* the VS Code extension and this CLI all write these files, sometimes at the
|
|
66
|
+
* same moment; with a plain writeFileSync one of them could read a truncated
|
|
67
|
+
* token and fall to FREE. When the rename is refused (a Windows reader
|
|
68
|
+
* holding the target open), fall back to a direct write rather than lose the
|
|
69
|
+
* update. Throws only when neither works.
|
|
46
70
|
*/
|
|
47
|
-
function
|
|
48
|
-
|
|
71
|
+
export function atomicWriteFile(target, content) {
|
|
72
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
73
|
+
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
49
74
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
76
|
+
try {
|
|
77
|
+
fs.renameSync(tmp, target);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
fs.writeFileSync(target, content, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
try {
|
|
85
|
+
fs.rmSync(tmp, { force: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* already renamed, or gone */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function readTrimmed(p) {
|
|
93
|
+
try {
|
|
94
|
+
const s = fs.readFileSync(p, "utf-8").trim();
|
|
95
|
+
return s.length > 0 ? s : null;
|
|
53
96
|
}
|
|
54
97
|
catch {
|
|
55
|
-
|
|
98
|
+
return null;
|
|
56
99
|
}
|
|
57
|
-
|
|
58
|
-
|
|
100
|
+
}
|
|
101
|
+
function sleepSync(ms) {
|
|
59
102
|
try {
|
|
60
|
-
|
|
61
|
-
fs.writeFileSync(p, id, "utf-8");
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
62
104
|
}
|
|
63
105
|
catch {
|
|
64
|
-
|
|
106
|
+
/* no Atomics.wait on this thread: retry immediately */
|
|
65
107
|
}
|
|
66
|
-
return id;
|
|
67
108
|
}
|
|
68
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Create `target` with `content` only if it does not exist yet, and return
|
|
111
|
+
* whatever the file holds afterwards: ours if we created it, the other
|
|
112
|
+
* writer's if they got there first. The content is staged in a temp file and
|
|
113
|
+
* hard-linked into place, so the file never exists empty; filesystems without
|
|
114
|
+
* hard links fall back to an exclusive create. A file that exists but is
|
|
115
|
+
* empty (a writer that died mid-write, or an exclusive create on a filesystem
|
|
116
|
+
* without hard links whose writer never finished) is replaced after a short
|
|
117
|
+
* wait, the Rust engine's rule: otherwise every caller would mint a new id
|
|
118
|
+
* that is never saved, and each check-in would register another device.
|
|
119
|
+
* Returns null when nothing can be written (read-only home).
|
|
120
|
+
*/
|
|
121
|
+
export function createFileIfAbsent(target, content) {
|
|
122
|
+
const existing = readTrimmed(target);
|
|
123
|
+
if (existing)
|
|
124
|
+
return existing;
|
|
69
125
|
try {
|
|
70
|
-
|
|
71
|
-
const n = Number(s);
|
|
72
|
-
return Number.isFinite(n) ? n : 0;
|
|
126
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
73
127
|
}
|
|
74
128
|
catch {
|
|
75
|
-
return
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
134
|
+
try {
|
|
135
|
+
fs.linkSync(tmp, target);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
const code = e.code;
|
|
139
|
+
if (code !== "EEXIST") {
|
|
140
|
+
try {
|
|
141
|
+
fs.writeFileSync(target, content, { encoding: "utf-8", flag: "wx" });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* someone else created it, or we cannot write: read below */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* cannot even stage the temp file */
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
try {
|
|
154
|
+
fs.rmSync(tmp, { force: true });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* ignore */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// An exclusive create by another writer can be visible before its content
|
|
161
|
+
// is: give a writer that is still running a moment rather than mint a
|
|
162
|
+
// second id. A file untouched for longer than that is a dead writer's.
|
|
163
|
+
if (!emptyFileIsStale(target)) {
|
|
164
|
+
for (let i = 0; i < 20; i++) {
|
|
165
|
+
const now = readTrimmed(target);
|
|
166
|
+
if (now)
|
|
167
|
+
return now;
|
|
168
|
+
sleepSync(10);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const now = readTrimmed(target);
|
|
172
|
+
if (now)
|
|
173
|
+
return now;
|
|
174
|
+
if (!fs.existsSync(target))
|
|
175
|
+
return null;
|
|
176
|
+
// Still empty: replace it, then return what the file holds (a writer that
|
|
177
|
+
// raced us to the same repair may have won; its id is the machine's).
|
|
178
|
+
try {
|
|
179
|
+
atomicWriteFile(target, content);
|
|
76
180
|
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return readTrimmed(target);
|
|
185
|
+
}
|
|
186
|
+
/** True when `target` exists and was last written over 2 s ago. */
|
|
187
|
+
function emptyFileIsStale(target) {
|
|
188
|
+
try {
|
|
189
|
+
return Date.now() - fs.statSync(target).mtimeMs > 2_000;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** A new random device id: sha256(hostname::16 random bytes), 32 hex chars. */
|
|
196
|
+
export function mintDeviceId() {
|
|
197
|
+
const raw = `${os.hostname()}::${crypto.randomBytes(16).toString("hex")}`;
|
|
198
|
+
return crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Stable anonymous device identifier, shared by every vexp client on the
|
|
202
|
+
* machine through `<home>/.vexp/device.id`. First caller creates it. Uses the
|
|
203
|
+
* OS hostname + a random salt — no PII, no hardware fingerprinting. The server
|
|
204
|
+
* counts these as devices, so two ids for one machine would burn two slots:
|
|
205
|
+
* the file is created only if absent and whoever loses the race adopts the
|
|
206
|
+
* winner's id.
|
|
207
|
+
*/
|
|
208
|
+
export function getDeviceId() {
|
|
209
|
+
const p = getDeviceIdPath();
|
|
210
|
+
const existing = readTrimmed(p);
|
|
211
|
+
if (existing)
|
|
212
|
+
return existing;
|
|
213
|
+
const id = mintDeviceId();
|
|
214
|
+
// Read-only FS: return the ephemeral id, refresh will still work.
|
|
215
|
+
return createFileIfAbsent(p, id) ?? id;
|
|
216
|
+
}
|
|
217
|
+
function readLastCheck() {
|
|
218
|
+
const s = readTrimmed(getLastCheckPath());
|
|
219
|
+
if (!s)
|
|
220
|
+
return 0;
|
|
221
|
+
const n = Number(s);
|
|
222
|
+
return Number.isFinite(n) ? n : 0;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* When this machine last heard back from vexp.dev (a registration, a device
|
|
226
|
+
* cap refusal or a revocation), or null if it never has. Read from the same
|
|
227
|
+
* stamp that throttles the check-in, so every client reports the same date.
|
|
228
|
+
*/
|
|
229
|
+
export function lastCheckIn() {
|
|
230
|
+
const ts = readLastCheck();
|
|
231
|
+
return ts > 0 ? new Date(ts) : null;
|
|
77
232
|
}
|
|
78
233
|
/** Forget when we last checked in, so the next refresh is not throttled. */
|
|
79
234
|
export function clearLastCheck() {
|
|
@@ -86,28 +241,19 @@ export function clearLastCheck() {
|
|
|
86
241
|
}
|
|
87
242
|
function writeLastCheck(ts) {
|
|
88
243
|
try {
|
|
89
|
-
|
|
90
|
-
fs.writeFileSync(getLastCheckPath(), String(ts), "utf-8");
|
|
244
|
+
atomicWriteFile(getLastCheckPath(), String(ts));
|
|
91
245
|
}
|
|
92
246
|
catch {
|
|
93
247
|
// ignore
|
|
94
248
|
}
|
|
95
249
|
}
|
|
96
250
|
function loadFreshToken() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!jwt)
|
|
100
|
-
return null;
|
|
101
|
-
return verifyAndDecode(jwt);
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
251
|
+
const jwt = readTrimmed(getFreshTokenPath());
|
|
252
|
+
return jwt ? verifyAndDecode(jwt) : null;
|
|
106
253
|
}
|
|
107
254
|
function saveFreshToken(jwt) {
|
|
108
255
|
try {
|
|
109
|
-
|
|
110
|
-
fs.writeFileSync(getFreshTokenPath(), jwt, "utf-8");
|
|
256
|
+
atomicWriteFile(getFreshTokenPath(), jwt);
|
|
111
257
|
}
|
|
112
258
|
catch {
|
|
113
259
|
// ignore
|
|
@@ -121,8 +267,34 @@ function removeFreshToken() {
|
|
|
121
267
|
// ignore
|
|
122
268
|
}
|
|
123
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Replace the long JWT on disk, but never with an older one. The Rust engine
|
|
272
|
+
* and the VS Code extension roll this file too; a slower writer holding a
|
|
273
|
+
* token minted earlier must not wind the machine back. Returns true when the
|
|
274
|
+
* file now holds `jwt`.
|
|
275
|
+
*/
|
|
276
|
+
function writeLongTokenIfNewer(jwt) {
|
|
277
|
+
const next = verifyAndDecode(jwt);
|
|
278
|
+
if (!next)
|
|
279
|
+
return false;
|
|
280
|
+
const currentJwt = readTrimmed(getLicensePath());
|
|
281
|
+
const current = currentJwt ? verifyAndDecode(currentJwt) : null;
|
|
282
|
+
if (current && current.exp > next.exp)
|
|
283
|
+
return false;
|
|
284
|
+
if (currentJwt === jwt)
|
|
285
|
+
return true;
|
|
286
|
+
try {
|
|
287
|
+
atomicWriteFile(getLicensePath(), jwt);
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// read-only FS: the freshToken still carries the new entitlement for this
|
|
292
|
+
// session; we just can't persist the long JWT.
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
124
296
|
function getDeviceBlockedPath() {
|
|
125
|
-
return path.join(
|
|
297
|
+
return path.join(vexpDir(), "device_blocked.json");
|
|
126
298
|
}
|
|
127
299
|
/** Returns the active device-blocked marker, if any. */
|
|
128
300
|
export function readDeviceBlocked() {
|
|
@@ -143,8 +315,7 @@ export function readDeviceBlocked() {
|
|
|
143
315
|
}
|
|
144
316
|
function writeDeviceBlocked(info) {
|
|
145
317
|
try {
|
|
146
|
-
|
|
147
|
-
fs.writeFileSync(getDeviceBlockedPath(), JSON.stringify(info), "utf-8");
|
|
318
|
+
atomicWriteFile(getDeviceBlockedPath(), JSON.stringify(info));
|
|
148
319
|
}
|
|
149
320
|
catch {
|
|
150
321
|
// ignore
|
|
@@ -158,33 +329,22 @@ function clearDeviceBlocked() {
|
|
|
158
329
|
// ignore
|
|
159
330
|
}
|
|
160
331
|
}
|
|
332
|
+
function num(v) {
|
|
333
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
334
|
+
}
|
|
161
335
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* exactly as before. This MUST never throw.
|
|
336
|
+
* One POST to /api/license/validate, classified. No file is touched here:
|
|
337
|
+
* the caller decides whether to apply the answer ({@link applyValidateResult}),
|
|
338
|
+
* which lets a provisional activation ask first and change nothing on refusal.
|
|
339
|
+
* Never throws.
|
|
167
340
|
*/
|
|
168
|
-
|
|
169
|
-
// Global kill switch for users who want to stay fully offline
|
|
341
|
+
async function callValidate(longJwt) {
|
|
170
342
|
if (process.env.VEXP_OFFLINE === "1")
|
|
171
|
-
return;
|
|
172
|
-
// Rate limit: at most one network call per REFRESH_BACKOFF_MS,
|
|
173
|
-
// UNLESS the long JWT is within the last 7 days of validity.
|
|
174
|
-
// That window is where expiry is imminent — bypass backoff so any
|
|
175
|
-
// CLI invocation in the danger zone attempts a refresh.
|
|
176
|
-
const last = readLastCheck();
|
|
177
|
-
const longClaims = verifyAndDecode(longJwt);
|
|
178
|
-
const nearExpiryMs = 7 * 24 * 60 * 60 * 1000;
|
|
179
|
-
const nearExpiry = longClaims
|
|
180
|
-
? longClaims.exp * 1000 - Date.now() < nearExpiryMs
|
|
181
|
-
: false;
|
|
182
|
-
if (!nearExpiry && Date.now() - last < REFRESH_BACKOFF_MS)
|
|
183
|
-
return;
|
|
343
|
+
return { outcome: { kind: "offline" } };
|
|
184
344
|
const controller = new AbortController();
|
|
185
345
|
const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
|
|
186
346
|
try {
|
|
187
|
-
const res = await fetch(`${
|
|
347
|
+
const res = await fetch(`${webOrigin()}/api/license/validate`, {
|
|
188
348
|
method: "POST",
|
|
189
349
|
headers: { "content-type": "application/json" },
|
|
190
350
|
body: JSON.stringify({
|
|
@@ -194,70 +354,216 @@ export async function tryOnlineRefresh(longJwt) {
|
|
|
194
354
|
}),
|
|
195
355
|
signal: controller.signal,
|
|
196
356
|
});
|
|
197
|
-
|
|
198
|
-
// 404 = endpoint not deployed yet (old server), 503 = kill switch
|
|
199
|
-
if (res.status === 404 || res.status === 503) {
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
357
|
+
// 404 = endpoint not deployed (old server), 503 = kill switch.
|
|
202
358
|
if (!res.ok)
|
|
203
|
-
return;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
fs.writeFileSync(licensePath, data.newLongToken, "utf-8");
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// read-only FS: the freshToken still carries the new entitlement
|
|
222
|
-
// for this session; we just can't persist the long JWT.
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
writeLastCheck(Date.now());
|
|
226
|
-
// Any previous device-blocked state is stale once the server issues
|
|
227
|
-
// a freshToken for us — we are back in a good state.
|
|
228
|
-
clearDeviceBlocked();
|
|
359
|
+
return { outcome: { kind: "http", status: res.status } };
|
|
360
|
+
let data;
|
|
361
|
+
try {
|
|
362
|
+
data = (await res.json());
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return { outcome: { kind: "rejected", reason: "unreadable_response" } };
|
|
366
|
+
}
|
|
367
|
+
if (!data || typeof data !== "object") {
|
|
368
|
+
return { outcome: { kind: "rejected", reason: "unreadable_response" } };
|
|
369
|
+
}
|
|
370
|
+
if (data.valid && data.freshToken) {
|
|
371
|
+
// Only trust a freshToken that verifies locally.
|
|
372
|
+
if (!verifyAndDecode(data.freshToken)) {
|
|
373
|
+
return { outcome: { kind: "rejected", reason: "unverifiable_token" }, data };
|
|
229
374
|
}
|
|
375
|
+
// An old server never sends deviceRegistered and registers whenever it
|
|
376
|
+
// answers valid; a 3.3+ server says false when the device store did
|
|
377
|
+
// not take this device, and then nothing may claim it is registered.
|
|
378
|
+
if (data.deviceRegistered === false)
|
|
379
|
+
return { outcome: { kind: "unconfirmed" }, data };
|
|
380
|
+
return {
|
|
381
|
+
outcome: {
|
|
382
|
+
kind: "registered",
|
|
383
|
+
currentDevices: num(data.currentDevices),
|
|
384
|
+
maxDevices: num(data.maxDevices),
|
|
385
|
+
},
|
|
386
|
+
data,
|
|
387
|
+
};
|
|
230
388
|
}
|
|
231
|
-
|
|
389
|
+
if (data.reason === "device_limit_exceeded") {
|
|
390
|
+
return {
|
|
391
|
+
outcome: {
|
|
392
|
+
kind: "device_limit_exceeded",
|
|
393
|
+
currentDevices: num(data.currentDevices),
|
|
394
|
+
maxDevices: num(data.maxDevices) ?? 4,
|
|
395
|
+
manageUrl: data.manageUrl ?? "https://vexp.dev/account/devices",
|
|
396
|
+
},
|
|
397
|
+
data,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (data.revoked) {
|
|
401
|
+
return {
|
|
402
|
+
outcome: { kind: data.reason === "deactivated" ? "deactivated" : "revoked" },
|
|
403
|
+
data,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return { outcome: { kind: "rejected", reason: data.reason ?? "not_valid" }, data };
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
// network error, timeout, abort
|
|
410
|
+
return { outcome: { kind: "unreachable", detail: networkErrorDetail(err) } };
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* What actually failed. Node's fetch reports every network failure as
|
|
418
|
+
* "fetch failed" and keeps the cause (ECONNREFUSED, ENOTFOUND, a TLS code
|
|
419
|
+
* such as SELF_SIGNED_CERT_IN_CHAIN) one level down.
|
|
420
|
+
*/
|
|
421
|
+
export function networkErrorDetail(err) {
|
|
422
|
+
if (!(err instanceof Error))
|
|
423
|
+
return String(err);
|
|
424
|
+
if (err.name === "AbortError" || err.name === "TimeoutError")
|
|
425
|
+
return "timed out";
|
|
426
|
+
const cause = err.cause;
|
|
427
|
+
if (cause && typeof cause === "object") {
|
|
428
|
+
if (typeof cause.code === "string" && cause.code)
|
|
429
|
+
return cause.code;
|
|
430
|
+
if (typeof cause.message === "string" && cause.message)
|
|
431
|
+
return cause.message;
|
|
432
|
+
}
|
|
433
|
+
return err.message;
|
|
434
|
+
}
|
|
435
|
+
const CERT_ERROR = /CERT|SELF_SIGNED|UNABLE_TO_GET_ISSUER|UNABLE_TO_VERIFY|DEPTH_ZERO|ERR_TLS/i;
|
|
436
|
+
/**
|
|
437
|
+
* One line of advice for an unreachable vexp.dev, or null. A proxy or an
|
|
438
|
+
* antivirus that re-signs HTTPS (common on company Windows machines) makes
|
|
439
|
+
* every check-in fail the same way, so "it will register on its next
|
|
440
|
+
* check-in" never comes true until Node trusts that certificate.
|
|
441
|
+
*/
|
|
442
|
+
export function unreachableHint(detail) {
|
|
443
|
+
if (!detail)
|
|
444
|
+
return null;
|
|
445
|
+
if (CERT_ERROR.test(detail)) {
|
|
446
|
+
return `The HTTPS certificate was not trusted (${detail}): a proxy or antivirus is probably inspecting HTTPS. Point NODE_EXTRA_CA_CERTS at its root certificate, or set NODE_USE_SYSTEM_CA=1 (Node 23.8 or later).`;
|
|
447
|
+
}
|
|
448
|
+
return `Cause: ${detail}.`;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Persist what the server said, exactly as every vexp client does, and
|
|
452
|
+
* return what the check-in came to. `sentJwt` is the long token that was
|
|
453
|
+
* posted: if license.jwt no longer holds it (another client activated a
|
|
454
|
+
* different key while the call was in flight), the answer is about the old
|
|
455
|
+
* key and nothing is written, or its tokens would replace the key just
|
|
456
|
+
* activated (the Rust engine's rule).
|
|
457
|
+
*/
|
|
458
|
+
function applyValidateResult(outcome, data, sentJwt) {
|
|
459
|
+
const writes = outcome.kind === "registered" ||
|
|
460
|
+
outcome.kind === "unconfirmed" ||
|
|
461
|
+
outcome.kind === "device_limit_exceeded" ||
|
|
462
|
+
outcome.kind === "revoked" ||
|
|
463
|
+
outcome.kind === "deactivated";
|
|
464
|
+
if (writes && readTrimmed(getLicensePath()) !== sentJwt) {
|
|
465
|
+
return { kind: "rejected", reason: "license_changed" };
|
|
466
|
+
}
|
|
467
|
+
switch (outcome.kind) {
|
|
468
|
+
case "registered":
|
|
469
|
+
case "unconfirmed": {
|
|
470
|
+
if (!data?.freshToken)
|
|
471
|
+
return outcome;
|
|
472
|
+
saveFreshToken(data.freshToken);
|
|
473
|
+
// The server re-issued the 30-day long token (rolled forward on every
|
|
474
|
+
// refresh since vexp-web 3.1; also on entitlement change). Replace the
|
|
475
|
+
// on-disk long JWT so the current entitlement survives even fully
|
|
476
|
+
// offline and a superseded one stops working — without the user
|
|
477
|
+
// re-pasting a key. Only if it verifies, and never with an older one.
|
|
478
|
+
if (data.newLongToken)
|
|
479
|
+
writeLongTokenIfNewer(data.newLongToken);
|
|
480
|
+
// Any previous device-blocked state is stale once the server issues
|
|
481
|
+
// a freshToken for us — we are back in a good state.
|
|
482
|
+
clearDeviceBlocked();
|
|
483
|
+
// The stamp says "this machine checked in and is on the device list".
|
|
484
|
+
// An unconfirmed answer is not that: no stamp, so the next check-in
|
|
485
|
+
// (any client) asks again instead of waiting a day.
|
|
486
|
+
if (outcome.kind === "registered")
|
|
487
|
+
writeLastCheck(Date.now());
|
|
488
|
+
return outcome;
|
|
489
|
+
}
|
|
490
|
+
case "device_limit_exceeded":
|
|
232
491
|
// This machine is over the subscription's device cap. We do NOT touch
|
|
233
492
|
// the long JWT on disk: the user still owns a valid subscription, they
|
|
234
493
|
// just need to free a slot on vexp.dev/account/devices. We persist a
|
|
235
494
|
// marker so the CLI can surface the error next time a command runs.
|
|
236
495
|
writeDeviceBlocked({
|
|
237
496
|
at: Date.now(),
|
|
238
|
-
maxDevices:
|
|
239
|
-
manageUrl:
|
|
497
|
+
maxDevices: outcome.maxDevices,
|
|
498
|
+
manageUrl: outcome.manageUrl,
|
|
240
499
|
});
|
|
241
500
|
// Drop any stale freshToken so readLicenseLimits downgrades to FREE
|
|
242
501
|
// until the user frees a slot and retries.
|
|
243
502
|
removeFreshToken();
|
|
244
503
|
writeLastCheck(Date.now());
|
|
245
|
-
|
|
246
|
-
|
|
504
|
+
return outcome;
|
|
505
|
+
case "revoked":
|
|
506
|
+
case "deactivated":
|
|
247
507
|
// Server confirmed revocation. Drop the cached freshToken but keep
|
|
248
508
|
// the long JWT on disk — the user will fall back to whatever the
|
|
249
509
|
// long JWT allows until it expires naturally.
|
|
250
510
|
removeFreshToken();
|
|
251
511
|
writeLastCheck(Date.now());
|
|
252
|
-
|
|
512
|
+
return outcome;
|
|
513
|
+
default:
|
|
514
|
+
// offline / unreachable / http / rejected: nothing changes, and no
|
|
515
|
+
// stamp, so the next command tries again.
|
|
516
|
+
return outcome;
|
|
253
517
|
}
|
|
254
|
-
|
|
255
|
-
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Opportunistic online license refresh: the check-in that registers this
|
|
521
|
+
* device and rolls the tokens.
|
|
522
|
+
*
|
|
523
|
+
* Additive-only: any failure (network, 404, 5xx, timeout, server disabled)
|
|
524
|
+
* leaves the local long JWT in charge exactly as before. It now RETURNS what
|
|
525
|
+
* happened instead of swallowing it, so the commands a person runs can say
|
|
526
|
+
* whether this machine is registered. Never throws.
|
|
527
|
+
*
|
|
528
|
+
* `force` skips the 24 h throttle (activation, an explicit status check).
|
|
529
|
+
*/
|
|
530
|
+
export async function tryOnlineRefresh(longJwt, opts = {}) {
|
|
531
|
+
try {
|
|
532
|
+
// Global kill switch for users who want to stay fully offline
|
|
533
|
+
if (process.env.VEXP_OFFLINE === "1")
|
|
534
|
+
return { kind: "offline" };
|
|
535
|
+
const longClaims = verifyAndDecode(longJwt);
|
|
536
|
+
if (!longClaims)
|
|
537
|
+
return { kind: "no_license" };
|
|
538
|
+
// Rate limit: at most one network call per REFRESH_BACKOFF_MS,
|
|
539
|
+
// UNLESS the long JWT is within the last 7 days of validity.
|
|
540
|
+
// That window is where expiry is imminent — bypass backoff so any
|
|
541
|
+
// CLI invocation in the danger zone attempts a refresh. A machine the
|
|
542
|
+
// server refused for the device cap is not throttled either: the user
|
|
543
|
+
// was told to free a slot and run a command, and the refusal itself
|
|
544
|
+
// stamped the throttle, so the retry would otherwise wait a day.
|
|
545
|
+
if (!opts.force && !readDeviceBlocked()) {
|
|
546
|
+
const last = readLastCheck();
|
|
547
|
+
const nearExpiryMs = 7 * 24 * 60 * 60 * 1000;
|
|
548
|
+
const nearExpiry = longClaims.exp * 1000 - Date.now() < nearExpiryMs;
|
|
549
|
+
if (!nearExpiry && Date.now() - last < REFRESH_BACKOFF_MS) {
|
|
550
|
+
return { kind: "throttled" };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const { outcome, data } = await callValidate(longJwt);
|
|
554
|
+
return applyValidateResult(outcome, data, longJwt);
|
|
256
555
|
}
|
|
257
|
-
|
|
258
|
-
|
|
556
|
+
catch (err) {
|
|
557
|
+
return { kind: "unreachable", detail: networkErrorDetail(err) };
|
|
259
558
|
}
|
|
260
559
|
}
|
|
560
|
+
/** Check in with the long JWT on disk (throttled unless `force`). */
|
|
561
|
+
export async function refreshFromDisk(opts = {}) {
|
|
562
|
+
const longJwt = readTrimmed(getLicensePath());
|
|
563
|
+
if (!longJwt)
|
|
564
|
+
return { kind: "no_license" };
|
|
565
|
+
return tryOnlineRefresh(longJwt, opts);
|
|
566
|
+
}
|
|
261
567
|
function verifyAndDecode(jwt) {
|
|
262
568
|
try {
|
|
263
569
|
const parts = jwt.split(".");
|
|
@@ -267,7 +573,7 @@ function verifyAndDecode(jwt) {
|
|
|
267
573
|
const signingInput = `${headerB64}.${payloadB64}`;
|
|
268
574
|
const signature = Buffer.from(signatureB64, "base64url");
|
|
269
575
|
const publicKey = crypto.createPublicKey({
|
|
270
|
-
key: Buffer.from(
|
|
576
|
+
key: Buffer.from(licensePublicKeyDer, "base64"),
|
|
271
577
|
format: "der",
|
|
272
578
|
type: "spki",
|
|
273
579
|
});
|
|
@@ -286,16 +592,9 @@ const FREE_LIMITS = {
|
|
|
286
592
|
allTools: false,
|
|
287
593
|
plan: "free",
|
|
288
594
|
};
|
|
289
|
-
/**
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (!claims)
|
|
293
|
-
throw new Error("Invalid license key (signature verification failed)");
|
|
294
|
-
if (claims.exp * 1000 < Date.now())
|
|
295
|
-
throw new Error("License key has expired");
|
|
296
|
-
const licensePath = getLicensePath();
|
|
297
|
-
fs.mkdirSync(path.dirname(licensePath), { recursive: true });
|
|
298
|
-
fs.writeFileSync(licensePath, jwt, "utf-8");
|
|
595
|
+
/** Install `jwt` as this machine's license (no network). */
|
|
596
|
+
function installLicense(jwt) {
|
|
597
|
+
atomicWriteFile(getLicensePath(), jwt);
|
|
299
598
|
// The freshly activated long JWT is authoritative. Drop any cached
|
|
300
599
|
// freshToken: it may be a 7-day token minted at the PREVIOUS tier, and both
|
|
301
600
|
// readLicenseLimits() and the Rust daemon prefer fresh.jwt over license.jwt —
|
|
@@ -310,8 +609,166 @@ export function activateLicense(jwt) {
|
|
|
310
609
|
// to register it" to someone who just did exactly that. Reported by a tier-4
|
|
311
610
|
// user whose dashboard read 0 of 10 right after activating.
|
|
312
611
|
clearLastCheck();
|
|
612
|
+
}
|
|
613
|
+
/** Activate a license key: verify and save to ~/.vexp/license.jwt (no
|
|
614
|
+
* network; {@link activateAndRegister} is what the commands call). */
|
|
615
|
+
export function activateLicense(jwt) {
|
|
616
|
+
const claims = verifyAndDecode(jwt);
|
|
617
|
+
if (!claims)
|
|
618
|
+
throw new Error("Invalid license key (signature verification failed)");
|
|
619
|
+
if (claims.exp * 1000 < Date.now())
|
|
620
|
+
throw new Error("License key has expired");
|
|
621
|
+
installLicense(jwt);
|
|
313
622
|
return claims;
|
|
314
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Activate a key AND register this device, awaiting the check-in: the one
|
|
626
|
+
* path both `vexp activate` and the interactive menu take, so neither can
|
|
627
|
+
* forget the registration again (the menu did, for months).
|
|
628
|
+
*
|
|
629
|
+
* A key whose signature verifies but whose 30-day token has expired (the
|
|
630
|
+
* AppSumo key from the purchase email, used after a month) is accepted
|
|
631
|
+
* provisionally: the server is asked first, and only its renewal installs
|
|
632
|
+
* it. Revoked, deactivated or unreachable: nothing on disk changes and the
|
|
633
|
+
* error says where to get the current key.
|
|
634
|
+
*/
|
|
635
|
+
export async function activateAndRegister(jwt) {
|
|
636
|
+
const key = jwt.trim();
|
|
637
|
+
const claims = verifyAndDecode(key);
|
|
638
|
+
if (!claims)
|
|
639
|
+
throw new Error("Invalid license key (signature verification failed)");
|
|
640
|
+
if (claims.exp * 1000 >= Date.now()) {
|
|
641
|
+
installLicense(key);
|
|
642
|
+
const outcome = await tryOnlineRefresh(key, { force: true });
|
|
643
|
+
return { claims, outcome, renewed: false };
|
|
644
|
+
}
|
|
645
|
+
// Expired: ask before touching anything.
|
|
646
|
+
const { outcome, data } = await callValidate(key);
|
|
647
|
+
switch (outcome.kind) {
|
|
648
|
+
case "registered":
|
|
649
|
+
case "unconfirmed":
|
|
650
|
+
case "device_limit_exceeded":
|
|
651
|
+
// The key is good (a full device list does not make it bad): install
|
|
652
|
+
// it, then let the answer roll it forward and register the device.
|
|
653
|
+
installLicense(key);
|
|
654
|
+
return { claims, outcome: applyValidateResult(outcome, data, key), renewed: true };
|
|
655
|
+
case "revoked":
|
|
656
|
+
throw new Error(`This license key has expired and vexp.dev reports it as revoked. Get your current key from ${ACCOUNT_URL}`);
|
|
657
|
+
case "deactivated":
|
|
658
|
+
throw new Error(`This license key has expired and vexp.dev reports it as deactivated. Get your current key from ${ACCOUNT_URL}`);
|
|
659
|
+
case "offline":
|
|
660
|
+
throw new Error(`This license key has expired and VEXP_OFFLINE=1 stops vexp from renewing it. Get your current key from ${ACCOUNT_URL}`);
|
|
661
|
+
case "unreachable":
|
|
662
|
+
case "http":
|
|
663
|
+
throw new Error(`This license key has expired and vexp.dev could not be reached to renew it. Get your current key from ${ACCOUNT_URL}`);
|
|
664
|
+
default:
|
|
665
|
+
throw new Error(`This license key has expired and vexp.dev did not renew it (${outcome.kind === "rejected" ? outcome.reason : outcome.kind}). Get your current key from ${ACCOUNT_URL}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The one line a person reads after activating or checking the license: is
|
|
670
|
+
* this machine registered, and if not, what happens next. `ok` is false when
|
|
671
|
+
* something needs their attention; `hint`, when present, is a second line
|
|
672
|
+
* with the cause (an unreachable vexp.dev says why).
|
|
673
|
+
*/
|
|
674
|
+
export function registrationLine(outcome) {
|
|
675
|
+
switch (outcome.kind) {
|
|
676
|
+
case "registered": {
|
|
677
|
+
const counts = outcome.currentDevices !== undefined && outcome.maxDevices !== undefined
|
|
678
|
+
? ` (${outcome.currentDevices} of ${outcome.maxDevices} devices)`
|
|
679
|
+
: "";
|
|
680
|
+
return { ok: true, text: `This device is registered with your license.${counts}` };
|
|
681
|
+
}
|
|
682
|
+
case "unconfirmed":
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
text: "vexp.dev renewed your license but could not confirm this device on your account yet; run `vexp license` later to check again.",
|
|
686
|
+
};
|
|
687
|
+
case "throttled":
|
|
688
|
+
return { ok: true, text: "This device checked in with vexp.dev less than a day ago." };
|
|
689
|
+
case "unreachable": {
|
|
690
|
+
const hint = unreachableHint(outcome.detail);
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
text: "Could not reach vexp.dev: vexp works offline and will register this device on its next check-in.",
|
|
694
|
+
...(hint ? { hint } : {}),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
case "http":
|
|
698
|
+
return {
|
|
699
|
+
ok: false,
|
|
700
|
+
text: `vexp.dev answered HTTP ${outcome.status}: vexp works offline and will register this device on its next check-in.`,
|
|
701
|
+
};
|
|
702
|
+
case "offline":
|
|
703
|
+
return {
|
|
704
|
+
ok: false,
|
|
705
|
+
text: "VEXP_OFFLINE=1 is set: this device is not registered with your license until vexp can check in with vexp.dev.",
|
|
706
|
+
};
|
|
707
|
+
case "revoked":
|
|
708
|
+
return {
|
|
709
|
+
ok: false,
|
|
710
|
+
text: `vexp.dev reports this license as revoked, so this device was not registered. Get your current key from ${ACCOUNT_URL}`,
|
|
711
|
+
};
|
|
712
|
+
case "deactivated":
|
|
713
|
+
return {
|
|
714
|
+
ok: false,
|
|
715
|
+
text: `vexp.dev reports this license as deactivated, so this device was not registered. Get your current key from ${ACCOUNT_URL}`,
|
|
716
|
+
};
|
|
717
|
+
case "device_limit_exceeded": {
|
|
718
|
+
const used = outcome.currentDevices ?? outcome.maxDevices;
|
|
719
|
+
return {
|
|
720
|
+
ok: false,
|
|
721
|
+
text: `This device is not registered: your license already has ${used} of ${outcome.maxDevices} devices. Free a slot at ${outcome.manageUrl}, then run \`vexp license\`.`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
case "no_license":
|
|
725
|
+
return { ok: false, text: "No license is active on this machine." };
|
|
726
|
+
case "rejected":
|
|
727
|
+
if (outcome.reason === "license_changed") {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
text: "The license key changed while vexp was checking in; the next check-in registers this device with the new key.",
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
ok: false,
|
|
735
|
+
text: `vexp.dev did not register this device (${outcome.reason}). Get your current key from ${ACCOUNT_URL}`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
export function registrationState() {
|
|
740
|
+
const blocked = readDeviceBlocked();
|
|
741
|
+
if (blocked)
|
|
742
|
+
return { state: "blocked", blocked };
|
|
743
|
+
const at = lastCheckIn();
|
|
744
|
+
const fresh = loadFreshToken();
|
|
745
|
+
const freshValid = fresh !== null && fresh.exp * 1000 > Date.now();
|
|
746
|
+
if (at)
|
|
747
|
+
return freshValid ? { state: "registered", at } : { state: "not_registered", at };
|
|
748
|
+
return freshValid ? { state: "unconfirmed" } : { state: "never" };
|
|
749
|
+
}
|
|
750
|
+
/** A date and time the way the license screens print it. */
|
|
751
|
+
export function formatCheckIn(at) {
|
|
752
|
+
return `${at.toLocaleDateString()} ${at.toLocaleTimeString()}`;
|
|
753
|
+
}
|
|
754
|
+
/** The status sentence for {@link registrationState}; `ok` only when registered. */
|
|
755
|
+
export function registrationStateLine(r) {
|
|
756
|
+
switch (r.state) {
|
|
757
|
+
case "registered":
|
|
758
|
+
return { ok: true, text: `This device is registered with your license (last check-in ${formatCheckIn(r.at)}).` };
|
|
759
|
+
case "not_registered":
|
|
760
|
+
return { ok: false, text: `The last check-in (${formatCheckIn(r.at)}) did not register this device.` };
|
|
761
|
+
case "unconfirmed":
|
|
762
|
+
return { ok: false, text: "vexp.dev has not confirmed this device on your account yet." };
|
|
763
|
+
case "never":
|
|
764
|
+
return { ok: false, text: "This device has never checked in with vexp.dev, so it is not on your account's device list yet." };
|
|
765
|
+
case "blocked":
|
|
766
|
+
return {
|
|
767
|
+
ok: false,
|
|
768
|
+
text: `This device is not registered: the license's ${r.blocked.maxDevices} device slots are full; free one at ${r.blocked.manageUrl}.`,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
315
772
|
/** Remove the current license file (and any cached freshToken) */
|
|
316
773
|
export function deactivateLicense() {
|
|
317
774
|
const licensePath = getLicensePath();
|
|
@@ -366,22 +823,16 @@ function claimsToLimits(claims) {
|
|
|
366
823
|
* (it was signed by the server during the last successful refresh).
|
|
367
824
|
* 2. Otherwise, fall back to the long JWT in ~/.vexp/license.jwt exactly as
|
|
368
825
|
* before (this is the zero-change path for users who never hit the server).
|
|
369
|
-
* 3. Fire a background refresh attempt
|
|
370
|
-
*
|
|
826
|
+
* 3. Fire a background refresh attempt (unless `refresh: false`, for callers
|
|
827
|
+
* that already awaited one). Any failure is silent — the caller never sees
|
|
828
|
+
* a network error.
|
|
371
829
|
*
|
|
372
830
|
* The long JWT is NEVER deleted based on server response. The worst a
|
|
373
831
|
* successful server revoke does is remove the cached freshToken, after
|
|
374
832
|
* which the long JWT expires naturally at its own `exp`.
|
|
375
833
|
*/
|
|
376
|
-
export function readLicenseLimits() {
|
|
377
|
-
const
|
|
378
|
-
let longJwt;
|
|
379
|
-
try {
|
|
380
|
-
longJwt = fs.readFileSync(licensePath, "utf-8").trim();
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
return FREE_LIMITS;
|
|
384
|
-
}
|
|
834
|
+
export function readLicenseLimits(opts = {}) {
|
|
835
|
+
const longJwt = readTrimmed(getLicensePath());
|
|
385
836
|
if (!longJwt)
|
|
386
837
|
return FREE_LIMITS;
|
|
387
838
|
const longClaims = verifyAndDecode(longJwt);
|
|
@@ -399,7 +850,8 @@ export function readLicenseLimits() {
|
|
|
399
850
|
// CLI startup on the network. We send the long JWT *even if it's already
|
|
400
851
|
// past `exp`*, because the server (Fix 3) accepts expired JWTs and
|
|
401
852
|
// re-issues freshTokens as long as the Stripe subscription (`pe`) holds.
|
|
402
|
-
|
|
853
|
+
if (opts.refresh !== false)
|
|
854
|
+
void tryOnlineRefresh(longJwt);
|
|
403
855
|
// Prefer a still-valid freshToken within the grace window
|
|
404
856
|
const fresh = loadFreshToken();
|
|
405
857
|
if (fresh && fresh.exp * 1000 > Date.now()) {
|