dsh-coding-subscription-oauth 0.5.0
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/CHANGELOG.md +136 -0
- package/CONTRIBUTING.md +109 -0
- package/INSTALL.md +220 -0
- package/LICENSE +19 -0
- package/NOTICE +11 -0
- package/README.de.md +254 -0
- package/README.es.md +254 -0
- package/README.fr.md +254 -0
- package/README.ja.md +254 -0
- package/README.ko.md +254 -0
- package/README.md +286 -0
- package/README.pt-BR.md +254 -0
- package/README.ru.md +254 -0
- package/README.zh-CN.md +271 -0
- package/cordis.patch.yml +13 -0
- package/docs/00-project-rules.md +195 -0
- package/docs/02-architecture.md +132 -0
- package/docs/02-architecture.zh-CN.md +128 -0
- package/lib/adapter.d.ts +24 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/alias-adapter.d.ts +43 -0
- package/lib/alias-adapter.d.ts.map +1 -0
- package/lib/auth-routes.d.ts +155 -0
- package/lib/auth-routes.d.ts.map +1 -0
- package/lib/auth.d.ts +29 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.d.ts.map +1 -0
- package/lib/bin.js +27591 -0
- package/lib/bin.js.map +7 -0
- package/lib/capability-routes.d.ts +43 -0
- package/lib/capability-routes.d.ts.map +1 -0
- package/lib/capability-runtime.d.ts +56 -0
- package/lib/capability-runtime.d.ts.map +1 -0
- package/lib/capability-settings.d.ts +263 -0
- package/lib/capability-settings.d.ts.map +1 -0
- package/lib/capability-tools.d.ts +50 -0
- package/lib/capability-tools.d.ts.map +1 -0
- package/lib/catalog.d.ts +53 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/client.js +3 -0
- package/lib/client.js.map +7 -0
- package/lib/codex-http.d.ts +77 -0
- package/lib/codex-http.d.ts.map +1 -0
- package/lib/codex-images.d.ts +109 -0
- package/lib/codex-images.d.ts.map +1 -0
- package/lib/codex-model-capabilities.d.ts +112 -0
- package/lib/codex-model-capabilities.d.ts.map +1 -0
- package/lib/codex-search.d.ts +96 -0
- package/lib/codex-search.d.ts.map +1 -0
- package/lib/codex-usage.d.ts +79 -0
- package/lib/codex-usage.d.ts.map +1 -0
- package/lib/gateway-anthropic-messages.d.ts +8 -0
- package/lib/gateway-anthropic-messages.d.ts.map +1 -0
- package/lib/gateway-auth.d.ts +22 -0
- package/lib/gateway-auth.d.ts.map +1 -0
- package/lib/gateway-backend.d.ts +62 -0
- package/lib/gateway-backend.d.ts.map +1 -0
- package/lib/gateway-body.d.ts +9 -0
- package/lib/gateway-body.d.ts.map +1 -0
- package/lib/gateway-config.d.ts +24 -0
- package/lib/gateway-config.d.ts.map +1 -0
- package/lib/gateway-http.d.ts +16 -0
- package/lib/gateway-http.d.ts.map +1 -0
- package/lib/gateway-openai-chat.d.ts +8 -0
- package/lib/gateway-openai-chat.d.ts.map +1 -0
- package/lib/gateway-openai-responses.d.ts +8 -0
- package/lib/gateway-openai-responses.d.ts.map +1 -0
- package/lib/gateway-parse.d.ts +10 -0
- package/lib/gateway-parse.d.ts.map +1 -0
- package/lib/gateway-protocol.d.ts +47 -0
- package/lib/gateway-protocol.d.ts.map +1 -0
- package/lib/gateway-routes.d.ts +21 -0
- package/lib/gateway-routes.d.ts.map +1 -0
- package/lib/gateway.d.ts +48 -0
- package/lib/gateway.d.ts.map +1 -0
- package/lib/grok-imagine.d.ts +271 -0
- package/lib/grok-imagine.d.ts.map +1 -0
- package/lib/grok-import.d.ts +21 -0
- package/lib/grok-import.d.ts.map +1 -0
- package/lib/http-json.d.ts +10 -0
- package/lib/http-json.d.ts.map +1 -0
- package/lib/ids.d.ts +33 -0
- package/lib/ids.d.ts.map +1 -0
- package/lib/imagine-routes.d.ts +59 -0
- package/lib/imagine-routes.d.ts.map +1 -0
- package/lib/index.d.ts +69 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +35355 -0
- package/lib/index.js.map +7 -0
- package/lib/invariant.d.ts +9 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +14 -0
- package/lib/invariant.js.map +7 -0
- package/lib/kimi-errors.d.ts +13 -0
- package/lib/kimi-errors.d.ts.map +1 -0
- package/lib/media-store.d.ts +130 -0
- package/lib/media-store.d.ts.map +1 -0
- package/lib/oauth-import-routes.d.ts +52 -0
- package/lib/oauth-import-routes.d.ts.map +1 -0
- package/lib/oauth-providers.d.ts +26 -0
- package/lib/oauth-providers.d.ts.map +1 -0
- package/lib/oauth-session.d.ts +40 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-sources.d.ts +205 -0
- package/lib/oauth-sources.d.ts.map +1 -0
- package/lib/oauth.d.ts +79 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/provider.d.ts +38 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/proxy.d.ts +17 -0
- package/lib/proxy.d.ts.map +1 -0
- package/lib/redact.d.ts +5 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/session.d.ts +40 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/web-origin.d.ts +10 -0
- package/lib/web-origin.d.ts.map +1 -0
- package/lib/web-routes.d.ts +20 -0
- package/lib/web-routes.d.ts.map +1 -0
- package/package.json +185 -0
- package/patches/dsh-agy@0.1.2.patch +25 -0
- package/scripts/release.mjs +166 -0
- package/scripts/smoke-deployed-routes.mjs +146 -0
- package/scripts/verify-deployed-catalog.mjs +87 -0
- package/src/adapter.ts +282 -0
- package/src/alias-adapter.ts +152 -0
- package/src/auth-routes.ts +871 -0
- package/src/auth.ts +67 -0
- package/src/bin.ts +350 -0
- package/src/capability-routes.ts +275 -0
- package/src/capability-runtime.ts +313 -0
- package/src/capability-settings.ts +657 -0
- package/src/capability-tools.ts +666 -0
- package/src/catalog.ts +271 -0
- package/src/client/GrokBuildSettings.tsx +2221 -0
- package/src/client/index.tsx +37 -0
- package/src/client/locales.ts +421 -0
- package/src/codex-http.ts +447 -0
- package/src/codex-images.ts +485 -0
- package/src/codex-model-capabilities.ts +320 -0
- package/src/codex-search.ts +245 -0
- package/src/codex-usage.ts +263 -0
- package/src/gateway-anthropic-messages.ts +84 -0
- package/src/gateway-auth.ts +100 -0
- package/src/gateway-backend.ts +274 -0
- package/src/gateway-body.ts +49 -0
- package/src/gateway-config.ts +76 -0
- package/src/gateway-http.ts +104 -0
- package/src/gateway-openai-chat.ts +124 -0
- package/src/gateway-openai-responses.ts +53 -0
- package/src/gateway-parse.ts +224 -0
- package/src/gateway-protocol.ts +52 -0
- package/src/gateway-routes.ts +152 -0
- package/src/gateway.ts +242 -0
- package/src/grok-imagine.ts +1627 -0
- package/src/grok-import.ts +151 -0
- package/src/http-json.ts +82 -0
- package/src/ids.ts +45 -0
- package/src/imagine-routes.ts +461 -0
- package/src/index.ts +598 -0
- package/src/invariant.ts +17 -0
- package/src/kimi-errors.ts +26 -0
- package/src/media-store.ts +927 -0
- package/src/oauth-import-routes.ts +314 -0
- package/src/oauth-providers.ts +152 -0
- package/src/oauth-session.ts +183 -0
- package/src/oauth-sources.ts +1104 -0
- package/src/oauth.ts +620 -0
- package/src/provider.ts +128 -0
- package/src/proxy.ts +99 -0
- package/src/redact.ts +72 -0
- package/src/session.ts +218 -0
- package/src/store.ts +217 -0
- package/src/web-origin.ts +60 -0
- package/src/web-routes.ts +75 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/** Same-origin Web settings routes for Grok Build OAuth. */
|
|
2
|
+
|
|
3
|
+
import type { ServerResponse } from "node:http";
|
|
4
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
5
|
+
import type {} from "@deepseek-ai/dsh-host-webserver";
|
|
6
|
+
import type {} from "@deepseek-ai/dsh-llm";
|
|
7
|
+
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
|
8
|
+
import { grokBuildAuthStatus, importGrokBuildSession, loginGrokBuildSession } from "./auth.ts";
|
|
9
|
+
import type { CatalogSource } from "./catalog.ts";
|
|
10
|
+
import { probeGrokAuth } from "./grok-import.ts";
|
|
11
|
+
import { readJsonRequest, requestErrorStatus } from "./http-json.ts";
|
|
12
|
+
import { ANTIGRAVITY_ROUTE, type CodingOAuthProviderSlug, XAI_PI_PROVIDER } from "./ids.ts";
|
|
13
|
+
import { loginGrokBuildPkce } from "./oauth.ts";
|
|
14
|
+
import type { SubscriptionLoginMethod } from "./oauth-providers.ts";
|
|
15
|
+
import type { OAuthProviderSession } from "./oauth-session.ts";
|
|
16
|
+
import { safeMessage } from "./redact.ts";
|
|
17
|
+
import type { GrokBuildSession } from "./session.ts";
|
|
18
|
+
import { isTrustedLoopbackWebRequest } from "./web-origin.ts";
|
|
19
|
+
import { registerWebRouteSetupAtomically } from "./web-routes.ts";
|
|
20
|
+
|
|
21
|
+
export const GROK_BUILD_AUTH_STATUS_PATH = "/plugins/dsh-grok-build/auth/status";
|
|
22
|
+
export const GROK_BUILD_AUTH_LOGIN_PATH = "/plugins/dsh-grok-build/auth/login";
|
|
23
|
+
export const GROK_BUILD_AUTH_LOGIN_CODE_PATH = "/plugins/dsh-grok-build/auth/login/code";
|
|
24
|
+
export const GROK_BUILD_AUTH_LOGIN_CANCEL_PATH = "/plugins/dsh-grok-build/auth/login/cancel";
|
|
25
|
+
export const GROK_BUILD_AUTH_IMPORT_PATH = "/plugins/dsh-grok-build/auth/import";
|
|
26
|
+
export const GROK_BUILD_AUTH_LOGOUT_PATH = "/plugins/dsh-grok-build/auth/logout";
|
|
27
|
+
export const GROK_BUILD_AUTH_MODELS_PATH = "/plugins/dsh-grok-build/auth/models";
|
|
28
|
+
|
|
29
|
+
export const CODING_OAUTH_STATUS_PATH = "/plugins/dsh-grok-build/oauth/status";
|
|
30
|
+
export const CODING_OAUTH_LOGIN_PATH = "/plugins/dsh-grok-build/oauth/login";
|
|
31
|
+
export const CODING_OAUTH_LOGIN_CODE_PATH = "/plugins/dsh-grok-build/oauth/code";
|
|
32
|
+
export const CODING_OAUTH_LOGIN_CANCEL_PATH = "/plugins/dsh-grok-build/oauth/cancel";
|
|
33
|
+
export const CODING_OAUTH_LOGOUT_PATH = "/plugins/dsh-grok-build/oauth/logout";
|
|
34
|
+
export const CODING_OAUTH_MODELS_PATH = "/plugins/dsh-grok-build/oauth/models";
|
|
35
|
+
|
|
36
|
+
export type GrokBuildLoginMethod = "pkce" | "device";
|
|
37
|
+
|
|
38
|
+
export type GrokBuildWebAuthStatus =
|
|
39
|
+
| { status: "signed-out"; grokImportAvailable: boolean }
|
|
40
|
+
| {
|
|
41
|
+
status: "signing-in";
|
|
42
|
+
method: GrokBuildLoginMethod;
|
|
43
|
+
url?: string;
|
|
44
|
+
userCode?: string;
|
|
45
|
+
grokImportAvailable: boolean;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
status: "signed-in";
|
|
49
|
+
models: string[];
|
|
50
|
+
available: string[];
|
|
51
|
+
selected: string[];
|
|
52
|
+
catalogSource: CatalogSource;
|
|
53
|
+
catalogError?: string;
|
|
54
|
+
grokImportAvailable: boolean;
|
|
55
|
+
}
|
|
56
|
+
| { status: "error"; message: string; grokImportAvailable: boolean };
|
|
57
|
+
|
|
58
|
+
export interface LoginChallenge {
|
|
59
|
+
method: GrokBuildLoginMethod;
|
|
60
|
+
url: string;
|
|
61
|
+
userCode?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function waitForPromptAbort(prompt: AuthPrompt): Promise<string> {
|
|
65
|
+
const signal = prompt.signal;
|
|
66
|
+
if (signal === undefined) return new Promise<string>(() => {});
|
|
67
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
68
|
+
return new Promise<string>((_resolve, reject) => {
|
|
69
|
+
signal.addEventListener(
|
|
70
|
+
"abort",
|
|
71
|
+
() => {
|
|
72
|
+
reject(signal.reason);
|
|
73
|
+
},
|
|
74
|
+
{ once: true },
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function grokImportAvailable(): Promise<boolean> {
|
|
80
|
+
return (await probeGrokAuth()).available;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One lifecycle owner for the pending login (PKCE or device), the published
|
|
85
|
+
* challenge, the pasted-code channel, and the public status.
|
|
86
|
+
*/
|
|
87
|
+
export class GrokBuildWebAuth {
|
|
88
|
+
private state: GrokBuildWebAuthStatus = { status: "signed-out", grokImportAvailable: false };
|
|
89
|
+
private operation: Promise<void> | undefined;
|
|
90
|
+
private cancellation: AbortController | undefined;
|
|
91
|
+
private method: GrokBuildLoginMethod = "pkce";
|
|
92
|
+
private challenge: LoginChallenge | undefined;
|
|
93
|
+
private challengeWaiters: Array<{ resolve(value: LoginChallenge): void; reject(error: unknown): void }> = [];
|
|
94
|
+
private codeResolver: ((code: string) => void) | undefined;
|
|
95
|
+
|
|
96
|
+
constructor(private readonly session: GrokBuildSession) {}
|
|
97
|
+
|
|
98
|
+
async status(): Promise<GrokBuildWebAuthStatus> {
|
|
99
|
+
if (this.operation !== undefined) return this.state;
|
|
100
|
+
if (this.state.status === "error") {
|
|
101
|
+
return { ...this.state, grokImportAvailable: await grokImportAvailable() };
|
|
102
|
+
}
|
|
103
|
+
return this.readStoredStatus();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Start (or join) a login. A different method aborts and restarts the flow. */
|
|
107
|
+
async signIn(method: GrokBuildLoginMethod): Promise<LoginChallenge> {
|
|
108
|
+
if (this.operation !== undefined && this.method !== method) {
|
|
109
|
+
await this.cancel();
|
|
110
|
+
}
|
|
111
|
+
if (this.operation === undefined) this.start(method);
|
|
112
|
+
if (this.challenge !== undefined) return this.challenge;
|
|
113
|
+
return new Promise<LoginChallenge>((resolve, reject) => {
|
|
114
|
+
this.challengeWaiters.push({ resolve, reject });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Hand a pasted authorization code (or redirect URL) to a pending PKCE login. */
|
|
119
|
+
async submitCode(code: string): Promise<void> {
|
|
120
|
+
const resolver = this.codeResolver;
|
|
121
|
+
if (resolver === undefined) {
|
|
122
|
+
throw new Error("grok-build: no authorization-code login is waiting for a code");
|
|
123
|
+
}
|
|
124
|
+
this.codeResolver = undefined;
|
|
125
|
+
resolver(code);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Abort a pending login without touching any stored credential. */
|
|
129
|
+
async cancel(): Promise<void> {
|
|
130
|
+
this.cancellation?.abort(new Error("grok-build: sign-in cancelled"));
|
|
131
|
+
await this.operation?.catch(() => undefined);
|
|
132
|
+
this.codeResolver = undefined;
|
|
133
|
+
this.challenge = undefined;
|
|
134
|
+
this.state = await this.readStoredStatus();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async importGrok(): Promise<void> {
|
|
138
|
+
this.cancellation?.abort(new Error("grok-build: sign-in cancelled"));
|
|
139
|
+
await this.operation?.catch(() => undefined);
|
|
140
|
+
this.codeResolver = undefined;
|
|
141
|
+
await importGrokBuildSession(this.session);
|
|
142
|
+
this.challenge = undefined;
|
|
143
|
+
this.state = await this.readStoredStatus();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async setModels(ids: readonly string[]): Promise<void> {
|
|
147
|
+
await this.session.setSelectedModels(ids);
|
|
148
|
+
this.state = await this.readStoredStatus();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async signOut(): Promise<void> {
|
|
152
|
+
this.cancellation?.abort(new Error("grok-build: sign-in cancelled"));
|
|
153
|
+
await this.operation?.catch(() => undefined);
|
|
154
|
+
this.codeResolver = undefined;
|
|
155
|
+
await this.session.logout();
|
|
156
|
+
this.state = { status: "signed-out", grokImportAvailable: await grokImportAvailable() };
|
|
157
|
+
this.challenge = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async dispose(): Promise<void> {
|
|
161
|
+
const failure = new Error("grok-build: plugin disposed");
|
|
162
|
+
this.cancellation?.abort(failure);
|
|
163
|
+
this.rejectChallenge(failure);
|
|
164
|
+
await this.operation?.catch(() => undefined);
|
|
165
|
+
this.codeResolver = undefined;
|
|
166
|
+
this.challenge = undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private start(method: GrokBuildLoginMethod): void {
|
|
170
|
+
const cancellation = new AbortController();
|
|
171
|
+
this.cancellation = cancellation;
|
|
172
|
+
this.method = method;
|
|
173
|
+
this.challenge = undefined;
|
|
174
|
+
this.state = { status: "signing-in", method, grokImportAvailable: false };
|
|
175
|
+
const run = method === "pkce" ? this.runPkce(cancellation) : this.runDevice(cancellation);
|
|
176
|
+
this.operation = run
|
|
177
|
+
.then(
|
|
178
|
+
async () => {
|
|
179
|
+
this.state = await this.readStoredStatus();
|
|
180
|
+
},
|
|
181
|
+
(error: unknown) => {
|
|
182
|
+
this.rejectChallenge(error);
|
|
183
|
+
this.state = { status: "error", message: safeMessage(error), grokImportAvailable: false };
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
.finally(() => {
|
|
187
|
+
this.operation = undefined;
|
|
188
|
+
this.cancellation = undefined;
|
|
189
|
+
this.codeResolver = undefined;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async runPkce(cancellation: AbortController): Promise<void> {
|
|
194
|
+
const credential = await loginGrokBuildPkce({
|
|
195
|
+
signal: cancellation.signal,
|
|
196
|
+
onAuthorizeUrl: (url) => this.acceptChallenge({ method: "pkce", url }),
|
|
197
|
+
awaitCode: (signal) =>
|
|
198
|
+
new Promise<string>((resolve, reject) => {
|
|
199
|
+
this.codeResolver = resolve;
|
|
200
|
+
const onAbort = (): void => {
|
|
201
|
+
this.codeResolver = undefined;
|
|
202
|
+
reject(new Error("grok-build: sign-in cancelled"));
|
|
203
|
+
};
|
|
204
|
+
if (signal.aborted) {
|
|
205
|
+
onAbort();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
const written = await this.session.store.modify(XAI_PI_PROVIDER, async () => credential);
|
|
212
|
+
if (written === undefined || written.type !== "oauth") {
|
|
213
|
+
throw new Error("grok-build: failed to persist the login credential");
|
|
214
|
+
}
|
|
215
|
+
await this.session.refreshLiveCatalog();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async runDevice(cancellation: AbortController): Promise<void> {
|
|
219
|
+
await loginGrokBuildSession(
|
|
220
|
+
{
|
|
221
|
+
signal: cancellation.signal,
|
|
222
|
+
prompt: (prompt) =>
|
|
223
|
+
prompt.type === "select"
|
|
224
|
+
? Promise.resolve(
|
|
225
|
+
prompt.options.some((option) => option.id === "oauth") ? "oauth" : (prompt.options[0]?.id ?? "oauth"),
|
|
226
|
+
)
|
|
227
|
+
: waitForPromptAbort(prompt),
|
|
228
|
+
notify: (event) => {
|
|
229
|
+
this.onEvent(event);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
this.session,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private onEvent(event: AuthEvent): void {
|
|
237
|
+
if (event.type === "device_code") {
|
|
238
|
+
this.acceptChallenge({
|
|
239
|
+
method: "device",
|
|
240
|
+
url: event.verificationUri,
|
|
241
|
+
...(event.userCode.length > 0 ? { userCode: event.userCode } : {}),
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (event.type === "auth_url") {
|
|
246
|
+
this.acceptChallenge({ method: this.method, url: event.url });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private acceptChallenge(challenge: LoginChallenge): void {
|
|
251
|
+
try {
|
|
252
|
+
const url = new URL(challenge.url);
|
|
253
|
+
if (url.protocol !== "https:") {
|
|
254
|
+
const error = new Error("xAI returned an unsafe authorization URL");
|
|
255
|
+
this.cancellation?.abort(error);
|
|
256
|
+
this.rejectChallenge(error);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
const error = new Error("xAI returned an invalid authorization URL");
|
|
261
|
+
this.cancellation?.abort(error);
|
|
262
|
+
this.rejectChallenge(error);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.challenge = challenge;
|
|
266
|
+
this.state = {
|
|
267
|
+
status: "signing-in",
|
|
268
|
+
method: challenge.method,
|
|
269
|
+
url: challenge.url,
|
|
270
|
+
grokImportAvailable: false,
|
|
271
|
+
...(challenge.userCode === undefined ? {} : { userCode: challenge.userCode }),
|
|
272
|
+
};
|
|
273
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.resolve(challenge);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async readStoredStatus(): Promise<GrokBuildWebAuthStatus> {
|
|
277
|
+
const [stored, grok] = await Promise.all([grokBuildAuthStatus(this.session.store), grokImportAvailable()]);
|
|
278
|
+
if (!stored.authenticated) return { status: "signed-out", grokImportAvailable: grok };
|
|
279
|
+
const available = this.session.availableModels().map((model) => model.id);
|
|
280
|
+
const selected = this.session.selectedModelIds();
|
|
281
|
+
return {
|
|
282
|
+
status: "signed-in",
|
|
283
|
+
models: this.session.visibleModels().map((model) => model.id),
|
|
284
|
+
available,
|
|
285
|
+
selected: selected ?? available,
|
|
286
|
+
catalogSource: this.session.catalogSource,
|
|
287
|
+
grokImportAvailable: grok,
|
|
288
|
+
...(this.session.catalogError === undefined ? {} : { catalogError: this.session.catalogError }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private rejectChallenge(error: unknown): void {
|
|
293
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.reject(error);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type SubscriptionWebAuthStatus = {
|
|
298
|
+
provider: Exclude<CodingOAuthProviderSlug, "grok">;
|
|
299
|
+
route: string;
|
|
300
|
+
displayName: string;
|
|
301
|
+
loginMethods: readonly SubscriptionLoginMethod[];
|
|
302
|
+
recommendedLoginMethod: SubscriptionLoginMethod;
|
|
303
|
+
models: string[];
|
|
304
|
+
available: string[];
|
|
305
|
+
selected: string[];
|
|
306
|
+
} & (
|
|
307
|
+
| { status: "signed-out" }
|
|
308
|
+
| { status: "signing-in"; method: SubscriptionLoginMethod; url?: string; userCode?: string }
|
|
309
|
+
| { status: "signed-in"; expiresAt?: number }
|
|
310
|
+
| { status: "error"; message: string }
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
export interface SubscriptionLoginChallenge {
|
|
314
|
+
method: SubscriptionLoginMethod;
|
|
315
|
+
url: string;
|
|
316
|
+
userCode?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function optionForLoginMethod(
|
|
320
|
+
prompt: Extract<AuthPrompt, { type: "select" }>,
|
|
321
|
+
method: SubscriptionLoginMethod,
|
|
322
|
+
): string {
|
|
323
|
+
const exactIds =
|
|
324
|
+
method === "device" ? ["device_code", "device-code", "device"] : ["browser", "browser_login", "browser-login"];
|
|
325
|
+
for (const id of exactIds) {
|
|
326
|
+
if (prompt.options.some((option) => option.id === id)) return id;
|
|
327
|
+
}
|
|
328
|
+
const label = method === "device" ? /device|headless/iu : /browser|pkce/iu;
|
|
329
|
+
return prompt.options.find((option) => label.test(option.label))?.id ?? prompt.options[0]?.id ?? "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Web lifecycle for one pi-ai subscription OAuth provider. */
|
|
333
|
+
export class SubscriptionWebAuth {
|
|
334
|
+
private state: SubscriptionWebAuthStatus | undefined;
|
|
335
|
+
private operation: Promise<void> | undefined;
|
|
336
|
+
private cancellation: AbortController | undefined;
|
|
337
|
+
private method: SubscriptionLoginMethod;
|
|
338
|
+
private challenge: SubscriptionLoginChallenge | undefined;
|
|
339
|
+
private challengeWaiters: Array<{ resolve(value: SubscriptionLoginChallenge): void; reject(error: unknown): void }> =
|
|
340
|
+
[];
|
|
341
|
+
private codeResolver: ((code: string) => void) | undefined;
|
|
342
|
+
|
|
343
|
+
constructor(
|
|
344
|
+
readonly session: OAuthProviderSession,
|
|
345
|
+
private readonly challengeTimeoutMs = 60_000,
|
|
346
|
+
) {
|
|
347
|
+
this.method = session.definition.recommendedLoginMethod;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async status(): Promise<SubscriptionWebAuthStatus> {
|
|
351
|
+
if (this.operation !== undefined && this.state !== undefined) return this.state;
|
|
352
|
+
return this.readStoredStatus();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async signIn(method: SubscriptionLoginMethod): Promise<SubscriptionLoginChallenge> {
|
|
356
|
+
if (!this.session.definition.loginMethods.includes(method)) {
|
|
357
|
+
throw new Error(`${this.session.definition.route}: login method "${method}" is not supported`);
|
|
358
|
+
}
|
|
359
|
+
if (this.operation !== undefined && this.method !== method) await this.cancel();
|
|
360
|
+
if (this.operation === undefined) this.start(method);
|
|
361
|
+
if (this.challenge !== undefined) return this.challenge;
|
|
362
|
+
return new Promise<SubscriptionLoginChallenge>((resolve, reject) => {
|
|
363
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
364
|
+
const waiter = {
|
|
365
|
+
resolve: (challenge: SubscriptionLoginChallenge): void => {
|
|
366
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
367
|
+
resolve(challenge);
|
|
368
|
+
},
|
|
369
|
+
reject: (error: unknown): void => {
|
|
370
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
371
|
+
reject(error);
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
this.challengeWaiters.push(waiter);
|
|
375
|
+
timer = setTimeout(() => {
|
|
376
|
+
const index = this.challengeWaiters.indexOf(waiter);
|
|
377
|
+
if (index >= 0) this.challengeWaiters.splice(index, 1);
|
|
378
|
+
const failure = new Error(`${this.session.definition.route}: timed out waiting for an OAuth challenge`);
|
|
379
|
+
this.cancellation?.abort(failure);
|
|
380
|
+
reject(failure);
|
|
381
|
+
}, this.challengeTimeoutMs);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async submitCode(code: string): Promise<void> {
|
|
386
|
+
const resolver = this.codeResolver;
|
|
387
|
+
if (resolver === undefined) {
|
|
388
|
+
throw new Error(`${this.session.definition.route}: no authorization-code login is waiting for a code`);
|
|
389
|
+
}
|
|
390
|
+
this.codeResolver = undefined;
|
|
391
|
+
resolver(code);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async cancel(): Promise<void> {
|
|
395
|
+
this.cancellation?.abort(new Error(`${this.session.definition.route}: sign-in cancelled`));
|
|
396
|
+
await this.operation?.catch(() => undefined);
|
|
397
|
+
this.codeResolver = undefined;
|
|
398
|
+
this.challenge = undefined;
|
|
399
|
+
this.state = await this.readStoredStatus();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async setModels(ids: readonly string[]): Promise<void> {
|
|
403
|
+
await this.session.setSelectedModels(ids);
|
|
404
|
+
this.state = await this.readStoredStatus();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async signOut(): Promise<void> {
|
|
408
|
+
this.cancellation?.abort(new Error(`${this.session.definition.route}: sign-in cancelled`));
|
|
409
|
+
await this.operation?.catch(() => undefined);
|
|
410
|
+
this.codeResolver = undefined;
|
|
411
|
+
await this.session.logout();
|
|
412
|
+
this.challenge = undefined;
|
|
413
|
+
this.state = await this.readStoredStatus();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async dispose(): Promise<void> {
|
|
417
|
+
this.cancellation?.abort(new Error(`${this.session.definition.route}: plugin disposed`));
|
|
418
|
+
await this.operation?.catch(() => undefined);
|
|
419
|
+
this.codeResolver = undefined;
|
|
420
|
+
this.rejectChallenge(new Error(`${this.session.definition.route}: plugin disposed`));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private baseStatus(): Omit<
|
|
424
|
+
SubscriptionWebAuthStatus,
|
|
425
|
+
"status" | "method" | "url" | "userCode" | "message" | "expiresAt"
|
|
426
|
+
> {
|
|
427
|
+
const available = this.session.availableModels().map((model) => model.id);
|
|
428
|
+
const selected = this.session.selectedModelIds() ?? available;
|
|
429
|
+
return {
|
|
430
|
+
provider: this.session.definition.slug,
|
|
431
|
+
route: this.session.definition.route,
|
|
432
|
+
displayName: this.session.definition.displayName,
|
|
433
|
+
loginMethods: this.session.definition.loginMethods,
|
|
434
|
+
recommendedLoginMethod: this.session.definition.recommendedLoginMethod,
|
|
435
|
+
models: this.session.visibleModels().map((model) => model.id),
|
|
436
|
+
available,
|
|
437
|
+
selected,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private async readStoredStatus(): Promise<SubscriptionWebAuthStatus> {
|
|
442
|
+
const base = this.baseStatus();
|
|
443
|
+
try {
|
|
444
|
+
const stored = await this.session.status();
|
|
445
|
+
return stored.authenticated
|
|
446
|
+
? { ...base, status: "signed-in", ...(stored.expiresAt === undefined ? {} : { expiresAt: stored.expiresAt }) }
|
|
447
|
+
: { ...base, status: "signed-out" };
|
|
448
|
+
} catch (error: unknown) {
|
|
449
|
+
return { ...base, status: "error", message: safeMessage(error) };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private start(method: SubscriptionLoginMethod): void {
|
|
454
|
+
const cancellation = new AbortController();
|
|
455
|
+
this.cancellation = cancellation;
|
|
456
|
+
this.method = method;
|
|
457
|
+
this.challenge = undefined;
|
|
458
|
+
this.state = { ...this.baseStatus(), status: "signing-in", method };
|
|
459
|
+
this.operation = this.run(cancellation)
|
|
460
|
+
.then(
|
|
461
|
+
async () => {
|
|
462
|
+
if (this.challenge === undefined)
|
|
463
|
+
this.rejectChallenge(new Error(`${this.session.definition.route}: login completed without a challenge`));
|
|
464
|
+
this.state = await this.readStoredStatus();
|
|
465
|
+
},
|
|
466
|
+
(error: unknown) => {
|
|
467
|
+
this.rejectChallenge(error);
|
|
468
|
+
this.state = { ...this.baseStatus(), status: "error", message: safeMessage(error) };
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
.finally(() => {
|
|
472
|
+
this.operation = undefined;
|
|
473
|
+
this.cancellation = undefined;
|
|
474
|
+
this.codeResolver = undefined;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async run(cancellation: AbortController): Promise<void> {
|
|
479
|
+
await this.session.login({
|
|
480
|
+
signal: cancellation.signal,
|
|
481
|
+
prompt: (prompt) => {
|
|
482
|
+
if (prompt.type === "select") return Promise.resolve(optionForLoginMethod(prompt, this.method));
|
|
483
|
+
if (prompt.type === "manual_code") return this.awaitCode(prompt, cancellation.signal);
|
|
484
|
+
return Promise.reject(new Error(`${this.session.definition.route}: unsupported OAuth prompt ${prompt.type}`));
|
|
485
|
+
},
|
|
486
|
+
notify: (event) => {
|
|
487
|
+
this.onEvent(event);
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private awaitCode(prompt: AuthPrompt, cancellation: AbortSignal): Promise<string> {
|
|
493
|
+
const signal = prompt.signal === undefined ? cancellation : AbortSignal.any([prompt.signal, cancellation]);
|
|
494
|
+
return new Promise<string>((resolve, reject) => {
|
|
495
|
+
const resolver = (code: string): void => {
|
|
496
|
+
signal.removeEventListener("abort", onAbort);
|
|
497
|
+
resolve(code);
|
|
498
|
+
};
|
|
499
|
+
const onAbort = (): void => {
|
|
500
|
+
if (this.codeResolver === resolver) this.codeResolver = undefined;
|
|
501
|
+
reject(signal.reason);
|
|
502
|
+
};
|
|
503
|
+
this.codeResolver = resolver;
|
|
504
|
+
if (signal.aborted) {
|
|
505
|
+
onAbort();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private onEvent(event: AuthEvent): void {
|
|
513
|
+
if (event.type === "device_code") {
|
|
514
|
+
this.acceptChallenge({
|
|
515
|
+
method: "device",
|
|
516
|
+
url: event.verificationUri,
|
|
517
|
+
...(event.userCode.length > 0 ? { userCode: event.userCode } : {}),
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (event.type === "auth_url") this.acceptChallenge({ method: this.method, url: event.url });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private acceptChallenge(challenge: SubscriptionLoginChallenge): void {
|
|
525
|
+
try {
|
|
526
|
+
const url = new URL(challenge.url);
|
|
527
|
+
if (url.protocol !== "https:") throw new Error("authorization URL must use HTTPS");
|
|
528
|
+
} catch (error) {
|
|
529
|
+
const failure = new Error(`${this.session.definition.route}: invalid authorization URL`, { cause: error });
|
|
530
|
+
this.cancellation?.abort(failure);
|
|
531
|
+
this.rejectChallenge(failure);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.challenge = challenge;
|
|
535
|
+
this.state = {
|
|
536
|
+
...this.baseStatus(),
|
|
537
|
+
status: "signing-in",
|
|
538
|
+
method: challenge.method,
|
|
539
|
+
url: challenge.url,
|
|
540
|
+
...(challenge.userCode === undefined ? {} : { userCode: challenge.userCode }),
|
|
541
|
+
};
|
|
542
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.resolve(challenge);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private rejectChallenge(error: unknown): void {
|
|
546
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.reject(error);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function json(res: ServerResponse, status: number, value: unknown): void {
|
|
551
|
+
res.writeHead(status, {
|
|
552
|
+
"content-type": "application/json; charset=utf-8",
|
|
553
|
+
"cache-control": "no-store",
|
|
554
|
+
"x-content-type-options": "nosniff",
|
|
555
|
+
});
|
|
556
|
+
res.end(JSON.stringify(value));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function readLoginMethod(body: unknown): GrokBuildLoginMethod {
|
|
560
|
+
if (typeof body === "object" && body !== null && "method" in body && body.method === "device") return "device";
|
|
561
|
+
return "pkce";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Register the plugin-owned OAuth routes when the Web server is composed. */
|
|
565
|
+
export function registerGrokBuildAuthRoutes(
|
|
566
|
+
ctx: Context,
|
|
567
|
+
session: GrokBuildSession,
|
|
568
|
+
existingAuth?: GrokBuildWebAuth,
|
|
569
|
+
): void {
|
|
570
|
+
const auth = existingAuth ?? new GrokBuildWebAuth(session);
|
|
571
|
+
const ownsAuth = existingAuth === undefined;
|
|
572
|
+
if (ownsAuth) {
|
|
573
|
+
ctx.effect(() => () => auth.dispose(), "dsh-coding-subscription-oauth: Grok OAuth auth lifetime");
|
|
574
|
+
}
|
|
575
|
+
ctx.effect(() => {
|
|
576
|
+
const releaseRoutes = registerWebRouteSetupAtomically(ctx.webServer, (webServer) => [
|
|
577
|
+
webServer.register({
|
|
578
|
+
kind: "exact",
|
|
579
|
+
path: GROK_BUILD_AUTH_STATUS_PATH,
|
|
580
|
+
handler: async (req, res) => {
|
|
581
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
582
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
583
|
+
json(res, 200, await auth.status());
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
webServer.register({
|
|
587
|
+
kind: "exact",
|
|
588
|
+
path: GROK_BUILD_AUTH_LOGIN_PATH,
|
|
589
|
+
handler: async (req, res) => {
|
|
590
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
591
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
592
|
+
try {
|
|
593
|
+
json(res, 200, await auth.signIn(readLoginMethod(await readJsonRequest(req))));
|
|
594
|
+
} catch (error: unknown) {
|
|
595
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
webServer.register({
|
|
600
|
+
kind: "exact",
|
|
601
|
+
path: GROK_BUILD_AUTH_LOGIN_CODE_PATH,
|
|
602
|
+
handler: async (req, res) => {
|
|
603
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
604
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
605
|
+
try {
|
|
606
|
+
const body = await readJsonRequest(req);
|
|
607
|
+
const code = typeof body === "object" && body !== null && "code" in body ? body.code : undefined;
|
|
608
|
+
if (typeof code !== "string" || code.trim().length === 0) {
|
|
609
|
+
return json(res, 400, { error: "code must be a non-empty string" });
|
|
610
|
+
}
|
|
611
|
+
await auth.submitCode(code);
|
|
612
|
+
json(res, 200, { ok: true });
|
|
613
|
+
} catch (error: unknown) {
|
|
614
|
+
json(res, requestErrorStatus(error, 409), { error: safeMessage(error) });
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
}),
|
|
618
|
+
webServer.register({
|
|
619
|
+
kind: "exact",
|
|
620
|
+
path: GROK_BUILD_AUTH_LOGIN_CANCEL_PATH,
|
|
621
|
+
handler: async (req, res) => {
|
|
622
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
623
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
624
|
+
await auth.cancel();
|
|
625
|
+
json(res, 200, await auth.status());
|
|
626
|
+
},
|
|
627
|
+
}),
|
|
628
|
+
webServer.register({
|
|
629
|
+
kind: "exact",
|
|
630
|
+
path: GROK_BUILD_AUTH_IMPORT_PATH,
|
|
631
|
+
handler: (req, res) => {
|
|
632
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
633
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
634
|
+
json(res, 410, { error: "legacy import retired; use OAuth Pull preview and confirmation" });
|
|
635
|
+
},
|
|
636
|
+
}),
|
|
637
|
+
webServer.register({
|
|
638
|
+
kind: "exact",
|
|
639
|
+
path: GROK_BUILD_AUTH_MODELS_PATH,
|
|
640
|
+
handler: async (req, res) => {
|
|
641
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
642
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
643
|
+
try {
|
|
644
|
+
const body = await readJsonRequest(req);
|
|
645
|
+
const selected =
|
|
646
|
+
typeof body === "object" && body !== null && "selected" in body ? body.selected : undefined;
|
|
647
|
+
if (!Array.isArray(selected) || selected.some((id) => typeof id !== "string")) {
|
|
648
|
+
return json(res, 400, { error: "selected must be an array of model ids" });
|
|
649
|
+
}
|
|
650
|
+
await auth.setModels(selected);
|
|
651
|
+
json(res, 200, await auth.status());
|
|
652
|
+
} catch (error: unknown) {
|
|
653
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
657
|
+
webServer.register({
|
|
658
|
+
kind: "exact",
|
|
659
|
+
path: GROK_BUILD_AUTH_LOGOUT_PATH,
|
|
660
|
+
handler: async (req, res) => {
|
|
661
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
662
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
663
|
+
await auth.signOut();
|
|
664
|
+
json(res, 200, { ok: true });
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
667
|
+
]);
|
|
668
|
+
return releaseRoutes;
|
|
669
|
+
}, "dsh-coding-subscription-oauth: Web OAuth routes");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export interface CodingOAuthWebStatus {
|
|
673
|
+
providers: {
|
|
674
|
+
grok: GrokBuildWebAuthStatus;
|
|
675
|
+
codex: SubscriptionWebAuthStatus;
|
|
676
|
+
kimi: SubscriptionWebAuthStatus;
|
|
677
|
+
claude: SubscriptionWebAuthStatus;
|
|
678
|
+
};
|
|
679
|
+
antigravity: {
|
|
680
|
+
installed: boolean;
|
|
681
|
+
route: typeof ANTIGRAVITY_ROUTE;
|
|
682
|
+
management: "cli";
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function recordBody(body: unknown): Record<string, unknown> {
|
|
687
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
688
|
+
throw new Error("request body must be an object");
|
|
689
|
+
}
|
|
690
|
+
return body as Record<string, unknown>;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function providerSlug(body: unknown): CodingOAuthProviderSlug {
|
|
694
|
+
const provider = recordBody(body)["provider"];
|
|
695
|
+
if (provider === "grok" || provider === "codex" || provider === "kimi" || provider === "claude") return provider;
|
|
696
|
+
throw new Error("provider must be one of grok, codex, kimi, or claude");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Register the unified Coding OAuth API plus the compatibility Grok routes. */
|
|
700
|
+
export function registerCodingOAuthRoutes(
|
|
701
|
+
ctx: Context,
|
|
702
|
+
grokSession: GrokBuildSession,
|
|
703
|
+
subscriptionSessions: readonly OAuthProviderSession[],
|
|
704
|
+
): void {
|
|
705
|
+
const grok = new GrokBuildWebAuth(grokSession);
|
|
706
|
+
const subscriptions = new Map(
|
|
707
|
+
subscriptionSessions.map((session) => [session.definition.slug, new SubscriptionWebAuth(session)]),
|
|
708
|
+
);
|
|
709
|
+
const subscription = (slug: Exclude<CodingOAuthProviderSlug, "grok">): SubscriptionWebAuth => {
|
|
710
|
+
const auth = subscriptions.get(slug);
|
|
711
|
+
if (auth === undefined) throw new Error(`OAuth provider "${slug}" is not configured`);
|
|
712
|
+
return auth;
|
|
713
|
+
};
|
|
714
|
+
ctx.effect(
|
|
715
|
+
() => () =>
|
|
716
|
+
Promise.all([grok.dispose(), ...[...subscriptions.values()].map((auth) => auth.dispose())]).then(() => undefined),
|
|
717
|
+
"dsh-coding-subscription-oauth: Coding OAuth auth lifetime",
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
registerGrokBuildAuthRoutes(ctx, grokSession, grok);
|
|
721
|
+
|
|
722
|
+
const allStatus = async (): Promise<CodingOAuthWebStatus> => {
|
|
723
|
+
const [grokStatus, codex, kimi, claude] = await Promise.all([
|
|
724
|
+
grok.status().catch(
|
|
725
|
+
async (error: unknown): Promise<GrokBuildWebAuthStatus> => ({
|
|
726
|
+
status: "error",
|
|
727
|
+
message: safeMessage(error),
|
|
728
|
+
grokImportAvailable: await grokImportAvailable().catch(() => false),
|
|
729
|
+
}),
|
|
730
|
+
),
|
|
731
|
+
subscription("codex").status(),
|
|
732
|
+
subscription("kimi").status(),
|
|
733
|
+
subscription("claude").status(),
|
|
734
|
+
]);
|
|
735
|
+
let antigravityInstalled = false;
|
|
736
|
+
try {
|
|
737
|
+
antigravityInstalled = ctx.llm.listProviders().some((provider) => provider.id === ANTIGRAVITY_ROUTE);
|
|
738
|
+
} catch {
|
|
739
|
+
// Account cards remain usable even when an unrelated adapter list fails.
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
providers: { grok: grokStatus, codex, kimi, claude },
|
|
743
|
+
antigravity: {
|
|
744
|
+
installed: antigravityInstalled,
|
|
745
|
+
route: ANTIGRAVITY_ROUTE,
|
|
746
|
+
management: "cli",
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
ctx.effect(() => {
|
|
752
|
+
const releaseRoutes = registerWebRouteSetupAtomically(ctx.webServer, (webServer) => [
|
|
753
|
+
webServer.register({
|
|
754
|
+
kind: "exact",
|
|
755
|
+
path: CODING_OAUTH_STATUS_PATH,
|
|
756
|
+
handler: async (req, res) => {
|
|
757
|
+
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
758
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
759
|
+
try {
|
|
760
|
+
json(res, 200, await allStatus());
|
|
761
|
+
} catch (error: unknown) {
|
|
762
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
}),
|
|
766
|
+
webServer.register({
|
|
767
|
+
kind: "exact",
|
|
768
|
+
path: CODING_OAUTH_LOGIN_PATH,
|
|
769
|
+
handler: async (req, res) => {
|
|
770
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
771
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
772
|
+
try {
|
|
773
|
+
const body = await readJsonRequest(req);
|
|
774
|
+
const slug = providerSlug(body);
|
|
775
|
+
const value = recordBody(body);
|
|
776
|
+
if (slug === "grok") {
|
|
777
|
+
const method: GrokBuildLoginMethod = value["method"] === "device" ? "device" : "pkce";
|
|
778
|
+
return json(res, 200, await grok.signIn(method));
|
|
779
|
+
}
|
|
780
|
+
const auth = subscription(slug);
|
|
781
|
+
const requested = value["method"];
|
|
782
|
+
const method: SubscriptionLoginMethod =
|
|
783
|
+
requested === "browser" || requested === "device"
|
|
784
|
+
? requested
|
|
785
|
+
: auth.session.definition.recommendedLoginMethod;
|
|
786
|
+
json(res, 200, await auth.signIn(method));
|
|
787
|
+
} catch (error: unknown) {
|
|
788
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
}),
|
|
792
|
+
webServer.register({
|
|
793
|
+
kind: "exact",
|
|
794
|
+
path: CODING_OAUTH_LOGIN_CODE_PATH,
|
|
795
|
+
handler: async (req, res) => {
|
|
796
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
797
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
798
|
+
try {
|
|
799
|
+
const body = await readJsonRequest(req);
|
|
800
|
+
const slug = providerSlug(body);
|
|
801
|
+
const code = recordBody(body)["code"];
|
|
802
|
+
if (typeof code !== "string" || code.trim().length === 0) {
|
|
803
|
+
return json(res, 400, { error: "code must be a non-empty string" });
|
|
804
|
+
}
|
|
805
|
+
if (slug === "grok") await grok.submitCode(code);
|
|
806
|
+
else await subscription(slug).submitCode(code);
|
|
807
|
+
json(res, 200, { ok: true });
|
|
808
|
+
} catch (error: unknown) {
|
|
809
|
+
json(res, requestErrorStatus(error, 409), { error: safeMessage(error) });
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
}),
|
|
813
|
+
webServer.register({
|
|
814
|
+
kind: "exact",
|
|
815
|
+
path: CODING_OAUTH_LOGIN_CANCEL_PATH,
|
|
816
|
+
handler: async (req, res) => {
|
|
817
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
818
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
819
|
+
try {
|
|
820
|
+
const body = await readJsonRequest(req);
|
|
821
|
+
const slug = providerSlug(body);
|
|
822
|
+
if (slug === "grok") await grok.cancel();
|
|
823
|
+
else await subscription(slug).cancel();
|
|
824
|
+
json(res, 200, await allStatus());
|
|
825
|
+
} catch (error: unknown) {
|
|
826
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
}),
|
|
830
|
+
webServer.register({
|
|
831
|
+
kind: "exact",
|
|
832
|
+
path: CODING_OAUTH_MODELS_PATH,
|
|
833
|
+
handler: async (req, res) => {
|
|
834
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
835
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
836
|
+
try {
|
|
837
|
+
const body = await readJsonRequest(req);
|
|
838
|
+
const slug = providerSlug(body);
|
|
839
|
+
const selected = recordBody(body)["selected"];
|
|
840
|
+
if (!Array.isArray(selected) || selected.some((id) => typeof id !== "string")) {
|
|
841
|
+
return json(res, 400, { error: "selected must be an array of model ids" });
|
|
842
|
+
}
|
|
843
|
+
if (slug === "grok") await grok.setModels(selected);
|
|
844
|
+
else await subscription(slug).setModels(selected);
|
|
845
|
+
json(res, 200, await allStatus());
|
|
846
|
+
} catch (error: unknown) {
|
|
847
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
848
|
+
}
|
|
849
|
+
},
|
|
850
|
+
}),
|
|
851
|
+
webServer.register({
|
|
852
|
+
kind: "exact",
|
|
853
|
+
path: CODING_OAUTH_LOGOUT_PATH,
|
|
854
|
+
handler: async (req, res) => {
|
|
855
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
856
|
+
if (!isTrustedLoopbackWebRequest(req)) return json(res, 403, { error: "forbidden" });
|
|
857
|
+
try {
|
|
858
|
+
const body = await readJsonRequest(req);
|
|
859
|
+
const slug = providerSlug(body);
|
|
860
|
+
if (slug === "grok") await grok.signOut();
|
|
861
|
+
else await subscription(slug).signOut();
|
|
862
|
+
json(res, 200, await allStatus());
|
|
863
|
+
} catch (error: unknown) {
|
|
864
|
+
json(res, requestErrorStatus(error, 500), { error: safeMessage(error) });
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
}),
|
|
868
|
+
]);
|
|
869
|
+
return releaseRoutes;
|
|
870
|
+
}, "dsh-coding-subscription-oauth: Coding OAuth routes");
|
|
871
|
+
}
|