@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/client.js +61 -10
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +13 -1
- package/lib/index.mjs +80 -9
- package/package.json +2 -2
- package/src/client/auth-state.ts +19 -0
- package/src/client/client.ts +27 -4
- package/src/client/transport.ts +28 -0
- package/src/client/ui/sidebar-action.tsx +36 -7
- package/src/host.ts +7 -0
- package/src/routes.ts +63 -1
- package/src/service.ts +44 -2
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 () =>
|
|
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.
|
|
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.
|
|
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.
|
|
35
|
+
"@huaqiu/dsh-plugin-log": "0.3.17"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"lib",
|
package/src/client/auth-state.ts
CHANGED
|
@@ -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
|
package/src/client/client.ts
CHANGED
|
@@ -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:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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()
|
package/src/client/transport.ts
CHANGED
|
@@ -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
|
-
}, [
|
|
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
|
-
? (
|
|
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
|
-
{
|
|
380
|
-
<div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={
|
|
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
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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),
|