@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/LICENSE +21 -0
- package/README.md +22 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1427 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +73 -0
- package/lib/index.mjs +291 -0
- package/package.json +55 -0
- package/src/client/auth-state.ts +96 -0
- package/src/client/client.ts +169 -0
- package/src/client/i18n.ts +97 -0
- package/src/client/index.tsx +109 -0
- package/src/client/lib.ts +234 -0
- package/src/client/storage.ts +43 -0
- package/src/client/transport.ts +36 -0
- package/src/client/ui/common.tsx +169 -0
- package/src/client/ui/hq-icon.tsx +50 -0
- package/src/client/ui/login-dialog.ts +219 -0
- package/src/client/ui/needs-auth-toolview.tsx +118 -0
- package/src/client/ui/sidebar-action.tsx +395 -0
- package/src/client/ui-env.ts +175 -0
- package/src/host.ts +139 -0
- package/src/index.ts +42 -0
- package/src/routes.ts +84 -0
- package/src/service.ts +172 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-auth` — node plugin entry.
|
|
3
|
+
*
|
|
4
|
+
* Provides the `huaqiuAuth` service (capability, not token transport) and
|
|
5
|
+
* mounts the browser→node credential routes on `ctx.webServer`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
9
|
+
import { InMemoryHuaqiuAuthService, type HuaqiuAuthService } from './service.js'
|
|
10
|
+
import { AUTH_ROUTE_PREFIX, createAuthHandler } from './routes.js'
|
|
11
|
+
import type { HuaqiuAuthConfig } from './host.js'
|
|
12
|
+
|
|
13
|
+
export type { HuaqiuAuthApi, HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
|
|
14
|
+
export type { HuaqiuAuthConfig } from './host.js'
|
|
15
|
+
|
|
16
|
+
export const name = '@huaqiu/dsh-auth'
|
|
17
|
+
export const inject = ['webServer'] as const
|
|
18
|
+
|
|
19
|
+
declare module '@deepseek-ai/cordis' {
|
|
20
|
+
interface Context {
|
|
21
|
+
huaqiuAuth: HuaqiuAuthService
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param ctx cordis context
|
|
27
|
+
* @param config overlay `config` (hq-edge supervisor injects `hqEdgeBaseUrl`
|
|
28
|
+
* here). When present with a base URL, the plugin runs in HQ Edge host mode:
|
|
29
|
+
* the node half fetches the credential from HQ Edge instead of waiting for a
|
|
30
|
+
* browser login. Env (`HQ_EDGE_BASE_URL` …) is the fallback for installs
|
|
31
|
+
* without a supervisor (spec §6.4).
|
|
32
|
+
*/
|
|
33
|
+
export function apply(ctx: Context, config?: Partial<HuaqiuAuthConfig>): void {
|
|
34
|
+
const service = new InMemoryHuaqiuAuthService(config)
|
|
35
|
+
ctx.effect(() => ctx.provide('huaqiuAuth', service))
|
|
36
|
+
|
|
37
|
+
ctx.effect(() => ctx.webServer.register({
|
|
38
|
+
kind: 'prefix',
|
|
39
|
+
path: AUTH_ROUTE_PREFIX,
|
|
40
|
+
handler: createAuthHandler(service),
|
|
41
|
+
}))
|
|
42
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP adapter: receives the browser-pushed credentials and serves the node
|
|
3
|
+
* auth state (probe / boot restore). Same-origin through `ctx.webServer`:
|
|
4
|
+
*
|
|
5
|
+
* POST /api/v1/huaqiu/auth/session body { token, userInfo? } → cache set
|
|
6
|
+
* POST /api/v1/huaqiu/auth/logout → cache cleared
|
|
7
|
+
* GET /api/v1/huaqiu/auth/session → { authenticated, user }
|
|
8
|
+
*
|
|
9
|
+
* These routes are the browser→node transport for Phase 0A (start-p0.md §4:
|
|
10
|
+
* smallest supported extension point — `apiProxy`'s dispatch table is closed,
|
|
11
|
+
* so a plugin-owned `webServer` route is the documented channel).
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
|
+
import type { HuaqiuAuthService, HuaqiuUserInfo } from './service.js'
|
|
15
|
+
|
|
16
|
+
export const AUTH_ROUTE_PREFIX = '/api/v1/huaqiu/auth'
|
|
17
|
+
|
|
18
|
+
export type AuthHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void> | void
|
|
19
|
+
|
|
20
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
21
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
22
|
+
res.end(JSON.stringify(body))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const chunks: Buffer[] = []
|
|
28
|
+
req.on('data', (c: Buffer) => chunks.push(c))
|
|
29
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
30
|
+
req.on('error', reject)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeUserInfo(data: Record<string, unknown>): HuaqiuUserInfo | null {
|
|
35
|
+
const token = typeof data.token === 'string' && data.token.length > 0 ? data.token : null
|
|
36
|
+
const id = typeof data.userId === 'string' && data.userId.length > 0
|
|
37
|
+
? data.userId
|
|
38
|
+
: typeof data.userId === 'number' && Number.isFinite(data.userId) ? String(data.userId)
|
|
39
|
+
: typeof data.id === 'string' && data.id.length > 0 ? data.id
|
|
40
|
+
: typeof data.id === 'number' && Number.isFinite(data.id) ? String(data.id)
|
|
41
|
+
: null
|
|
42
|
+
if (!token || !id) return null
|
|
43
|
+
const nickname = typeof data.nickname === 'string' && data.nickname.length > 0 ? data.nickname : undefined
|
|
44
|
+
return { id, token, ...(nickname ? { nickname } : {}) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAuthHandler(service: HuaqiuAuthService): AuthHandler {
|
|
48
|
+
return async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const url = req.url ?? ''
|
|
51
|
+
const q = url.indexOf('?')
|
|
52
|
+
const pathname = (q >= 0 ? url.slice(0, q) : url).replace(/\/+$/, '')
|
|
53
|
+
|
|
54
|
+
if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
|
|
55
|
+
const body = JSON.parse(await readBody(req) || '{}') as Record<string, unknown>
|
|
56
|
+
const info = normalizeUserInfo(body)
|
|
57
|
+
if (!info) {
|
|
58
|
+
sendJson(res, 400, { error: 'token and userId are required' })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
service.setCredentials(info)
|
|
62
|
+
sendJson(res, 200, { ok: true })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (req.method === 'POST' && pathname === `${AUTH_ROUTE_PREFIX}/logout`) {
|
|
67
|
+
service.invalidate()
|
|
68
|
+
sendJson(res, 200, { ok: true })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (req.method === 'GET' && pathname === `${AUTH_ROUTE_PREFIX}/session`) {
|
|
73
|
+
const user = await service.auth.getUserInfo()
|
|
74
|
+
const authenticated = service.auth.isAuthenticated()
|
|
75
|
+
sendJson(res, 200, { authenticated, user })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sendJson(res, 404, { error: 'not found' })
|
|
80
|
+
} catch (err) {
|
|
81
|
+
sendJson(res, 500, { error: 'internal error', detail: String(err) })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-side `huaqiuAuth` service.
|
|
3
|
+
*
|
|
4
|
+
* Holds the credential used by the tools (`getAccessToken()` → `x-user-token`,
|
|
5
|
+
* `getUserInfo()` → `x-user-id`). Two sources feed it:
|
|
6
|
+
*
|
|
7
|
+
* - **pushed** — the browser half owns the login flow (auth.eda.cn iframe +
|
|
8
|
+
* postMessage) and pushes credentials over a plugin-owned `webServer` route
|
|
9
|
+
* (`setCredentials`).
|
|
10
|
+
* - **host** — when HQ Edge is the host, the node half fetches the operator
|
|
11
|
+
* token directly from HQ Edge's loopback route (host mode, `src/host.ts`).
|
|
12
|
+
*
|
|
13
|
+
* `huaqiuAuth.auth` is a capability (`isAuthenticated`/`getAccessToken`/
|
|
14
|
+
* `getUserInfo`), NOT a promise that the browser and node tokens are the same
|
|
15
|
+
* value (migration plan review #7).
|
|
16
|
+
*
|
|
17
|
+
* Resolution order inside `getUserInfo()` (spec §6.2):
|
|
18
|
+
* 1. host session — HQ Edge configured → fetch + cache (TTL)
|
|
19
|
+
* 2. pushed session — what the browser half sent
|
|
20
|
+
* 3. persisted file — `~/.dsh/auth/session.json`, written on every set
|
|
21
|
+
* 4. null → tools return needs_auth
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
|
|
26
|
+
import {
|
|
27
|
+
HostSessionResolver,
|
|
28
|
+
resolveHostConfig,
|
|
29
|
+
type HuaqiuAuthConfig,
|
|
30
|
+
type ResolvedHostUser,
|
|
31
|
+
} from './host.js'
|
|
32
|
+
|
|
33
|
+
export interface HuaqiuUserInfo {
|
|
34
|
+
id: string
|
|
35
|
+
token: string
|
|
36
|
+
nickname?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HuaqiuAuthApi {
|
|
40
|
+
isAuthenticated(): boolean
|
|
41
|
+
getAccessToken(): Promise<string | null>
|
|
42
|
+
getUserInfo(): Promise<HuaqiuUserInfo | null>
|
|
43
|
+
/** Node-side no-op: login always happens in the browser. */
|
|
44
|
+
login(): Promise<void>
|
|
45
|
+
logout(): Promise<void>
|
|
46
|
+
onAuthStateChanged(listener: (info: HuaqiuUserInfo | null) => void): () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface HuaqiuAuthService {
|
|
50
|
+
auth: HuaqiuAuthApi
|
|
51
|
+
/** Node-only setters used by the webServer route handlers. */
|
|
52
|
+
setCredentials(info: HuaqiuUserInfo): void
|
|
53
|
+
invalidate(): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PERSIST_FILE = 'session.json'
|
|
57
|
+
const PERSIST_DIR = () => dshHomePath('auth')
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the persisted session, or null if absent/unreadable. Best-effort: a
|
|
61
|
+
* corrupt or unreadable file is treated as "no session" rather than thrown.
|
|
62
|
+
*/
|
|
63
|
+
function readPersisted(): HuaqiuUserInfo | null {
|
|
64
|
+
try {
|
|
65
|
+
const file = join(PERSIST_DIR(), PERSIST_FILE)
|
|
66
|
+
if (!existsSync(file)) return null
|
|
67
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
68
|
+
const id = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : null
|
|
69
|
+
const token = typeof raw.token === 'string' && raw.token.length > 0 ? raw.token : null
|
|
70
|
+
if (!id || !token) return null
|
|
71
|
+
const nickname = typeof raw.nickname === 'string' && raw.nickname.length > 0
|
|
72
|
+
? raw.nickname
|
|
73
|
+
: undefined
|
|
74
|
+
return { id, token, ...(nickname ? { nickname } : {}) }
|
|
75
|
+
} catch {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writePersisted(info: HuaqiuUserInfo): void {
|
|
81
|
+
try {
|
|
82
|
+
const dir = PERSIST_DIR()
|
|
83
|
+
mkdirSync(dir, { recursive: true })
|
|
84
|
+
const file = join(dir, PERSIST_FILE)
|
|
85
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
86
|
+
writeFileSync(tmp, JSON.stringify(info), 'utf8')
|
|
87
|
+
renameSync(tmp, file)
|
|
88
|
+
} catch {
|
|
89
|
+
/* persistence is best-effort; never break the auth flow over a disk error */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deletePersisted(): void {
|
|
94
|
+
try {
|
|
95
|
+
const file = join(PERSIST_DIR(), PERSIST_FILE)
|
|
96
|
+
if (existsSync(file)) rmSync(file, { force: true })
|
|
97
|
+
} catch {
|
|
98
|
+
/* best-effort */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class InMemoryHuaqiuAuthService implements HuaqiuAuthService {
|
|
103
|
+
private current: HuaqiuUserInfo | null = null
|
|
104
|
+
private listeners = new Set<(info: HuaqiuUserInfo | null) => void>()
|
|
105
|
+
private readonly host: HostSessionResolver
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
config?: Partial<HuaqiuAuthConfig> | null,
|
|
109
|
+
opts?: { fetchImpl?: typeof fetch },
|
|
110
|
+
) {
|
|
111
|
+
const resolved = resolveHostConfig(config)
|
|
112
|
+
this.host = new HostSessionResolver(
|
|
113
|
+
resolved.hqEdgeBaseUrl ?? '',
|
|
114
|
+
resolved.hostAuthPath ?? '/api/v1/auth/token',
|
|
115
|
+
(resolved.hostSessionTtlSeconds ?? 300) * 1000,
|
|
116
|
+
opts?.fetchImpl,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
readonly auth: HuaqiuAuthApi = {
|
|
121
|
+
isAuthenticated: () => this.host.enabled || this.current !== null || readPersisted() !== null,
|
|
122
|
+
getAccessToken: async () => (await this.resolve())?.token ?? null,
|
|
123
|
+
getUserInfo: async () => this.resolve(),
|
|
124
|
+
login: async () => {
|
|
125
|
+
/* login is a browser action */
|
|
126
|
+
},
|
|
127
|
+
logout: async () => this.invalidate(),
|
|
128
|
+
onAuthStateChanged: (listener) => this.on(listener),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Spec §6.2 resolution order: host → pushed → persisted → null. */
|
|
132
|
+
private async resolve(): Promise<HuaqiuUserInfo | null> {
|
|
133
|
+
if (this.host.enabled) {
|
|
134
|
+
const host = await this.host.resolve()
|
|
135
|
+
if (host) return toUserInfo(host)
|
|
136
|
+
}
|
|
137
|
+
if (this.current) return this.current
|
|
138
|
+
return readPersisted()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setCredentials(info: HuaqiuUserInfo): void {
|
|
142
|
+
this.current = info
|
|
143
|
+
void writePersisted(info)
|
|
144
|
+
this.emit()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
invalidate(): void {
|
|
148
|
+
const was = this.host.enabled || this.current !== null || readPersisted() !== null
|
|
149
|
+
this.current = null
|
|
150
|
+
this.host.clear()
|
|
151
|
+
deletePersisted()
|
|
152
|
+
if (was) this.emit()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private on(listener: (info: HuaqiuUserInfo | null) => void): () => void {
|
|
156
|
+
this.listeners.add(listener)
|
|
157
|
+
return () => this.listeners.delete(listener)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private emit(): void {
|
|
161
|
+
const snapshot = this.current
|
|
162
|
+
for (const listener of this.listeners) listener(snapshot)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toUserInfo(host: ResolvedHostUser): HuaqiuUserInfo {
|
|
167
|
+
return {
|
|
168
|
+
id: host.id,
|
|
169
|
+
token: host.token,
|
|
170
|
+
...(host.nickname !== undefined ? { nickname: host.nickname } : {}),
|
|
171
|
+
}
|
|
172
|
+
}
|