@huaqiu/dsh-auth 0.3.15 → 0.3.17

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/lib/index.mjs CHANGED
@@ -24,10 +24,10 @@ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
24
24
  * 3. persisted file — node-side `~/.dsh/auth/session.json`, written on every set
25
25
  * 4. null → tools return needs_auth
26
26
  */
27
- let _log = null;
28
- function log$1() {
29
- if (_log === null) _log = getLogger("dsh-auth");
30
- return _log;
27
+ let _log$1 = null;
28
+ function log$2() {
29
+ if (_log$1 === null) _log$1 = getLogger("dsh-auth");
30
+ return _log$1;
31
31
  }
32
32
  /**
33
33
  * Resolve the effective config: overlay `config` (highest) > env (safety net
@@ -38,6 +38,7 @@ function resolveHostConfig(config, env = process.env) {
38
38
  const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "";
39
39
  const hostAuthPath = config?.hostAuthPath ?? env.HQ_EDGE_AUTH_PATH ?? "/api/v1/auth/token";
40
40
  const hostLoginPath = config?.hostLoginPath ?? env.HQ_EDGE_LOGIN_PATH ?? "/api/v1/auth/login";
41
+ const hostLogoutPath = config?.hostLogoutPath ?? env.HQ_EDGE_LOGOUT_PATH ?? "/api/v1/auth/logout";
41
42
  const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS;
42
43
  let ttl = 300;
43
44
  if (typeof ttlRaw === "number" && Number.isFinite(ttlRaw) && ttlRaw > 0) ttl = ttlRaw;
@@ -56,6 +57,7 @@ function resolveHostConfig(config, env = process.env) {
56
57
  hqEdgeBaseUrl: baseUrl,
57
58
  hostAuthPath,
58
59
  hostLoginPath,
60
+ hostLogoutPath,
59
61
  hostSessionTtlSeconds: ttl,
60
62
  validationTtlSeconds
61
63
  };
@@ -102,7 +104,7 @@ var HostSessionResolver = class {
102
104
  status = res.status;
103
105
  if (res.ok) info = normalizeHostUser(await res.json());
104
106
  } catch (err) {
105
- log$1().warn("host session fetch failed", {
107
+ log$2().warn("host session fetch failed", {
106
108
  url,
107
109
  error: String(err)
108
110
  });
@@ -113,7 +115,7 @@ var HostSessionResolver = class {
113
115
  info,
114
116
  fetchedAt: now
115
117
  };
116
- log$1().debug("host session resolved", {
118
+ log$2().debug("host session resolved", {
117
119
  url,
118
120
  status,
119
121
  userId: info.id,
@@ -123,7 +125,7 @@ var HostSessionResolver = class {
123
125
  return info;
124
126
  }
125
127
  this.cache = null;
126
- log$1().info("no usable host session", {
128
+ log$2().info("no usable host session", {
127
129
  url,
128
130
  status,
129
131
  parsed: info !== null,
@@ -323,6 +325,12 @@ var TokenValidator = class {
323
325
  const PERSIST_FILE = "session.json";
324
326
  const PERSIST_DIR = () => dshHomePath("auth");
325
327
  /**
328
+ * Bounded wait for the host to confirm a logout. Shorter than the login wait
329
+ * (which must cover a human completing the EDA dialog): logout is a host-side
330
+ * operation, so a confirmation that takes longer than this means it failed.
331
+ */
332
+ const LOGOUT_WAIT_MS = 3e4;
333
+ /**
326
334
  * Returns the persisted session, or null if absent/unreadable. Best-effort: a
327
335
  * corrupt or unreadable file is treated as "no session" rather than thrown.
328
336
  */
@@ -369,6 +377,7 @@ var InMemoryHuaqiuAuthService = class {
369
377
  validator;
370
378
  doFetch;
371
379
  hostLoginPath;
380
+ hostLogoutPath;
372
381
  /** Bound on the host login RPC (user must complete the EDA dialog). */
373
382
  loginWaitMs;
374
383
  /**
@@ -389,6 +398,7 @@ var InMemoryHuaqiuAuthService = class {
389
398
  this.doFetch = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis);
390
399
  this.host = new HostSessionResolver(resolved.hqEdgeBaseUrl ?? "", resolved.hostAuthPath ?? "/api/v1/auth/token", (resolved.hostSessionTtlSeconds ?? 300) * 1e3, this.doFetch);
391
400
  this.hostLoginPath = resolved.hostLoginPath ?? "/api/v1/auth/login";
401
+ this.hostLogoutPath = resolved.hostLogoutPath ?? "/api/v1/auth/logout";
392
402
  this.loginWaitMs = 3e5;
393
403
  this.validator = new TokenValidator({
394
404
  ttlMs: (resolved.validationTtlSeconds ?? 60) * 1e3,
@@ -424,7 +434,22 @@ var InMemoryHuaqiuAuthService = class {
424
434
  this.validator.invalidate();
425
435
  this.emit();
426
436
  },
427
- logout: async () => this.invalidate(),
437
+ logout: async () => {
438
+ if (!this.host.enabled) {
439
+ this.invalidate();
440
+ return;
441
+ }
442
+ const res = await this.doFetch(`${this.host.baseUrl}${this.hostLogoutPath}`, {
443
+ method: "POST",
444
+ headers: { accept: "application/json" },
445
+ signal: AbortSignal.timeout(LOGOUT_WAIT_MS)
446
+ });
447
+ if (!res.ok) throw new Error(`host logout not completed: HTTP ${res.status}`);
448
+ this.host.clear();
449
+ this.stale = true;
450
+ this.validator.invalidate();
451
+ this.emit();
452
+ },
428
453
  validate: () => this.validateInternal(),
429
454
  invalidate: () => this.markStale(),
430
455
  onAuthStateChanged: (listener) => this.on(listener)
@@ -576,6 +601,43 @@ function toUserInfo(host) {
576
601
  }
577
602
  //#endregion
578
603
  //#region src/routes.ts
604
+ let _log = null;
605
+ function log$1() {
606
+ if (_log === null) _log = getLogger("dsh-auth");
607
+ return _log;
608
+ }
609
+ /**
610
+ * eda.cn user-profile endpoint, reached with the access token as `X-token`
611
+ * (the same endpoint hq-eda-ai's `/api/middleware/getUserInfo` proxies).
612
+ * Returns `{ code: 200000, result: { id, nickname, headimage, phone, username } }`.
613
+ */
614
+ const EDA_USER_INFO_URL = "https://www.eda.cn/api/chiplet/u/base/get";
615
+ /** Fetch the eda.cn profile for a token. Null on any failure (UI degrades). */
616
+ async function fetchEdaUserProfile(token, doFetch = globalThis.fetch.bind(globalThis)) {
617
+ try {
618
+ const res = await doFetch(EDA_USER_INFO_URL, {
619
+ method: "POST",
620
+ headers: {
621
+ "content-type": "application/json",
622
+ "X-token": token
623
+ },
624
+ body: "{}"
625
+ });
626
+ if (!res.ok) return null;
627
+ const body = await res.json();
628
+ if (body.code !== 2e5 || !body.result) return null;
629
+ const r = body.result;
630
+ return {
631
+ ...typeof r.nickname === "string" && r.nickname ? { nickname: r.nickname } : {},
632
+ ...typeof r.headimage === "string" && r.headimage ? { headimage: r.headimage } : {},
633
+ ...typeof r.phone === "string" && r.phone ? { phone: r.phone } : {},
634
+ ...typeof r.username === "string" && r.username ? { username: r.username } : {}
635
+ };
636
+ } catch (err) {
637
+ log$1().warn("fetchEdaUserProfile failed", { error: String(err?.message || err) });
638
+ return null;
639
+ }
640
+ }
579
641
  const AUTH_ROUTE_PREFIX = "/api/v1/huaqiu/auth";
580
642
  function sendJson(res, status, body) {
581
643
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
@@ -619,7 +681,7 @@ function createAuthHandler(service) {
619
681
  return;
620
682
  }
621
683
  if (req.method === "POST" && pathname === `/api/v1/huaqiu/auth/logout`) {
622
- service.invalidate();
684
+ await service.auth.logout();
623
685
  sendJson(res, 200, { ok: true });
624
686
  return;
625
687
  }
@@ -635,6 +697,15 @@ function createAuthHandler(service) {
635
697
  sendJson(res, 200, { hostMode: service.hostMode });
636
698
  return;
637
699
  }
700
+ if (req.method === "GET" && pathname === `/api/v1/huaqiu/auth/user-info`) {
701
+ const token = await service.auth.getAccessToken();
702
+ if (!token) {
703
+ sendJson(res, 200, { userInfo: null });
704
+ return;
705
+ }
706
+ sendJson(res, 200, { userInfo: await fetchEdaUserProfile(token) });
707
+ return;
708
+ }
638
709
  if (req.method === "POST" && pathname === `/api/v1/huaqiu/auth/login`) {
639
710
  await service.auth.login();
640
711
  const user = await service.auth.getUserInfo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-auth",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0",
35
- "@huaqiu/dsh-plugin-log": "0.3.15"
35
+ "@huaqiu/dsh-plugin-log": "0.3.17"
36
36
  },
37
37
  "files": [
38
38
  "lib",
@@ -22,6 +22,11 @@ export interface AuthState {
22
22
  token?: string
23
23
  /** Bound mobile number, forwarded to the profile page as `phone=`. */
24
24
  phone?: string
25
+ /**
26
+ * Rich eda.cn profile fetched in host mode (host session carries only
27
+ * {token, userId}): nickname + headimage, merged above the host session.
28
+ */
29
+ profile?: { nickname?: string; headimage?: string }
25
30
  }
26
31
 
27
32
  /** Snapshot for one credential payload (`null` = logged out). */
@@ -76,6 +81,20 @@ export function subscribeAuth(callback: () => void): () => void {
76
81
  return () => listeners.delete(callback)
77
82
  }
78
83
 
84
+ /**
85
+ * Merge the rich eda.cn profile (nickname + headimage) into the store —
86
+ * sidebar components call this after `auth.getUserProfile()` in host mode.
87
+ * The trigger then renders the real name/photo instead of the bare HQ icon.
88
+ */
89
+ export function setProfile(profile: { nickname?: string; headimage?: string } | null | undefined): void {
90
+ setState({ ...state, profile: profile ?? undefined })
91
+ }
92
+
93
+ /** Current profile (nickname + headimage) resolved from the host, if any. */
94
+ export function getProfile(): { nickname?: string; headimage?: string } | undefined {
95
+ return state.profile
96
+ }
97
+
79
98
  /** Register the node re-sync hook (wired in apply(); called by the login card on mount). */
80
99
  export function registerAuthSync(fn: () => void): void {
81
100
  syncNow = fn
@@ -31,7 +31,20 @@ export interface AuthClient {
31
31
  isHostMode(): boolean
32
32
  getAccessToken(): Promise<string | null>
33
33
  getUserInfo(): Promise<AuthTokenPayload | null>
34
+ /**
35
+ * Rich eda.cn profile (nickname + headimage) for the current token. The
36
+ * node half resolves the token and calls eda.cn — used by the sidebar in
37
+ * host mode when the host session carries only {token, userId} (e.g.
38
+ * kicad). Null on failure; the UI degrades to the HQ icon.
39
+ */
40
+ getUserProfile(): Promise<{ nickname?: string; headimage?: string } | null>
34
41
  login(options?: LoginOptions): Promise<void>
42
+ /**
43
+ * Request logout. In host mode this asks the EDA host (through the node
44
+ * half → hq-edge → TriggerLogout) to log out; it never declares the
45
+ * operator logged out locally. Throws when the request is rejected, so
46
+ * the UI keeps showing the authenticated state.
47
+ */
35
48
  logout(): Promise<void>
36
49
  onAuthStateChanged(listener: (info: AuthTokenPayload | null) => void): () => void
37
50
  }
@@ -140,6 +153,11 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
140
153
  if (hostMode && !hostSessionLoaded) await resolveHost()
141
154
  return hostMode ? hostSession : (hostSession ?? storage.get())
142
155
  },
156
+ getUserProfile: async (): Promise<{ nickname?: string; headimage?: string } | null> => {
157
+ if (hostMode && !hostSessionLoaded) await resolveHost()
158
+ if (!(await auth.getAccessToken())) return null
159
+ return transport.fetchUserInfo()
160
+ },
143
161
  login: async (options?: LoginOptions): Promise<void> => {
144
162
  // Host mode: hq-edge owns the session — ask the host (EDA) to open its
145
163
  // own login dialog instead of the auth.eda.cn iframe. The node route
@@ -157,10 +175,15 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
157
175
  },
158
176
  logout: async (): Promise<void> => {
159
177
  if (hostMode) {
160
- // Host-owned session: nothing to revoke browser-side beyond our cache.
161
- hostSession = null
162
- storage.clear()
163
- emit(null)
178
+ // Host-owned session: ask the EDA host to log out (node half →
179
+ // hq-edge → AuthService.TriggerLogout). Success only means the
180
+ // request was accepted, so re-resolve the host session and emit the
181
+ // HOST's verdict — never a locally invented "logged out". A rejected
182
+ // request throws and leaves `hostSession` (the real state) intact.
183
+ await transport.pushLogout()
184
+ hostSessionLoaded = false
185
+ await resolveHost()
186
+ emit(hostSession)
164
187
  return
165
188
  }
166
189
  storage.clear()
@@ -30,6 +30,13 @@ export interface AuthTransport {
30
30
  * session to pick up the fresh credential.
31
31
  */
32
32
  triggerLogin(): Promise<void>
33
+ /**
34
+ * Fetch the rich eda.cn profile (nickname + headimage) for the current
35
+ * token. The node half resolves the token (host → pushed → persisted) and
36
+ * calls eda.cn; used by the sidebar in host mode when the host session
37
+ * carries only {token, userId} — e.g. host mode under kicad.
38
+ */
39
+ fetchUserInfo(): Promise<EdaUserProfile | null>
33
40
  }
34
41
 
35
42
  /** Minimal session user shape returned by the node `/session` route. */
@@ -40,6 +47,14 @@ export interface HostSessionUser {
40
47
  expiresAt?: number
41
48
  }
42
49
 
50
+ /** Rich eda.cn profile served by the node `/user-info` route. */
51
+ export interface EdaUserProfile {
52
+ nickname?: string
53
+ headimage?: string
54
+ phone?: string
55
+ username?: string
56
+ }
57
+
43
58
  export function createWebServerAuthTransport(
44
59
  base: string = '/api/v1/huaqiu/auth',
45
60
  doFetch: typeof fetch = globalThis.fetch.bind(globalThis),
@@ -95,5 +110,18 @@ export function createWebServerAuthTransport(
95
110
  })
96
111
  if (!res.ok) throw new Error(`auth login trigger failed: HTTP ${res.status}`)
97
112
  },
113
+ async fetchUserInfo() {
114
+ try {
115
+ const res = await doFetch(`${base}/user-info`, {
116
+ method: 'GET',
117
+ headers: { accept: 'application/json' },
118
+ })
119
+ if (!res.ok) return null
120
+ const body = await res.json() as { userInfo?: EdaUserProfile | null }
121
+ return body.userInfo ?? null
122
+ } catch {
123
+ return null
124
+ }
125
+ },
98
126
  }
99
127
  }
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
27
27
  import { createPortal } from 'react-dom'
28
- import { getAuth, getAuthState, getHqEdge, subscribeAuth } from '../auth-state.js'
28
+ import { getAuth, getAuthState, getHqEdge, setProfile, subscribeAuth } from '../auth-state.js'
29
29
  import { buildProfileUrl } from '../lib.js'
30
30
  import { useIsDark, useLocale } from '../ui-env.js'
31
31
  import { useT } from '../i18n.js'
@@ -222,7 +222,6 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
222
222
 
223
223
  const palette = dark ? DARK_PALETTE : LIGHT_PALETTE
224
224
  const authenticated = authState.authenticated
225
- const avatar = authenticated && !avatarBroken ? authState.avatar : undefined
226
225
  const showLabel = wide !== false
227
226
 
228
227
  // Hide the trigger only when an HQ Edge host that ALREADY exposes its own
@@ -234,10 +233,36 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
234
233
  const targetHost = hostMode ? (getHqEdge()?.context?.getTargetHost?.() ?? '') : ''
235
234
  if (hostMode && targetHost === 'hq-eda') return null
236
235
 
236
+ // Host sessions (kicad/generic) carry only {token, userId} — no nickname,
237
+ // no avatar. Fetch the rich eda.cn profile (name + photo) so the trigger
238
+ // renders the real account instead of a bare HQ icon. Standalone DSH already
239
+ // has both from the auth.eda.cn payload, so this is host-only.
240
+ const profile = authState.profile
241
+ useEffect(() => {
242
+ if (!hostMode || targetHost === 'hq-eda') return
243
+ if (!authenticated) {
244
+ setProfile(null)
245
+ return
246
+ }
247
+ let cancelled = false
248
+ auth?.getUserProfile?.()
249
+ .then((info) => {
250
+ if (cancelled) return
251
+ setProfile(info)
252
+ })
253
+ .catch(() => { /* UI degrades to the HQ icon */ })
254
+ return () => { cancelled = true }
255
+ // eslint-disable-next-line react-hooks/exhaustive-deps
256
+ }, [hostMode, targetHost, authenticated])
257
+
258
+ const displayName = profile?.nickname ?? authState.nickname
259
+ const displayAvatar = profile?.headimage ?? authState.avatar
260
+ const avatar = authenticated && !avatarBroken ? displayAvatar : undefined
261
+
237
262
  // A new avatar URL is a fresh chance to render it.
238
263
  useEffect(() => {
239
264
  setAvatarBroken(false)
240
- }, [authState.avatar])
265
+ }, [displayAvatar])
241
266
 
242
267
  // Logging out (from anywhere: menu, another tab surface, node invalidation)
243
268
  // must never leave an orphan menu pointing at a signed-out account.
@@ -309,7 +334,7 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
309
334
  }
310
335
 
311
336
  const label = authenticated
312
- ? (authState.nickname ?? t('sidebar.account'))
337
+ ? (displayName ?? t('sidebar.account'))
313
338
  : t('sidebar.login')
314
339
 
315
340
  const title = authenticated ? t('sidebar.accountTitle') : t('sidebar.loginTitle')
@@ -376,8 +401,8 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
376
401
  {menuOpen && menuStyle
377
402
  ? createPortal(
378
403
  <div ref={menuRef} role="menu" style={menuStyle}>
379
- {authState.nickname ? (
380
- <div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={authState.nickname}>{authState.nickname}</div>
404
+ {displayName ? (
405
+ <div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={displayName}>{displayName}</div>
381
406
  ) : null}
382
407
  <MenuItem
383
408
  label={t('menu.profile')}
@@ -392,7 +417,11 @@ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ w
392
417
  palette={palette}
393
418
  onSelect={() => {
394
419
  setMenuOpen(false)
395
- void auth.logout()
420
+ // Host mode: a rejected logout request must leave the UI
421
+ // authenticated — `auth.logout()` only REQUESTS the logout,
422
+ // the state comes from the host. Swallow the rejection so it
423
+ // never becomes an unhandled promise error.
424
+ void auth.logout().catch(() => { /* host refused — stay authenticated */ })
396
425
  }}
397
426
  />
398
427
  </div>,
package/src/host.ts CHANGED
@@ -39,6 +39,8 @@ export interface HuaqiuAuthConfig {
39
39
  hostAuthPath?: string
40
40
  /** Host endpoint that triggers the EDA login dialog; default "/api/v1/auth/login". */
41
41
  hostLoginPath?: string
42
+ /** Host endpoint that requests EDA logout; default "/api/v1/auth/logout". */
43
+ hostLogoutPath?: string
42
44
  /** Seconds a host session is reused before re-fetching. Default 300. */
43
45
  hostSessionTtlSeconds?: number
44
46
  /** Seconds remote token-validation results are cached. Default 60. */
@@ -47,6 +49,7 @@ export interface HuaqiuAuthConfig {
47
49
 
48
50
  export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
49
51
  export const DEFAULT_HOST_LOGIN_PATH = '/api/v1/auth/login'
52
+ export const DEFAULT_HOST_LOGOUT_PATH = '/api/v1/auth/logout'
50
53
  export const DEFAULT_HOST_TTL_SECONDS = 300
51
54
  export const DEFAULT_VALIDATION_TTL_SECONDS = 60
52
55
 
@@ -68,6 +71,9 @@ export function resolveHostConfig(
68
71
  const hostLoginPath = config?.hostLoginPath
69
72
  ?? env.HQ_EDGE_LOGIN_PATH
70
73
  ?? DEFAULT_HOST_LOGIN_PATH
74
+ const hostLogoutPath = config?.hostLogoutPath
75
+ ?? env.HQ_EDGE_LOGOUT_PATH
76
+ ?? DEFAULT_HOST_LOGOUT_PATH
71
77
  const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS
72
78
  let ttl = DEFAULT_HOST_TTL_SECONDS
73
79
  if (typeof ttlRaw === 'number' && Number.isFinite(ttlRaw) && ttlRaw > 0) {
@@ -88,6 +94,7 @@ export function resolveHostConfig(
88
94
  hqEdgeBaseUrl: baseUrl,
89
95
  hostAuthPath,
90
96
  hostLoginPath,
97
+ hostLogoutPath,
91
98
  hostSessionTtlSeconds: ttl,
92
99
  validationTtlSeconds,
93
100
  }
package/src/routes.ts CHANGED
@@ -27,6 +27,50 @@ function log(): PluginLogger {
27
27
  return _log
28
28
  }
29
29
 
30
+ /**
31
+ * eda.cn user-profile endpoint, reached with the access token as `X-token`
32
+ * (the same endpoint hq-eda-ai's `/api/middleware/getUserInfo` proxies).
33
+ * Returns `{ code: 200000, result: { id, nickname, headimage, phone, username } }`.
34
+ */
35
+ export const EDA_USER_INFO_URL = 'https://www.eda.cn/api/chiplet/u/base/get'
36
+
37
+ export interface EdaUserProfile {
38
+ nickname?: string
39
+ headimage?: string
40
+ phone?: string
41
+ username?: string
42
+ }
43
+
44
+ /** Fetch the eda.cn profile for a token. Null on any failure (UI degrades). */
45
+ export async function fetchEdaUserProfile(
46
+ token: string,
47
+ doFetch: typeof fetch = globalThis.fetch.bind(globalThis),
48
+ ): Promise<EdaUserProfile | null> {
49
+ try {
50
+ const res = await doFetch(EDA_USER_INFO_URL, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ 'X-token': token,
55
+ },
56
+ body: '{}',
57
+ })
58
+ if (!res.ok) return null
59
+ const body = (await res.json()) as { code?: number; result?: Record<string, unknown> }
60
+ if (body.code !== 200000 || !body.result) return null
61
+ const r = body.result
62
+ return {
63
+ ...(typeof r.nickname === 'string' && r.nickname ? { nickname: r.nickname } : {}),
64
+ ...(typeof r.headimage === 'string' && r.headimage ? { headimage: r.headimage } : {}),
65
+ ...(typeof r.phone === 'string' && r.phone ? { phone: r.phone } : {}),
66
+ ...(typeof r.username === 'string' && r.username ? { username: r.username } : {}),
67
+ }
68
+ } catch (err) {
69
+ log().warn('fetchEdaUserProfile failed', { error: String((err as Error)?.message || err) })
70
+ return null
71
+ }
72
+ }
73
+
30
74
  export const AUTH_ROUTE_PREFIX = '/api/v1/huaqiu/auth'
31
75
 
32
76
  export type AuthHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void> | void
@@ -86,7 +130,11 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
86
130
  }
87
131
 
88
132
  if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/logout`) {
89
- service.invalidate()
133
+ // Forward through `auth.logout()` rather than invalidating locally: in
134
+ // host mode the EDA host owns the session, so only it can log the
135
+ // operator out. A rejected request surfaces as 500 and the browser
136
+ // half keeps its current (authenticated) state.
137
+ await service.auth.logout()
90
138
  sendJson(res, 200, { ok: true })
91
139
  return
92
140
  }
@@ -103,6 +151,20 @@ export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
103
151
  return
104
152
  }
105
153
 
154
+ if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/user-info`) {
155
+ // Rich profile (nickname + headimage) for the sidebar when the host
156
+ // session carries only {token, userId} — e.g. host mode under kicad.
157
+ // The token is resolved by the node half (host → pushed → persisted).
158
+ const token = await service.auth.getAccessToken()
159
+ if (!token) {
160
+ sendJson(res, 200, { userInfo: null })
161
+ return
162
+ }
163
+ const userInfo = await fetchEdaUserProfile(token)
164
+ sendJson(res, 200, { userInfo })
165
+ return
166
+ }
167
+
106
168
  if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/login`) {
107
169
  // Host-mode login: forward to HQ Edge, which asks EDA (KiCad) to open
108
170
  // its TriggerLoginDialog. Blocks until the dialog is completed.
package/src/service.ts CHANGED
@@ -51,8 +51,17 @@ export interface HuaqiuAuthApi {
51
51
  isAuthenticated(): Promise<boolean>
52
52
  getAccessToken(): Promise<string | null>
53
53
  getUserInfo(): Promise<HuaqiuUserInfo | null>
54
- /** Node-side no-op: login always happens in the browser. */
54
+ /** Node-side no-op in standalone: login always happens in the browser. */
55
55
  login(): Promise<void>
56
+ /**
57
+ * Request logout.
58
+ *
59
+ * In host mode this asks the EDA host (through hq-edge) to log out — it is
60
+ * a REQUEST, never a local declaration. The authoritative state keeps coming
61
+ * from the host, so on success we only drop the cached host session and let
62
+ * the next `resolve()` re-ask hq-edge. A failed request throws and leaves
63
+ * the current authentication state untouched.
64
+ */
56
65
  logout(): Promise<void>
57
66
  /**
58
67
  * Single authoritative validation path (spec §7). Works identically for
@@ -91,6 +100,13 @@ export interface HuaqiuAuthService {
91
100
  const PERSIST_FILE = 'session.json'
92
101
  const PERSIST_DIR = () => dshHomePath('auth')
93
102
 
103
+ /**
104
+ * Bounded wait for the host to confirm a logout. Shorter than the login wait
105
+ * (which must cover a human completing the EDA dialog): logout is a host-side
106
+ * operation, so a confirmation that takes longer than this means it failed.
107
+ */
108
+ const LOGOUT_WAIT_MS = 30_000
109
+
94
110
  /**
95
111
  * Returns the persisted session, or null if absent/unreadable. Best-effort: a
96
112
  * corrupt or unreadable file is treated as "no session" rather than thrown.
@@ -149,6 +165,7 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
149
165
  private readonly validator: TokenValidator
150
166
  private readonly doFetch: typeof fetch
151
167
  private readonly hostLoginPath: string
168
+ private readonly hostLogoutPath: string
152
169
  /** Bound on the host login RPC (user must complete the EDA dialog). */
153
170
  private readonly loginWaitMs: number
154
171
  /**
@@ -178,6 +195,7 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
178
195
  this.doFetch,
179
196
  )
180
197
  this.hostLoginPath = resolved.hostLoginPath ?? '/api/v1/auth/login'
198
+ this.hostLogoutPath = resolved.hostLogoutPath ?? '/api/v1/auth/logout'
181
199
  this.loginWaitMs = 5 * 60_000
182
200
  this.validator = new TokenValidator({
183
201
  ttlMs: (resolved.validationTtlSeconds ?? 60) * 1000,
@@ -231,7 +249,31 @@ export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
231
249
  this.validator.invalidate()
232
250
  this.emit()
233
251
  },
234
- logout: async () => this.invalidate(),
252
+ logout: async () => {
253
+ // Standalone: the browser owns the credential — drop it locally.
254
+ if (!this.host.enabled) {
255
+ this.invalidate()
256
+ return
257
+ }
258
+ // Host mode: the EDA host (KiCad) owns the session, so logout is a
259
+ // REQUEST forwarded through hq-edge (→ AuthService.TriggerLogout).
260
+ // Success only means the host accepted/performed it; the authoritative
261
+ // state is whatever the host reports next, so we drop the cached host
262
+ // session and let the following resolve() re-ask hq-edge instead of
263
+ // declaring the operator logged out ourselves.
264
+ const res = await this.doFetch(`${this.host.baseUrl}${this.hostLogoutPath}`, {
265
+ method: 'POST',
266
+ headers: { accept: 'application/json' },
267
+ signal: AbortSignal.timeout(LOGOUT_WAIT_MS),
268
+ })
269
+ if (!res.ok) {
270
+ throw new Error(`host logout not completed: HTTP ${res.status}`)
271
+ }
272
+ this.host.clear()
273
+ this.stale = true
274
+ this.validator.invalidate()
275
+ this.emit()
276
+ },
235
277
  validate: () => this.validateInternal(),
236
278
  invalidate: () => this.markStale(),
237
279
  onAuthStateChanged: (listener) => this.on(listener),