dsh-oauth-login 0.2.4

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 (64) hide show
  1. package/INSTALL.md +59 -0
  2. package/INSTALL.zh.md +45 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +104 -0
  5. package/README.md +104 -0
  6. package/README.zh.md +3 -0
  7. package/SECURITY.md +33 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/network-proxy.md +45 -0
  10. package/docs/openrouter-sync.md +84 -0
  11. package/docs/screenshots/01-providers-signed-out.png +0 -0
  12. package/docs/screenshots/03-copilot-signing-in.png +0 -0
  13. package/docs/screenshots/04-mixed-live-states.png +0 -0
  14. package/docs/screenshots/05-copilot-sign-in.gif +0 -0
  15. package/docs/screenshots/06-cli-status-store.png +0 -0
  16. package/lib/bin.d.ts +5 -0
  17. package/lib/bin.js +2132 -0
  18. package/lib/client.js +1915 -0
  19. package/lib/client.js.map +1 -0
  20. package/lib/index.d.ts +482 -0
  21. package/lib/index.js +3105 -0
  22. package/lib/invariant.d.ts +7 -0
  23. package/lib/invariant.js +8 -0
  24. package/package.json +180 -0
  25. package/src/adapter.ts +207 -0
  26. package/src/auth-routes.ts +654 -0
  27. package/src/auth.ts +76 -0
  28. package/src/bin.ts +185 -0
  29. package/src/catalog.ts +129 -0
  30. package/src/client/OpenRouterSyncStatus.tsx +66 -0
  31. package/src/client/PiLoginSettings.tsx +470 -0
  32. package/src/client/ProxySettings.tsx +123 -0
  33. package/src/client/SearchableModelSelect.tsx +595 -0
  34. package/src/client/draft-input.ts +12 -0
  35. package/src/client/index.tsx +71 -0
  36. package/src/client/locales.ts +176 -0
  37. package/src/client/login-input-copy.ts +34 -0
  38. package/src/client/login-window.ts +24 -0
  39. package/src/client/model-filter.ts +45 -0
  40. package/src/client/openrouter-store.ts +122 -0
  41. package/src/client/proxy-draft.ts +57 -0
  42. package/src/extra-models.ts +201 -0
  43. package/src/hosted-capture.ts +41 -0
  44. package/src/hosted-images.ts +154 -0
  45. package/src/http.ts +241 -0
  46. package/src/ids.ts +17 -0
  47. package/src/index.ts +183 -0
  48. package/src/invariant.ts +12 -0
  49. package/src/model-error-hint.ts +98 -0
  50. package/src/native-tools.ts +442 -0
  51. package/src/oauth-refresh.ts +49 -0
  52. package/src/openrouter-catalog.ts +245 -0
  53. package/src/openrouter-free.ts +76 -0
  54. package/src/openrouter-models.ts +155 -0
  55. package/src/openrouter-types.ts +40 -0
  56. package/src/plugin-config.ts +30 -0
  57. package/src/provider.ts +137 -0
  58. package/src/proxy-config.ts +61 -0
  59. package/src/proxy-store.ts +45 -0
  60. package/src/proxy-transport.ts +220 -0
  61. package/src/redact.ts +22 -0
  62. package/src/responses-tap.ts +97 -0
  63. package/src/session.ts +104 -0
  64. package/src/store.ts +234 -0
@@ -0,0 +1,654 @@
1
+ /** Same-origin Web settings routes for Pi-native multi-provider OAuth. */
2
+
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import type { AuthEvent, AuthPrompt } from '@earendil-works/pi-ai'
5
+ import type { Context } from '@deepseek-ai/cordis'
6
+ import type {} from '@deepseek-ai/dsh-client-connection'
7
+ import type {} from '@deepseek-ai/dsh-host-webserver'
8
+ import { loginPiProviderSession, piLoginStatus } from './auth.ts'
9
+ import { PI_LOGIN_PROVIDERS, requirePiLoginProvider } from './catalog.ts'
10
+ import type { PiLoginProvider } from './catalog.ts'
11
+ import { isSafeAuthUrl, safeMessage } from './redact.ts'
12
+ import type { PiLoginSession } from './session.ts'
13
+ import { OPENROUTER_CATALOG_PATH, OPENROUTER_REFRESH_PATH } from './openrouter-types.ts'
14
+ import { parseProxySettings, PROXY_SETTINGS_PATH } from './proxy-config.ts'
15
+ import { ProxySettingsConflict } from './proxy-store.ts'
16
+
17
+ export const PI_LOGIN_AUTH_STATUS_PATH = '/plugins/dsh-oauth-login/auth/status'
18
+ export const PI_LOGIN_AUTH_LOGIN_PATH = '/plugins/dsh-oauth-login/auth/login'
19
+ export const PI_LOGIN_AUTH_CANCEL_PATH = '/plugins/dsh-oauth-login/auth/cancel'
20
+ export const PI_LOGIN_AUTH_COMPLETE_PATH = '/plugins/dsh-oauth-login/auth/complete'
21
+ export const PI_LOGIN_AUTH_LOGOUT_PATH = '/plugins/dsh-oauth-login/auth/logout'
22
+
23
+ export interface LoginInputChallenge {
24
+ type: 'secret' | 'text' | 'manual_code'
25
+ message: string
26
+ placeholder?: string
27
+ }
28
+
29
+ type InputAuthPrompt = Extract<AuthPrompt, { type: 'secret' | 'text' | 'manual_code' }>
30
+
31
+ /** Preserve the Pi prompt discriminator on the browser wire contract. */
32
+ export function loginInputChallenge(prompt: InputAuthPrompt): LoginInputChallenge {
33
+ return {
34
+ type: prompt.type,
35
+ message: prompt.message,
36
+ ...'placeholder' in prompt && prompt.placeholder !== undefined
37
+ ? { placeholder: prompt.placeholder }
38
+ : {},
39
+ }
40
+ }
41
+
42
+ export type PiLoginAccountState =
43
+ | { status: 'signed-out' }
44
+ | {
45
+ status: 'signing-in'
46
+ kind?: 'browser' | 'input'
47
+ url?: string
48
+ userCode?: string
49
+ input?: LoginInputChallenge
50
+ }
51
+ | { status: 'signed-in'; models: string[]; expiresAt?: string }
52
+ | { status: 'error'; message: string }
53
+
54
+ export interface PiLoginProviderStatus {
55
+ id: string
56
+ route: string
57
+ displayName: string
58
+ shortName: string
59
+ authType: PiLoginProvider['authType']
60
+ account: PiLoginAccountState
61
+ }
62
+
63
+ export interface LoginChallenge {
64
+ provider: string
65
+ kind: 'browser' | 'input'
66
+ url?: string
67
+ userCode?: string
68
+ input?: LoginInputChallenge
69
+ }
70
+
71
+ /** Keep the browser authorization URL available while Pi also waits for a manual callback. */
72
+ export function mergeLoginChallenge(
73
+ previous: LoginChallenge | undefined,
74
+ next: LoginChallenge,
75
+ ): LoginChallenge {
76
+ return {
77
+ ...next,
78
+ ...next.url === undefined && previous?.url !== undefined ? { url: previous.url } : {},
79
+ ...next.userCode === undefined && previous?.userCode !== undefined ? { userCode: previous.userCode } : {},
80
+ }
81
+ }
82
+
83
+ function waitForPromptAbort(prompt: AuthPrompt): Promise<string> {
84
+ const signal = prompt.signal
85
+ if (signal === undefined) return new Promise<string>(() => {})
86
+ if (signal.aborted) return Promise.reject(signal.reason)
87
+ return new Promise<string>((_resolve, reject) => {
88
+ signal.addEventListener('abort', () => { reject(signal.reason) }, { once: true })
89
+ })
90
+ }
91
+
92
+ function answerSelectPrompt(prompt: AuthPrompt): string | undefined {
93
+ if (prompt.type === 'select') {
94
+ const oauth = prompt.options.find(option => option.id === 'oauth' || option.id.includes('oauth'))
95
+ const browser = prompt.options.find(option => option.id.includes('browser'))
96
+ return oauth?.id ?? browser?.id ?? prompt.options[0]?.id ?? 'oauth'
97
+ }
98
+ return undefined
99
+ }
100
+
101
+ interface PendingInput {
102
+ readonly operationId: number
103
+ resolve(value: string): void
104
+ reject(error: unknown): void
105
+ }
106
+
107
+ interface LoginOperation {
108
+ readonly id: number
109
+ readonly cancellation: AbortController
110
+ readonly done: Promise<void>
111
+ cancelled?: Error
112
+ failure?: Error
113
+ result?: PiLoginAccountState
114
+ finish(): void
115
+ }
116
+
117
+ class ProviderAuth {
118
+ state: PiLoginAccountState = { status: 'signed-out' }
119
+ private operation: LoginOperation | undefined
120
+ private nextOperationId = 0
121
+ private challenge: LoginChallenge | undefined
122
+ private challengeWaiters: Array<{ resolve(value: LoginChallenge): void; reject(error: unknown): void }> = []
123
+ private pendingInput: PendingInput | undefined
124
+
125
+ constructor(
126
+ private readonly spec: PiLoginProvider,
127
+ private readonly session: PiLoginSession,
128
+ ) {}
129
+
130
+ async snapshot(): Promise<PiLoginAccountState> {
131
+ if (this.operation !== undefined) return this.state
132
+ if (this.state.status === 'error') return this.state
133
+ return this.readStored()
134
+ }
135
+
136
+ async signIn(): Promise<LoginChallenge> {
137
+ if (this.operation === undefined) this.start()
138
+ if (this.challenge !== undefined) return this.challenge
139
+ return new Promise<LoginChallenge>((resolve, reject) => {
140
+ this.challengeWaiters.push({ resolve, reject })
141
+ })
142
+ }
143
+
144
+ async signOut(): Promise<void> {
145
+ const revision = this.nextOperationId
146
+ const error = new Error('Pi login cancelled')
147
+ this.stop(this.operation, error)
148
+ await this.session.logout(this.spec.id)
149
+ if (this.operation === undefined && this.nextOperationId === revision) {
150
+ this.state = { status: 'signed-out' }
151
+ this.challenge = undefined
152
+ }
153
+ }
154
+
155
+ async cancel(): Promise<void> {
156
+ const revision = this.nextOperationId
157
+ const error = new Error('Pi login cancelled')
158
+ this.stop(this.operation, error)
159
+ const stored = await this.readStored()
160
+ if (this.operation === undefined && this.nextOperationId === revision) {
161
+ this.state = stored
162
+ this.challenge = undefined
163
+ }
164
+ }
165
+
166
+ async submitInput(value: string): Promise<PiLoginAccountState> {
167
+ const pending = this.pendingInput
168
+ if (pending === undefined) {
169
+ throw new Error(`${this.spec.displayName} is not waiting for a credential`)
170
+ }
171
+ const normalized = value.trim()
172
+ if (normalized.length === 0) throw new Error('credential must not be empty')
173
+ if (normalized.length > 4096) throw new Error('credential is too long')
174
+ const operation = this.operation
175
+ if (operation === undefined || operation.id !== pending.operationId) {
176
+ throw new Error(`${this.spec.displayName} login operation is no longer active`)
177
+ }
178
+ pending.resolve(normalized)
179
+ await operation.done
180
+ if (operation.cancelled !== undefined) throw operation.cancelled
181
+ if (operation.failure !== undefined) throw operation.failure
182
+ return operation.result ?? this.state
183
+ }
184
+
185
+ /** Wait until an in-flight sign-in settles (success or error). No-op if idle. */
186
+ async waitUntilSettled(): Promise<void> {
187
+ await this.operation?.done
188
+ }
189
+
190
+ async dispose(): Promise<void> {
191
+ const error = new Error('Pi login plugin disposed')
192
+ this.stop(this.operation, error)
193
+ }
194
+
195
+ private start(): void {
196
+ const cancellation = new AbortController()
197
+ const id = ++this.nextOperationId
198
+ let finish!: () => void
199
+ const done = new Promise<void>(resolve => { finish = resolve })
200
+ const operation: LoginOperation = { id, cancellation, done, finish }
201
+ this.operation = operation
202
+ this.challenge = undefined
203
+ this.pendingInput = undefined
204
+ this.state = { status: 'signing-in' }
205
+ void loginPiProviderSession(this.spec.id, {
206
+ signal: cancellation.signal,
207
+ prompt: prompt => this.onPrompt(id, prompt),
208
+ notify: event => { this.onEvent(id, event) },
209
+ }, this.session).then(async () => {
210
+ if (!this.isCurrent(id)) return
211
+ const stored = await this.readStored()
212
+ if (!this.isCurrent(id)) return
213
+ operation.result = stored
214
+ this.state = stored
215
+ }).catch((error: unknown) => {
216
+ if (!this.isCurrent(id)) return
217
+ this.rejectChallenge(error)
218
+ this.rejectInput(error)
219
+ operation.failure = new Error(safeMessage(error))
220
+ this.state = { status: 'error', message: operation.failure.message }
221
+ }).finally(() => {
222
+ if (this.isCurrent(id)) this.operation = undefined
223
+ operation.finish()
224
+ })
225
+ }
226
+
227
+ private onPrompt(operationId: number, prompt: AuthPrompt): Promise<string> {
228
+ if (!this.isCurrent(operationId)) return Promise.reject(new Error('Pi login cancelled'))
229
+ const selected = answerSelectPrompt(prompt)
230
+ if (selected !== undefined) return Promise.resolve(selected)
231
+ // Preserve the existing optional-text behavior in OAuth providers. Secret
232
+ // and manual-code prompts require an explicit local user action.
233
+ if (prompt.type === 'text' && this.spec.authType === 'oauth') return Promise.resolve('')
234
+ if (prompt.type === 'secret' || prompt.type === 'manual_code' || prompt.type === 'text') {
235
+ return this.requestInput(operationId, prompt)
236
+ }
237
+ return waitForPromptAbort(prompt)
238
+ }
239
+
240
+ private requestInput(operationId: number, prompt: InputAuthPrompt): Promise<string> {
241
+ if (this.pendingInput !== undefined) {
242
+ return Promise.reject(new Error(`${this.spec.displayName} already has a pending credential prompt`))
243
+ }
244
+ const input = loginInputChallenge(prompt)
245
+ const operation = this.operation
246
+ if (operation === undefined || operation.id !== operationId) return Promise.reject(new Error('Pi login cancelled'))
247
+ const signals = [operation.cancellation.signal, prompt.signal]
248
+ .filter((signal): signal is AbortSignal => signal !== undefined)
249
+ const wait = new Promise<string>((resolve, reject) => {
250
+ let settled = false
251
+ const onAbort = (): void => {
252
+ const signal = signals.find(candidate => candidate.aborted)
253
+ settleReject(signal?.reason ?? new Error('credential prompt cancelled'))
254
+ }
255
+ const cleanup = (): void => {
256
+ for (const signal of signals) signal.removeEventListener('abort', onAbort)
257
+ }
258
+ const settleResolve = (value: string): void => {
259
+ if (settled) return
260
+ settled = true
261
+ cleanup()
262
+ this.pendingInput = undefined
263
+ resolve(value)
264
+ }
265
+ const settleReject = (error: unknown): void => {
266
+ if (settled) return
267
+ settled = true
268
+ cleanup()
269
+ this.pendingInput = undefined
270
+ reject(error)
271
+ }
272
+ this.pendingInput = { operationId, resolve: settleResolve, reject: settleReject }
273
+ for (const signal of signals) signal.addEventListener('abort', onAbort, { once: true })
274
+ if (signals.some(signal => signal.aborted)) onAbort()
275
+ })
276
+ this.acceptChallenge({
277
+ provider: this.spec.id,
278
+ kind: 'input',
279
+ ...this.spec.loginUrl === undefined ? {} : { url: this.spec.loginUrl },
280
+ input,
281
+ })
282
+ return wait
283
+ }
284
+
285
+ private onEvent(operationId: number, event: AuthEvent): void {
286
+ if (!this.isCurrent(operationId)) return
287
+ if (event.type === 'device_code') {
288
+ this.acceptChallenge({
289
+ provider: this.spec.id,
290
+ kind: 'browser',
291
+ url: event.verificationUri,
292
+ ...event.userCode.length > 0 ? { userCode: event.userCode } : {},
293
+ })
294
+ return
295
+ }
296
+ if (event.type === 'auth_url') {
297
+ this.acceptChallenge({ provider: this.spec.id, kind: 'browser', url: event.url })
298
+ }
299
+ }
300
+
301
+ private acceptChallenge(challenge: LoginChallenge): void {
302
+ if (challenge.url !== undefined && !isSafeAuthUrl(challenge.url, this.spec)) {
303
+ const error = new Error(`${this.spec.id} returned an authorization URL outside its official hosts`)
304
+ this.stop(this.operation, error)
305
+ return
306
+ }
307
+ const merged = mergeLoginChallenge(this.challenge, challenge)
308
+ this.challenge = merged
309
+ this.state = {
310
+ status: 'signing-in',
311
+ kind: merged.kind,
312
+ ...merged.url === undefined ? {} : { url: merged.url },
313
+ ...merged.userCode === undefined ? {} : { userCode: merged.userCode },
314
+ ...merged.input === undefined ? {} : { input: merged.input },
315
+ }
316
+ for (const waiter of this.challengeWaiters.splice(0)) waiter.resolve(merged)
317
+ }
318
+
319
+ private async readStored(): Promise<PiLoginAccountState> {
320
+ const [stored] = await piLoginStatus(this.session.store, this.spec.id)
321
+ if (stored === undefined || !stored.authenticated) return { status: 'signed-out' }
322
+ return {
323
+ status: 'signed-in',
324
+ models: this.session.visibleModels(this.spec.id).map(model => model.id),
325
+ ...stored.expiresAt === undefined || Number.isNaN(stored.expiresAt.valueOf())
326
+ ? {}
327
+ : { expiresAt: stored.expiresAt.toISOString() },
328
+ }
329
+ }
330
+
331
+ private rejectChallenge(error: unknown): void {
332
+ for (const waiter of this.challengeWaiters.splice(0)) waiter.reject(error)
333
+ }
334
+
335
+ private rejectInput(error: unknown): void {
336
+ this.pendingInput?.reject(error)
337
+ }
338
+
339
+ private isCurrent(operationId: number): boolean {
340
+ return this.operation?.id === operationId
341
+ }
342
+
343
+ private stop(operation: LoginOperation | undefined, error: Error): void {
344
+ if (operation === undefined || this.operation?.id !== operation.id) return
345
+ this.operation = undefined
346
+ operation.cancelled = error
347
+ this.rejectChallenge(error)
348
+ this.rejectInput(error)
349
+ operation.cancellation.abort(error)
350
+ operation.finish()
351
+ }
352
+ }
353
+
354
+ export class PiLoginWebAuth {
355
+ private readonly session: PiLoginSession
356
+ private readonly byId = new Map<string, ProviderAuth>()
357
+
358
+ constructor(session: PiLoginSession) {
359
+ this.session = session
360
+ for (const spec of PI_LOGIN_PROVIDERS) {
361
+ this.byId.set(spec.id, new ProviderAuth(spec, session))
362
+ }
363
+ }
364
+
365
+ private slot(id: string): ProviderAuth {
366
+ const slot = this.byId.get(id)
367
+ if (slot === undefined) throw new Error(`dsh-oauth-login: unknown provider "${id}"`)
368
+ return slot
369
+ }
370
+
371
+ async status(): Promise<PiLoginProviderStatus[]> {
372
+ await this.session.refreshStoredGrants()
373
+ await this.session.openRouter.syncAuthentication()
374
+ const out: PiLoginProviderStatus[] = []
375
+ for (const spec of PI_LOGIN_PROVIDERS) {
376
+ out.push({
377
+ id: spec.id,
378
+ route: spec.route,
379
+ displayName: spec.displayName,
380
+ shortName: spec.shortName,
381
+ authType: spec.authType,
382
+ account: await this.slot(spec.id).snapshot(),
383
+ })
384
+ }
385
+ return out
386
+ }
387
+
388
+ async signIn(id: string): Promise<LoginChallenge> {
389
+ requirePiLoginProvider(id)
390
+ return this.slot(id).signIn()
391
+ }
392
+
393
+ async signOut(id: string): Promise<void> {
394
+ requirePiLoginProvider(id)
395
+ await this.slot(id).signOut()
396
+ }
397
+
398
+ async cancel(id: string): Promise<void> {
399
+ requirePiLoginProvider(id)
400
+ await this.slot(id).cancel()
401
+ }
402
+
403
+ async submitInput(id: string, value: string): Promise<PiLoginAccountState> {
404
+ requirePiLoginProvider(id)
405
+ return this.slot(id).submitInput(value)
406
+ }
407
+
408
+ /** Wait until the named provider's in-flight sign-in settles. */
409
+ async waitUntilSettled(id: string): Promise<void> {
410
+ requirePiLoginProvider(id)
411
+ await this.slot(id).waitUntilSettled()
412
+ }
413
+
414
+ async dispose(): Promise<void> {
415
+ await Promise.all([...this.byId.values()].map(slot => slot.dispose()))
416
+ }
417
+ }
418
+
419
+ function trustedRequest(req: IncomingMessage): boolean {
420
+ const remote = req.socket.remoteAddress
421
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') return false
422
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
423
+ const host = req.headers.host
424
+ if (host === undefined) return false
425
+ const origin = req.headers.origin
426
+ if (origin === undefined) return true
427
+ try {
428
+ return new URL(origin).host === new URL(`http://${host}`).host
429
+ } catch {
430
+ return false
431
+ }
432
+ }
433
+
434
+ function trustedProxyRequest(req: IncomingMessage): boolean {
435
+ if (!trustedRequest(req)) return false
436
+ // A proxy endpoint controls where credentials travel. Reject a rebound
437
+ // public hostname even when its connection happens to originate on loopback.
438
+ try {
439
+ const host = new URL(`http://${req.headers.host}`).hostname
440
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
441
+ } catch { return false }
442
+ }
443
+
444
+ function rejectWebRequest(ctx: Context, req: IncomingMessage, res: ServerResponse, proxy = false): boolean {
445
+ const rejection = ctx.connection.requestRejection(req)
446
+ if (rejection !== undefined) {
447
+ json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
448
+ return true
449
+ }
450
+ if (proxy ? !trustedProxyRequest(req) : !trustedRequest(req)) {
451
+ json(res, 403, { error: 'forbidden' })
452
+ return true
453
+ }
454
+ return false
455
+ }
456
+
457
+ function json(res: ServerResponse, status: number, value: unknown): void {
458
+ res.writeHead(status, {
459
+ 'content-type': 'application/json; charset=utf-8',
460
+ 'cache-control': 'no-store',
461
+ 'x-content-type-options': 'nosniff',
462
+ })
463
+ res.end(JSON.stringify(value))
464
+ }
465
+
466
+ async function readJson(req: IncomingMessage): Promise<unknown> {
467
+ const chunks: Buffer[] = []
468
+ let size = 0
469
+ for await (const chunk of req) {
470
+ size += chunk.length
471
+ if (size > 4096) throw new Error('request body too large')
472
+ chunks.push(chunk as Buffer)
473
+ }
474
+ if (chunks.length === 0) return {}
475
+ return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
476
+ }
477
+
478
+ function providerIdFrom(value: unknown): string {
479
+ if (typeof value !== 'object' || value === null || !('provider' in value) || typeof value.provider !== 'string') {
480
+ throw new Error('expected { "provider": "<id>" }')
481
+ }
482
+ return requirePiLoginProvider(value.provider).id
483
+ }
484
+
485
+ function providerInputFrom(value: unknown): { providerId: string; value: string } {
486
+ if (
487
+ typeof value !== 'object'
488
+ || value === null
489
+ || !('provider' in value)
490
+ || typeof value.provider !== 'string'
491
+ || !('value' in value)
492
+ || typeof value.value !== 'string'
493
+ ) {
494
+ throw new Error('expected { "provider": "<id>", "value": "<credential>" }')
495
+ }
496
+ return {
497
+ providerId: requirePiLoginProvider(value.provider).id,
498
+ value: value.value,
499
+ }
500
+ }
501
+
502
+ export interface PiLoginAuthRouteOptions {
503
+ /** Called after a successful sign-in or sign-out so the host can refresh LLM routes. */
504
+ onAuthChanged?: () => void | Promise<void>
505
+ }
506
+
507
+ export function registerPiLoginAuthRoutes(
508
+ ctx: Context,
509
+ session: PiLoginSession,
510
+ options: PiLoginAuthRouteOptions = {},
511
+ ): void {
512
+ const auth = new PiLoginWebAuth(session)
513
+ const notifyAuthChanged = async (): Promise<void> => {
514
+ await options.onAuthChanged?.()
515
+ }
516
+ ctx.effect(() => {
517
+ const routes = [
518
+ ctx.webServer.register({
519
+ kind: 'exact',
520
+ path: PROXY_SETTINGS_PATH,
521
+ handler: async (req, res) => {
522
+ if (rejectWebRequest(ctx, req, res, true)) return
523
+ if (req.method === 'GET') {
524
+ try { return json(res, 200, await session.proxy.settings.read()) } catch {
525
+ return json(res, 503, { error: 'Network settings could not be read' })
526
+ }
527
+ }
528
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
529
+ if (req.headers['content-type']?.split(';')[0]?.trim() !== 'application/json') {
530
+ return json(res, 415, { error: 'application/json required' })
531
+ }
532
+ let settings
533
+ try { settings = parseProxySettings(await readJson(req)) } catch {
534
+ return json(res, 400, { error: 'Invalid network settings: use HTTP(S) proxy URLs without credentials or paths' })
535
+ }
536
+ try { json(res, 200, await session.proxy.save(settings)) } catch (error) {
537
+ if (error instanceof ProxySettingsConflict) return json(res, 409, { error: error.message })
538
+ json(res, 503, { error: 'Network settings could not be saved' })
539
+ }
540
+ },
541
+ }),
542
+ ctx.webServer.register({
543
+ kind: 'exact',
544
+ path: OPENROUTER_CATALOG_PATH,
545
+ handler: async (req, res) => {
546
+ if (rejectWebRequest(ctx, req, res)) return
547
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
548
+ try {
549
+ await session.openRouter.syncAuthentication()
550
+ json(res, 200, session.openRouter.snapshot())
551
+ } catch {
552
+ json(res, 503, { error: 'OpenRouter model catalog unavailable' })
553
+ }
554
+ },
555
+ }),
556
+ ctx.webServer.register({
557
+ kind: 'exact',
558
+ path: OPENROUTER_REFRESH_PATH,
559
+ handler: async (req, res) => {
560
+ if (rejectWebRequest(ctx, req, res)) return
561
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
562
+ try {
563
+ await session.openRouter.syncAuthentication()
564
+ if (!session.openRouter.snapshot().connected) {
565
+ return json(res, 409, { error: 'Sign in to OpenRouter first' })
566
+ }
567
+ await session.openRouter.refresh(true)
568
+ json(res, 200, session.openRouter.snapshot())
569
+ } catch {
570
+ json(res, 503, { error: 'OpenRouter model catalog unavailable' })
571
+ }
572
+ },
573
+ }),
574
+ ctx.webServer.register({
575
+ kind: 'exact',
576
+ path: PI_LOGIN_AUTH_STATUS_PATH,
577
+ handler: async (req, res) => {
578
+ if (rejectWebRequest(ctx, req, res)) return
579
+ if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
580
+ json(res, 200, await auth.status())
581
+ },
582
+ }),
583
+ ctx.webServer.register({
584
+ kind: 'exact',
585
+ path: PI_LOGIN_AUTH_LOGIN_PATH,
586
+ handler: async (req, res) => {
587
+ if (rejectWebRequest(ctx, req, res)) return
588
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
589
+ try {
590
+ const challenge = await auth.signIn(providerIdFrom(await readJson(req)))
591
+ if (challenge.kind === 'browser') {
592
+ // OAuth finishes in the browser; refresh LLM routes once the grant lands.
593
+ void auth.waitUntilSettled(challenge.provider).then(async () => {
594
+ await notifyAuthChanged()
595
+ })
596
+ }
597
+ json(res, 200, challenge)
598
+ } catch (error: unknown) {
599
+ json(res, 500, { error: safeMessage(error) })
600
+ }
601
+ },
602
+ }),
603
+ ctx.webServer.register({
604
+ kind: 'exact',
605
+ path: PI_LOGIN_AUTH_CANCEL_PATH,
606
+ handler: async (req, res) => {
607
+ if (rejectWebRequest(ctx, req, res)) return
608
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
609
+ try {
610
+ await auth.cancel(providerIdFrom(await readJson(req)))
611
+ json(res, 200, { ok: true })
612
+ } catch (error: unknown) {
613
+ json(res, 500, { error: safeMessage(error) })
614
+ }
615
+ },
616
+ }),
617
+ ctx.webServer.register({
618
+ kind: 'exact',
619
+ path: PI_LOGIN_AUTH_COMPLETE_PATH,
620
+ handler: async (req, res) => {
621
+ if (rejectWebRequest(ctx, req, res)) return
622
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
623
+ try {
624
+ const submission = providerInputFrom(await readJson(req))
625
+ const account = await auth.submitInput(submission.providerId, submission.value)
626
+ await notifyAuthChanged()
627
+ json(res, 200, { ok: true, account })
628
+ } catch (error: unknown) {
629
+ json(res, 500, { error: safeMessage(error) })
630
+ }
631
+ },
632
+ }),
633
+ ctx.webServer.register({
634
+ kind: 'exact',
635
+ path: PI_LOGIN_AUTH_LOGOUT_PATH,
636
+ handler: async (req, res) => {
637
+ if (rejectWebRequest(ctx, req, res)) return
638
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
639
+ try {
640
+ await auth.signOut(providerIdFrom(await readJson(req)))
641
+ await notifyAuthChanged()
642
+ json(res, 200, { ok: true })
643
+ } catch (error: unknown) {
644
+ json(res, 500, { error: safeMessage(error) })
645
+ }
646
+ },
647
+ }),
648
+ ]
649
+ return async () => {
650
+ for (const dispose of routes) dispose()
651
+ await auth.dispose()
652
+ }
653
+ }, 'dsh-oauth-login: Web OAuth routes')
654
+ }