dsh-coding-subscription-oauth 0.6.0 → 0.6.3

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