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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pleumrouter",
3
- "version": "0.5.24",
3
+ "version": "0.5.25",
4
4
  "description": "Official PleumRouter terminal CLI — launch any coding agent against PleumRouter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -62,15 +62,9 @@ export function clearCredentials(existing) {
62
62
  }
63
63
 
64
64
  /**
65
- * 사용 가능한 세션 보유 여부. 레거시 `key`(plm_)만 있는 상태는 requireAuth()가 거부하므로
66
- * (아래 requireAuth 참조) 미로그인으로 취급한다 — 그래야 런처가 자동으로 재로그인을 태운다.
65
+ * JWT payload 파싱(서명 검증 없는 디코드) — iat, exp 등의 클레임 추출용.
67
66
  */
68
- export function isLoggedIn(cfg = readConfig()) {
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
- const payload = JSON.parse(json);
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
- /** access가 곧 만료(기본 60s 여유)면 true. */
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
- * 유효한 access_token을 반환. stale이면 refresh. 레거시 `key`(plm_)만 있으면 재로그인 안내.
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
- const cfg = readConfig();
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?.access_token || !cfg?.refresh_token) {
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
- if (!accessTokenIsStale(cfg.access_token)) {
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
- const refreshed = await refreshAccessToken(cfg);
116
- if (!refreshed) {
117
- console.error(t("Session expired. Run:\n pleum login"));
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) return null;
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?.access_token
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();