@cartanova/qgrid-cli 2.6.0 → 2.6.1

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 (29) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +3 -2
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +81 -24
  3. package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
  4. package/bundle/src/application/qgrid/oauth.ts +3 -0
  5. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +118 -27
  6. package/bundle/src/application/qgrid/qgrid.frame.ts +95 -40
  7. package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
  8. package/bundle/src/application/sonamu.generated.http +9 -0
  9. package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BGGBFmzE.js} +3 -3
  10. package/bundle/web-dist/client/assets/{logs-o8mm530D.js → logs-Dh19u6Tk.js} +1 -1
  11. package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-CyqMmy1G.js} +1 -1
  12. package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cy0gCq8c.js} +1 -1
  13. package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-BuT6Gp8V.js} +1 -1
  14. package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C5WxfoAt.js} +1 -1
  15. package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +1 -0
  16. package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DBkxL29Y.js} +1 -1
  17. package/bundle/web-dist/client/index.html +1 -1
  18. package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-D0z1Ghj1.js} +1 -1
  19. package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-dBXxnAyI.js} +1 -1
  20. package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-B3p7fuwk.js} +2 -2
  21. package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-BNuxWs7K.js} +9 -0
  22. package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-0iNioJ2I.js} +1 -1
  23. package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-CvytQ9Xy.js} +71 -6
  24. package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BBCC2GKM.js} +1 -1
  25. package/bundle/web-dist/server/entry-server.generated.js +6 -6
  26. package/package.json +1 -1
  27. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  28. package/skills/qgrid/references/token-auth-quota-lifecycle.md +5 -1
  29. package/bundle/web-dist/client/assets/tokens-B9Ig7-9Z.js +0 -1
@@ -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 = [
@@ -1,22 +1,54 @@
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
+ },
41
+ }));
42
+
43
+ function authUrlParam(authUrl: string, key: string): string | null {
44
+ return new URL(authUrl).searchParams.get(key);
45
+ }
46
+
47
+ function mockHttpContext(headers: Record<string, string>, protocol = "http"): void {
48
+ vi.spyOn(Sonamu, "getContext").mockReturnValue({ headers, request: { protocol } } as never);
17
49
  }
18
50
 
19
- describe("QgridFrame.oauthStart redirect derivation", () => {
51
+ describe("QgridFrame.oauthStart redirect resolution", () => {
20
52
  let savedPort: string | undefined;
21
53
 
22
54
  beforeEach(() => {
@@ -29,32 +61,91 @@ describe("QgridFrame.oauthStart redirect derivation", () => {
29
61
  vi.restoreAllMocks();
30
62
  });
31
63
 
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);
64
+ it("uses the redirect flow with the request-derived callback on loopback origins", async () => {
65
+ mockHttpContext({ origin: "http://localhost:44900" });
66
+
67
+ const { authUrl, mode } = await QgridFrame.oauthStart("tok");
68
+ expect(mode).toBe("redirect");
69
+ expect(authUrlParam(authUrl, "redirect_uri")).toBe("http://localhost:44900/callback");
70
+ });
71
+
72
+ it("switches to the code flow with the console callback on remote origins", async () => {
73
+ mockHttpContext({ origin: "https://qgrid.example.com" });
37
74
 
38
- const { authUrl } = await QgridFrame.oauthStart("tok");
39
- expect(redirectUriOf(authUrl)).toBe("https://qgrid.example.com/callback");
75
+ const { authUrl, mode } = await QgridFrame.oauthStart("tok");
76
+ expect(mode).toBe("code");
77
+ expect(authUrlParam(authUrl, "redirect_uri")).toBe(CONSOLE_CALLBACK_URL);
40
78
  });
41
79
 
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);
80
+ it("treats forwarded remote hosts as the code flow when Origin is absent", async () => {
81
+ mockHttpContext({ host: "qgrid.example.com", "x-forwarded-proto": "https" });
47
82
 
48
- const { authUrl } = await QgridFrame.oauthStart("tok");
49
- expect(redirectUriOf(authUrl)).toBe("https://dev0.cartanova.ai:44900/callback");
83
+ const { authUrl, mode } = await QgridFrame.oauthStart("tok");
84
+ expect(mode).toBe("code");
85
+ expect(authUrlParam(authUrl, "redirect_uri")).toBe(CONSOLE_CALLBACK_URL);
50
86
  });
51
87
 
52
- it("falls back to localhost without an HTTP context", async () => {
88
+ it("falls back to the localhost redirect flow without an HTTP context", async () => {
53
89
  vi.spyOn(Sonamu, "getContext").mockImplementation(() => {
54
90
  throw new Error("no context");
55
91
  });
56
92
 
57
- const { authUrl } = await QgridFrame.oauthStart("tok");
58
- expect(redirectUriOf(authUrl)).toBe("http://localhost:44900/callback");
93
+ const { authUrl, mode } = await QgridFrame.oauthStart("tok");
94
+ expect(mode).toBe("redirect");
95
+ expect(authUrlParam(authUrl, "redirect_uri")).toBe("http://localhost:44900/callback");
96
+ });
97
+ });
98
+
99
+ describe("QgridFrame.oauthComplete code flow", () => {
100
+ afterEach(() => {
101
+ vi.restoreAllMocks();
102
+ exchangeMock.mockReset();
103
+ tokenSaveMock.mockClear();
104
+ findByAccountMock.mockClear().mockResolvedValue([]);
105
+ });
106
+
107
+ it("exchanges a pasted code#state against the stored pending state and saves the token", async () => {
108
+ mockHttpContext({ origin: "https://qgrid.example.com" });
109
+ const { authUrl } = await QgridFrame.oauthStart("haze-2");
110
+ const state = authUrlParam(authUrl, "state")!;
111
+
112
+ exchangeMock.mockResolvedValueOnce({
113
+ accessToken: "at",
114
+ refreshToken: "rt",
115
+ expiresAt: 123,
116
+ scope: "user:inference",
117
+ accountUuid: "acc-1",
118
+ });
119
+
120
+ await expect(QgridFrame.oauthComplete(` the-code#${state} `)).resolves.toEqual({
121
+ added: true,
122
+ name: "haze-2",
123
+ });
124
+
125
+ expect(exchangeMock).toHaveBeenCalledWith(
126
+ "the-code",
127
+ expect.any(String),
128
+ state,
129
+ CONSOLE_CALLBACK_URL,
130
+ );
131
+ expect(tokenSaveMock).toHaveBeenCalledWith([
132
+ expect.objectContaining({
133
+ provider: "anthropic",
134
+ name: "haze-2",
135
+ credentials: expect.objectContaining({ accessToken: "at", accountUuid: "acc-1" }),
136
+ }),
137
+ ]);
138
+ });
139
+
140
+ it("rejects input without a #state part", async () => {
141
+ await expect(QgridFrame.oauthComplete("just-a-code")).rejects.toThrow(/code#state/);
142
+ expect(exchangeMock).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("rejects an unknown or expired state", async () => {
146
+ await expect(QgridFrame.oauthComplete("code#no-such-state")).rejects.toThrow(
147
+ /not found or expired/,
148
+ );
149
+ expect(exchangeMock).not.toHaveBeenCalled();
59
150
  });
60
151
  });
@@ -19,6 +19,7 @@ import { TokenCredentials } from "../token/token.types";
19
19
  import {
20
20
  type AnthropicUsageRaw,
21
21
  buildAuthUrl,
22
+ CONSOLE_CALLBACK_URL,
22
23
  exchangeCodeForTokens,
23
24
  fetchUsage,
24
25
  generatePKCE,
@@ -80,15 +81,14 @@ async function deleteOAuthState(state: string): Promise<void> {
80
81
  await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
81
82
  }
82
83
 
83
- // OAuth callback 은 대시보드가 접속한 qgrid 주소로 돌아온다. 브라우저가 보낸
84
- // Origin(우선) 또는 Host 헤더에서 base URL 을 파생하므로 별도 env 설정이 필요 없다.
85
- // HTTP context 가 없는 직접 호출(테스트 등)만 localhost 로 폴백한다.
86
- function getOAuthRedirectUri(): string {
84
+ // OAuth 접속 주소는 브라우저가 보낸 Origin(우선) 또는 Host 헤더에서 파생한다.
85
+ // HTTP context 가 없는 직접 호출(테스트 등)은 null 을 반환한다.
86
+ function deriveRequestBaseUrl(): string | null {
87
87
  try {
88
88
  const { headers, request } = Sonamu.getContext();
89
89
  const origin = headers.origin;
90
90
  if (typeof origin === "string" && /^https?:\/\//.test(origin)) {
91
- return `${origin.replace(/\/+$/, "")}/callback`;
91
+ return origin.replace(/\/+$/, "");
92
92
  }
93
93
  const forwardedHost = headers["x-forwarded-host"];
94
94
  const host = (Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost) ?? headers.host;
@@ -97,12 +97,34 @@ function getOAuthRedirectUri(): string {
97
97
  const proto =
98
98
  (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0] ??
99
99
  request.protocol;
100
- return `${proto}://${host}/callback`;
100
+ return `${proto}://${host}`;
101
101
  }
102
102
  } catch {
103
103
  // direct frame call — HTTP context 없음
104
104
  }
105
- return `http://localhost:${process.env.PORT ?? "44900"}/callback`;
105
+ return null;
106
+ }
107
+
108
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
109
+
110
+ function isLoopbackBase(base: string): boolean {
111
+ try {
112
+ return LOOPBACK_HOSTNAMES.has(new URL(base).hostname);
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // Anthropic OAuth client 는 redirect_uri 허용 목록을 강제한다: 루프백(localhost)과
119
+ // 콘솔 콜백만 허용된다. 따라서 루프백 접속은 자동 리다이렉트 플로우를,
120
+ // 원격 접속은 콘솔 콜백 + 코드 붙여넣기 플로우(Claude Code CLI 와 동일)를 쓴다.
121
+ function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
122
+ const base = deriveRequestBaseUrl();
123
+ if (base && !isLoopbackBase(base)) {
124
+ return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
125
+ }
126
+ const loopbackBase = base ?? `http://localhost:${process.env.PORT ?? "44900"}`;
127
+ return { redirectUri: `${loopbackBase}/callback`, mode: "redirect" };
106
128
  }
107
129
 
108
130
  function rejectImageGenerationStream(args: QueryInput): void {
@@ -533,54 +555,87 @@ class QgridFrameClass extends BaseFrameClass {
533
555
  async oauthStart(name: string): Promise<OAuthStartResult> {
534
556
  const { codeVerifier, codeChallenge, state } = generatePKCE();
535
557
 
536
- const redirectUri = getOAuthRedirectUri();
558
+ const { redirectUri, mode } = resolveOAuthRedirect();
537
559
  const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
538
560
 
539
561
  await setOAuthState(state, { codeVerifier, name, redirectUri });
540
562
 
541
- return { authUrl };
563
+ return { authUrl, mode };
542
564
  }
543
565
 
544
- async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
566
+ // 코드 붙여넣기 플로우(원격 접속): 콘솔 콜백이 표시한 `code#state` 를 받아 교환한다.
567
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
568
+ async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
569
+ const trimmed = pastedCode.trim();
570
+ const separatorAt = trimmed.indexOf("#");
571
+ const code = separatorAt > 0 ? trimmed.slice(0, separatorAt) : "";
572
+ const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1) : "";
573
+ if (!code || !state) {
574
+ throw new BadRequestException(
575
+ "expected the code shown after login, in code#state form" as LocalizedString,
576
+ );
577
+ }
578
+
545
579
  const pending = await getOAuthState(state);
546
580
  if (!pending) {
547
- logger.warn("oauth callback: invalid_state");
548
- return reply.redirect("/?oauth=error&reason=invalid_state");
581
+ throw new BadRequestException(
582
+ "login session not found or expired — start the login again" as LocalizedString,
583
+ );
549
584
  }
550
585
  await deleteOAuthState(state);
551
586
 
552
- try {
553
- const tokens = await exchangeCodeForTokens(
554
- code,
555
- pending.codeVerifier,
556
- state,
557
- pending.redirectUri,
558
- );
587
+ await this.completeAnthropicLogin(code, state, pending);
588
+ return { added: true, name: pending.name };
589
+ }
559
590
 
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
- }
591
+ // 교환 + 계정 중복 제거 + 저장 — redirect 콜백과 코드 붙여넣기 플로우가 공유한다.
592
+ private async completeAnthropicLogin(
593
+ code: string,
594
+ state: string,
595
+ pending: PendingOAuth,
596
+ ): Promise<void> {
597
+ const tokens = await exchangeCodeForTokens(
598
+ code,
599
+ pending.codeVerifier,
600
+ state,
601
+ pending.redirectUri,
602
+ );
603
+
604
+ if (tokens.accountUuid) {
605
+ const oldEntries = await TokenModel.findByAccountIdentifier(
606
+ "A",
607
+ "anthropic",
608
+ tokens.accountUuid,
609
+ );
610
+ if (oldEntries.length > 0) {
611
+ await TokenModel.del(oldEntries.map((o) => o.id));
569
612
  }
613
+ }
570
614
 
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,
615
+ await TokenModel.save([
616
+ {
617
+ provider: "anthropic",
618
+ credentials: {
619
+ accessToken: tokens.accessToken,
620
+ refreshToken: tokens.refreshToken,
621
+ expiresAt: tokens.expiresAt ?? 0,
622
+ accountUuid: tokens.accountUuid ?? "",
581
623
  },
582
- ]);
624
+ name: pending.name,
625
+ },
626
+ ]);
627
+ }
628
+
629
+ async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
630
+ const pending = await getOAuthState(state);
631
+ if (!pending) {
632
+ logger.warn("oauth callback: invalid_state");
633
+ return reply.redirect("/?oauth=error&reason=invalid_state");
634
+ }
635
+ await deleteOAuthState(state);
583
636
 
637
+ try {
638
+ await this.completeAnthropicLogin(code, state, pending);
584
639
  return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
585
640
  } catch (e) {
586
641
  return reply.redirect(`/?oauth=error&reason=${encodeURIComponent((e as Error).message)}`);
@@ -625,7 +680,7 @@ class QgridFrameClass extends BaseFrameClass {
625
680
  logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
626
681
  });
627
682
 
628
- return { authUrl };
683
+ return { authUrl, mode: "redirect" };
629
684
  }
630
685
 
631
686
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -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
 
@@ -500,6 +500,15 @@ Content-Type: application/json
500
500
 
501
501
  ###
502
502
 
503
+ POST {{baseUrl}}/api/qgrid/oauthComplete
504
+ Content-Type: application/json
505
+
506
+ {
507
+ "pastedCode": "PASTEDCODE"
508
+ }
509
+
510
+ ###
511
+
503
512
  POST {{baseUrl}}/api/qgrid/oauthStartOpenAI
504
513
  Content-Type: application/json
505
514