pleumrouter 0.5.24 → 0.5.25
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/package.json +1 -1
- package/src/config.mjs +69 -22
- package/src/health.mjs +2 -2
- package/src/menu.mjs +2 -1
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -62,15 +62,9 @@ export function clearCredentials(existing) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
66
|
-
* (아래 requireAuth 참조) 미로그인으로 취급한다 — 그래야 런처가 자동으로 재로그인을 태운다.
|
|
65
|
+
* JWT payload 파싱(서명 검증 없는 디코드) — iat, exp 등의 클레임 추출용.
|
|
67
66
|
*/
|
|
68
|
-
export function
|
|
69
|
-
return Boolean(cfg?.access_token);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** JWT payload `exp`(epoch sec) — 서명 검증 없이 refresh 트리거 힌트. */
|
|
73
|
-
export function readJwtExp(token) {
|
|
67
|
+
export function readJwtPayload(token) {
|
|
74
68
|
if (typeof token !== "string") return null;
|
|
75
69
|
const parts = token.split(".");
|
|
76
70
|
const segment = parts[1];
|
|
@@ -80,14 +74,32 @@ export function readJwtExp(token) {
|
|
|
80
74
|
const pad = b64.length % 4;
|
|
81
75
|
if (pad) b64 += "=".repeat(4 - pad);
|
|
82
76
|
const json = Buffer.from(b64, "base64").toString("utf8");
|
|
83
|
-
|
|
84
|
-
return typeof payload.exp === "number" ? payload.exp : null;
|
|
77
|
+
return JSON.parse(json);
|
|
85
78
|
} catch {
|
|
86
79
|
return null;
|
|
87
80
|
}
|
|
88
81
|
}
|
|
89
82
|
|
|
90
|
-
/**
|
|
83
|
+
/** JWT payload `exp`(epoch sec) */
|
|
84
|
+
export function readJwtExp(token) {
|
|
85
|
+
const payload = readJwtPayload(token);
|
|
86
|
+
return typeof payload?.exp === "number" ? payload.exp : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** JWT payload `iat`(epoch sec) */
|
|
90
|
+
export function readJwtIat(token) {
|
|
91
|
+
const payload = readJwtPayload(token);
|
|
92
|
+
return typeof payload?.iat === "number" ? payload.iat : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 토큰 만료 여부 판정 (skewSeconds 여유 포함, unparseable은 false 반환) */
|
|
96
|
+
export function isTokenExpired(token, skewSeconds = 0) {
|
|
97
|
+
const exp = readJwtExp(token);
|
|
98
|
+
if (exp == null) return false;
|
|
99
|
+
return exp * 1000 <= Date.now() + skewSeconds * 1000;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** access가 곧 만료(기본 60s 여유)면 true. unparseable은 만료로 간주. */
|
|
91
103
|
export function accessTokenIsStale(accessToken, skewSeconds = 60) {
|
|
92
104
|
const exp = readJwtExp(accessToken);
|
|
93
105
|
if (exp == null) return true;
|
|
@@ -95,29 +107,59 @@ export function accessTokenIsStale(accessToken, skewSeconds = 60) {
|
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/**
|
|
98
|
-
*
|
|
110
|
+
* 사용 가능한 세션 보유 여부.
|
|
111
|
+
* 1) access_token 또는 refresh_token이 없으면 미로그인.
|
|
112
|
+
* 2) refresh_token이 이미 7일 만료되었으면 미로그인.
|
|
113
|
+
* 3) 레거시 `key`(plm_)만 있는 상태는 requireAuth()가 거부하므로 미로그인.
|
|
114
|
+
*/
|
|
115
|
+
export function isLoggedIn(cfg = readConfig()) {
|
|
116
|
+
if (!cfg?.access_token || !cfg?.refresh_token) return false;
|
|
117
|
+
if (isTokenExpired(cfg.refresh_token)) return false;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 7일 슬라이딩 세션 갱신 (1시간 스로틀링).
|
|
123
|
+
* CLI 접속 시 마지막 갱신(iat)으로부터 minIntervalSeconds(기본 3600초=1시간) 이상 지났거나
|
|
124
|
+
* access_token이 곧 만료(stale)되면 /v1/auth/refresh를 호출해 만료일을 현재+7일로 연장한다.
|
|
125
|
+
*/
|
|
126
|
+
export async function touchSession(cfg = readConfig(), { minIntervalSeconds = 3600 } = {}) {
|
|
127
|
+
if (!isLoggedIn(cfg)) return null;
|
|
128
|
+
const iat = readJwtIat(cfg.refresh_token) ?? readJwtIat(cfg.access_token);
|
|
129
|
+
const ageSeconds = iat ? Math.max(0, Math.floor(Date.now() / 1000 - iat)) : Infinity;
|
|
130
|
+
const isStale = accessTokenIsStale(cfg.access_token, 300);
|
|
131
|
+
if (!isStale && ageSeconds < minIntervalSeconds) {
|
|
132
|
+
return cfg.access_token;
|
|
133
|
+
}
|
|
134
|
+
return await refreshAccessToken(cfg);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 유효한 access_token을 반환. stale이거나 1시간 경과 시 refresh하여 슬라이딩 윈도우 유지.
|
|
139
|
+
* 레거시 `key`(plm_)만 있으면 재로그인 안내.
|
|
99
140
|
*/
|
|
100
141
|
export async function requireAuth() {
|
|
101
|
-
|
|
142
|
+
let cfg = readConfig();
|
|
102
143
|
if (cfg?.key && !cfg?.access_token) {
|
|
103
144
|
console.error(
|
|
104
145
|
t("Legacy API key login is no longer supported. Run:\n pleum login"),
|
|
105
146
|
);
|
|
106
147
|
process.exit(1);
|
|
107
148
|
}
|
|
108
|
-
if (!cfg
|
|
149
|
+
if (!isLoggedIn(cfg)) {
|
|
109
150
|
console.error(t("Login required. Run this first:\n pleum login"));
|
|
110
151
|
process.exit(1);
|
|
111
152
|
}
|
|
112
|
-
|
|
153
|
+
const token = await touchSession(cfg);
|
|
154
|
+
if (token) return token;
|
|
155
|
+
|
|
156
|
+
cfg = readConfig();
|
|
157
|
+
if (cfg?.access_token && !accessTokenIsStale(cfg.access_token, 0)) {
|
|
113
158
|
return cfg.access_token;
|
|
114
159
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
process.exit(1);
|
|
119
|
-
}
|
|
120
|
-
return refreshed;
|
|
160
|
+
|
|
161
|
+
console.error(t("Session expired. Run:\n pleum login"));
|
|
162
|
+
process.exit(1);
|
|
121
163
|
}
|
|
122
164
|
|
|
123
165
|
export async function refreshAccessToken(cfg = readConfig()) {
|
|
@@ -129,7 +171,12 @@ export async function refreshAccessToken(cfg = readConfig()) {
|
|
|
129
171
|
headers: { "Content-Type": "application/json" },
|
|
130
172
|
body: JSON.stringify({ refresh_token: cfg.refresh_token }),
|
|
131
173
|
});
|
|
132
|
-
if (!res.ok)
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
if (res.status === 401 || res.status === 403) {
|
|
176
|
+
writeConfig(clearCredentials(cfg));
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
133
180
|
const data = await res.json();
|
|
134
181
|
if (!data.access_token || !data.refresh_token) return null;
|
|
135
182
|
const exp = readJwtExp(data.access_token);
|
package/src/health.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `pleum health` — 게이트웨이 연결성 + 로그인 상태 한눈에.
|
|
2
2
|
// 셀프호스트/스테이징(PLEUM_BASE_URL) 점검·고객지원 트리아지용. 공개 /v1/health 재사용.
|
|
3
3
|
|
|
4
|
-
import { ROOT, V1, readConfig } from "./config.mjs";
|
|
4
|
+
import { ROOT, V1, readConfig, isLoggedIn } from "./config.mjs";
|
|
5
5
|
import { style, divider } from "./ansi.mjs";
|
|
6
6
|
import { t } from "./i18n.mjs";
|
|
7
7
|
|
|
@@ -39,7 +39,7 @@ export function renderHealth({ root, health, error, ms, keyPrefix }, styler = st
|
|
|
39
39
|
|
|
40
40
|
export async function health() {
|
|
41
41
|
const cfg = readConfig();
|
|
42
|
-
const keyPrefix = cfg
|
|
42
|
+
const keyPrefix = isLoggedIn(cfg)
|
|
43
43
|
? "oauth"
|
|
44
44
|
: cfg?.key
|
|
45
45
|
? cfg.key.slice(0, 8)
|
package/src/menu.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { spawn } from "node:child_process";
|
|
|
13
13
|
import { createInterface } from "node:readline";
|
|
14
14
|
import { style, displayWidth, terminalWidth, hyperlink, sanitizeTerminalText } from "./ansi.mjs";
|
|
15
15
|
import { keyFeeder, select } from "./interactive.mjs";
|
|
16
|
-
import { readConfig, writeConfig, isLoggedIn } from "./config.mjs";
|
|
16
|
+
import { readConfig, writeConfig, isLoggedIn, touchSession } from "./config.mjs";
|
|
17
17
|
import { RECIPES, MANUAL } from "./recipes.mjs";
|
|
18
18
|
import { onPath, launch } from "./launch.mjs";
|
|
19
19
|
import { fetchModels, matchModels, listModels, formatModelLabel } from "./models.mjs";
|
|
@@ -303,6 +303,7 @@ export async function mainMenu() {
|
|
|
303
303
|
// login()의 실패(취소·2분 타임아웃·state 불일치)는 그대로 던져 bin/pleum.mjs의
|
|
304
304
|
// 최상위 catch가 `✗ <메시지>` + exit 1로 처리하게 둔다(메뉴를 띄우지 않는다).
|
|
305
305
|
if (!isLoggedIn()) await login();
|
|
306
|
+
else await touchSession().catch(() => {});
|
|
306
307
|
|
|
307
308
|
for (;;) {
|
|
308
309
|
const picked = await menuSelect();
|