@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.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@huaqiu/dsh-auth",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./lib/index.mjs",
6
+ "types": "./lib/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.mts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./client": {
13
+ "default": "./lib/client.js"
14
+ },
15
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime"
26
+ ]
27
+ }
28
+ },
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.1",
31
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.0 <0.2.0"
32
+ },
33
+ "dependencies": {
34
+ "@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.0 <0.2.0"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "src",
39
+ "cordis.patch.yml"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "devDependencies": {
45
+ "@types/react": "^19.2.18",
46
+ "@types/react-dom": "^19.2.5",
47
+ "react": "^19.2.8",
48
+ "react-dom": "^19.2.8"
49
+ },
50
+ "scripts": {
51
+ "typecheck": "tsc --noEmit",
52
+ "build": "tsdown",
53
+ "test": "vitest run"
54
+ }
55
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Module-level auth state store shared by the client React components.
3
+ *
4
+ * The DSH slot system injects React components with props, not the cordis ctx,
5
+ * so the components read login state through this tiny external store
6
+ * (`useSyncExternalStore`), fed by the singleton `huaqiuAuth` client service
7
+ * created in `apply()`. When the user logs in (in the sidebar overlay or an
8
+ * embedded card iframe), `onAuthStateChanged` fires and every mounted card /
9
+ * sidebar button re-renders.
10
+ */
11
+ import type { AuthClient } from './client.js'
12
+
13
+ export interface AuthState {
14
+ authenticated: boolean
15
+ nickname?: string
16
+ /** Avatar URL for the sidebar trigger; absent → the HQ icon is shown. */
17
+ avatar?: string
18
+ /**
19
+ * Access token, needed by「Go to profile」(eda.cn takes it from the query
20
+ * and hides it itself). Kept in the store, never rendered or logged.
21
+ */
22
+ token?: string
23
+ /** Bound mobile number, forwarded to the profile page as `phone=`. */
24
+ phone?: string
25
+ }
26
+
27
+ /** Snapshot for one credential payload (`null` = logged out). */
28
+ function stateOf(info: AuthTokenPayloadLike | null): AuthState {
29
+ if (!info) return { authenticated: false }
30
+ return {
31
+ authenticated: true,
32
+ ...(info.nickname ? { nickname: info.nickname } : {}),
33
+ ...(info.avatar ? { avatar: info.avatar } : {}),
34
+ ...(info.token ? { token: info.token } : {}),
35
+ ...(info.phone ? { phone: info.phone } : {}),
36
+ }
37
+ }
38
+
39
+ type AuthTokenPayloadLike = { nickname?: string; avatar?: string; token?: string; phone?: string } | null
40
+
41
+ let auth: AuthClient['auth'] | null = null
42
+ let state: AuthState = { authenticated: false }
43
+ const listeners = new Set<() => void>()
44
+ let unsubscribe: (() => void) | null = null
45
+ let syncNow: (() => void) | null = null
46
+
47
+ function setState(next: AuthState): void {
48
+ state = next
49
+ for (const l of listeners) l()
50
+ }
51
+
52
+ /** Attach the singleton auth capability and push the initial snapshot. */
53
+ export function registerAuth(a: AuthClient['auth']): void {
54
+ auth = a
55
+ unsubscribe = a.onAuthStateChanged((info) => {
56
+ setState(stateOf(info))
57
+ })
58
+ void a.getUserInfo()
59
+ .then((info) => setState(stateOf(info)))
60
+ .catch(() => setState({ authenticated: false }))
61
+ }
62
+
63
+ /** The live auth capability (for login()/logout() from components). */
64
+ export function getAuth(): AuthClient['auth'] | null {
65
+ return auth
66
+ }
67
+
68
+ /** Current snapshot, for `useSyncExternalStore`'s getSnapshot. */
69
+ export function getAuthState(): AuthState {
70
+ return state
71
+ }
72
+
73
+ /** Subscribe, for `useSyncExternalStore`'s subscribe. */
74
+ export function subscribeAuth(callback: () => void): () => void {
75
+ listeners.add(callback)
76
+ return () => listeners.delete(callback)
77
+ }
78
+
79
+ /** Register the node re-sync hook (wired in apply(); called by the login card on mount). */
80
+ export function registerAuthSync(fn: () => void): void {
81
+ syncNow = fn
82
+ }
83
+
84
+ /** Re-push the persisted credential to the node half, if one is available. */
85
+ export function syncAuthNow(): void {
86
+ syncNow?.()
87
+ }
88
+
89
+ export function disposeAuth(): void {
90
+ unsubscribe?.()
91
+ unsubscribe = null
92
+ auth = null
93
+ syncNow = null
94
+ listeners.clear()
95
+ state = { authenticated: false }
96
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Auth client core — the Phase 0A POC logic, factored as a testable factory.
3
+ * `apply()` in index.ts wires this to the real window/document/localStorage.
4
+ */
5
+ import {
6
+ AUTH_ORIGIN,
7
+ buildLoginUrl,
8
+ handleAuthMessage,
9
+ type AuthMessageEventLike,
10
+ type AuthTokenPayload,
11
+ type LoginOptions,
12
+ } from './lib.js'
13
+ import { closeLoginDialog, isLoginDialogOpen, openLoginDialog } from './ui/login-dialog.js'
14
+ import type { AuthStorage } from './storage.js'
15
+ import type { AuthTransport } from './transport.js'
16
+
17
+ export interface AuthClientDeps {
18
+ storage: AuthStorage
19
+ transport: AuthTransport
20
+ /** Strict origin gate; defaults to auth.eda.cn. */
21
+ trustedOrigin?: string
22
+ loginUrl?: string
23
+ windowLike: Pick<Window, 'addEventListener' | 'removeEventListener'>
24
+ documentLike: Pick<Document, 'createElement' | 'body'>
25
+ }
26
+
27
+ export interface AuthClient {
28
+ auth: {
29
+ isAuthenticated(): boolean
30
+ getAccessToken(): Promise<string | null>
31
+ getUserInfo(): Promise<AuthTokenPayload | null>
32
+ login(options?: LoginOptions): Promise<void>
33
+ logout(): Promise<void>
34
+ onAuthStateChanged(listener: (info: AuthTokenPayload | null) => void): () => void
35
+ }
36
+ /** Route window 'message' events here. Exposed for direct testing. */
37
+ handleMessageEvent(event: AuthMessageEventLike): void
38
+ /** Re-push persisted credentials on boot (acceptance group D). */
39
+ restore(): Promise<void>
40
+ /** Re-push persisted credentials on demand (heals a reset/absent node half). */
41
+ syncNow(): Promise<void>
42
+ dispose(): void
43
+ }
44
+
45
+ export function createAuthClient(deps: AuthClientDeps): AuthClient {
46
+ const trustedOrigin = deps.trustedOrigin ?? AUTH_ORIGIN
47
+ const loginUrl = deps.loginUrl ?? `${AUTH_ORIGIN}/`
48
+ const { storage, transport } = deps
49
+ const listeners = new Set<(info: AuthTokenPayload | null) => void>()
50
+
51
+ const emit = (info: AuthTokenPayload | null): void => {
52
+ for (const listener of listeners) listener(info)
53
+ }
54
+ const closeIframe = (): void => {
55
+ // The dialog module owns the DOM. Tear it down on any close path
56
+ // (token success, logout, close_dialog postMessage, dispose).
57
+ if (isLoginDialogOpen()) closeLoginDialog()
58
+ }
59
+ /**
60
+ * Open the login dialog (backdrop + centered card + auth.eda.cn iframe).
61
+ *
62
+ * The dialog is ALWAYS transparent (no `transparent` option exists): the
63
+ * embedded doc sets its own root to `background: transparent` (we never
64
+ * send `fill=full`), and the iframe sits inside a host-painted card so
65
+ * Blink's white base canvas never reaches the user. See the long header
66
+ * in `ui/login-dialog.ts` for the full why.
67
+ */
68
+ const openIframe = (options: LoginOptions = {}): void => {
69
+ if (isLoginDialogOpen()) return
70
+ const baseUrl = loginUrl
71
+ openLoginDialog(
72
+ {
73
+ ...(options.lang ? { lang: options.lang } : {}),
74
+ ...(options.theme ? { theme: options.theme } : {}),
75
+ },
76
+ () => {
77
+ // Re-render safety: nothing to do here — the auth client's own state
78
+ // is just the dialog-open boolean, which `isLoginDialogOpen()` reads
79
+ // directly from the dialog module.
80
+ void baseUrl
81
+ },
82
+ )
83
+ }
84
+
85
+ const auth = {
86
+ isAuthenticated: (): boolean => storage.get() !== null,
87
+ getAccessToken: async (): Promise<string | null> => storage.get()?.token ?? null,
88
+ getUserInfo: async (): Promise<AuthTokenPayload | null> => storage.get(),
89
+ login: async (options?: LoginOptions): Promise<void> => openIframe(options ?? {}),
90
+ logout: async (): Promise<void> => {
91
+ storage.clear()
92
+ try {
93
+ await transport.pushLogout()
94
+ } catch {
95
+ /* node may be absent — local state is still cleared */
96
+ }
97
+ emit(null)
98
+ closeIframe()
99
+ },
100
+ onAuthStateChanged: (listener: (info: AuthTokenPayload | null) => void): (() => void) => {
101
+ listeners.add(listener)
102
+ return () => listeners.delete(listener)
103
+ },
104
+ }
105
+
106
+ const handleMessageEvent = (event: AuthMessageEventLike): void => {
107
+ // Offline deployment: no origin gate (see lib.ts). Envelope validation is
108
+ // the only gate, so unrelated window messages can never corrupt state.
109
+ const msg = handleAuthMessage(event)
110
+ if (!msg) return
111
+ if (msg.kind === 'token') {
112
+ storage.set(msg.info)
113
+ void transport.pushSession(msg.info).catch(() => { /* node push is best-effort; syncNow heals */ })
114
+ emit(msg.info)
115
+ closeIframe()
116
+ } else if (msg.kind === 'logout') {
117
+ storage.clear()
118
+ emit(null)
119
+ void transport.pushLogout().catch(() => { /* local state already cleared */ })
120
+ closeIframe()
121
+ } else if (msg.kind === 'close') {
122
+ closeIframe()
123
+ }
124
+ }
125
+
126
+ const onWindowMessage = (event: MessageEvent): void => {
127
+ handleMessageEvent({ origin: event.origin, data: event.data })
128
+ }
129
+
130
+ deps.windowLike.addEventListener('message', onWindowMessage)
131
+
132
+ const restore = async (): Promise<void> => {
133
+ const restored = storage.get()
134
+ if (restored) {
135
+ try {
136
+ await transport.pushSession(restored)
137
+ } catch {
138
+ /* boot push is best-effort; syncNow heals */
139
+ }
140
+ }
141
+ }
142
+
143
+ return {
144
+ auth,
145
+ handleMessageEvent,
146
+ restore,
147
+ /**
148
+ * Re-push the persisted credential to the node half. Healing path: the
149
+ * node keeps auth in memory, so a `dsh web` restart (or a failed first
150
+ * push) drops it while the browser still has the token. Callers re-sync on
151
+ * focus / visibilitychange / login-card mount so the tool gate reflects
152
+ * the actual browser login without requiring a page reload.
153
+ */
154
+ async syncNow(): Promise<void> {
155
+ const info = storage.get()
156
+ if (!info) return
157
+ try {
158
+ await transport.pushSession(info)
159
+ } catch {
160
+ /* sync is best-effort; a later focus event retries */
161
+ }
162
+ },
163
+ dispose() {
164
+ deps.windowLike.removeEventListener('message', onWindowMessage)
165
+ closeIframe()
166
+ void trustedOrigin // referenced for clarity: the auth iframe URL comes from it
167
+ },
168
+ }
169
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * zh / en copy for every user-visible string of the auth UI (sidebar trigger,
3
+ * account menu, login tool card).
4
+ *
5
+ * Kept self-contained rather than registered into DSH's `ctx.locale`
6
+ * namespace — same call the sibling packages made
7
+ * (`dsh-tool-symbol-footprint/src/client/i18n.ts`) — because the slot system
8
+ * hands components props, not ctx, and a missing namespace would leave the UI
9
+ * blank. `en` is typed as `Record<AuthCopyKey, string>`, so a key added to one
10
+ * language without the other is a COMPILE error (bilingual balance enforced at
11
+ * build time, mirroring DSH's own locale registry).
12
+ *
13
+ * The en「Go to profile」/「Log out」wording is the one the sidebar spec asks
14
+ * for; the zh side follows `hq-eda-ai`'s `locales/cn.ts` (个人中心 / 退出登录).
15
+ */
16
+ import { useMemo } from 'react'
17
+ import type { AuthLocale } from './lib.js'
18
+ import { useLocale } from './ui-env.js'
19
+
20
+ const zh = {
21
+ 'sidebar.login': '华秋EDA AI登录',
22
+ 'sidebar.loginTitle': '登录华秋 EDA AI(eda.cn)账号',
23
+ 'sidebar.accountTitle': '华秋 EDA AI 账号',
24
+ 'sidebar.account': '华秋EDA AI · 已登录',
25
+
26
+ 'menu.profile': '个人中心',
27
+ 'menu.logout': '退出登录',
28
+
29
+ 'card.title': '华秋 EDA AI(eda.cn)登录',
30
+ 'card.desc': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。',
31
+ 'card.loggedIn': '✓ 已登录{nickname} —— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。',
32
+ 'card.loggedOut': '未登录 —— 请在上方登录华秋 EDA AI(eda.cn)账号,或点击左侧「华秋EDA AI登录」按钮;登录完成后让助手重试。',
33
+ 'card.tool': '工具:{tool}',
34
+ 'card.empty': '(无输出)',
35
+ // Substituted into `{nickname}` by `card.loggedIn`. zh uses a full-width
36
+ // colon, en a half-width one plus a space; hardcoding ':' made the English
37
+ // card read "Logged in:John".
38
+ 'card.nicknameSep': ':{nickname}',
39
+
40
+ 'dialog.close': '关闭',
41
+ } as const
42
+
43
+ /** Every key of the zh dictionary — the contract both languages satisfy. */
44
+ export type AuthCopyKey = keyof typeof zh
45
+
46
+ const en: Record<AuthCopyKey, string> = {
47
+ 'sidebar.login': 'Huaqiu EDA AI login',
48
+ 'sidebar.loginTitle': 'Sign in to your Huaqiu EDA AI (eda.cn) account',
49
+ 'sidebar.accountTitle': 'Huaqiu EDA AI account',
50
+ 'sidebar.account': 'Huaqiu EDA AI · signed in',
51
+
52
+ 'menu.profile': 'Go to profile',
53
+ 'menu.logout': 'Log out',
54
+
55
+ 'card.title': 'Huaqiu EDA AI (eda.cn) login',
56
+ // The reply phrase used to be hardcoded to the Chinese "已登录,请重试" even
57
+ // in these English strings, telling an English-speaking user to type Chinese.
58
+ 'card.desc': 'Tool "{tool}" needs a Huaqiu EDA AI account. Complete the login below (or use the Huaqiu EDA AI button in the sidebar), then reply "I have logged in, please retry" so the assistant can retry the tool.',
59
+ 'card.loggedIn': '✓ Logged in{nickname} — reply "I have logged in, please retry" and the assistant will retry the tool.',
60
+ 'card.loggedOut': 'Not logged in — sign in above, or use the Huaqiu EDA AI button in the sidebar, then ask the assistant to retry.',
61
+ 'card.tool': 'Tool: {tool}',
62
+ 'card.empty': '(no output)',
63
+ 'card.nicknameSep': ': {nickname}',
64
+
65
+ 'dialog.close': 'Close',
66
+ }
67
+
68
+ const COPY: Record<AuthLocale, Record<AuthCopyKey, string>> = { zh, en }
69
+
70
+ /** Every copy key, in declaration order (used to assert bilingual balance). */
71
+ export const AUTH_COPY_KEYS = Object.keys(zh) as AuthCopyKey[]
72
+
73
+ export type Translate = (key: AuthCopyKey, params?: Record<string, unknown>) => string
74
+
75
+ /**
76
+ * Look a key up, interpolating `{name}` placeholders.
77
+ *
78
+ * Chain: active locale → zh (the source of truth) → the key itself, so a
79
+ * missing translation stays VISIBLE instead of blanking the UI.
80
+ */
81
+ export function translate(locale: AuthLocale, key: AuthCopyKey, params?: Record<string, unknown>): string {
82
+ const template = COPY[locale]?.[key] ?? COPY.zh[key] ?? key
83
+ if (!params) return template
84
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
85
+ name in params ? String(params[name]) : match)
86
+ }
87
+
88
+ /** Translate bound to one locale (stable for the lifetime of that locale). */
89
+ export function createT(locale: AuthLocale): Translate {
90
+ return (key, params) => translate(locale, key, params)
91
+ }
92
+
93
+ /** Translate bound to the host UI language, re-created when it changes. */
94
+ export function useT(): Translate {
95
+ const locale = useLocale()
96
+ return useMemo(() => createT(locale), [locale])
97
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@huaqiu/dsh-auth` — browser half (the Phase 0A POC).
3
+ *
4
+ * Opens the auth.eda.cn login page in an overlay iframe, STRICTLY validates
5
+ * the postMessage origin, caches credentials in localStorage (reload restore),
6
+ * and pushes them to the node half over the plugin-owned webServer routes.
7
+ * Provides the client-side `huaqiuAuth` service mirroring the node surface.
8
+ *
9
+ * On top of the credential flow it wires the two UI surfaces the login UX
10
+ * needs:
11
+ * - `sidebar.footer.action` — a persistent 华秋EDA login entrypoint at the
12
+ * bottom of the sidebar (login/logout, live state).
13
+ * - `tool.call.toolview` (keyed per Huaqiu tool) — when a node tool returns
14
+ * `status: "needs_auth"` the tool card becomes the login HIT: an embedded
15
+ * auth.eda.cn iframe + login-state line, so login is a step of the
16
+ * conversation instead of a dead error the agent has to relay.
17
+ */
18
+ import { createAuthStorage } from './storage.js'
19
+ import { createWebServerAuthTransport } from './transport.js'
20
+ import { createAuthClient, type AuthClient } from './client.js'
21
+ import { disposeAuth, registerAuth, registerAuthSync } from './auth-state.js'
22
+ import { HuaqiuToolView } from './ui/needs-auth-toolview.jsx'
23
+ import { HuaqiuAuthSidebarAction } from './ui/sidebar-action.jsx'
24
+ import { disposeUiEnv } from './ui-env.js'
25
+
26
+ /**
27
+ * Client cordis inject: REAL service names only (the loader maps these to
28
+ * `ctx.inject([...])` dependencies). The `slots` registry service comes from
29
+ * `@deepseek-ai/dsh-client-ui-slots`; it is required to register the toolview
30
+ * and sidebar entries. The PACKAGE-level `dsh.client.inject` in package.json
31
+ * (graph ordering) stays as-is and is NOT this export.
32
+ */
33
+ export const inject: string[] = ['slots']
34
+
35
+ /**
36
+ * Huaqiu tools that still surface the auth login card via this plugin.
37
+ *
38
+ * Currently EMPTY: all five Huaqiu tools now own their keyed HIT cards in
39
+ * their own plugins (`@huaqiu/dsh-tool-symbol-footprint` for the three
40
+ * symbol/footprint generators, `@huaqiu/dsh-tool-schematic-gen` for the two
41
+ * schematic/system generators), and each renders its own inline login card for
42
+ * `needs_auth`. Keeping the toolview keys here would double-register the same
43
+ * `tool.call.toolview` slot with an ambiguous winner.
44
+ *
45
+ * The auth plugin remains the credential owner: the `huaqiuAuth` client
46
+ * service, the sidebar login entrypoint and the webServer credential channel.
47
+ */
48
+ export const AUTH_TOOL_NAMES: readonly string[] = []
49
+
50
+ /** Minimal structural client context (dsh-client-runtime provides this). */
51
+ export interface ClientContext {
52
+ provide?(name: string, value: unknown): () => void
53
+ slots?: {
54
+ inject(key: string, callback: () => () => void): () => void
55
+ register(spec: { name: string; key?: string; id?: string }, component: unknown): unknown
56
+ }
57
+ }
58
+
59
+ export function apply(ctx: ClientContext): () => void {
60
+ const client: AuthClient = createAuthClient({
61
+ storage: createAuthStorage(localStorage),
62
+ transport: createWebServerAuthTransport(),
63
+ windowLike: window,
64
+ documentLike: document,
65
+ })
66
+
67
+ const disposers: Array<() => void> = []
68
+ const disposeProvide = ctx.provide?.('huaqiuAuth', { auth: client.auth })
69
+ registerAuth(client.auth)
70
+ registerAuthSync(() => { void client.syncNow() })
71
+ void client.restore()
72
+ disposers.push(client.auth.onAuthStateChanged((info) => {
73
+ void client.syncNow()
74
+ }))
75
+
76
+ // Healing: the node half keeps auth in memory, so a server restart drops it
77
+ // while the browser still holds the token. Re-sync whenever the tab regains
78
+ // focus/visibility so the tool gate flips back to authenticated without a
79
+ // reload.
80
+ const sync = (): void => { void client.syncNow() }
81
+ window.addEventListener('focus', sync)
82
+ document.addEventListener('visibilitychange', sync)
83
+ disposers.push(() => {
84
+ window.removeEventListener('focus', sync)
85
+ document.removeEventListener('visibilitychange', sync)
86
+ })
87
+
88
+ const slots = ctx.slots
89
+ if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
90
+ for (const toolName of AUTH_TOOL_NAMES) {
91
+ disposers.push(slots.inject('tool.call.toolview', () => slots.register({ name: 'tool.call.toolview', key: toolName }, HuaqiuToolView) as () => void))
92
+ }
93
+ disposers.push(slots.inject('sidebar.footer.action', () => slots.register({ name: 'sidebar.footer.action', id: 'huaqiu-auth' }, HuaqiuAuthSidebarAction) as () => void))
94
+ }
95
+
96
+ return () => {
97
+ for (const dispose of disposers) {
98
+ try {
99
+ dispose()
100
+ } catch {
101
+ /* already disposed */
102
+ }
103
+ }
104
+ disposeProvide?.()
105
+ client.dispose()
106
+ disposeAuth()
107
+ disposeUiEnv()
108
+ }
109
+ }