@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.
Files changed (59) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +22 -3
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +104 -34
  5. package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
  6. package/bundle/dist/application/qgrid/token-death.js +52 -0
  7. package/bundle/dist/application/queries.generated.js +2 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +69 -27
  9. package/bundle/dist/application/sonamu.generated.js +20 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  11. package/bundle/dist/application/token/token.model.js +42 -1
  12. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  13. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  14. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  15. package/bundle/dist/utils/slack-notify.js +65 -0
  16. package/bundle/src/application/qgrid/oauth.ts +23 -1
  17. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +133 -27
  18. package/bundle/src/application/qgrid/qgrid.frame.ts +132 -66
  19. package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
  20. package/bundle/src/application/qgrid/token-death.test.ts +120 -0
  21. package/bundle/src/application/qgrid/token-death.ts +67 -0
  22. package/bundle/src/application/queries.generated.ts +2 -2
  23. package/bundle/src/application/request-log/request-log.entity.json +20 -6
  24. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  25. package/bundle/src/application/request-log/request-log.model.ts +86 -40
  26. package/bundle/src/application/sonamu.generated.http +22 -1
  27. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  28. package/bundle/src/application/sonamu.generated.ts +16 -1
  29. package/bundle/src/application/token/token.model.test.ts +39 -0
  30. package/bundle/src/application/token/token.model.ts +77 -0
  31. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  32. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  33. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  34. package/bundle/src/utils/slack-notify.test.ts +97 -0
  35. package/bundle/src/utils/slack-notify.ts +74 -0
  36. package/bundle/web-dist/client/assets/index-2cAgaLOm.css +1 -0
  37. package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BgWzzjvj.js} +3 -3
  38. package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +1 -0
  39. package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-BSa5lSZp.js} +1 -1
  40. package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cp0re-Nn.js} +2 -2
  41. package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-B-dMUtEu.js} +1 -1
  42. package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C1jEPhuS.js} +1 -1
  43. package/bundle/web-dist/client/assets/tokens-1e66MAC0.js +1 -0
  44. package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DueEppqt.js} +1 -1
  45. package/bundle/web-dist/client/index.html +2 -2
  46. package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-CwoOsveV.js} +11 -4
  47. package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-DIsEsDJN.js} +1 -1
  48. package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-Bts-KJ9W.js} +7 -3
  49. package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-Bh7Vs8Ds.js} +16 -7
  50. package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-DJfaCNUw.js} +1 -1
  51. package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-BA4vwDat.js} +215 -52
  52. package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BQRzEPy2.js} +1 -1
  53. package/bundle/web-dist/server/entry-server.generated.js +6 -6
  54. package/package.json +1 -1
  55. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  56. package/skills/qgrid/references/token-auth-quota-lifecycle.md +16 -1
  57. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  58. package/bundle/web-dist/client/assets/logs-o8mm530D.js +0 -1
  59. 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 Error(`Token refresh failed (${res.status}): ${text}`);
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
- // oauthStart 는 PKCE 상태를 cache 에 저장한다 — redirect 파생 검증에는 실제 cache 가 필요 없다.
7
- vi.mock("sonamu/cache", () => ({
8
- getCacheManagerRef: () => ({
9
- set: vi.fn(async () => {}),
10
- get: vi.fn(async () => undefined),
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
- function redirectUriOf(authUrl: string): string | null {
16
- return new URL(authUrl).searchParams.get("redirect_uri");
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 derivation", () => {
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("derives the callback from the browser-sent Origin header", async () => {
33
- vi.spyOn(Sonamu, "getContext").mockReturnValue({
34
- headers: { origin: "https://qgrid.example.com" },
35
- request: { protocol: "https" },
36
- } as never);
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(redirectUriOf(authUrl)).toBe("https://qgrid.example.com/callback");
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("falls back to forwarded proto and host headers when Origin is absent", async () => {
43
- vi.spyOn(Sonamu, "getContext").mockReturnValue({
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(redirectUriOf(authUrl)).toBe("https://dev0.cartanova.ai:44900/callback");
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(redirectUriOf(authUrl)).toBe("http://localhost:44900/callback");
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 callback 은 대시보드가 접속한 qgrid 주소로 돌아온다. 브라우저가 보낸
84
- // Origin(우선) 또는 Host 헤더에서 base URL 을 파생하므로 별도 env 설정이 필요 없다.
85
- // HTTP context 가 없는 직접 호출(테스트 등)만 localhost 로 폴백한다.
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 `${origin.replace(/\/+$/, "")}/callback`;
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}/callback`;
103
+ return `${proto}://${host}`;
101
104
  }
102
105
  } catch {
103
106
  // direct frame call — HTTP context 없음
104
107
  }
105
- return `http://localhost:${process.env.PORT ?? "44900"}/callback`;
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(tokenName?: string): Promise<{ usd: number }> {
447
- return { usd: await RequestLogModel.totalCost({ token_name: tokenName }) };
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 = getOAuthRedirectUri();
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
- const tokens = await exchangeCodeForTokens(
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
- if (creds.accountId) {
600
- const oldEntries = await TokenModel.findByAccountIdentifier(
601
- "A",
602
- "openai",
603
- creds.accountId,
604
- );
605
- if (oldEntries.length > 0) {
606
- await TokenModel.del(oldEntries.map((o) => o.id));
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
- const refreshed = await refreshAccessToken(rt);
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