@cartanova/qgrid-cli 2.6.0 → 2.6.2
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.ko.md +2 -0
- package/README.md +2 -0
- package/bundle/dist/application/qgrid/oauth.js +22 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +104 -34
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
- package/bundle/dist/application/qgrid/token-death.js +52 -0
- package/bundle/dist/application/queries.generated.js +2 -2
- package/bundle/dist/application/request-log/request-log.model.js +69 -27
- package/bundle/dist/application/sonamu.generated.js +20 -3
- package/bundle/dist/application/sonamu.generated.sso.js +18 -2
- package/bundle/dist/application/token/token.model.js +42 -1
- package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
- package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
- package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
- package/bundle/dist/utils/slack-notify.js +65 -0
- package/bundle/src/application/qgrid/oauth.ts +23 -1
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +133 -27
- package/bundle/src/application/qgrid/qgrid.frame.ts +132 -66
- package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
- package/bundle/src/application/qgrid/token-death.test.ts +120 -0
- package/bundle/src/application/qgrid/token-death.ts +67 -0
- package/bundle/src/application/queries.generated.ts +2 -2
- package/bundle/src/application/request-log/request-log.entity.json +20 -6
- package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
- package/bundle/src/application/request-log/request-log.model.ts +86 -40
- package/bundle/src/application/sonamu.generated.http +22 -1
- package/bundle/src/application/sonamu.generated.sso.ts +16 -0
- package/bundle/src/application/sonamu.generated.ts +16 -1
- package/bundle/src/application/token/token.model.test.ts +39 -0
- package/bundle/src/application/token/token.model.ts +77 -0
- package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
- package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
- package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
- package/bundle/src/utils/slack-notify.test.ts +97 -0
- package/bundle/src/utils/slack-notify.ts +74 -0
- package/bundle/web-dist/client/assets/index-2cAgaLOm.css +1 -0
- package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BgWzzjvj.js} +3 -3
- package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
- package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-BSa5lSZp.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cp0re-Nn.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-B-dMUtEu.js} +1 -1
- package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C1jEPhuS.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-1e66MAC0.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DueEppqt.js} +1 -1
- package/bundle/web-dist/client/index.html +2 -2
- package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-CwoOsveV.js} +11 -4
- package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-DIsEsDJN.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-Bts-KJ9W.js} +7 -3
- package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-Bh7Vs8Ds.js} +16 -7
- package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-DJfaCNUw.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-BA4vwDat.js} +215 -52
- package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BQRzEPy2.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +6 -6
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +16 -1
- package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
- package/bundle/web-dist/client/assets/logs-o8mm530D.js +0 -1
- package/bundle/web-dist/client/assets/tokens-B9Ig7-9Z.js +0 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
//#region src/utils/slack-notify.ts
|
|
3
|
+
/**
|
|
4
|
+
* Slack 알림 (chat.postMessage).
|
|
5
|
+
*
|
|
6
|
+
* fail-open: env 미설정이면 조용히 no-op 이고, 전송 실패는 warn 로그로만 남긴다.
|
|
7
|
+
* 알림 경로가 토큰 라우팅이나 오류 전파를 지연·차단해서는 안 된다.
|
|
8
|
+
*/
|
|
9
|
+
const logger = getLogger(["qgrid", "slack"]);
|
|
10
|
+
const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
|
|
11
|
+
const SLACK_TIMEOUT_MS = 5e3;
|
|
12
|
+
/** 상태를 색으로 먼저 읽히게 하는 attachment 색상 바. */
|
|
13
|
+
const SLACK_COLOR = {
|
|
14
|
+
good: "#2eb886",
|
|
15
|
+
bad: "#e01e5a"
|
|
16
|
+
};
|
|
17
|
+
async function notifySlack(notification) {
|
|
18
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
19
|
+
const channel = process.env.SLACK_CHANNEL_ID;
|
|
20
|
+
const { title, subject, context, color } = notification;
|
|
21
|
+
if (!botToken || !channel) {
|
|
22
|
+
logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const blocks = [{
|
|
26
|
+
type: "section",
|
|
27
|
+
text: {
|
|
28
|
+
type: "mrkdwn",
|
|
29
|
+
text: subject ? `*${title}* \`${subject}\`` : `*${title}*`
|
|
30
|
+
}
|
|
31
|
+
}];
|
|
32
|
+
if (context) blocks.push({
|
|
33
|
+
type: "context",
|
|
34
|
+
elements: [{
|
|
35
|
+
type: "mrkdwn",
|
|
36
|
+
text: context
|
|
37
|
+
}]
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(SLACK_POST_MESSAGE_URL, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
44
|
+
Authorization: `Bearer ${botToken}`
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
channel,
|
|
48
|
+
text: subject ? `${title} — ${subject}` : title,
|
|
49
|
+
attachments: [{
|
|
50
|
+
...color ? { color } : {},
|
|
51
|
+
blocks
|
|
52
|
+
}]
|
|
53
|
+
}),
|
|
54
|
+
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS)
|
|
55
|
+
});
|
|
56
|
+
const body = await res.json().catch(() => null);
|
|
57
|
+
if (!body?.ok) logger.warn(`slack notification rejected: ${body?.error ?? `http ${res.status}`}`);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
logger.warn(`slack notification failed: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { SLACK_COLOR, notifySlack };
|
|
64
|
+
|
|
65
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xhY2stbm90aWZ5LmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy91dGlscy9zbGFjay1ub3RpZnkudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBTbGFjayDslYzrprwgKGNoYXQucG9zdE1lc3NhZ2UpLlxuICpcbiAqIGZhaWwtb3BlbjogZW52IOuvuOyEpOygleydtOuptCDsobDsmqntnoggbm8tb3Ag7J206rOgLCDsoITshqEg7Iuk7Yyo64qUIHdhcm4g66Gc6re466Gc66eMIOuCqOq4tOuLpC5cbiAqIOyVjOumvCDqsr3roZzqsIAg7Yag7YGwIOudvOyasO2MheydtOuCmCDsmKTrpZgg7KCE7YyM66W8IOyngOyXsMK37LCo64uo7ZW07ISc64qUIOyViCDrkJzri6QuXG4gKi9cbmltcG9ydCB7IGdldExvZ2dlciB9IGZyb20gXCJAbG9ndGFwZS9sb2d0YXBlXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcInNsYWNrXCJdKTtcblxuY29uc3QgU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCA9IFwiaHR0cHM6Ly9zbGFjay5jb20vYXBpL2NoYXQucG9zdE1lc3NhZ2VcIjtcbmNvbnN0IFNMQUNLX1RJTUVPVVRfTVMgPSA1XzAwMDtcblxuLyoqIOyDge2DnOulvCDsg4nsnLzroZwg66i87KCAIOydve2eiOqyjCDtlZjripQgYXR0YWNobWVudCDsg4nsg4Eg67CULiAqL1xuZXhwb3J0IGNvbnN0IFNMQUNLX0NPTE9SID0ge1xuICBnb29kOiBcIiMyZWI4ODZcIixcbiAgYmFkOiBcIiNlMDFlNWFcIixcbn0gYXMgY29uc3Q7XG5cbmV4cG9ydCB0eXBlIFNsYWNrTm90aWZpY2F0aW9uID0ge1xuICAvKiog7ZWcIOykhCDsoJzrqqkuIOustOyKqCDsnbzsnbQg7J287Ja064Ks64qU7KeA66eMIOuLtOuKlOuLpC4gKi9cbiAgdGl0bGU6IHN0cmluZztcbiAgLyoqIOygnOuqqSDsmIYg7L2U65OcIOyKpO2DgOydvOuhnCDrtpnripQg64yA7IOBIOyLneuzhOyekC4gKi9cbiAgc3ViamVjdD86IHN0cmluZztcbiAgLyoqIOygnOuqqSDslYTrnpgg7J6R7J2AIOq4gOyUqOuhnCDrgrTroKTqsIDripQg67aA6rCAIOygleuztCDigJQg7J297KeAIOyViuyVhOuPhCDrkJjripQg6rKD65OkLiAqL1xuICBjb250ZXh0Pzogc3RyaW5nO1xuICBjb2xvcj86ICh0eXBlb2YgU0xBQ0tfQ09MT1IpW2tleW9mIHR5cGVvZiBTTEFDS19DT0xPUl07XG59O1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbm90aWZ5U2xhY2sobm90aWZpY2F0aW9uOiBTbGFja05vdGlmaWNhdGlvbik6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBib3RUb2tlbiA9IHByb2Nlc3MuZW52LlNMQUNLX0JPVF9UT0tFTjtcbiAgY29uc3QgY2hhbm5lbCA9IHByb2Nlc3MuZW52LlNMQUNLX0NIQU5ORUxfSUQ7XG4gIGNvbnN0IHsgdGl0bGUsIHN1YmplY3QsIGNvbnRleHQsIGNvbG9yIH0gPSBub3RpZmljYXRpb247XG4gIGlmICghYm90VG9rZW4gfHwgIWNoYW5uZWwpIHtcbiAgICBsb2dnZXIuZGVidWcoYHNsYWNrIG5vdCBjb25maWd1cmVkLCBza2lwcGluZyBub3RpZmljYXRpb246ICR7dGl0bGV9ICR7c3ViamVjdCA/PyBcIlwifWApO1xuICAgIHJldHVybjtcbiAgfVxuXG4gIGNvbnN0IGJsb2NrczogdW5rbm93bltdID0gW1xuICAgIHtcbiAgICAgIHR5cGU6IFwic2VjdGlvblwiLFxuICAgICAgdGV4dDogeyB0eXBlOiBcIm1ya2R3blwiLCB0ZXh0OiBzdWJqZWN0ID8gYCoke3RpdGxlfSogIFxcYCR7c3ViamVjdH1cXGBgIDogYCoke3RpdGxlfSpgIH0sXG4gICAgfSxcbiAgXTtcbiAgaWYgKGNvbnRleHQpIHtcbiAgICBibG9ja3MucHVzaCh7IHR5cGU6IFwiY29udGV4dFwiLCBlbGVtZW50czogW3sgdHlwZTogXCJtcmtkd25cIiwgdGV4dDogY29udGV4dCB9XSB9KTtcbiAgfVxuXG4gIHRyeSB7XG4gICAgY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCwge1xuICAgICAgbWV0aG9kOiBcIlBPU1RcIixcbiAgICAgIGhlYWRlcnM6IHtcbiAgICAgICAgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PXV0Zi04XCIsXG4gICAgICAgIEF1dGhvcml6YXRpb246IGBCZWFyZXIgJHtib3RUb2tlbn1gLFxuICAgICAgfSxcbiAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHtcbiAgICAgICAgY2hhbm5lbCxcbiAgICAgICAgLy8g7JWM66a8IOuvuOumrOuztOq4sMK37KCR6re87ISx7JqpIOuMgOyytCDthY3siqTtirguIGJsb2NrcyDrp4wg67O064K066m0IO2RuOyLnCDslYzrprzsnbQg67mI7Lm47Jy866GcIOucrOuLpC5cbiAgICAgICAgdGV4dDogc3ViamVjdCA/IGAke3RpdGxlfSDigJQgJHtzdWJqZWN0fWAgOiB0aXRsZSxcbiAgICAgICAgYXR0YWNobWVudHM6IFt7IC4uLihjb2xvciA/IHsgY29sb3IgfSA6IHt9KSwgYmxvY2tzIH1dLFxuICAgICAgfSksXG4gICAgICBzaWduYWw6IEFib3J0U2lnbmFsLnRpbWVvdXQoU0xBQ0tfVElNRU9VVF9NUyksXG4gICAgfSk7XG5cbiAgICAvLyBib3Qg66+47LSI64yAKG5vdF9pbl9jaGFubmVsKcK37J6Y66q765CcIOyxhOuEkCDrk7Eg6rCA7J6lIO2dlO2VnCDsmKTshKTsoJXsnYAgSFRUUCAyMDAgKyBvazpmYWxzZSDroZwg7Jio64ukLlxuICAgIC8vIHN0YXR1cyDrp4wg67O066m0IOyhsOyaqe2eiCDshLHqs7Ug7LKY66as64+8IOyVjOumvOydtCDthrXsp7jroZwg7IKs65287KeE64ukLlxuICAgIGNvbnN0IGJvZHkgPSAoYXdhaXQgcmVzLmpzb24oKS5jYXRjaCgoKSA9PiBudWxsKSkgYXMgeyBvaz86IGJvb2xlYW47IGVycm9yPzogc3RyaW5nIH0gfCBudWxsO1xuICAgIGlmICghYm9keT8ub2spIHtcbiAgICAgIGxvZ2dlci53YXJuKGBzbGFjayBub3RpZmljYXRpb24gcmVqZWN0ZWQ6ICR7Ym9keT8uZXJyb3IgPz8gYGh0dHAgJHtyZXMuc3RhdHVzfWB9YCk7XG4gICAgfVxuICB9IGNhdGNoIChlKSB7XG4gICAgbG9nZ2VyLndhcm4oYHNsYWNrIG5vdGlmaWNhdGlvbiBmYWlsZWQ6ICR7KGUgYXMgRXJyb3IpLm1lc3NhZ2V9YCk7XG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7QUFRQSxNQUFNLFNBQVMsVUFBVSxDQUFDLFNBQVMsT0FBTyxDQUFDO0FBRTNDLE1BQU0seUJBQXlCO0FBQy9CLE1BQU0sbUJBQW1COztBQUd6QixNQUFhLGNBQWM7Q0FDekIsTUFBTTtDQUNOLEtBQUs7QUFDUDtBQVlBLGVBQXNCLFlBQVksY0FBZ0Q7Q0FDaEYsTUFBTSxXQUFXLFFBQVEsSUFBSTtDQUM3QixNQUFNLFVBQVUsUUFBUSxJQUFJO0NBQzVCLE1BQU0sRUFBRSxPQUFPLFNBQVMsU0FBUyxVQUFVO0NBQzNDLElBQUksQ0FBQyxZQUFZLENBQUMsU0FBUztFQUN6QixPQUFPLE1BQU0sZ0RBQWdELE1BQU0sR0FBRyxXQUFXLElBQUk7RUFDckY7Q0FDRjtDQUVBLE1BQU0sU0FBb0IsQ0FDeEI7RUFDRSxNQUFNO0VBQ04sTUFBTTtHQUFFLE1BQU07R0FBVSxNQUFNLFVBQVUsSUFBSSxNQUFNLE9BQU8sUUFBUSxNQUFNLElBQUksTUFBTTtFQUFHO0NBQ3RGLENBQ0Y7Q0FDQSxJQUFJLFNBQ0YsT0FBTyxLQUFLO0VBQUUsTUFBTTtFQUFXLFVBQVUsQ0FBQztHQUFFLE1BQU07R0FBVSxNQUFNO0VBQVEsQ0FBQztDQUFFLENBQUM7Q0FHaEYsSUFBSTtFQUNGLE1BQU0sTUFBTSxNQUFNLE1BQU0sd0JBQXdCO0dBQzlDLFFBQVE7R0FDUixTQUFTO0lBQ1AsZ0JBQWdCO0lBQ2hCLGVBQWUsVUFBVTtHQUMzQjtHQUNBLE1BQU0sS0FBSyxVQUFVO0lBQ25CO0lBRUEsTUFBTSxVQUFVLEdBQUcsTUFBTSxLQUFLLFlBQVk7SUFDMUMsYUFBYSxDQUFDO0tBQUUsR0FBSSxRQUFRLEVBQUUsTUFBTSxJQUFJLENBQUM7S0FBSTtJQUFPLENBQUM7R0FDdkQsQ0FBQztHQUNELFFBQVEsWUFBWSxRQUFRLGdCQUFnQjtFQUM5QyxDQUFDO0VBSUQsTUFBTSxPQUFRLE1BQU0sSUFBSSxLQUFLLENBQUMsQ0FBQyxZQUFZLElBQUk7RUFDL0MsSUFBSSxDQUFDLE1BQU0sSUFDVCxPQUFPLEtBQUssZ0NBQWdDLE1BQU0sU0FBUyxRQUFRLElBQUksVUFBVTtDQUVyRixTQUFTLEdBQUc7RUFDVixPQUFPLEtBQUssOEJBQStCLEVBQVksU0FBUztDQUNsRTtBQUNGIn0=
|
|
@@ -20,6 +20,9 @@ const logger = getLogger(["qgrid", "oauth"]);
|
|
|
20
20
|
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
21
21
|
const AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
|
|
22
22
|
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
23
|
+
// client 허용 목록에 있는 콘솔 콜백. 인증 후 콘솔 페이지가 `code#state`를 표시하고
|
|
24
|
+
// 사용자가 붙여넣는 코드 플로우(Claude Code CLI와 동일)에 사용한다.
|
|
25
|
+
export const CONSOLE_CALLBACK_URL = "https://console.anthropic.com/oauth/code/callback";
|
|
23
26
|
|
|
24
27
|
// login 시 사용 (authorize URL)
|
|
25
28
|
const ALL_SCOPES = [
|
|
@@ -114,6 +117,25 @@ export async function exchangeCodeForTokens(
|
|
|
114
117
|
};
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
/**
|
|
121
|
+
* refresh 실패를 status 와 함께 전달한다. 호출부가 문자열 메시지를 파싱하지 않고
|
|
122
|
+
* 영구 실패(400/401)를 판별할 수 있어야 하기 때문이다.
|
|
123
|
+
*/
|
|
124
|
+
export class RefreshFailedError extends Error {
|
|
125
|
+
override readonly name = "RefreshFailedError";
|
|
126
|
+
constructor(
|
|
127
|
+
readonly status: number,
|
|
128
|
+
readonly body: string,
|
|
129
|
+
) {
|
|
130
|
+
super(`Token refresh failed (${status}): ${body}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 재로그인 외 복구 불가한 인증 실패인지. 일시 오류(5xx·네트워크)는 제외된다. */
|
|
134
|
+
get isAuthDead(): boolean {
|
|
135
|
+
return this.status === 400 || this.status === 401;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
|
|
118
140
|
const res = await fetch(TOKEN_URL, {
|
|
119
141
|
method: "POST",
|
|
@@ -127,7 +149,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTok
|
|
|
127
149
|
});
|
|
128
150
|
if (!res.ok) {
|
|
129
151
|
const text = await res.text();
|
|
130
|
-
throw new
|
|
152
|
+
throw new RefreshFailedError(res.status, text);
|
|
131
153
|
}
|
|
132
154
|
const data = (await res.json()) as {
|
|
133
155
|
access_token: string;
|
|
@@ -1,22 +1,69 @@
|
|
|
1
1
|
import { Sonamu } from "sonamu";
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
|
+
import { CONSOLE_CALLBACK_URL } from "./oauth";
|
|
4
5
|
import { QgridFrame } from "./qgrid.frame";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
vi.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
delete: vi.fn(async () => {}),
|
|
12
|
-
}),
|
|
7
|
+
const { exchangeMock, tokenSaveMock, tokenDelMock, findByAccountMock } = vi.hoisted(() => ({
|
|
8
|
+
exchangeMock: vi.fn(),
|
|
9
|
+
tokenSaveMock: vi.fn(async () => [1]),
|
|
10
|
+
tokenDelMock: vi.fn(async () => 1),
|
|
11
|
+
findByAccountMock: vi.fn(async () => []),
|
|
13
12
|
}));
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// oauthStart 는 PKCE 상태를 cache 에 저장한다 — Map 스텁으로 왕복만 지원한다.
|
|
15
|
+
vi.mock("sonamu/cache", () => {
|
|
16
|
+
const store = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
getCacheManagerRef: () => ({
|
|
19
|
+
set: vi.fn(async ({ key, value }: { key: string; value: string }) => {
|
|
20
|
+
store.set(key, value);
|
|
21
|
+
}),
|
|
22
|
+
get: vi.fn(async ({ key }: { key: string }) => store.get(key)),
|
|
23
|
+
delete: vi.fn(async ({ key }: { key: string }) => {
|
|
24
|
+
store.delete(key);
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock("./oauth", async (importOriginal) => ({
|
|
31
|
+
...(await importOriginal<typeof import("./oauth")>()),
|
|
32
|
+
exchangeCodeForTokens: exchangeMock,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../token/token.model", () => ({
|
|
36
|
+
TokenModel: {
|
|
37
|
+
save: tokenSaveMock,
|
|
38
|
+
del: tokenDelMock,
|
|
39
|
+
findByAccountIdentifier: findByAccountMock,
|
|
40
|
+
// 실제 구현과 같은 순서(dedup → save)로 동작시켜 저장 payload 검증이 유지되게 한다.
|
|
41
|
+
replaceByAccount: async (
|
|
42
|
+
_provider: string,
|
|
43
|
+
accountId: string | undefined,
|
|
44
|
+
saveParams: unknown,
|
|
45
|
+
) => {
|
|
46
|
+
let isNew = false;
|
|
47
|
+
if (accountId) {
|
|
48
|
+
const olds = (await findByAccountMock()) as unknown as { id: number; active: boolean }[];
|
|
49
|
+
if (olds.length > 0) await tokenDelMock();
|
|
50
|
+
else isNew = true;
|
|
51
|
+
}
|
|
52
|
+
await (tokenSaveMock as unknown as (rows: unknown[]) => Promise<number[]>)([saveParams]);
|
|
53
|
+
return { isNew };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
function authUrlParam(authUrl: string, key: string): string | null {
|
|
59
|
+
return new URL(authUrl).searchParams.get(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mockHttpContext(headers: Record<string, string>, protocol = "http"): void {
|
|
63
|
+
vi.spyOn(Sonamu, "getContext").mockReturnValue({ headers, request: { protocol } } as never);
|
|
17
64
|
}
|
|
18
65
|
|
|
19
|
-
describe("QgridFrame.oauthStart redirect
|
|
66
|
+
describe("QgridFrame.oauthStart redirect resolution", () => {
|
|
20
67
|
let savedPort: string | undefined;
|
|
21
68
|
|
|
22
69
|
beforeEach(() => {
|
|
@@ -29,32 +76,91 @@ describe("QgridFrame.oauthStart redirect derivation", () => {
|
|
|
29
76
|
vi.restoreAllMocks();
|
|
30
77
|
});
|
|
31
78
|
|
|
32
|
-
it("
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
79
|
+
it("uses the redirect flow with the request-derived callback on loopback origins", async () => {
|
|
80
|
+
mockHttpContext({ origin: "http://localhost:44900" });
|
|
81
|
+
|
|
82
|
+
const { authUrl, mode } = await QgridFrame.oauthStart("tok");
|
|
83
|
+
expect(mode).toBe("redirect");
|
|
84
|
+
expect(authUrlParam(authUrl, "redirect_uri")).toBe("http://localhost:44900/callback");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("switches to the code flow with the console callback on remote origins", async () => {
|
|
88
|
+
mockHttpContext({ origin: "https://qgrid.example.com" });
|
|
37
89
|
|
|
38
|
-
const { authUrl } = await QgridFrame.oauthStart("tok");
|
|
39
|
-
expect(
|
|
90
|
+
const { authUrl, mode } = await QgridFrame.oauthStart("tok");
|
|
91
|
+
expect(mode).toBe("code");
|
|
92
|
+
expect(authUrlParam(authUrl, "redirect_uri")).toBe(CONSOLE_CALLBACK_URL);
|
|
40
93
|
});
|
|
41
94
|
|
|
42
|
-
it("
|
|
43
|
-
|
|
44
|
-
headers: { host: "dev0.cartanova.ai:44900", "x-forwarded-proto": "https" },
|
|
45
|
-
request: { protocol: "http" },
|
|
46
|
-
} as never);
|
|
95
|
+
it("treats forwarded remote hosts as the code flow when Origin is absent", async () => {
|
|
96
|
+
mockHttpContext({ host: "qgrid.example.com", "x-forwarded-proto": "https" });
|
|
47
97
|
|
|
48
|
-
const { authUrl } = await QgridFrame.oauthStart("tok");
|
|
49
|
-
expect(
|
|
98
|
+
const { authUrl, mode } = await QgridFrame.oauthStart("tok");
|
|
99
|
+
expect(mode).toBe("code");
|
|
100
|
+
expect(authUrlParam(authUrl, "redirect_uri")).toBe(CONSOLE_CALLBACK_URL);
|
|
50
101
|
});
|
|
51
102
|
|
|
52
|
-
it("falls back to localhost without an HTTP context", async () => {
|
|
103
|
+
it("falls back to the localhost redirect flow without an HTTP context", async () => {
|
|
53
104
|
vi.spyOn(Sonamu, "getContext").mockImplementation(() => {
|
|
54
105
|
throw new Error("no context");
|
|
55
106
|
});
|
|
56
107
|
|
|
57
|
-
const { authUrl } = await QgridFrame.oauthStart("tok");
|
|
58
|
-
expect(
|
|
108
|
+
const { authUrl, mode } = await QgridFrame.oauthStart("tok");
|
|
109
|
+
expect(mode).toBe("redirect");
|
|
110
|
+
expect(authUrlParam(authUrl, "redirect_uri")).toBe("http://localhost:44900/callback");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("QgridFrame.oauthComplete code flow", () => {
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
exchangeMock.mockReset();
|
|
118
|
+
tokenSaveMock.mockClear();
|
|
119
|
+
findByAccountMock.mockClear().mockResolvedValue([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("exchanges a pasted code#state against the stored pending state and saves the token", async () => {
|
|
123
|
+
mockHttpContext({ origin: "https://qgrid.example.com" });
|
|
124
|
+
const { authUrl } = await QgridFrame.oauthStart("test-token-2");
|
|
125
|
+
const state = authUrlParam(authUrl, "state")!;
|
|
126
|
+
|
|
127
|
+
exchangeMock.mockResolvedValueOnce({
|
|
128
|
+
accessToken: "at",
|
|
129
|
+
refreshToken: "rt",
|
|
130
|
+
expiresAt: 123,
|
|
131
|
+
scope: "user:inference",
|
|
132
|
+
accountUuid: "acc-1",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await expect(QgridFrame.oauthComplete(` the-code#${state} `)).resolves.toEqual({
|
|
136
|
+
added: true,
|
|
137
|
+
name: "test-token-2",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(exchangeMock).toHaveBeenCalledWith(
|
|
141
|
+
"the-code",
|
|
142
|
+
expect.any(String),
|
|
143
|
+
state,
|
|
144
|
+
CONSOLE_CALLBACK_URL,
|
|
145
|
+
);
|
|
146
|
+
expect(tokenSaveMock).toHaveBeenCalledWith([
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
provider: "anthropic",
|
|
149
|
+
name: "test-token-2",
|
|
150
|
+
credentials: expect.objectContaining({ accessToken: "at", accountUuid: "acc-1" }),
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects input without a #state part", async () => {
|
|
156
|
+
await expect(QgridFrame.oauthComplete("just-a-code")).rejects.toThrow(/code#state/);
|
|
157
|
+
expect(exchangeMock).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects an unknown or expired state", async () => {
|
|
161
|
+
await expect(QgridFrame.oauthComplete("code#no-such-state")).rejects.toThrow(
|
|
162
|
+
/not found or expired/,
|
|
163
|
+
);
|
|
164
|
+
expect(exchangeMock).not.toHaveBeenCalled();
|
|
59
165
|
});
|
|
60
166
|
});
|
|
@@ -13,16 +13,19 @@ import {
|
|
|
13
13
|
} from "../../utils/providers/common/credentials";
|
|
14
14
|
import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
|
|
15
15
|
import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
|
|
16
|
+
import { type RequestLogListParams } from "../request-log/request-log.types";
|
|
16
17
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
17
18
|
import { TokenModel, type TokenUpdateFields } from "../token/token.model";
|
|
18
19
|
import { TokenCredentials } from "../token/token.types";
|
|
19
20
|
import {
|
|
20
21
|
type AnthropicUsageRaw,
|
|
21
22
|
buildAuthUrl,
|
|
23
|
+
CONSOLE_CALLBACK_URL,
|
|
22
24
|
exchangeCodeForTokens,
|
|
23
25
|
fetchUsage,
|
|
24
26
|
generatePKCE,
|
|
25
27
|
refreshAccessToken,
|
|
28
|
+
RefreshFailedError,
|
|
26
29
|
} from "./oauth";
|
|
27
30
|
import {
|
|
28
31
|
afterQuery,
|
|
@@ -44,6 +47,7 @@ import {
|
|
|
44
47
|
type TokenStats,
|
|
45
48
|
type UsageResponse,
|
|
46
49
|
} from "./qgrid.types";
|
|
50
|
+
import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
|
|
47
51
|
import { ToolSchemaCompositionError } from "./tool-emulation-schema";
|
|
48
52
|
|
|
49
53
|
const pendingStreams = new Map<string, QueryInput>();
|
|
@@ -80,15 +84,14 @@ async function deleteOAuthState(state: string): Promise<void> {
|
|
|
80
84
|
await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
// OAuth
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
function getOAuthRedirectUri(): string {
|
|
87
|
+
// OAuth 접속 주소는 브라우저가 보낸 Origin(우선) 또는 Host 헤더에서 파생한다.
|
|
88
|
+
// HTTP context 가 없는 직접 호출(테스트 등)은 null 을 반환한다.
|
|
89
|
+
function deriveRequestBaseUrl(): string | null {
|
|
87
90
|
try {
|
|
88
91
|
const { headers, request } = Sonamu.getContext();
|
|
89
92
|
const origin = headers.origin;
|
|
90
93
|
if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
|
|
91
|
-
return
|
|
94
|
+
return origin.replace(/\/+$/, "");
|
|
92
95
|
}
|
|
93
96
|
const forwardedHost = headers["x-forwarded-host"];
|
|
94
97
|
const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? headers.host;
|
|
@@ -97,12 +100,34 @@ function getOAuthRedirectUri(): string {
|
|
|
97
100
|
const proto =
|
|
98
101
|
(Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0] ??
|
|
99
102
|
request.protocol;
|
|
100
|
-
return `${proto}://${host}
|
|
103
|
+
return `${proto}://${host}`;
|
|
101
104
|
}
|
|
102
105
|
} catch {
|
|
103
106
|
// direct frame call — HTTP context 없음
|
|
104
107
|
}
|
|
105
|
-
return
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
112
|
+
|
|
113
|
+
function isLoopbackBase(base: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return LOOPBACK_HOSTNAMES.has(new URL(base).hostname);
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Anthropic OAuth client 는 redirect_uri 허용 목록을 강제한다: 루프백(localhost)과
|
|
122
|
+
// 콘솔 콜백만 허용된다. 따라서 루프백 접속은 자동 리다이렉트 플로우를,
|
|
123
|
+
// 원격 접속은 콘솔 콜백 + 코드 붙여넣기 플로우(Claude Code CLI 와 동일)를 쓴다.
|
|
124
|
+
function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
|
|
125
|
+
const base = deriveRequestBaseUrl();
|
|
126
|
+
if (base && !isLoopbackBase(base)) {
|
|
127
|
+
return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
|
|
128
|
+
}
|
|
129
|
+
const loopbackBase = base ?? `http://localhost:${process.env.PORT ?? "44900"}`;
|
|
130
|
+
return { redirectUri: `${loopbackBase}/callback`, mode: "redirect" };
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
function rejectImageGenerationStream(args: QueryInput): void {
|
|
@@ -175,6 +200,9 @@ function createHttpDisconnectHandle(): {
|
|
|
175
200
|
const logger = getLogger(["qgrid"]);
|
|
176
201
|
const oauthLogger = getLogger(["qgrid", "oauth"]);
|
|
177
202
|
|
|
203
|
+
// per-token refresh dedup — 회전 겹침으로 인한 사망 오판을 막는다.
|
|
204
|
+
const inflightAnthropicRefresh = new Map<number, Promise<string>>();
|
|
205
|
+
|
|
178
206
|
class QgridFrameClass extends BaseFrameClass {
|
|
179
207
|
constructor() {
|
|
180
208
|
super("Qgrid");
|
|
@@ -442,9 +470,11 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
442
470
|
return QgridDispatcher.getStats();
|
|
443
471
|
}
|
|
444
472
|
|
|
473
|
+
// 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
|
|
474
|
+
// 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
|
|
445
475
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
446
|
-
async totalCost(
|
|
447
|
-
return { usd: await RequestLogModel.totalCost(
|
|
476
|
+
async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
|
|
477
|
+
return { usd: await RequestLogModel.totalCost(params) };
|
|
448
478
|
}
|
|
449
479
|
|
|
450
480
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -533,12 +563,64 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
533
563
|
async oauthStart(name: string): Promise<OAuthStartResult> {
|
|
534
564
|
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
|
535
565
|
|
|
536
|
-
const redirectUri =
|
|
566
|
+
const { redirectUri, mode } = resolveOAuthRedirect();
|
|
537
567
|
const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
|
|
538
568
|
|
|
539
569
|
await setOAuthState(state, { codeVerifier, name, redirectUri });
|
|
540
570
|
|
|
541
|
-
return { authUrl };
|
|
571
|
+
return { authUrl, mode };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 코드 붙여넣기 플로우(원격 접속): 콘솔 콜백이 표시한 `code#state` 를 받아 교환한다.
|
|
575
|
+
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
576
|
+
async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
|
|
577
|
+
const trimmed = pastedCode.trim();
|
|
578
|
+
const separatorAt = trimmed.indexOf("#");
|
|
579
|
+
const code = separatorAt > 0 ? trimmed.slice(0, separatorAt) : "";
|
|
580
|
+
const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1) : "";
|
|
581
|
+
if (!code || !state) {
|
|
582
|
+
throw new BadRequestException(
|
|
583
|
+
"expected the code shown after login, in code#state form" as LocalizedString,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const pending = await getOAuthState(state);
|
|
588
|
+
if (!pending) {
|
|
589
|
+
throw new BadRequestException(
|
|
590
|
+
"login session not found or expired — start the login again" as LocalizedString,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
await deleteOAuthState(state);
|
|
594
|
+
|
|
595
|
+
await this.completeAnthropicLogin(code, state, pending);
|
|
596
|
+
return { added: true, name: pending.name };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 교환 + 계정 중복 제거 + 저장 — redirect 콜백과 코드 붙여넣기 플로우가 공유한다.
|
|
600
|
+
private async completeAnthropicLogin(
|
|
601
|
+
code: string,
|
|
602
|
+
state: string,
|
|
603
|
+
pending: PendingOAuth,
|
|
604
|
+
): Promise<void> {
|
|
605
|
+
const tokens = await exchangeCodeForTokens(
|
|
606
|
+
code,
|
|
607
|
+
pending.codeVerifier,
|
|
608
|
+
state,
|
|
609
|
+
pending.redirectUri,
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const { isNew } = await TokenModel.replaceByAccount("anthropic", tokens.accountUuid, {
|
|
613
|
+
provider: "anthropic",
|
|
614
|
+
credentials: {
|
|
615
|
+
accessToken: tokens.accessToken,
|
|
616
|
+
refreshToken: tokens.refreshToken,
|
|
617
|
+
expiresAt: tokens.expiresAt ?? 0,
|
|
618
|
+
accountUuid: tokens.accountUuid ?? "",
|
|
619
|
+
},
|
|
620
|
+
name: pending.name,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (isNew) notifyTokenAdded(pending.name, "anthropic");
|
|
542
624
|
}
|
|
543
625
|
|
|
544
626
|
async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
|
|
@@ -550,37 +632,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
550
632
|
await deleteOAuthState(state);
|
|
551
633
|
|
|
552
634
|
try {
|
|
553
|
-
|
|
554
|
-
code,
|
|
555
|
-
pending.codeVerifier,
|
|
556
|
-
state,
|
|
557
|
-
pending.redirectUri,
|
|
558
|
-
);
|
|
559
|
-
|
|
560
|
-
if (tokens.accountUuid) {
|
|
561
|
-
const oldEntries = await TokenModel.findByAccountIdentifier(
|
|
562
|
-
"A",
|
|
563
|
-
"anthropic",
|
|
564
|
-
tokens.accountUuid,
|
|
565
|
-
);
|
|
566
|
-
if (oldEntries.length > 0) {
|
|
567
|
-
await TokenModel.del(oldEntries.map((o) => o.id));
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
await TokenModel.save([
|
|
572
|
-
{
|
|
573
|
-
provider: "anthropic",
|
|
574
|
-
credentials: {
|
|
575
|
-
accessToken: tokens.accessToken,
|
|
576
|
-
refreshToken: tokens.refreshToken,
|
|
577
|
-
expiresAt: tokens.expiresAt ?? 0,
|
|
578
|
-
accountUuid: tokens.accountUuid ?? "",
|
|
579
|
-
},
|
|
580
|
-
name: pending.name,
|
|
581
|
-
},
|
|
582
|
-
]);
|
|
583
|
-
|
|
635
|
+
await this.completeAnthropicLogin(code, state, pending);
|
|
584
636
|
return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
|
|
585
637
|
} catch (e) {
|
|
586
638
|
return reply.redirect(`/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`);
|
|
@@ -596,36 +648,25 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
596
648
|
QgridDispatcher.openaiDispatcher
|
|
597
649
|
.completeBrowserLogin()
|
|
598
650
|
.then(async (creds) => {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
creds.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
await TokenModel.save([
|
|
610
|
-
{
|
|
611
|
-
provider: "openai",
|
|
612
|
-
credentials: {
|
|
613
|
-
accessToken: creds.accessToken,
|
|
614
|
-
refreshToken: creds.refreshToken,
|
|
615
|
-
idToken: creds.idToken,
|
|
616
|
-
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
617
|
-
accountId: creds.accountId,
|
|
618
|
-
},
|
|
619
|
-
name,
|
|
651
|
+
const { isNew } = await TokenModel.replaceByAccount("openai", creds.accountId, {
|
|
652
|
+
provider: "openai",
|
|
653
|
+
credentials: {
|
|
654
|
+
accessToken: creds.accessToken,
|
|
655
|
+
refreshToken: creds.refreshToken,
|
|
656
|
+
idToken: creds.idToken,
|
|
657
|
+
accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
|
|
658
|
+
accountId: creds.accountId,
|
|
620
659
|
},
|
|
621
|
-
|
|
660
|
+
name,
|
|
661
|
+
});
|
|
622
662
|
logger.info(`OpenAI token saved for ${name}`);
|
|
663
|
+
if (isNew) notifyTokenAdded(name, "openai");
|
|
623
664
|
})
|
|
624
665
|
.catch((e) => {
|
|
625
666
|
logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
|
|
626
667
|
});
|
|
627
668
|
|
|
628
|
-
return { authUrl };
|
|
669
|
+
return { authUrl, mode: "redirect" };
|
|
629
670
|
}
|
|
630
671
|
|
|
631
672
|
@api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
|
|
@@ -699,11 +740,36 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
699
740
|
return convertAnthropicUsage(raw);
|
|
700
741
|
}
|
|
701
742
|
|
|
743
|
+
// 동시 refresh 가 회전을 겹치면 뒤늦은 호출이 이미 폐기된 refresh token 을 쓰게 되어
|
|
744
|
+
// 멀쩡한 토큰이 사망으로 오판된다. openai-refresh.ts 와 같은 per-token dedup 을 둔다.
|
|
702
745
|
async refreshToken(token: TokenSubsetA): Promise<string> {
|
|
746
|
+
const inflight = inflightAnthropicRefresh.get(token.id);
|
|
747
|
+
if (inflight) return inflight;
|
|
748
|
+
|
|
749
|
+
const promise = this.doRefreshToken(token);
|
|
750
|
+
inflightAnthropicRefresh.set(token.id, promise);
|
|
751
|
+
try {
|
|
752
|
+
return await promise;
|
|
753
|
+
} finally {
|
|
754
|
+
inflightAnthropicRefresh.delete(token.id);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private async doRefreshToken(token: TokenSubsetA): Promise<string> {
|
|
703
759
|
const creds = token.credentials;
|
|
704
760
|
const rt = getRefreshToken(creds);
|
|
705
761
|
if (!rt) throw new Error("No refresh token");
|
|
706
|
-
|
|
762
|
+
|
|
763
|
+
let refreshed;
|
|
764
|
+
try {
|
|
765
|
+
refreshed = await refreshAccessToken(rt);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
if (e instanceof RefreshFailedError && e.isAuthDead) {
|
|
768
|
+
await deactivateAuthDeadToken(token, rt, `anthropic:${e.status}`);
|
|
769
|
+
}
|
|
770
|
+
throw e;
|
|
771
|
+
}
|
|
772
|
+
|
|
707
773
|
const updated = TokenCredentials.parse({
|
|
708
774
|
...creds,
|
|
709
775
|
accessToken: refreshed.accessToken,
|
|
@@ -274,6 +274,9 @@ export type TokenStats = z.infer<typeof TokenStats>;
|
|
|
274
274
|
|
|
275
275
|
export const OAuthStartResult = z.object({
|
|
276
276
|
authUrl: z.string(),
|
|
277
|
+
// redirect: 브라우저 자동 복귀(루프백 접속). code: 콘솔 콜백 + 코드 붙여넣기(원격 접속).
|
|
278
|
+
// OpenAI 경로는 codex 가 콜백을 처리하므로 항상 redirect 를 반환한다.
|
|
279
|
+
mode: z.enum(["redirect", "code"]),
|
|
277
280
|
});
|
|
278
281
|
export type OAuthStartResult = z.infer<typeof OAuthStartResult>;
|
|
279
282
|
|