@dsh-plus/secret-env 0.1.0

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/api.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * secret-env HTTP 端点:GET list / POST global.set / global.unset / session.set / session.unset。
3
+ * 与 dsh web 同源(webServer 默认 loopback / 反代信任域),无独立鉴权
4
+ * (与 usage-panel/notify-email 自定义端点同一暴露面约定)。
5
+ * 值只在「浏览器 → 本端点 → 服务」链路出现,响应永不回显值。
6
+ * @module secret-env/api
7
+ */
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type {} from '@deepseek-ai/dsh-host-webserver'
12
+ import { SecretEnvError } from './errors.ts'
13
+ import { normalizeSuffix, validateSuffix } from './names.ts'
14
+ import type { SecretEnvService } from './service.ts'
15
+
16
+ const ROUTE = '/dsh-plus/secret-env'
17
+
18
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
19
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
20
+ res.end(JSON.stringify(body))
21
+ }
22
+
23
+ function readBody(req: IncomingMessage): Promise<string> {
24
+ return new Promise((resolve, reject) => {
25
+ const chunks: Buffer[] = []
26
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
27
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
28
+ req.on('error', reject)
29
+ })
30
+ }
31
+
32
+ /** 解析并校验请求里的变量名后缀;失败抛 SecretEnvError。 */
33
+ function readSuffix(raw: unknown): string {
34
+ if (typeof raw !== 'string') throw new SecretEnvError('invalid-name', 'name must be a string')
35
+ const suffix = normalizeSuffix(raw)
36
+ const failure = validateSuffix(suffix)
37
+ if (failure !== null) throw new SecretEnvError('invalid-name', `invalid name: ${failure}`)
38
+ return suffix
39
+ }
40
+
41
+ function readString(raw: unknown): string {
42
+ return typeof raw === 'string' ? raw : ''
43
+ }
44
+
45
+ /** 错误 → HTTP 映射(值类错误 400,遮蔽/冲突 409,其余 500;均只带错误码与消息)。 */
46
+ function sendError(res: ServerResponse, error: unknown): void {
47
+ if (error instanceof SecretEnvError) {
48
+ const status = error.code === 'shadowed' || error.code === 'conflict' ? 409 : 400
49
+ sendJson(res, status, { ok: false, error: error.code, message: error.message })
50
+ return
51
+ }
52
+ sendJson(res, 500, { ok: false, error: 'internal' })
53
+ }
54
+
55
+ /** 注册端点(webServer 缺席时由调用方保证不调用)。 */
56
+ export function registerSecretEnvApi(ctx: Context, service: SecretEnvService): void {
57
+ const logger = ctx.logger('secret-env')
58
+ const guard = (label: string, run: () => Promise<void>, res: ServerResponse): void => {
59
+ run().catch((error: unknown) => {
60
+ if (!(error instanceof SecretEnvError)) {
61
+ logger.warn(`${label} failed: ${error instanceof Error ? error.message : String(error)}`)
62
+ }
63
+ sendError(res, error)
64
+ })
65
+ }
66
+
67
+ ctx.webServer.register({
68
+ kind: 'prefix',
69
+ path: `${ROUTE}/list`,
70
+ handler: async (req, res) => {
71
+ const url = new URL(req.url ?? '', 'http://localhost')
72
+ const sessionId = url.searchParams.get('sessionId') ?? undefined
73
+ guard('list', async () => sendJson(res, 200, await service.list(sessionId)), res)
74
+ },
75
+ })
76
+
77
+ ctx.webServer.register({
78
+ kind: 'prefix',
79
+ path: `${ROUTE}/global/set`,
80
+ handler: async (req, res) => {
81
+ guard(
82
+ 'global/set',
83
+ async () => {
84
+ if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
85
+ const body = JSON.parse(await readBody(req)) as Record<string, unknown>
86
+ await service.setGlobal(
87
+ readSuffix(body.name),
88
+ readString(body.value),
89
+ readString(body.description),
90
+ )
91
+ sendJson(res, 200, { ok: true })
92
+ },
93
+ res,
94
+ )
95
+ },
96
+ })
97
+
98
+ ctx.webServer.register({
99
+ kind: 'prefix',
100
+ path: `${ROUTE}/global/unset`,
101
+ handler: async (req, res) => {
102
+ guard(
103
+ 'global/unset',
104
+ async () => {
105
+ if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
106
+ const body = JSON.parse(await readBody(req)) as Record<string, unknown>
107
+ await service.unsetGlobal(readSuffix(body.name))
108
+ sendJson(res, 200, { ok: true })
109
+ },
110
+ res,
111
+ )
112
+ },
113
+ })
114
+
115
+ ctx.webServer.register({
116
+ kind: 'prefix',
117
+ path: `${ROUTE}/session/set`,
118
+ handler: async (req, res) => {
119
+ guard(
120
+ 'session/set',
121
+ async () => {
122
+ if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
123
+ const body = JSON.parse(await readBody(req)) as Record<string, unknown>
124
+ service.setSession(
125
+ readString(body.sessionId),
126
+ readSuffix(body.name),
127
+ readString(body.value),
128
+ readString(body.description),
129
+ body.once === true,
130
+ )
131
+ sendJson(res, 200, { ok: true })
132
+ },
133
+ res,
134
+ )
135
+ },
136
+ })
137
+
138
+ ctx.webServer.register({
139
+ kind: 'prefix',
140
+ path: `${ROUTE}/session/unset`,
141
+ handler: async (req, res) => {
142
+ guard(
143
+ 'session/unset',
144
+ async () => {
145
+ if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
146
+ const body = JSON.parse(await readBody(req)) as Record<string, unknown>
147
+ service.unsetSession(readString(body.sessionId), readSuffix(body.name))
148
+ sendJson(res, 200, { ok: true })
149
+ },
150
+ res,
151
+ )
152
+ },
153
+ })
154
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 数据端点通道:GET list / POST set/unset(同源 fetch,值不上行到任何对话通道)。
3
+ * @module secret-env/client/api
4
+ */
5
+ import { getJson, postJson } from '@dsh-plus/shared/client'
6
+
7
+ export interface GlobalWireEntry {
8
+ name: string
9
+ envName: string
10
+ description: string
11
+ configured: boolean
12
+ source?: string
13
+ writable: boolean
14
+ }
15
+
16
+ export interface SessionWireEntry {
17
+ name: string
18
+ envName: string
19
+ description: string
20
+ once: boolean
21
+ createdAt: string
22
+ }
23
+
24
+ export interface SecretList {
25
+ global: GlobalWireEntry[]
26
+ session: SessionWireEntry[]
27
+ }
28
+
29
+ interface WriteResult {
30
+ ok: boolean
31
+ error?: string
32
+ message?: string
33
+ }
34
+
35
+ /** 端点错误:携带结构化错误码,UI 按码映射文案。 */
36
+ export class ApiError extends Error {
37
+ constructor(readonly code: string) {
38
+ super(code)
39
+ }
40
+ }
41
+
42
+ export async function fetchSecrets(sessionId?: string): Promise<SecretList> {
43
+ const query = sessionId === undefined ? '' : `?sessionId=${encodeURIComponent(sessionId)}`
44
+ return getJson<SecretList>(`/dsh-plus/secret-env/list${query}`)
45
+ }
46
+
47
+ async function write(path: string, body: Record<string, unknown>): Promise<void> {
48
+ try {
49
+ // 非 2xx 时 postJson 抛 Error(body.error),message 即结构化错误码。
50
+ await postJson<WriteResult>(`/dsh-plus/secret-env/${path}`, body)
51
+ } catch (error) {
52
+ throw new ApiError(error instanceof Error ? error.message : 'internal')
53
+ }
54
+ }
55
+
56
+ export function setGlobal(name: string, value: string, description: string): Promise<void> {
57
+ return write('global/set', { name, value, description })
58
+ }
59
+
60
+ export function unsetGlobal(name: string): Promise<void> {
61
+ return write('global/unset', { name })
62
+ }
63
+
64
+ export function setSession(
65
+ sessionId: string,
66
+ name: string,
67
+ value: string,
68
+ description: string,
69
+ once: boolean,
70
+ ): Promise<void> {
71
+ return write('session/set', { sessionId, name, value, description, once })
72
+ }
73
+
74
+ export function unsetSession(sessionId: string, name: string): Promise<void> {
75
+ return write('session/unset', { sessionId, name })
76
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 浏览器半入口:
3
+ * - settings.section 官方插槽注册「密钥变量」独立设置页(全局管理);
4
+ * - conversation.input.right session 作用域插槽注册 composer 钥匙胶囊
5
+ * (会话级密钥面板;会话 id 由插槽 inject 工厂直接供给)。
6
+ * 数据均经同源 HTTP 端点(值不上行经任何对话通道)。
7
+ * @module @dsh-plus/secret-env/client
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+
11
+ import { ComposerSecret } from './composer.tsx'
12
+ import { en, NS, zh } from './i18n.ts'
13
+ import { SecretsSection } from './section.tsx'
14
+ import { injectSecretEnvStyle } from './styles.ts'
15
+
16
+ export const name = 'dsh-plus-secret-env'
17
+
18
+ /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
19
+ export const inject = ['slots', 'locale'] as const
20
+
21
+ interface SlotsLike {
22
+ inject(key: string, callback: () => unknown): unknown
23
+ register(options: Record<string, unknown>, component: unknown): () => void
24
+ }
25
+
26
+ interface LocaleLike {
27
+ register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
28
+ bind(ns: string): (key: string) => string
29
+ }
30
+
31
+ interface ClientContext {
32
+ slots: SlotsLike
33
+ locale: LocaleLike
34
+ effect(execute: () => () => void, label?: string): unknown
35
+ }
36
+
37
+ export function apply(ctx: Context): void {
38
+ const c = ctx as unknown as ClientContext
39
+ const styleTag = injectSecretEnvStyle('@dsh-plus/secret-env')
40
+ c.effect(
41
+ () => () => {
42
+ styleTag?.remove()
43
+ },
44
+ 'secret-env: style',
45
+ )
46
+ c.effect(() => c.locale.register(NS, { zh, en }), 'secret-env: locale')
47
+
48
+ const t = c.locale.bind(NS)
49
+ // 独立设置页(settings.section:官方设置导航;order 16 排在用量统计之后)。
50
+ c.slots.inject('settings.section', () =>
51
+ c.slots.register(
52
+ {
53
+ name: 'settings.section',
54
+ id: 'dsh-plus-secret-env',
55
+ order: 16,
56
+ label: () => t('nav'),
57
+ inject: () => ({ t }),
58
+ },
59
+ SecretsSection,
60
+ ),
61
+ )
62
+
63
+ // composer 工具行钥匙胶囊(conversation.input.right:session 作用域 list 槽,
64
+ // inject 工厂收框架解析的 sessionId)。
65
+ c.slots.inject('conversation.input.right', () =>
66
+ c.slots.register(
67
+ {
68
+ name: 'conversation.input.right',
69
+ id: 'dsh-plus-secret-env',
70
+ order: 40,
71
+ locale: NS,
72
+ inject: (sessionId: string) => ({ sessionId }),
73
+ },
74
+ ComposerSecret,
75
+ ),
76
+ )
77
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 浏览器半共享小件:错误码文案映射与复制助手(设置页与会话控件共用)。
3
+ * @module secret-env/client/common
4
+ */
5
+ import { ApiError } from './api.ts'
6
+
7
+ /** 端点错误 → 本地化文案(未知码回落 internal)。 */
8
+ export function errorText(t: (key: string) => string, error: unknown): string {
9
+ const code = error instanceof ApiError ? error.code : 'internal'
10
+ const text = t(`error.${code}`)
11
+ // locale.bind 对缺失键原样回显键名,据此判断回落。
12
+ return text === `error.${code}` ? t('error.internal') : text
13
+ }
14
+
15
+ /** 复制文本到剪贴板(clipboard API 缺席时退 execCommand)。 */
16
+ export async function copyText(text: string): Promise<boolean> {
17
+ try {
18
+ await navigator.clipboard.writeText(text)
19
+ return true
20
+ } catch {
21
+ // 回落:非安全上下文(如非 localhost 直连)无 clipboard API。
22
+ const area = document.createElement('textarea')
23
+ area.value = text
24
+ area.style.position = 'fixed'
25
+ area.style.opacity = '0'
26
+ document.body.appendChild(area)
27
+ area.select()
28
+ const ok = document.execCommand('copy')
29
+ area.remove()
30
+ return ok
31
+ }
32
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * 会话注入控件(conversation.input.right 官方插槽,session 作用域):
3
+ * composer 工具行的钥匙胶囊,弹出本会话的会话级密钥面板(列表 + 增删)。
4
+ * 会话 id 由插槽 inject 工厂直接供给(框架解析的严格 session 作用域)。
5
+ * 窄屏(≤767px)popover 转底部抽屉;桌面点外部或 Esc 关闭。
6
+ * @module secret-env/client/composer
7
+ */
8
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
9
+
10
+ import { fetchSecrets, type SessionWireEntry, setSession, unsetSession } from './api.ts'
11
+ import { copyText, errorText } from './common.ts'
12
+
13
+ export interface ComposerSecretProps {
14
+ /** 插槽 inject 工厂注入的当前会话 id(严格 session 作用域,必存在)。 */
15
+ sessionId: string
16
+ t(key: string): string
17
+ }
18
+
19
+ interface FormState {
20
+ name: string
21
+ value: string
22
+ description: string
23
+ once: boolean
24
+ }
25
+
26
+ const EMPTY_FORM: FormState = { name: '', value: '', description: '', once: false }
27
+
28
+ function SessionRow(props: {
29
+ t: ComposerSecretProps['t']
30
+ sessionId: string
31
+ entry: SessionWireEntry
32
+ onDeleted(): void
33
+ onError(text: string): void
34
+ }): ReactElement {
35
+ const { t, entry } = props
36
+ const [copied, setCopied] = useState(false)
37
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
38
+
39
+ useEffect(
40
+ () => () => {
41
+ if (timer.current !== null) clearTimeout(timer.current)
42
+ },
43
+ [],
44
+ )
45
+
46
+ return (
47
+ <div className="dse-row">
48
+ <div className="dse-rowMain">
49
+ <div className="dse-env">
50
+ <span className="dse-envName">${entry.envName}</span>
51
+ <button
52
+ type="button"
53
+ className="dse-iconBtn"
54
+ title={t('copy')}
55
+ aria-label={t('copy')}
56
+ onClick={() => {
57
+ void copyText(`$${entry.envName}`).then((ok) => {
58
+ if (!ok) return
59
+ setCopied(true)
60
+ if (timer.current !== null) clearTimeout(timer.current)
61
+ timer.current = setTimeout(() => setCopied(false), 1500)
62
+ })
63
+ }}
64
+ >
65
+ {copied ? '✓' : '⧉'}
66
+ </button>
67
+ </div>
68
+ {entry.description !== '' ? <span className="dse-note">{entry.description}</span> : null}
69
+ </div>
70
+ <div className="dse-badges">
71
+ {entry.once ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
72
+ <button
73
+ type="button"
74
+ className="dse-iconBtn dse-delBtn"
75
+ title={t('delete')}
76
+ aria-label={t('delete')}
77
+ onClick={() => {
78
+ unsetSession(props.sessionId, entry.name)
79
+ .then(() => props.onDeleted())
80
+ .catch((error: unknown) => props.onError(errorText(t, error)))
81
+ }}
82
+ >
83
+
84
+ </button>
85
+ </div>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ export function ComposerSecret(props: ComposerSecretProps): ReactElement {
91
+ const { sessionId, t } = props
92
+ const [open, setOpen] = useState(false)
93
+ const [items, setItems] = useState<SessionWireEntry[] | null>(null)
94
+ const [form, setForm] = useState<FormState>(EMPTY_FORM)
95
+ const [saving, setSaving] = useState(false)
96
+ const [status, setStatus] = useState<string | null>(null)
97
+ const wrapRef = useRef<HTMLSpanElement | null>(null)
98
+
99
+ const load = (): void => {
100
+ fetchSecrets(sessionId)
101
+ .then((list) => setItems(list.session))
102
+ .catch(() => setStatus(errorText(t, new Error('internal'))))
103
+ }
104
+
105
+ // 打开面板与会话切换时拉取;闭合计时清零。
106
+ // biome-ignore lint/correctness/useExhaustiveDependencies: load 为闭包稳定函数,仅需随 open/sessionId 触发
107
+ useEffect(() => {
108
+ if (!open) return
109
+ load()
110
+ }, [open, sessionId])
111
+
112
+ // 桌面:点外部 / Esc 关闭。
113
+ useEffect(() => {
114
+ if (!open) return
115
+ const onDown = (event: MouseEvent): void => {
116
+ if (wrapRef.current !== null && !wrapRef.current.contains(event.target as Node)) {
117
+ setOpen(false)
118
+ }
119
+ }
120
+ const onKey = (event: KeyboardEvent): void => {
121
+ if (event.key === 'Escape') setOpen(false)
122
+ }
123
+ document.addEventListener('mousedown', onDown)
124
+ document.addEventListener('keydown', onKey)
125
+ return () => {
126
+ document.removeEventListener('mousedown', onDown)
127
+ document.removeEventListener('keydown', onKey)
128
+ }
129
+ }, [open])
130
+
131
+ const onSave = (): void => {
132
+ setSaving(true)
133
+ setStatus(null)
134
+ setSession(sessionId, form.name, form.value, form.description, form.once)
135
+ .then(() => {
136
+ setForm(EMPTY_FORM)
137
+ load()
138
+ })
139
+ .catch((error: unknown) => setStatus(errorText(t, error)))
140
+ .finally(() => setSaving(false))
141
+ }
142
+
143
+ const count = items?.length ?? 0
144
+ const canSave = form.name.trim() !== '' && form.value !== '' && !saving
145
+
146
+ return (
147
+ <span className="dse-wrap" ref={wrapRef}>
148
+ <button
149
+ type="button"
150
+ className="dse-chip"
151
+ aria-expanded={open}
152
+ aria-label={t('sessionSecrets')}
153
+ title={t('sessionSecrets')}
154
+ onClick={() => setOpen(!open)}
155
+ >
156
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
157
+ <path
158
+ d="M14.5 2a7.5 7.5 0 0 0-7.36 9.04L2 20.19V22h1.81l1.5-1.5v-1.81h1.81v-1.81h1.81l1.22-1.22A7.5 7.5 0 1 0 14.5 2Zm2 5.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"
159
+ fill="currentColor"
160
+ />
161
+ </svg>
162
+ {count > 0 ? <span className="dse-count">{count}</span> : null}
163
+ </button>
164
+ {open ? (
165
+ <div className="dse-pop" role="dialog" aria-label={t('sessionSecrets')}>
166
+ <div className="dse-popHead">
167
+ <p className="dse-popTitle">{t('sessionSecrets')}</p>
168
+ <button
169
+ type="button"
170
+ className="dse-iconBtn"
171
+ title={t('refresh')}
172
+ aria-label={t('refresh')}
173
+ onClick={load}
174
+ >
175
+
176
+ </button>
177
+ <button
178
+ type="button"
179
+ className="dse-iconBtn"
180
+ title={t('close')}
181
+ aria-label={t('close')}
182
+ onClick={() => setOpen(false)}
183
+ >
184
+
185
+ </button>
186
+ </div>
187
+ <p className="dse-popHint">{t('sessionHint')}</p>
188
+ <div className="dse-popList">
189
+ {items === null || items.length === 0 ? (
190
+ <p className="dse-empty">{t('sessionEmpty')}</p>
191
+ ) : (
192
+ items.map((entry) => (
193
+ <SessionRow
194
+ key={entry.name}
195
+ t={t}
196
+ sessionId={sessionId}
197
+ entry={entry}
198
+ onDeleted={load}
199
+ onError={(text) => setStatus(text === '' ? null : text)}
200
+ />
201
+ ))
202
+ )}
203
+ </div>
204
+ <div className="dse-popForm">
205
+ <div className="dse-field">
206
+ <div className="dse-head">
207
+ <label className="dse-label" htmlFor="dse-s-name">
208
+ {t('addSession')}
209
+ </label>
210
+ </div>
211
+ <input
212
+ id="dse-s-name"
213
+ className="dse-input"
214
+ placeholder="API_TOKEN"
215
+ value={form.name}
216
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
217
+ />
218
+ <input
219
+ className="dse-input"
220
+ type="password"
221
+ autoComplete="off"
222
+ placeholder={t('valueLabel')}
223
+ value={form.value}
224
+ onChange={(event) => setForm({ ...form, value: event.target.value })}
225
+ />
226
+ <input
227
+ className="dse-input"
228
+ placeholder={t('descLabel')}
229
+ value={form.description}
230
+ onChange={(event) => setForm({ ...form, description: event.target.value })}
231
+ />
232
+ <div className="dse-checkRow">
233
+ <input
234
+ id="dse-s-once"
235
+ type="checkbox"
236
+ role="switch"
237
+ aria-checked={form.once}
238
+ checked={form.once}
239
+ onChange={(event) => setForm({ ...form, once: event.target.checked })}
240
+ />
241
+ <label htmlFor="dse-s-once">{t('onceLabel')}</label>
242
+ </div>
243
+ </div>
244
+ <div className="dse-foot">
245
+ {status !== null ? <p className="dse-status dse-statusError">{status}</p> : null}
246
+ <button
247
+ type="button"
248
+ className="dse-btn dse-btnPrimary"
249
+ disabled={!canSave}
250
+ onClick={onSave}
251
+ >
252
+ {saving ? t('saving') : t('save')}
253
+ </button>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ ) : null}
258
+ </span>
259
+ )
260
+ }