@huaqiu/dsh-auth 0.1.1

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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * `sidebar.footer.action` entry: the Huaqiu EDA account trigger at the bottom
3
+ * of the DSH sidebar (beside Settings).
4
+ *
5
+ * - Not logged in: shows the HQ icon and opens the login dialog through
6
+ * `auth.login({ lang, theme })` — a real modal (backdrop + centered card +
7
+ * auth.eda.cn iframe) that is ALWAYS TRANSPARENT in the embed itself
8
+ * (`fill=full` is never sent, see `lib.ts#buildLoginUrl`), and the card
9
+ * surface masks Blink's white base canvas so the login card floats over
10
+ * the dimmed app in both light and dark themes. `lang`/`theme` follow the
11
+ * host UI. Click on the backdrop, the × button, or Escape closes it, and
12
+ * auth.eda.cn's own `close_dialog` postMessage closes it as well.
13
+ * - Logged in: the trigger becomes the user's AVATAR (`headimage` from the
14
+ * auth.eda.cn payload, HQ icon while it is missing/fails to load) and a click
15
+ * opens a context menu with「Go to profile」(the eda.cn account page, with
16
+ * the access token) and「Log out」— the same shape as `hq-eda-ai`'s
17
+ * `UserMenu`, portalled to `document.body` with fixed positioning so the
18
+ * sidebar's `overflow: hidden` can never clip it.
19
+ *
20
+ * THEMING: colors prefer DSH's `--dsw-alias-*` tokens (so a custom host theme
21
+ * is honored) and fall back to an explicit light/dark pair chosen from
22
+ * `useIsDark()`; the two paths cannot disagree, because ui-layout's presenter
23
+ * writes `body[data-ds-dark-theme]` from the very snapshot that installs those
24
+ * tokens.
25
+ */
26
+ import { memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
27
+ import { createPortal } from 'react-dom'
28
+ import { getAuth, getAuthState, subscribeAuth } from '../auth-state.js'
29
+ import { buildProfileUrl } from '../lib.js'
30
+ import { useIsDark, useLocale } from '../ui-env.js'
31
+ import { useT } from '../i18n.js'
32
+ import { HQ_ICON } from './hq-icon.jsx'
33
+
34
+ export interface SidebarFooterActionOwnerProps {
35
+ wide?: boolean
36
+ }
37
+
38
+ const AVATAR_SIZE = 26
39
+ const ICON_SIZE = 22
40
+
41
+ /** One color scheme's menu colors (DSH token first, explicit fallback second). */
42
+ interface Palette {
43
+ surface: string
44
+ border: string
45
+ text: string
46
+ muted: string
47
+ hover: string
48
+ danger: string
49
+ dangerHover: string
50
+ avatarBg: string
51
+ shadow: string
52
+ }
53
+
54
+ const LIGHT_PALETTE: Palette = {
55
+ surface: 'var(--dsw-alias-bg-overlay, #ffffff)',
56
+ border: 'var(--dsw-alias-border-l1, #e4e7ec)',
57
+ text: 'var(--dsw-alias-label-primary, #3a4356)',
58
+ muted: 'var(--dsw-alias-label-secondary, #8a94a6)',
59
+ hover: 'var(--dsw-alias-interactive-bg-hover, #f5f7fa)',
60
+ danger: 'var(--dsw-alias-state-error-primary, #d4380d)',
61
+ dangerHover: 'rgba(216, 56, 13, 0.08)',
62
+ avatarBg: 'var(--dsw-alias-bg-layer-2, #eef2f7)',
63
+ shadow: '0 12px 32px rgba(15, 23, 42, 0.16)',
64
+ }
65
+
66
+ const DARK_PALETTE: Palette = {
67
+ surface: 'var(--dsw-alias-bg-overlay, #20242c)',
68
+ border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))',
69
+ text: 'var(--dsw-alias-label-primary, #e6eaf0)',
70
+ muted: 'var(--dsw-alias-label-secondary, #8b95a5)',
71
+ hover: 'var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))',
72
+ danger: 'var(--dsw-alias-state-error-primary, #ff7875)',
73
+ dangerHover: 'rgba(255, 120, 117, 0.14)',
74
+ avatarBg: 'var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.10))',
75
+ shadow: '0 12px 32px rgba(0, 0, 0, 0.46)',
76
+ }
77
+
78
+ const TRIGGER_BASE: CSSProperties = {
79
+ width: '100%',
80
+ display: 'flex',
81
+ alignItems: 'center',
82
+ gap: 8,
83
+ padding: '8px 12px',
84
+ border: 'none',
85
+ borderRadius: 8,
86
+ background: 'transparent',
87
+ fontSize: 13,
88
+ fontWeight: 500,
89
+ cursor: 'pointer',
90
+ textAlign: 'left',
91
+ whiteSpace: 'nowrap',
92
+ overflow: 'hidden',
93
+ }
94
+
95
+ const MENU_BASE: CSSProperties = {
96
+ position: 'fixed',
97
+ zIndex: 2147483000,
98
+ minWidth: 184,
99
+ padding: 6,
100
+ borderWidth: 1,
101
+ borderStyle: 'solid',
102
+ borderRadius: 12,
103
+ fontFamily: 'inherit',
104
+ fontSize: 13,
105
+ }
106
+
107
+ const MENU_HEADER_BASE: CSSProperties = {
108
+ padding: '6px 10px 8px',
109
+ fontSize: 12,
110
+ overflow: 'hidden',
111
+ textOverflow: 'ellipsis',
112
+ whiteSpace: 'nowrap',
113
+ }
114
+
115
+ const MENU_ITEM_BASE: CSSProperties = {
116
+ display: 'flex',
117
+ alignItems: 'center',
118
+ gap: 8,
119
+ width: '100%',
120
+ padding: '8px 10px',
121
+ border: 'none',
122
+ borderRadius: 8,
123
+ background: 'transparent',
124
+ font: 'inherit',
125
+ fontSize: 13,
126
+ textAlign: 'left',
127
+ cursor: 'pointer',
128
+ }
129
+
130
+ /**
131
+ * One menu row. Hover is tracked in state: the client bundle ships no CSS
132
+ * file, so inline styles cannot express `:hover`.
133
+ */
134
+ function MenuItem({
135
+ label,
136
+ icon,
137
+ danger,
138
+ palette,
139
+ onSelect,
140
+ }: {
141
+ label: string
142
+ icon: React.JSX.Element
143
+ danger?: boolean
144
+ palette: Palette
145
+ onSelect: () => void
146
+ }): React.JSX.Element {
147
+ const [hovered, setHovered] = useState(false)
148
+ return (
149
+ <button
150
+ type="button"
151
+ role="menuitem"
152
+ style={{
153
+ ...MENU_ITEM_BASE,
154
+ color: danger ? palette.danger : palette.text,
155
+ background: hovered ? (danger ? palette.dangerHover : palette.hover) : 'transparent',
156
+ }}
157
+ onMouseEnter={() => setHovered(true)}
158
+ onMouseLeave={() => setHovered(false)}
159
+ onClick={onSelect}
160
+ >
161
+ {icon}
162
+ <span>{label}</span>
163
+ </button>
164
+ )
165
+ }
166
+
167
+ function UserIcon(): React.JSX.Element {
168
+ return (
169
+ <svg
170
+ xmlns="http://www.w3.org/2000/svg"
171
+ width={15}
172
+ height={15}
173
+ viewBox="0 0 24 24"
174
+ fill="none"
175
+ stroke="currentColor"
176
+ strokeWidth={2}
177
+ strokeLinecap="round"
178
+ strokeLinejoin="round"
179
+ aria-hidden
180
+ style={{ flex: '0 0 auto' }}
181
+ >
182
+ <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
183
+ <circle cx="12" cy="7" r="4" />
184
+ </svg>
185
+ )
186
+ }
187
+
188
+ function LogoutIcon(): React.JSX.Element {
189
+ return (
190
+ <svg
191
+ xmlns="http://www.w3.org/2000/svg"
192
+ width={15}
193
+ height={15}
194
+ viewBox="0 0 24 24"
195
+ fill="none"
196
+ stroke="currentColor"
197
+ strokeWidth={2}
198
+ strokeLinecap="round"
199
+ strokeLinejoin="round"
200
+ aria-hidden
201
+ style={{ flex: '0 0 auto' }}
202
+ >
203
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
204
+ <polyline points="16 17 21 12 16 7" />
205
+ <line x1="21" x2="9" y1="12" y2="12" />
206
+ </svg>
207
+ )
208
+ }
209
+
210
+ export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ wide }: SidebarFooterActionOwnerProps): React.JSX.Element | null {
211
+ const authState = useSyncExternalStore(subscribeAuth, getAuthState)
212
+ const auth = getAuth()
213
+ const dark = useIsDark()
214
+ const locale = useLocale()
215
+ const t = useT()
216
+ const [menuOpen, setMenuOpen] = useState(false)
217
+ const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null)
218
+ const [avatarBroken, setAvatarBroken] = useState(false)
219
+ const [hovered, setHovered] = useState(false)
220
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
221
+ const menuRef = useRef<HTMLDivElement | null>(null)
222
+
223
+ const palette = dark ? DARK_PALETTE : LIGHT_PALETTE
224
+ const authenticated = authState.authenticated
225
+ const avatar = authenticated && !avatarBroken ? authState.avatar : undefined
226
+ const showLabel = wide !== false
227
+
228
+ // A new avatar URL is a fresh chance to render it.
229
+ useEffect(() => {
230
+ setAvatarBroken(false)
231
+ }, [authState.avatar])
232
+
233
+ // Logging out (from anywhere: menu, another tab surface, node invalidation)
234
+ // must never leave an orphan menu pointing at a signed-out account.
235
+ useEffect(() => {
236
+ if (!authenticated) setMenuOpen(false)
237
+ }, [authenticated])
238
+
239
+ // Anchor the portalled menu to the trigger before paint: the sidebar footer
240
+ // sits at the bottom edge, so the menu grows UPWARD from the trigger's top.
241
+ useLayoutEffect(() => {
242
+ if (!menuOpen || !triggerRef.current) return
243
+ const rect = triggerRef.current.getBoundingClientRect()
244
+ setMenuStyle({
245
+ ...MENU_BASE,
246
+ background: palette.surface,
247
+ borderColor: palette.border,
248
+ color: palette.text,
249
+ boxShadow: palette.shadow,
250
+ left: Math.max(8, Math.round(rect.left)),
251
+ bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
252
+ ...(wide ? { width: Math.round(rect.width) } : {}),
253
+ })
254
+ }, [menuOpen, wide, avatar, palette])
255
+
256
+ // Close on: outside click, Escape, resize or scroll (the anchor moved).
257
+ useEffect(() => {
258
+ if (!menuOpen) return
259
+ const onPointerDown = (event: MouseEvent): void => {
260
+ const target = event.target as Node
261
+ if (triggerRef.current?.contains(target)) return
262
+ if (menuRef.current?.contains(target)) return
263
+ setMenuOpen(false)
264
+ }
265
+ const onKeyDown = (event: KeyboardEvent): void => {
266
+ if (event.key === 'Escape') setMenuOpen(false)
267
+ }
268
+ const dismiss = (): void => setMenuOpen(false)
269
+ document.addEventListener('mousedown', onPointerDown)
270
+ document.addEventListener('keydown', onKeyDown)
271
+ window.addEventListener('resize', dismiss)
272
+ window.addEventListener('scroll', dismiss, true)
273
+ return () => {
274
+ document.removeEventListener('mousedown', onPointerDown)
275
+ document.removeEventListener('keydown', onKeyDown)
276
+ window.removeEventListener('resize', dismiss)
277
+ window.removeEventListener('scroll', dismiss, true)
278
+ }
279
+ }, [menuOpen])
280
+
281
+ if (!auth) return null
282
+
283
+ /**
284
+ *「Go to profile」always carries the token, so eda.cn can establish the
285
+ * session in the opened tab (it hides the token itself — see
286
+ * `lib.ts#buildProfileUrl`). The snapshot normally has it; fall back to the
287
+ * client so a stale snapshot can never open an unauthenticated tab.
288
+ */
289
+ const openProfile = (): void => {
290
+ setMenuOpen(false)
291
+ void (async () => {
292
+ const info = authState.token
293
+ ? { token: authState.token, phone: authState.phone }
294
+ : await auth.getUserInfo()
295
+ .then((i) => (i ? { token: i.token, phone: i.phone } : null))
296
+ .catch(() => null)
297
+ if (!info?.token) return
298
+ window.open(buildProfileUrl(info), '_blank', 'noopener,noreferrer')
299
+ })()
300
+ }
301
+
302
+ const label = authenticated
303
+ ? (authState.nickname ?? t('sidebar.account'))
304
+ : t('sidebar.login')
305
+
306
+ const title = authenticated ? t('sidebar.accountTitle') : t('sidebar.loginTitle')
307
+ const triggerBackground = menuOpen || hovered ? palette.hover : 'transparent'
308
+
309
+ return (
310
+ <div style={{ position: 'relative', width: '100%' }}>
311
+ <button
312
+ ref={triggerRef}
313
+ type="button"
314
+ aria-haspopup="menu"
315
+ aria-expanded={menuOpen}
316
+ onClick={() => {
317
+ if (!authenticated) {
318
+ // Always-transparent embed in the host's language and color scheme;
319
+ // `closeOnOutsideClick` defaults to true. The login dialog owns
320
+ // the DOM (backdrop + card + iframe) and closes itself on
321
+ // backdrop click, Escape, the × button, or the embed's
322
+ // `close_dialog` postMessage.
323
+ void auth.login({ lang: locale, theme: dark ? 'dark' : 'light' })
324
+ return
325
+ }
326
+ setMenuOpen((open) => !open)
327
+ }}
328
+ onMouseEnter={() => setHovered(true)}
329
+ onMouseLeave={() => setHovered(false)}
330
+ style={{
331
+ ...TRIGGER_BASE,
332
+ color: palette.text,
333
+ padding: wide ? '8px 12px' : '8px 6px',
334
+ background: triggerBackground,
335
+ }}
336
+ title={title}
337
+ >
338
+ {avatar ? (
339
+ <span
340
+ style={{
341
+ flex: '0 0 auto',
342
+ width: AVATAR_SIZE,
343
+ height: AVATAR_SIZE,
344
+ borderRadius: '50%',
345
+ overflow: 'hidden',
346
+ background: palette.avatarBg,
347
+ display: 'block',
348
+ }}
349
+ >
350
+ <img
351
+ src={avatar}
352
+ alt=""
353
+ width={AVATAR_SIZE}
354
+ height={AVATAR_SIZE}
355
+ onError={() => setAvatarBroken(true)}
356
+ style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
357
+ />
358
+ </span>
359
+ ) : (
360
+ <HQ_ICON size={ICON_SIZE} />
361
+ )}
362
+ {showLabel ? (
363
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
364
+ ) : null}
365
+ </button>
366
+
367
+ {menuOpen && menuStyle
368
+ ? createPortal(
369
+ <div ref={menuRef} role="menu" style={menuStyle}>
370
+ {authState.nickname ? (
371
+ <div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={authState.nickname}>{authState.nickname}</div>
372
+ ) : null}
373
+ <MenuItem
374
+ label={t('menu.profile')}
375
+ icon={<UserIcon />}
376
+ palette={palette}
377
+ onSelect={openProfile}
378
+ />
379
+ <MenuItem
380
+ label={t('menu.logout')}
381
+ icon={<LogoutIcon />}
382
+ danger
383
+ palette={palette}
384
+ onSelect={() => {
385
+ setMenuOpen(false)
386
+ void auth.logout()
387
+ }}
388
+ />
389
+ </div>,
390
+ document.body,
391
+ )
392
+ : null}
393
+ </div>
394
+ )
395
+ })
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Host theme + locale sensing for the client UI.
3
+ *
4
+ * The DSH slot system injects React components with PROPS, not the cordis ctx,
5
+ * so the cards cannot reach `ctx.theme` / `ctx.locale` the way a plugin body
6
+ * can. Both services do, however, publish their state into the DOM, and that
7
+ * is what this module reads:
8
+ *
9
+ * - THEME — `ui-layout`'s presenter switches `body[data-ds-dark-theme]` from
10
+ * the resolved snapshot (`packages/client/ui-layout/src/client/theme-presenter.ts`,
11
+ * `DARK_ATTRIBUTE`), so the attribute's presence IS the dark palette. Same
12
+ * signal the sibling packages already use
13
+ * (`dsh-tool-schematic-gen/src/client/theme.ts`). `prefers-color-scheme` is
14
+ * deliberately NOT consulted: DSH resolves `system` itself, and an OS-dark /
15
+ * DSH-light combination would then be misdetected.
16
+ * - LOCALE — `dsh-client-locale` writes `<html lang>` on every locale change
17
+ * (`syncDocumentLanguage`: `zh-CN` | `en`). Falling back to the browser's
18
+ * own `navigator.languages` keeps the UI usable on hosts without that
19
+ * plugin. Chinese is the last resort because this is a Chinese-first app
20
+ * (and `hq-eda-ai` defaults to zh: `languageMap[lang] || "zh"`).
21
+ *
22
+ * Both are exposed as `useSyncExternalStore` snapshots so every mounted card
23
+ * re-renders together when the user flips theme or language.
24
+ */
25
+ import { useSyncExternalStore } from 'react'
26
+ import type { AuthLocale, AuthTheme } from './lib.js'
27
+
28
+ /** DSH's dark-palette marker, written by ui-layout's theme presenter. */
29
+ export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
30
+
31
+ function isDarkDocument(): boolean {
32
+ if (typeof document === 'undefined') return false
33
+ if (document.body?.hasAttribute(DARK_ATTRIBUTE)) return true
34
+ // Fallbacks for hosts that mark the scheme on <html> instead of <body>.
35
+ const root = document.documentElement
36
+ if (!root) return false
37
+ const dataTheme = root.getAttribute('data-theme')
38
+ if (dataTheme !== null) return dataTheme.toLowerCase() === 'dark'
39
+ return root.classList.contains('dark')
40
+ }
41
+
42
+ /** `zh-CN`, `zh-Hans`, `en-GB`, … → our locale id (`undefined` = unknown). */
43
+ function localeFromTag(tag: string | null | undefined): AuthLocale | undefined {
44
+ if (!tag) return undefined
45
+ const primary = tag.toLowerCase().split('-')[0]
46
+ return primary === 'zh' || primary === 'en' ? primary : undefined
47
+ }
48
+
49
+ function detectLocale(): AuthLocale {
50
+ if (typeof document !== 'undefined') {
51
+ const fromDocument = localeFromTag(document.documentElement?.getAttribute('lang'))
52
+ if (fromDocument) return fromDocument
53
+ }
54
+ if (typeof navigator !== 'undefined' && typeof window !== 'undefined') {
55
+ // `window` is the browser test: Node exposes a global `navigator`
56
+ // reporting the machine's own language, which would otherwise decide the
57
+ // locale for non-browser runs (same guard DSH's locale plugin uses).
58
+ for (const tag of [...(navigator.languages ?? []), navigator.language]) {
59
+ const match = localeFromTag(tag)
60
+ if (match) return match
61
+ }
62
+ }
63
+ return 'zh'
64
+ }
65
+
66
+ let dark = isDarkDocument()
67
+ let locale = detectLocale()
68
+ const listeners = new Set<() => void>()
69
+ let darkObserver: MutationObserver | null = null
70
+ let localeObserver: MutationObserver | null = null
71
+
72
+ function notify(): void {
73
+ for (const listener of [...listeners]) {
74
+ try {
75
+ listener()
76
+ } catch {
77
+ /* one crashing subscriber must not strand the rest on a stale value */
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Re-read the DOM and notify only what actually changed. */
83
+ export function syncUiEnv(): void {
84
+ let changed = false
85
+ const nextDark = isDarkDocument()
86
+ if (nextDark !== dark) {
87
+ dark = nextDark
88
+ changed = true
89
+ }
90
+ const nextLocale = detectLocale()
91
+ if (nextLocale !== locale) {
92
+ locale = nextLocale
93
+ changed = true
94
+ }
95
+ if (changed) notify()
96
+ }
97
+
98
+ /** Start observing (idempotent; also re-reads so no change is missed). */
99
+ function watch(): void {
100
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
101
+ if (!darkObserver && document.body) {
102
+ darkObserver = new MutationObserver(syncUiEnv)
103
+ darkObserver.observe(document.body, { attributes: true, attributeFilter: [DARK_ATTRIBUTE] })
104
+ }
105
+ if (!localeObserver && document.documentElement) {
106
+ localeObserver = new MutationObserver(syncUiEnv)
107
+ localeObserver.observe(document.documentElement, {
108
+ attributes: true,
109
+ attributeFilter: ['lang', 'data-theme', 'class'],
110
+ })
111
+ }
112
+ syncUiEnv()
113
+ }
114
+
115
+ function subscribe(callback: () => void): () => void {
116
+ watch()
117
+ listeners.add(callback)
118
+ return () => {
119
+ listeners.delete(callback)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Imperative subscription for non-React consumers (e.g. the login dialog's
125
+ * backdrop/card DOM). The callback fires on every theme or locale flip.
126
+ */
127
+ export function subscribeUiEnv(callback: () => void): () => void {
128
+ return subscribe(callback)
129
+ }
130
+
131
+ const getDark = (): boolean => dark
132
+ const getLocale = (): AuthLocale => locale
133
+
134
+ /**
135
+ * Synchronous read of the current dark-palette state. Safe outside React
136
+ * (the auth client uses it when appending the overlay iframe, before any
137
+ * component has a chance to subscribe).
138
+ */
139
+ export function getCurrentDark(): boolean {
140
+ return dark
141
+ }
142
+
143
+ /** Synchronous read of the current host UI locale. */
144
+ export function getCurrentLocale(): AuthLocale {
145
+ return locale
146
+ }
147
+
148
+ /** Synchronous read of the current host surface color (matches the palette). */
149
+ export function getCurrentSurfaceColor(): string {
150
+ return dark ? 'var(--dsw-alias-bg-layer-1, #20242c)' : 'var(--dsw-alias-bg-layer-1, #ffffff)'
151
+ }
152
+
153
+ /** `true` while the host renders the dark palette. */
154
+ export function useIsDark(): boolean {
155
+ return useSyncExternalStore(subscribe, getDark, getDark)
156
+ }
157
+
158
+ /** The host UI language. */
159
+ export function useLocale(): AuthLocale {
160
+ return useSyncExternalStore(subscribe, getLocale, getLocale)
161
+ }
162
+
163
+ /** The host color scheme in auth.eda.cn's own vocabulary. */
164
+ export function useColorScheme(): AuthTheme {
165
+ return useIsDark() ? 'dark' : 'light'
166
+ }
167
+
168
+ /** Release the observers (called from `apply()`'s disposer). */
169
+ export function disposeUiEnv(): void {
170
+ darkObserver?.disconnect()
171
+ localeObserver?.disconnect()
172
+ darkObserver = null
173
+ localeObserver = null
174
+ listeners.clear()
175
+ }
package/src/host.ts ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * HQ Edge host mode for `dsh-auth`.
3
+ *
4
+ * In standalone DSH, credentials arrive only when the user logs in through the
5
+ * browser (auth.eda.cn iframe → postMessage → `webServer` route →
6
+ * `setCredentials`). That means the node half is empty until a browser tab
7
+ * focuses the card, and every tool returns `needs_auth` in the gap — including
8
+ * across a process restart (the in-memory cache is gone).
9
+ *
10
+ * Host mode removes that gap. When HQ Edge is the host, it already holds the
11
+ * operator-supplied token + user id, and exposes them on a loopback route. The
12
+ * node half fetches them on boot and is authoritative immediately, without
13
+ * waiting for a browser. The browser half is untouched — host mode is *mode*,
14
+ * not API (spec §5/§14).
15
+ *
16
+ * Resolution order inside `getUserInfo()` (spec §6.2):
17
+ * 1. host session — HQ Edge configured → fetch + cache (TTL)
18
+ * 2. pushed session — what the browser half sent (today's behaviour)
19
+ * 3. persisted file — node-side `~/.dsh/auth/session.json`, written on every set
20
+ * 4. null → tools return needs_auth
21
+ */
22
+
23
+ export interface HuaqiuAuthConfig {
24
+ /** HQ Edge base URL, e.g. "http://localhost:18080". Absent → standalone. */
25
+ hqEdgeBaseUrl?: string
26
+ /** Path on the host; default "/api/v1/auth/token". */
27
+ hostAuthPath?: string
28
+ /** Seconds a host session is reused before re-fetching. Default 300. */
29
+ hostSessionTtlSeconds?: number
30
+ }
31
+
32
+ export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
33
+ export const DEFAULT_HOST_TTL_SECONDS = 300
34
+
35
+ /**
36
+ * Resolve the effective config: overlay `config` (highest) > env (safety net
37
+ * for non-supervisor installs) > defaults. Centralised here so only `dsh-auth`
38
+ * inspects these variables (spec §8).
39
+ */
40
+ export function resolveHostConfig(
41
+ config?: Partial<HuaqiuAuthConfig> | null,
42
+ env: NodeJS.ProcessEnv = process.env,
43
+ ): HuaqiuAuthConfig {
44
+ const baseUrl = config?.hqEdgeBaseUrl
45
+ ?? env.HQ_EDGE_BASE_URL
46
+ ?? ''
47
+ const hostAuthPath = config?.hostAuthPath
48
+ ?? env.HQ_EDGE_AUTH_PATH
49
+ ?? DEFAULT_HOST_AUTH_PATH
50
+ const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS
51
+ let ttl = DEFAULT_HOST_TTL_SECONDS
52
+ if (typeof ttlRaw === 'number' && Number.isFinite(ttlRaw) && ttlRaw > 0) {
53
+ ttl = ttlRaw
54
+ } else if (typeof ttlRaw === 'string' && ttlRaw.trim().length > 0) {
55
+ const parsed = Number.parseInt(ttlRaw, 10)
56
+ if (Number.isFinite(parsed) && parsed > 0) ttl = parsed
57
+ }
58
+ return { hqEdgeBaseUrl: baseUrl, hostAuthPath, hostSessionTtlSeconds: ttl }
59
+ }
60
+
61
+ export interface HostSession {
62
+ info: ResolvedHostUser
63
+ fetchedAt: number
64
+ }
65
+
66
+ export interface ResolvedHostUser {
67
+ id: string
68
+ token: string
69
+ nickname?: string
70
+ }
71
+
72
+ /**
73
+ * Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
74
+ * TTL; the loopback GET is cheap and the token is static, so we never persist it.
75
+ */
76
+ export class HostSessionResolver {
77
+ private cache: HostSession | null = null
78
+
79
+ constructor(
80
+ readonly baseUrl: string,
81
+ readonly path: string,
82
+ readonly ttlMs: number,
83
+ private readonly doFetch: typeof fetch = globalThis.fetch.bind(globalThis),
84
+ ) {}
85
+
86
+ /** Host mode is active iff a base URL was configured. */
87
+ get enabled(): boolean {
88
+ return this.baseUrl.length > 0
89
+ }
90
+
91
+ async resolve(): Promise<ResolvedHostUser | null> {
92
+ if (!this.enabled) return null
93
+ const now = Date.now()
94
+ if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) {
95
+ return this.cache.info
96
+ }
97
+ try {
98
+ const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
99
+ method: 'GET',
100
+ headers: { accept: 'application/json' },
101
+ })
102
+ if (!res.ok) return this.cache?.info ?? null
103
+ const data = await res.json() as Record<string, unknown>
104
+ const info = normalizeHostUser(data)
105
+ if (!info) return this.cache?.info ?? null
106
+ this.cache = { info, fetchedAt: now }
107
+ return info
108
+ } catch {
109
+ // Network error: fall back to a previously cached value if we have one.
110
+ return this.cache?.info ?? null
111
+ }
112
+ }
113
+
114
+ /** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
115
+ clear(): void {
116
+ this.cache = null
117
+ }
118
+ }
119
+
120
+ function asId(raw: unknown): string | null {
121
+ if (typeof raw === 'string' && raw.length > 0) return raw
122
+ if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw)
123
+ return null
124
+ }
125
+
126
+ /** Parse the host route payload into a credential, tolerating key-name drift. */
127
+ export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUser | null {
128
+ const token = typeof data.token === 'string' && data.token.length > 0
129
+ ? data.token
130
+ : null
131
+ const id = asId(data.userId)
132
+ ?? asId(data.id)
133
+ ?? asId(data.user_id)
134
+ if (!token || !id) return null
135
+ const nickname = typeof data.nickname === 'string' && data.nickname.length > 0
136
+ ? data.nickname
137
+ : undefined
138
+ return { id, token, ...(nickname ? { nickname } : {}) }
139
+ }