vexp-cli 1.3.11 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/license.js CHANGED
@@ -2,12 +2,215 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import * as os from "os";
5
+ import { CLI_VERSION } from "./version.js";
5
6
  // Ed25519 PUBLIC key (DER, base64) — same key as vexp-vscode/src/license.ts.
6
7
  // Only the server (vexp.dev) holds the matching private key.
7
8
  const VEXP_LICENSE_PUBLIC_KEY = "MCowBQYDK2VwAyEApwiWYGCyhCaGHAHWn/RTBSGo/1MNmGyZUgSNBQ5YE4g=";
9
+ const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
10
+ // 14-day grace window after the last successful online refresh before we
11
+ // stop trusting the cached freshToken and fall back to the long JWT only.
12
+ const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
13
+ // Opportunistic refresh cadence: don't hit the network more than once per 24h.
14
+ const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
15
+ // 3 second timeout on validate calls — any slower falls back silently.
16
+ const VALIDATE_TIMEOUT_MS = 3000;
8
17
  function getLicensePath() {
9
18
  return path.join(os.homedir(), ".vexp", "license.jwt");
10
19
  }
20
+ function getFreshTokenPath() {
21
+ return path.join(os.homedir(), ".vexp", "fresh.jwt");
22
+ }
23
+ function getLastCheckPath() {
24
+ return path.join(os.homedir(), ".vexp", "last_online_check");
25
+ }
26
+ function getDeviceIdPath() {
27
+ return path.join(os.homedir(), ".vexp", "device.id");
28
+ }
29
+ /**
30
+ * Stable anonymous device identifier. First call creates and persists it.
31
+ * Uses OS hostname + a random salt — no PII, no hardware fingerprinting.
32
+ */
33
+ function getDeviceId() {
34
+ const p = getDeviceIdPath();
35
+ try {
36
+ const existing = fs.readFileSync(p, "utf-8").trim();
37
+ if (existing)
38
+ return existing;
39
+ }
40
+ catch {
41
+ // first run
42
+ }
43
+ const raw = `${os.hostname()}::${crypto.randomBytes(16).toString("hex")}`;
44
+ const id = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
45
+ try {
46
+ fs.mkdirSync(path.dirname(p), { recursive: true });
47
+ fs.writeFileSync(p, id, "utf-8");
48
+ }
49
+ catch {
50
+ // read-only FS: return the ephemeral id, refresh will still work
51
+ }
52
+ return id;
53
+ }
54
+ function readLastCheck() {
55
+ try {
56
+ const s = fs.readFileSync(getLastCheckPath(), "utf-8").trim();
57
+ const n = Number(s);
58
+ return Number.isFinite(n) ? n : 0;
59
+ }
60
+ catch {
61
+ return 0;
62
+ }
63
+ }
64
+ function writeLastCheck(ts) {
65
+ try {
66
+ fs.mkdirSync(path.dirname(getLastCheckPath()), { recursive: true });
67
+ fs.writeFileSync(getLastCheckPath(), String(ts), "utf-8");
68
+ }
69
+ catch {
70
+ // ignore
71
+ }
72
+ }
73
+ function loadFreshToken() {
74
+ try {
75
+ const jwt = fs.readFileSync(getFreshTokenPath(), "utf-8").trim();
76
+ if (!jwt)
77
+ return null;
78
+ return verifyAndDecode(jwt);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ function saveFreshToken(jwt) {
85
+ try {
86
+ fs.mkdirSync(path.dirname(getFreshTokenPath()), { recursive: true });
87
+ fs.writeFileSync(getFreshTokenPath(), jwt, "utf-8");
88
+ }
89
+ catch {
90
+ // ignore
91
+ }
92
+ }
93
+ function removeFreshToken() {
94
+ try {
95
+ fs.unlinkSync(getFreshTokenPath());
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ }
101
+ function getDeviceBlockedPath() {
102
+ return path.join(os.homedir(), ".vexp", "device_blocked.json");
103
+ }
104
+ /** Returns the active device-blocked marker, if any. */
105
+ export function readDeviceBlocked() {
106
+ try {
107
+ const raw = fs.readFileSync(getDeviceBlockedPath(), "utf-8");
108
+ const parsed = JSON.parse(raw);
109
+ if (parsed &&
110
+ typeof parsed.at === "number" &&
111
+ typeof parsed.maxDevices === "number" &&
112
+ typeof parsed.manageUrl === "string") {
113
+ return parsed;
114
+ }
115
+ return null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function writeDeviceBlocked(info) {
122
+ try {
123
+ fs.mkdirSync(path.dirname(getDeviceBlockedPath()), { recursive: true });
124
+ fs.writeFileSync(getDeviceBlockedPath(), JSON.stringify(info), "utf-8");
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ }
130
+ function clearDeviceBlocked() {
131
+ try {
132
+ fs.unlinkSync(getDeviceBlockedPath());
133
+ }
134
+ catch {
135
+ // ignore
136
+ }
137
+ }
138
+ /**
139
+ * Opportunistic online license refresh.
140
+ *
141
+ * Additive-only: any failure (network, 404, 5xx, timeout, server disabled)
142
+ * results in a silent no-op. The caller keeps using the local long JWT
143
+ * exactly as before. This MUST never throw.
144
+ */
145
+ export async function tryOnlineRefresh(longJwt) {
146
+ // Rate limit: at most one network call per REFRESH_BACKOFF_MS
147
+ const last = readLastCheck();
148
+ if (Date.now() - last < REFRESH_BACKOFF_MS)
149
+ return;
150
+ // Global kill switch for users who want to stay fully offline
151
+ if (process.env.VEXP_OFFLINE === "1")
152
+ return;
153
+ const controller = new AbortController();
154
+ const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
155
+ try {
156
+ const res = await fetch(`${VEXP_WEB_ORIGIN}/api/license/validate`, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ jwt: longJwt,
161
+ deviceId: getDeviceId(),
162
+ clientVersion: CLI_VERSION,
163
+ }),
164
+ signal: controller.signal,
165
+ });
166
+ clearTimeout(timer);
167
+ // 404 = endpoint not deployed yet (old server), 503 = kill switch
168
+ if (res.status === 404 || res.status === 503) {
169
+ return;
170
+ }
171
+ if (!res.ok)
172
+ return;
173
+ const data = (await res.json());
174
+ if (data && data.valid && data.freshToken) {
175
+ // Only save if the freshToken itself verifies locally
176
+ if (verifyAndDecode(data.freshToken)) {
177
+ saveFreshToken(data.freshToken);
178
+ writeLastCheck(Date.now());
179
+ // Any previous device-blocked state is stale once the server issues
180
+ // a freshToken for us — we are back in a good state.
181
+ clearDeviceBlocked();
182
+ }
183
+ }
184
+ else if (data && data.reason === "device_limit_exceeded") {
185
+ // This machine is over the subscription's device cap. We do NOT touch
186
+ // the long JWT on disk: the user still owns a valid subscription, they
187
+ // just need to free a slot on vexp.dev/account/devices. We persist a
188
+ // marker so the CLI can surface the error next time a command runs.
189
+ writeDeviceBlocked({
190
+ at: Date.now(),
191
+ maxDevices: data.maxDevices ?? 4,
192
+ manageUrl: data.manageUrl ?? "https://vexp.dev/account/devices",
193
+ });
194
+ // Drop any stale freshToken so readLicenseLimits downgrades to FREE
195
+ // until the user frees a slot and retries.
196
+ removeFreshToken();
197
+ writeLastCheck(Date.now());
198
+ }
199
+ else if (data && data.revoked) {
200
+ // Server confirmed revocation. Drop the cached freshToken but keep
201
+ // the long JWT on disk — the user will fall back to whatever the
202
+ // long JWT allows until it expires naturally.
203
+ removeFreshToken();
204
+ writeLastCheck(Date.now());
205
+ }
206
+ }
207
+ catch {
208
+ // network error, timeout, abort — silent no-op
209
+ }
210
+ finally {
211
+ clearTimeout(timer);
212
+ }
213
+ }
11
214
  function verifyAndDecode(jwt) {
12
215
  try {
13
216
  const parts = jwt.split(".");
@@ -48,7 +251,7 @@ export function activateLicense(jwt) {
48
251
  fs.writeFileSync(licensePath, jwt, "utf-8");
49
252
  return claims;
50
253
  }
51
- /** Remove the current license file */
254
+ /** Remove the current license file (and any cached freshToken) */
52
255
  export function deactivateLicense() {
53
256
  const licensePath = getLicensePath();
54
257
  try {
@@ -58,24 +261,17 @@ export function deactivateLicense() {
58
261
  catch {
59
262
  // ignore
60
263
  }
61
- }
62
- /** Read license from ~/.vexp/license.jwt, verify, and return tier limits */
63
- export function readLicenseLimits() {
64
- const licensePath = getLicensePath();
65
- let jwt;
264
+ removeFreshToken();
265
+ clearDeviceBlocked();
66
266
  try {
67
- jwt = fs.readFileSync(licensePath, "utf-8").trim();
267
+ if (fs.existsSync(getLastCheckPath()))
268
+ fs.unlinkSync(getLastCheckPath());
68
269
  }
69
270
  catch {
70
- return FREE_LIMITS;
71
- }
72
- if (!jwt)
73
- return FREE_LIMITS;
74
- const claims = verifyAndDecode(jwt);
75
- if (!claims || claims.exp * 1000 < Date.now()) {
76
- return FREE_LIMITS;
271
+ // ignore
77
272
  }
78
- // pe = Stripe period_end (renewal date), fallback to exp (JWT expiry)
273
+ }
274
+ function claimsToLimits(claims) {
79
275
  const renewsAt = new Date((claims.pe ?? claims.exp) * 1000);
80
276
  switch (claims.plan) {
81
277
  case "team":
@@ -86,4 +282,55 @@ export function readLicenseLimits() {
86
282
  return FREE_LIMITS;
87
283
  }
88
284
  }
89
- //# sourceMappingURL=license.js.map
285
+ /**
286
+ * Read license from ~/.vexp/license.jwt, verify, and return tier limits.
287
+ *
288
+ * Phase 3 online validation is additive:
289
+ * 1. If a freshToken exists in ~/.vexp/fresh.jwt and is still valid, trust it
290
+ * (it was signed by the server during the last successful refresh).
291
+ * 2. Otherwise, fall back to the long JWT in ~/.vexp/license.jwt exactly as
292
+ * before (this is the zero-change path for users who never hit the server).
293
+ * 3. Fire a background refresh attempt. Any failure is silent — the caller
294
+ * never sees a network error.
295
+ *
296
+ * The long JWT is NEVER deleted based on server response. The worst a
297
+ * successful server revoke does is remove the cached freshToken, after
298
+ * which the long JWT expires naturally at its own `exp`.
299
+ */
300
+ export function readLicenseLimits() {
301
+ const licensePath = getLicensePath();
302
+ let longJwt;
303
+ try {
304
+ longJwt = fs.readFileSync(licensePath, "utf-8").trim();
305
+ }
306
+ catch {
307
+ return FREE_LIMITS;
308
+ }
309
+ if (!longJwt)
310
+ return FREE_LIMITS;
311
+ const longClaims = verifyAndDecode(longJwt);
312
+ if (!longClaims || longClaims.exp * 1000 < Date.now()) {
313
+ return FREE_LIMITS;
314
+ }
315
+ // Device cap enforcement: if this machine is over-cap, the server told
316
+ // us to downgrade here until a slot is freed. The long JWT stays on disk
317
+ // so that once the slot is freed, the next refresh lifts the block.
318
+ if (readDeviceBlocked()) {
319
+ return FREE_LIMITS;
320
+ }
321
+ // Fire-and-forget opportunistic refresh. Never awaited — we do not block
322
+ // CLI startup on the network.
323
+ void tryOnlineRefresh(longJwt);
324
+ // Prefer a still-valid freshToken within the grace window
325
+ const fresh = loadFreshToken();
326
+ if (fresh && fresh.exp * 1000 > Date.now()) {
327
+ const lastCheck = readLastCheck();
328
+ if (lastCheck === 0 || Date.now() - lastCheck < GRACE_MS) {
329
+ return claimsToLimits(fresh);
330
+ }
331
+ // freshToken is valid but the last successful refresh is older than the
332
+ // grace window → server is unreachable for too long. Fall back to the
333
+ // long JWT (keeps working as today, no downgrade to free).
334
+ }
335
+ return claimsToLimits(longClaims);
336
+ }
@@ -87,4 +87,3 @@ export async function checkForUpdate() {
87
87
  process.exit(1);
88
88
  }
89
89
  }
90
- //# sourceMappingURL=update-check.js.map
package/dist/version.js CHANGED
@@ -2,4 +2,3 @@ import { createRequire } from "module";
2
2
  const require = createRequire(import.meta.url);
3
3
  const pkg = require("../package.json");
4
4
  export const CLI_VERSION = pkg.version || "0.0.0";
5
- //# sourceMappingURL=version.js.map