@huaqiu/dsh-auth 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts CHANGED
@@ -20,11 +20,25 @@
20
20
  * 4. null → tools return needs_auth
21
21
  */
22
22
 
23
+ import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
24
+
25
+ // Lazy so test suites that `vi.mock('@deepseek-ai/dsh-home-paths', …)` before
26
+ // the test's TMP constant is initialised don't trigger `dshHomePath('logs')` at
27
+ // module-load time. Every site goes through `log()` instead of holding a module
28
+ // reference to the logger.
29
+ let _log: PluginLogger | null = null
30
+ function log(): PluginLogger {
31
+ if (_log === null) _log = getLogger('dsh-auth')
32
+ return _log
33
+ }
34
+
23
35
  export interface HuaqiuAuthConfig {
24
36
  /** HQ Edge base URL, e.g. "http://localhost:18080". Absent → standalone. */
25
37
  hqEdgeBaseUrl?: string
26
38
  /** Path on the host; default "/api/v1/auth/token". */
27
39
  hostAuthPath?: string
40
+ /** Host endpoint that triggers the EDA login dialog; default "/api/v1/auth/login". */
41
+ hostLoginPath?: string
28
42
  /** Seconds a host session is reused before re-fetching. Default 300. */
29
43
  hostSessionTtlSeconds?: number
30
44
  /** Seconds remote token-validation results are cached. Default 60. */
@@ -32,6 +46,7 @@ export interface HuaqiuAuthConfig {
32
46
  }
33
47
 
34
48
  export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
49
+ export const DEFAULT_HOST_LOGIN_PATH = '/api/v1/auth/login'
35
50
  export const DEFAULT_HOST_TTL_SECONDS = 300
36
51
  export const DEFAULT_VALIDATION_TTL_SECONDS = 60
37
52
 
@@ -50,6 +65,9 @@ export function resolveHostConfig(
50
65
  const hostAuthPath = config?.hostAuthPath
51
66
  ?? env.HQ_EDGE_AUTH_PATH
52
67
  ?? DEFAULT_HOST_AUTH_PATH
68
+ const hostLoginPath = config?.hostLoginPath
69
+ ?? env.HQ_EDGE_LOGIN_PATH
70
+ ?? DEFAULT_HOST_LOGIN_PATH
53
71
  const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS
54
72
  let ttl = DEFAULT_HOST_TTL_SECONDS
55
73
  if (typeof ttlRaw === 'number' && Number.isFinite(ttlRaw) && ttlRaw > 0) {
@@ -69,6 +87,7 @@ export function resolveHostConfig(
69
87
  return {
70
88
  hqEdgeBaseUrl: baseUrl,
71
89
  hostAuthPath,
90
+ hostLoginPath,
72
91
  hostSessionTtlSeconds: ttl,
73
92
  validationTtlSeconds,
74
93
  }
@@ -83,11 +102,37 @@ export interface ResolvedHostUser {
83
102
  id: string
84
103
  token: string
85
104
  nickname?: string
105
+ /**
106
+ * The HOST's own verdict on the credential.
107
+ *
108
+ * HQ Edge receives its credential from EDA (`AuthStateChanged`) and reports
109
+ * `authenticated` alongside the token; that verdict is authoritative for host
110
+ * mode. This plugin must not substitute its own guess: the token class EDA
111
+ * hands to HQ Edge is not necessarily known to the public `www.eda.cn`
112
+ * token-validation endpoint, so a remote probe there can answer "not valid"
113
+ * for a credential the operator's own editor considers live.
114
+ */
115
+ authenticated: boolean
116
+ /**
117
+ * Host-side state version (HQ Edge bumps it on every credential replace and
118
+ * clear). A change means "this is a different credential than the one you
119
+ * looked at", which is how a 401-driven invalidation gets undone.
120
+ */
121
+ version?: number
122
+ /** Unix seconds, when the host knows the expiry. */
123
+ expiresAt?: number
86
124
  }
87
125
 
88
126
  /**
89
- * Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
90
- * TTL; the loopback GET is cheap and the token is static, so we never persist it.
127
+ * Fetches and caches the host (HQ Edge) session. The cache is memory-only with
128
+ * a TTL; the loopback GET is cheap, so we never persist the credential.
129
+ *
130
+ * Caching rule — **only a usable session is cached**. A host that is up but has
131
+ * no credential yet (EDA not logged in, or the login dialog still open) must
132
+ * NOT be cached: the operator may complete the login a second later, and a
133
+ * cached negative would pin every tool to `needs_auth` for the whole TTL. The
134
+ * old code cached whatever it got, so a DSH that booted before EDA
135
+ * authenticated stayed "logged out" for up to `hostSessionTtlSeconds` (300s).
91
136
  */
92
137
  export class HostSessionResolver {
93
138
  private cache: HostSession | null = null
@@ -110,21 +155,47 @@ export class HostSessionResolver {
110
155
  if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) {
111
156
  return this.cache.info
112
157
  }
158
+ const url = `${this.baseUrl}${this.path}`
159
+ let info: ResolvedHostUser | null = null
160
+ let status: number | null = null
113
161
  try {
114
- const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
162
+ const res = await this.doFetch(url, {
115
163
  method: 'GET',
116
164
  headers: { accept: 'application/json' },
117
165
  })
118
- if (!res.ok) return this.cache?.info ?? null
119
- const data = await res.json() as Record<string, unknown>
120
- const info = normalizeHostUser(data)
121
- if (!info) return this.cache?.info ?? null
166
+ status = res.status
167
+ if (res.ok) {
168
+ const data = await res.json() as Record<string, unknown>
169
+ info = normalizeHostUser(data)
170
+ }
171
+ } catch (err) {
172
+ // Network error: nothing usable right now.
173
+ log().warn('host session fetch failed', { url, error: String(err) })
174
+ info = null
175
+ }
176
+ if (info !== null && info.authenticated) {
122
177
  this.cache = { info, fetchedAt: now }
178
+ // Never log the credential itself — id/version are enough to correlate
179
+ // this plugin with the HQ Edge log line that served it.
180
+ log().debug('host session resolved', {
181
+ url,
182
+ status,
183
+ userId: info.id,
184
+ version: info.version ?? null,
185
+ authenticated: info.authenticated,
186
+ })
123
187
  return info
124
- } catch {
125
- // Network error: fall back to a previously cached value if we have one.
126
- return this.cache?.info ?? null
127
188
  }
189
+ // Unusable (unreachable, unparseable, or the host says it has no
190
+ // credential). Drop the cache so the very next call re-asks the host.
191
+ this.cache = null
192
+ log().info('no usable host session', {
193
+ url,
194
+ status,
195
+ parsed: info !== null,
196
+ hostAuthenticated: info?.authenticated ?? null,
197
+ })
198
+ return null
128
199
  }
129
200
 
130
201
  /** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
@@ -139,7 +210,15 @@ function asId(raw: unknown): string | null {
139
210
  return null
140
211
  }
141
212
 
142
- /** Parse the host route payload into a credential, tolerating key-name drift. */
213
+ /**
214
+ * Parse the host route payload into a credential, tolerating key-name drift.
215
+ *
216
+ * HQ Edge answers `GET /api/v1/auth/token` with
217
+ * `{ authenticated, token, userId, version }`. The `authenticated` flag is the
218
+ * host's own verdict and is carried through verbatim; hosts that predate it
219
+ * (or a minimal `{ token, userId }` stub) omit the key, in which case the
220
+ * presence of a usable token+id is taken as the old contract was: authenticated.
221
+ */
143
222
  export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUser | null {
144
223
  const token = typeof data.token === 'string' && data.token.length > 0
145
224
  ? data.token
@@ -151,5 +230,19 @@ export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUs
151
230
  const nickname = typeof data.nickname === 'string' && data.nickname.length > 0
152
231
  ? data.nickname
153
232
  : undefined
154
- return { id, token, ...(nickname ? { nickname } : {}) }
233
+ const authenticated = data.authenticated === undefined ? true : data.authenticated === true
234
+ const version = typeof data.version === 'number' && Number.isFinite(data.version)
235
+ ? data.version
236
+ : undefined
237
+ const expiresAt = typeof data.expiresAt === 'number' && Number.isFinite(data.expiresAt)
238
+ ? data.expiresAt
239
+ : undefined
240
+ return {
241
+ id,
242
+ token,
243
+ authenticated,
244
+ ...(nickname ? { nickname } : {}),
245
+ ...(version !== undefined ? { version } : {}),
246
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
247
+ }
155
248
  }
package/src/index.ts CHANGED
@@ -6,10 +6,13 @@
6
6
  */
7
7
  import type { Context } from '@deepseek-ai/cordis'
8
8
  import type {} from '@deepseek-ai/dsh-host-webserver'
9
+ import { getLogger } from '@huaqiu/dsh-plugin-log'
9
10
  import { InMemoryHuaqiuAuthService, type HuaqiuAuthService } from './service.js'
10
11
  import { AUTH_ROUTE_PREFIX, createAuthHandler } from './routes.js'
11
12
  import type { HuaqiuAuthConfig } from './host.js'
12
13
 
14
+ const log = getLogger('dsh-auth')
15
+
13
16
  export type { HuaqiuAuthApi, HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
14
17
  export { InMemoryHuaqiuAuthService } from './service.js'
15
18
  export { AUTH_ROUTE_PREFIX, createAuthHandler, type AuthHandler } from './routes.js'
@@ -33,6 +36,16 @@ declare module '@deepseek-ai/cordis' {
33
36
  * without a supervisor (spec §6.4).
34
37
  */
35
38
  export function apply(ctx: Context, config?: Partial<HuaqiuAuthConfig>): void {
39
+ // The single most useful line when a harness upgrade "loses" the credential:
40
+ // it proves whether the overlay `config` still reached `apply(ctx, config)`.
41
+ // When `hostMode` is false under HQ Edge, the endpoint was not delivered and
42
+ // every tool will report `needs_auth` no matter what the browser does.
43
+ log.info('applying dsh-auth node half', {
44
+ hasConfig: config != null,
45
+ configKeys: config != null ? Object.keys(config) : [],
46
+ hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? null,
47
+ })
48
+
36
49
  const service = new InMemoryHuaqiuAuthService(config)
37
50
  ctx.effect(() => ctx.provide('huaqiuAuth', service))
38
51
 
@@ -41,4 +54,6 @@ export function apply(ctx: Context, config?: Partial<HuaqiuAuthConfig>): void {
41
54
  path: AUTH_ROUTE_PREFIX,
42
55
  handler: createAuthHandler(service),
43
56
  }))
57
+
58
+ log.info('dsh-auth node half ready', { routes: AUTH_ROUTE_PREFIX, hostMode: service.hostMode })
44
59
  }
package/src/routes.ts CHANGED
@@ -15,8 +15,18 @@
15
15
  * the documented channel).
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from 'node:http'
18
+ import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
18
19
  import type { HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
19
20
 
21
+ // Lazy so test suites that `vi.mock('@deepseek-ai/dsh-home-paths', …)` before
22
+ // their TMP constant is initialised don't trigger `dshHomePath('logs')` at
23
+ // module-load time.
24
+ let _log: PluginLogger | null = null
25
+ function log(): PluginLogger {
26
+ if (_log === null) _log = getLogger('dsh-auth')
27
+ return _log
28
+ }
29
+
20
30
  export const AUTH_ROUTE_PREFIX = '/api/v1/huaqiu/auth'
21
31
 
22
32
  export type AuthHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void> | void
@@ -93,6 +103,15 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
93
103
  return
94
104
  }
95
105
 
106
+ if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/login`) {
107
+ // Host-mode login: forward to HQ Edge, which asks EDA (KiCad) to open
108
+ // its TriggerLoginDialog. Blocks until the dialog is completed.
109
+ await service.auth.login()
110
+ const user = await service.auth.getUserInfo()
111
+ sendJson(res, 200, { ok: true, authenticated: user !== null, user })
112
+ return
113
+ }
114
+
96
115
  sendJson(res, 404, { error: 'not found' })
97
116
  } catch (err) {
98
117
  sendJson(res, 500, { error: 'internal error', detail: String(err) })
package/src/service.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
24
24
  import { join } from 'node:path'
25
25
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
26
+ import { getLogger, type PluginLogger } from '@huaqiu/dsh-plugin-log'
26
27
  import {
27
28
  HostSessionResolver,
28
29
  resolveHostConfig,
@@ -146,29 +147,48 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
146
147
  private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
147
148
  private readonly host: HostSessionResolver
148
149
  private readonly validator: TokenValidator
150
+ private readonly doFetch: typeof fetch
151
+ private readonly hostLoginPath: string
152
+ /** Bound on the host login RPC (user must complete the EDA dialog). */
153
+ private readonly loginWaitMs: number
149
154
  /**
150
155
  * True once the current credential has been rejected (API 401) or explicitly
151
156
  * invalidated. Keeps the credential for recovery but makes `isAuthenticated()`
152
157
  * short-circuit to false and forces a fresh remote validation next time.
153
158
  */
154
159
  private stale = false
160
+ /** Last host credential version seen; see `observeHostVersion`. */
161
+ private lastHostVersion: number | null = null
162
+ private _log: PluginLogger | null = null
163
+ private get log(): PluginLogger {
164
+ if (this._log === null) this._log = getLogger('dsh-auth')
165
+ return this._log
166
+ }
155
167
 
156
168
  constructor(
157
169
  config?: Partial<HuaqiuAuthConfig> | null,
158
170
  opts?: { fetchImpl?: typeof fetch },
159
171
  ) {
160
172
  const resolved = resolveHostConfig(config)
173
+ this.doFetch = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis)
161
174
  this.host = new HostSessionResolver(
162
175
  resolved.hqEdgeBaseUrl ?? '',
163
176
  resolved.hostAuthPath ?? '/api/v1/auth/token',
164
177
  (resolved.hostSessionTtlSeconds ?? 300) * 1000,
165
- opts?.fetchImpl,
178
+ this.doFetch,
166
179
  )
180
+ this.hostLoginPath = resolved.hostLoginPath ?? '/api/v1/auth/login'
181
+ this.loginWaitMs = 5 * 60_000
167
182
  this.validator = new TokenValidator({
168
183
  ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
169
- fetchImpl: opts?.fetchImpl,
184
+ fetchImpl: this.doFetch,
170
185
  })
171
186
  this.hostMode = this.host.enabled
187
+ this.log.info('auth service started', {
188
+ hostMode: this.hostMode,
189
+ hostAuthPath: resolved.hostAuthPath,
190
+ hostSessionTtlSeconds: resolved.hostSessionTtlSeconds,
191
+ })
172
192
  }
173
193
 
174
194
  /** Host mode is active iff a host base URL was configured (see HostSessionResolver.enabled). */
@@ -176,13 +196,40 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
176
196
 
177
197
  readonly auth: HuaqiuAuthApi = {
178
198
  isAuthenticated: async () => {
199
+ // `stale` is "an upstream 401 was reported against a previous credential".
200
+ // Run resolve() *first* so the host's version comparator can run — HQ Edge
201
+ // bumps its credential version when the operator re-authes, and a newer
202
+ // version means the previous 401 no longer applies. Only after that
203
+ // observation do we honour the stale latch (if the credential is
204
+ // unchanged). Without this ordering, a 401-driven invalidation would
205
+ // keep the operator logged out even after HQ Edge swapped credentials.
206
+ const info = await this.resolve()
207
+ if (info === null) return false
179
208
  if (this.stale) return false
180
209
  return (await this.validateInternal()).status === 'valid'
181
210
  },
182
211
  getAccessToken: async () => (await this.resolve())?.token ?? null,
183
212
  getUserInfo: async () => this.resolve(),
184
213
  login: async () => {
185
- /* login is a browser action */
214
+ // Standalone: login is a browser action (auth.eda.cn iframe) — no-op.
215
+ if (!this.host.enabled) return
216
+ // Host mode: the browser-side iframe is suppressed, so clicking login
217
+ // must ask the host (hq-edge → EDA TriggerLoginDialog) to open the EDA
218
+ // login dialog. The host route blocks until AuthStateChanged flips the
219
+ // credential (bounded), then we drop the cached host session so the next
220
+ // resolve() re-fetches the fresh token.
221
+ const res = await this.doFetch(`${this.host.baseUrl}${this.hostLoginPath}`, {
222
+ method: 'POST',
223
+ headers: { accept: 'application/json' },
224
+ signal: AbortSignal.timeout(this.loginWaitMs),
225
+ })
226
+ if (!res.ok) {
227
+ throw new Error(`host login not completed: HTTP ${res.status}`)
228
+ }
229
+ this.host.clear()
230
+ this.stale = false
231
+ this.validator.invalidate()
232
+ this.emit()
186
233
  },
187
234
  logout: async () => this.invalidate(),
188
235
  validate: () => this.validateInternal(),
@@ -199,24 +246,78 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
199
246
  * standalone (auth.eda.cn) login. Otherwise a stale `~/.dsh/auth/session.json`
200
247
  * would let the DSH plugins keep calling the backend while hq-edge itself has
201
248
  * no auth info. The standalone fallbacks only apply when no host is configured.
249
+ *
250
+ * A host credential the host itself reports as `authenticated: false` counts
251
+ * as "no credential" — otherwise `/session` would hand the browser half a user
252
+ * object its own `authenticated` flag contradicts, and the browser half (which
253
+ * adopts the session only when `authenticated` is true) would silently drop
254
+ * it. That mismatch is exactly how "the token reached HQ Edge but never
255
+ * reached the plugin" presents.
202
256
  */
203
257
  private async resolve(): Promise<HuaqiuUserInfo | null> {
204
258
  if (this.host.enabled) {
205
259
  const host = await this.host.resolve()
206
- if (host) return toUserInfo(host)
207
- return null
260
+ if (!host || !host.authenticated) {
261
+ this.log.debug('host session unusable', {
262
+ hostReachable: host !== null,
263
+ hostAuthenticated: host?.authenticated ?? null,
264
+ })
265
+ return null
266
+ }
267
+ this.observeHostVersion(host.version)
268
+ return toUserInfo(host)
208
269
  }
209
270
  if (this.current) return this.current
210
271
  return readPersisted()
211
272
  }
212
273
 
213
- /** Spec §7: resolve → local expiry → remote validation → update state. */
274
+ /**
275
+ * Track the host's credential version so a replaced credential re-arms the
276
+ * session after a 401.
277
+ *
278
+ * `invalidate()` (called when a backend answers 401) must keep the session
279
+ * down — but only for *that* credential. HQ Edge bumps `version` whenever EDA
280
+ * replaces or clears its credential, so a new version means the operator has
281
+ * a new token and the previous rejection no longer applies.
282
+ */
283
+ private observeHostVersion(version?: number): void {
284
+ if (typeof version !== 'number' || !Number.isFinite(version)) return
285
+ if (this.lastHostVersion !== null && version !== this.lastHostVersion) {
286
+ this.stale = false
287
+ this.validator.invalidate()
288
+ this.log.info('host credential replaced — re-arming session', { version })
289
+ }
290
+ this.lastHostVersion = version
291
+ }
292
+
293
+ /**
294
+ * Spec §7: resolve → local expiry → remote validation → update state.
295
+ *
296
+ * In HOST mode there is no remote validation: the host is the authority. It
297
+ * received the credential from EDA and publishes its own `authenticated`
298
+ * verdict, so probing `www.eda.cn/api/token/validate` here is not just
299
+ * redundant — it is wrong. That endpoint does not know the credential class
300
+ * EDA hands to HQ Edge (it answers `result: false`) and is unreachable in
301
+ * offline deployments, so gating on it made every host-mode session read as
302
+ * unauthenticated even while hq-edge reported `authenticated: true`.
303
+ */
214
304
  private async validateInternal(): Promise<AuthValidationResult> {
215
305
  const info = await this.resolve()
216
306
  if (!info) {
217
- this.stale = true
307
+ // Host mode deliberately does NOT latch `stale` here: an unreachable or
308
+ // not-yet-logged-in host is a transient condition, and latching would
309
+ // freeze the session even after the host recovers.
310
+ if (!this.host.enabled) this.stale = true
218
311
  return { status: 'invalid', reason: 'invalid' }
219
312
  }
313
+ if (this.host.enabled) {
314
+ if (this.validator.isLocallyExpired(info.expiresAt)) {
315
+ this.validator.invalidate(info.token)
316
+ this.stale = true
317
+ return { status: 'invalid', reason: 'expired' }
318
+ }
319
+ return { status: 'valid', userId: info.id, ...(info.expiresAt !== undefined ? { expiresAt: info.expiresAt } : {}) }
320
+ }
220
321
  // Local expiry is an optimization; remote validation stays authoritative.
221
322
  if (this.validator.isLocallyExpired(info.expiresAt)) {
222
323
  this.validator.invalidate(info.token)
@@ -231,10 +332,18 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
231
332
  return result
232
333
  }
233
334
 
234
- /** Validation-scoped invalidation: keep the credential, drop cached validity. */
335
+ /** Validation-scoped invalidation: keep the credential, drop cached validity.
336
+ *
337
+ * In host mode the host cache *is* the cached validity — the validator only
338
+ * confirmed what the host already said — so the next call must re-fetch:
339
+ * otherwise a 401-driven `auth.invalidate()` would keep stale=true latched
340
+ * for the full `hostSessionTtlSeconds` (default 300 s), and an HQ-Edge
341
+ * credential replace in between would be invisible until the cache expired.
342
+ */
235
343
  private markStale(): void {
236
344
  this.stale = true
237
345
  this.validator.invalidate()
346
+ if (this.host.enabled) this.host.clear()
238
347
  }
239
348
 
240
349
  setCredentials(info: HuaqiuUserInfo): void {
@@ -275,5 +384,6 @@ function toUserInfo(host: ResolvedHostUser): HuaqiuUserInfo {
275
384
  id: host.id,
276
385
  token: host.token,
277
386
  ...(host.nickname !== undefined ? { nickname: host.nickname } : {}),
387
+ ...(host.expiresAt !== undefined ? { expiresAt: host.expiresAt } : {}),
278
388
  }
279
389
  }