@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.
- package/bundle/dist/application/qgrid/oauth.js +3 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +81 -24
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -2
- package/bundle/src/application/qgrid/oauth.ts +3 -0
- package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +118 -27
- package/bundle/src/application/qgrid/qgrid.frame.ts +95 -40
- package/bundle/src/application/qgrid/qgrid.types.ts +3 -0
- package/bundle/src/application/sonamu.generated.http +9 -0
- package/bundle/web-dist/client/assets/{index-YowQRE2y.js → index-BGGBFmzE.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-o8mm530D.js → logs-Dh19u6Tk.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-B51yXcqk.js → monit-CyqMmy1G.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BqaUiBc0.js → routes-Cy0gCq8c.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-D3MH3EfY.js → services.generated-BuT6Gp8V.js} +1 -1
- package/bundle/web-dist/client/assets/{show-LAFY13v6.js → show-C5WxfoAt.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +1 -0
- package/bundle/web-dist/client/assets/{use-update-token-mutation-CLhv6SUL.js → use-update-token-mutation-DBkxL29Y.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{logs-Dbk2Ig2z.js → logs-D0z1Ghj1.js} +1 -1
- package/bundle/web-dist/server/assets/{monit-Hm9dIPhx.js → monit-dBXxnAyI.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-DztxeUjk.js → routes-B3p7fuwk.js} +2 -2
- package/bundle/web-dist/server/assets/{services.generated-1Kcul41h.js → services.generated-BNuxWs7K.js} +9 -0
- package/bundle/web-dist/server/assets/{show-BHUSw7qv.js → show-0iNioJ2I.js} +1 -1
- package/bundle/web-dist/server/assets/{tokens-DHU9lUT2.js → tokens-CvytQ9Xy.js} +71 -6
- package/bundle/web-dist/server/assets/{use-update-token-mutation-axqnye4L.js → use-update-token-mutation-BBCC2GKM.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 +5 -1
- 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
|
-
|
|
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
|
+
},
|
|
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
|
|
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("
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
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("
|
|
43
|
-
|
|
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(
|
|
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(
|
|
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
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
|
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}
|
|
100
|
+
return `${proto}://${host}`;
|
|
101
101
|
}
|
|
102
102
|
} catch {
|
|
103
103
|
// direct frame call — HTTP context 없음
|
|
104
104
|
}
|
|
105
|
-
return
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|