@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/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
+ }