@dsh-ft/dsh-fft-settings 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +392 -2
- package/cordis.patch.yml +18 -0
- package/docs/compatibility-dsh-0.1.6-alpha.1.md +58 -0
- package/docs/extension-service-design.md +351 -0
- package/docs/extension-service-sequences.html +545 -0
- package/docs/ftshare-settings-er.html +139 -0
- package/docs/ftshare-settings-er.svg +113 -0
- package/docs/ftshare-settings-results-er.html +148 -0
- package/docs/ftshare-settings-results-er.svg +122 -0
- package/docs/kline-integration.md +258 -0
- package/lib/agent.d.ts +24 -0
- package/lib/agent.js +26 -0
- package/lib/brand.d.ts +53 -0
- package/lib/brand.js +50 -0
- package/lib/client.d.ts +8 -0
- package/lib/client.js +822 -0
- package/lib/client.js.map +1 -0
- package/lib/contract.d.ts +14 -0
- package/lib/contract.js +14 -0
- package/lib/core/client-transport.d.ts +15 -0
- package/lib/core/client-transport.js +31 -0
- package/lib/core/contract.d.ts +62 -0
- package/lib/core/contract.js +28 -0
- package/lib/core/module-registry.d.ts +54 -0
- package/lib/core/module-registry.js +68 -0
- package/lib/core/rpc-core.d.ts +69 -0
- package/lib/core/rpc-core.js +88 -0
- package/lib/core/shell.d.ts +59 -0
- package/lib/core/shell.js +28 -0
- package/lib/core/styles.d.ts +31 -0
- package/lib/core/styles.js +43 -0
- package/lib/core/transport.d.ts +19 -0
- package/lib/core/transport.js +80 -0
- package/lib/default-preset.d.ts +52 -0
- package/lib/default-preset.js +121 -0
- package/lib/features/api-key/card.d.ts +24 -0
- package/lib/features/api-key/card.js +39 -0
- package/lib/features/api-key/client-api.d.ts +4 -0
- package/lib/features/api-key/client-api.js +11 -0
- package/lib/features/api-key/contract.d.ts +69 -0
- package/lib/features/api-key/contract.js +24 -0
- package/lib/features/api-key/host.d.ts +17 -0
- package/lib/features/api-key/host.js +32 -0
- package/lib/features/api-key/index.d.ts +13 -0
- package/lib/features/api-key/index.js +12 -0
- package/lib/features/api-key/logging.d.ts +15 -0
- package/lib/features/api-key/logging.js +1 -0
- package/lib/features/api-key/model.d.ts +24 -0
- package/lib/features/api-key/model.js +105 -0
- package/lib/features/api-key/probe.d.ts +11 -0
- package/lib/features/api-key/probe.js +142 -0
- package/lib/features/api-key/rpc.d.ts +4 -0
- package/lib/features/api-key/rpc.js +17 -0
- package/lib/features/api-key/service.d.ts +21 -0
- package/lib/features/api-key/service.js +181 -0
- package/lib/features/api-key/styles.d.ts +9 -0
- package/lib/features/api-key/styles.js +24 -0
- package/lib/features/logs/archive.d.ts +87 -0
- package/lib/features/logs/archive.js +156 -0
- package/lib/features/logs/card.d.ts +22 -0
- package/lib/features/logs/card.js +107 -0
- package/lib/features/logs/client-api.d.ts +4 -0
- package/lib/features/logs/client-api.js +11 -0
- package/lib/features/logs/collector.d.ts +91 -0
- package/lib/features/logs/collector.js +258 -0
- package/lib/features/logs/config.d.ts +70 -0
- package/lib/features/logs/config.js +97 -0
- package/lib/features/logs/contract.d.ts +130 -0
- package/lib/features/logs/contract.js +67 -0
- package/lib/features/logs/export.d.ts +92 -0
- package/lib/features/logs/export.js +212 -0
- package/lib/features/logs/format.d.ts +42 -0
- package/lib/features/logs/format.js +172 -0
- package/lib/features/logs/home.d.ts +18 -0
- package/lib/features/logs/home.js +39 -0
- package/lib/features/logs/host.d.ts +19 -0
- package/lib/features/logs/host.js +139 -0
- package/lib/features/logs/index.d.ts +12 -0
- package/lib/features/logs/index.js +12 -0
- package/lib/features/logs/rpc.d.ts +6 -0
- package/lib/features/logs/rpc.js +27 -0
- package/lib/features/logs/styles.d.ts +9 -0
- package/lib/features/logs/styles.js +13 -0
- package/lib/guidance.d.ts +41 -0
- package/lib/guidance.js +88 -0
- package/lib/index.d.ts +67 -0
- package/lib/index.js +50 -0
- package/lib/rpc.d.ts +10 -0
- package/lib/rpc.js +10 -0
- package/package.json +86 -4
- package/src/agent.ts +31 -0
- package/src/brand.ts +79 -0
- package/src/client.tsx +21 -0
- package/src/contract.ts +14 -0
- package/src/core/client-transport.ts +37 -0
- package/src/core/contract.ts +57 -0
- package/src/core/module-registry.ts +102 -0
- package/src/core/rpc-core.ts +107 -0
- package/src/core/shell.tsx +81 -0
- package/src/core/styles.ts +43 -0
- package/src/core/transport.ts +82 -0
- package/src/default-preset.ts +151 -0
- package/src/features/api-key/card.tsx +77 -0
- package/src/features/api-key/client-api.ts +12 -0
- package/src/features/api-key/contract.ts +63 -0
- package/src/features/api-key/host.ts +36 -0
- package/src/features/api-key/index.ts +13 -0
- package/src/features/api-key/logging.ts +15 -0
- package/src/features/api-key/model.ts +63 -0
- package/src/features/api-key/probe.ts +116 -0
- package/src/features/api-key/rpc.ts +18 -0
- package/src/features/api-key/service.ts +157 -0
- package/src/features/api-key/styles.ts +24 -0
- package/src/features/logs/archive.ts +230 -0
- package/src/features/logs/card.tsx +169 -0
- package/src/features/logs/client-api.ts +20 -0
- package/src/features/logs/collector.ts +313 -0
- package/src/features/logs/config.ts +115 -0
- package/src/features/logs/contract.ts +149 -0
- package/src/features/logs/export.ts +274 -0
- package/src/features/logs/format.ts +188 -0
- package/src/features/logs/home.ts +43 -0
- package/src/features/logs/host.ts +153 -0
- package/src/features/logs/index.ts +12 -0
- package/src/features/logs/rpc.ts +29 -0
- package/src/features/logs/styles.ts +13 -0
- package/src/guidance.ts +114 -0
- package/src/index.ts +59 -0
- package/src/rpc.ts +10 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim the finance preset as the default once per installation.
|
|
3
|
+
*
|
|
4
|
+
* The bundle patch already carries `default: ft-finance` on the preset
|
|
5
|
+
* registry row, which decides every session on a machine that never chose a
|
|
6
|
+
* default. A machine whose user *did* choose one keeps that choice, because
|
|
7
|
+
* `selectedDefault` outranks `default` — this module overrides it exactly once,
|
|
8
|
+
* then records the claim so a later user choice is never touched again.
|
|
9
|
+
*
|
|
10
|
+
* The write goes through the settings service, the same path the settings page
|
|
11
|
+
* uses: only volatile fields are writable there, so the patch this module hands
|
|
12
|
+
* over carries `selectedDefault` and nothing else. The row it lands in is wider
|
|
13
|
+
* than that patch — the service writes the effective config's non-volatile keys
|
|
14
|
+
* alongside the volatile change, so `default: ft-finance` is copied into the
|
|
15
|
+
* profile row too. Because it is still the one change this plugin ever makes to
|
|
16
|
+
* the user's own profile patch, the file is copied into this plugin's data
|
|
17
|
+
* directory first, and a claim without that copy never happens.
|
|
18
|
+
* @module @dsh-ft/dsh-fft-settings/default-preset
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
25
|
+
import { resolveDshHome } from './features/logs/home.js'
|
|
26
|
+
|
|
27
|
+
/** The preset this bundle wants as the default. */
|
|
28
|
+
export const DEFAULT_PRESET_ID = 'ft-finance'
|
|
29
|
+
|
|
30
|
+
/** Id of the profile entry that owns the preset registry's config. */
|
|
31
|
+
export const REGISTRY_ENTRY_ID = 'agent-preset-registry'
|
|
32
|
+
|
|
33
|
+
/** Data directory this plugin owns under the DSH home. */
|
|
34
|
+
export const DATA_DIR_NAME = '.dsh-fft-settings'
|
|
35
|
+
|
|
36
|
+
/** File whose presence means the claim already happened on this machine. */
|
|
37
|
+
export const CLAIM_FILE_NAME = 'default-preset.json'
|
|
38
|
+
|
|
39
|
+
/** Snapshot of the profile patch, taken before this module's single write. */
|
|
40
|
+
export const BACKUP_FILE_NAME = 'cordis.patch.yml.bak'
|
|
41
|
+
|
|
42
|
+
/** The registry's two calls this module makes, declared structurally so the package keeps no harness dependency. */
|
|
43
|
+
interface PresetRegistry {
|
|
44
|
+
/** Effective default preset id for a new session. */
|
|
45
|
+
readonly defaultId: string
|
|
46
|
+
/** Every registered preset. */
|
|
47
|
+
list(): Promise<readonly { readonly id: string }[]>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The settings service's single write this module makes. */
|
|
51
|
+
interface SettingsWriter {
|
|
52
|
+
update(ns: string, patch: Record<string, unknown>, expectedRevision?: number): Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The boot context that owns the profile patch the settings write lands in. */
|
|
56
|
+
interface ProfileContext {
|
|
57
|
+
/** Path of the profile's own `cordis.patch.yml`. */
|
|
58
|
+
readonly patchPath: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Record that this installation claimed the default, once.
|
|
63
|
+
* @param home - the DSH home the plugin writes its own data into.
|
|
64
|
+
* @returns the claim file path.
|
|
65
|
+
*/
|
|
66
|
+
export function claimFilePath(home = resolveDshHome()): string {
|
|
67
|
+
return join(home, DATA_DIR_NAME, CLAIM_FILE_NAME)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Snapshot path for the profile patch.
|
|
72
|
+
* @param home - the DSH home the plugin writes its own data into.
|
|
73
|
+
* @returns the snapshot file path.
|
|
74
|
+
*/
|
|
75
|
+
export function backupFilePath(home = resolveDshHome()): string {
|
|
76
|
+
return join(home, DATA_DIR_NAME, BACKUP_FILE_NAME)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make `ft-finance` the default unless this machine already made a claim.
|
|
81
|
+
*
|
|
82
|
+
* Every step that cannot complete skips silently and retries on the next start:
|
|
83
|
+
* a missing registry or settings service, a preset that is not registered yet,
|
|
84
|
+
* and a failed write all leave no claim file behind. Nothing here may fail the
|
|
85
|
+
* plugin's activation.
|
|
86
|
+
* @param ctx - the plugin's host context.
|
|
87
|
+
*/
|
|
88
|
+
export function installDefaultPresetOnce(ctx: Context): void {
|
|
89
|
+
ctx.inject(['settings', 'agentPresets'], (scope) => {
|
|
90
|
+
const registry = scope.get('agentPresets') as PresetRegistry | undefined
|
|
91
|
+
const settings = scope.get('settings') as SettingsWriter | undefined
|
|
92
|
+
if (registry === undefined || settings === undefined) return
|
|
93
|
+
const profile = scope.get('profileContext') as ProfileContext | undefined
|
|
94
|
+
void claimDefaultPreset(registry, settings, profile, ctx)
|
|
95
|
+
.catch((error: unknown) => {
|
|
96
|
+
ctx.logger.warn('dsh-fft-settings: could not claim the default preset', error)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Perform the one-time claim, snapshotting the file it is about to change.
|
|
103
|
+
* @param registry - the live preset registry.
|
|
104
|
+
* @param settings - the settings writer.
|
|
105
|
+
* @param profile - the profile context, absent in a context that has no patch file.
|
|
106
|
+
* @param ctx - the plugin context, used for the claim log line.
|
|
107
|
+
*/
|
|
108
|
+
async function claimDefaultPreset(
|
|
109
|
+
registry: PresetRegistry,
|
|
110
|
+
settings: SettingsWriter,
|
|
111
|
+
profile: ProfileContext | undefined,
|
|
112
|
+
ctx: Context,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const file = claimFilePath()
|
|
115
|
+
if (existsSync(file)) return
|
|
116
|
+
// An unregistered preset would pin an id that resolves to nothing, so the
|
|
117
|
+
// claim waits for the composition that defines it.
|
|
118
|
+
const presets = await registry.list()
|
|
119
|
+
if (!presets.some(preset => preset.id === DEFAULT_PRESET_ID)) return
|
|
120
|
+
let backup: string | undefined
|
|
121
|
+
if (registry.defaultId !== DEFAULT_PRESET_ID) {
|
|
122
|
+
// Insurance first: the editor writes atomically and rolls back a failed
|
|
123
|
+
// reconcile, but this write still lands in the user's own profile patch, so
|
|
124
|
+
// a claim is only recorded once the snapshot exists.
|
|
125
|
+
if (profile !== undefined) backup = await snapshotProfilePatch(profile.patchPath)
|
|
126
|
+
await settings.update(REGISTRY_ENTRY_ID, { selectedDefault: DEFAULT_PRESET_ID })
|
|
127
|
+
ctx.logger.info(`dsh-fft-settings: ${DEFAULT_PRESET_ID} is now the default preset`)
|
|
128
|
+
}
|
|
129
|
+
await mkdir(dirname(file), { recursive: true })
|
|
130
|
+
const claim = { preset: DEFAULT_PRESET_ID, at: new Date().toISOString(), ...(backup === undefined ? {} : { backup }) }
|
|
131
|
+
await writeFile(file, `${JSON.stringify(claim, null, 2)}\n`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Copy the profile patch into this plugin's data directory, once.
|
|
136
|
+
*
|
|
137
|
+
* A failed copy throws, and the caller aborts the claim: the write must not
|
|
138
|
+
* happen without its snapshot. The first copy wins, so a retry after a failed
|
|
139
|
+
* write still holds the state from before the very first attempt. A missing
|
|
140
|
+
* patch file — a profile that never wrote anything — needs no copy.
|
|
141
|
+
* @param patchPath - the profile's own `cordis.patch.yml`.
|
|
142
|
+
* @returns the snapshot path, or `undefined` when there was nothing to copy.
|
|
143
|
+
*/
|
|
144
|
+
async function snapshotProfilePatch(patchPath: string): Promise<string | undefined> {
|
|
145
|
+
if (!existsSync(patchPath)) return undefined
|
|
146
|
+
const snapshot = backupFilePath()
|
|
147
|
+
if (existsSync(snapshot)) return snapshot
|
|
148
|
+
await mkdir(dirname(snapshot), { recursive: true })
|
|
149
|
+
await copyFile(patchPath, snapshot)
|
|
150
|
+
return snapshot
|
|
151
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
|
|
2
|
+
import { FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, type SubscribeChanges, type ApiKeyPublicApi } from './contract.js'
|
|
3
|
+
import { FTSHARE_SETTINGS_CARD_SLOT } from '../../core/contract.js'
|
|
4
|
+
import { FtshareSettingsModel } from './model.js'
|
|
5
|
+
import { createApiKeyClient } from './client-api.js'
|
|
6
|
+
import { createSettingsTransport } from '../../core/client-transport.js'
|
|
7
|
+
import type { FtshareSettingsShellContext } from '../../core/shell.js'
|
|
8
|
+
import { API_KEY_CARD_CSS } from './styles.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Browser context this card requires: the shell's slots service plus the
|
|
12
|
+
* invalidations that make a cached credential status stale.
|
|
13
|
+
*/
|
|
14
|
+
export interface ApiKeyCardContext extends FtshareSettingsShellContext {
|
|
15
|
+
remote: { $on(event: string, listener: (ref: string) => void): () => void }
|
|
16
|
+
on(event: 'connection/reset', listener: () => void): () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Contribute the built-in API Key card through the same slot a third-party
|
|
21
|
+
* card uses. Nothing about it is privileged: same declaration, same options.
|
|
22
|
+
* @param ctx - client context carrying the renderer's slots service.
|
|
23
|
+
* @returns idempotent disposer for the card registration.
|
|
24
|
+
*/
|
|
25
|
+
export function registerApiKeyCard(ctx: ApiKeyCardContext): () => void {
|
|
26
|
+
return ctx.slots.inject(FTSHARE_SETTINGS_CARD_SLOT, () => ctx.slots.register(
|
|
27
|
+
{ name: FTSHARE_SETTINGS_CARD_SLOT, id: 'apiKey', order: 10 },
|
|
28
|
+
createApiKeyCard(ctx),
|
|
29
|
+
))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Bind the card's adapters; subscriptions are acquired in the component's effect. */
|
|
33
|
+
function createApiKeyCard(ctx: ApiKeyCardContext): () => ReactNode {
|
|
34
|
+
const api = createApiKeyClient(createSettingsTransport())
|
|
35
|
+
const subscribeChanges: SubscribeChanges = listener => {
|
|
36
|
+
const disposers = FTSHARE_CREDENTIAL_EVENTS.map(event => ctx.remote.$on(event, ref => { if (ref === FTSHARE_API_KEY_REF) listener() }))
|
|
37
|
+
disposers.push(ctx.on('connection/reset', listener))
|
|
38
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
39
|
+
}
|
|
40
|
+
return () => <ApiKeyCard api={api} subscribeChanges={subscribeChanges} />
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ApiKeyCard({ api, subscribeChanges }: { readonly api: ApiKeyPublicApi; readonly subscribeChanges?: SubscribeChanges }): ReactNode {
|
|
44
|
+
const model = useMemo(() => new FtshareSettingsModel(api), [api])
|
|
45
|
+
const { status, busy, message, statusError, save } = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot)
|
|
46
|
+
const [draft, setDraft] = useState('')
|
|
47
|
+
useEffect(() => model.connect(subscribeChanges), [model, subscribeChanges])
|
|
48
|
+
const disabled = busy || !status?.writable
|
|
49
|
+
const configure = async () => { if (await model.configureAndTest(draft)) setDraft('') }
|
|
50
|
+
return <>
|
|
51
|
+
<style>{API_KEY_CARD_CSS}</style>
|
|
52
|
+
<section className="ftshare-settings" aria-label="FTShare 设置" aria-busy={busy}>
|
|
53
|
+
<header><div><span className="ftshare-eyebrow">FTSHARE / API KEY</span><h2>访问凭据</h2></div></header>
|
|
54
|
+
<p>目前集成 FTShare 数据源。</p>
|
|
55
|
+
{status && !status.writable && <p>当前来源为只读,可测试连接;修改密钥请到对应来源操作。</p>}
|
|
56
|
+
<div className="ftshare-card"><label htmlFor="ftshare-api-key">FTShare API Key</label>
|
|
57
|
+
<input id="ftshare-api-key" type="password" value={draft} placeholder={status?.configured ? status.maskedValue ?? '••••••••' : '请输入 API Key'} onChange={e => setDraft(e.target.value)} disabled={disabled} autoComplete="new-password" maxLength={512} />
|
|
58
|
+
<div className="ftshare-actions">
|
|
59
|
+
<button className="primary" type="button" onClick={() => void configure()} disabled={disabled || !draft.trim()}>配置并测试</button>
|
|
60
|
+
<button type="button" onClick={() => void model.testConnection()} disabled={busy || !status}>测试当前连接</button>
|
|
61
|
+
<button type="button" onClick={() => void model.clear()} disabled={disabled || !status?.configured}>清除已保存密钥</button>
|
|
62
|
+
<button type="button" onClick={() => void model.refresh()} disabled={busy} title="重新读取已保存的配置和上次检测结果,不会发起联网检测">重新读取状态</button>
|
|
63
|
+
<a href="https://ftai.chat/me/profile?tab=api-key" target="_blank" rel="noreferrer">获取 API Key ↗</a>
|
|
64
|
+
</div>
|
|
65
|
+
{draft && <small>“测试当前连接”使用已生效的密钥,不测试输入框内尚未保存的内容。</small>}
|
|
66
|
+
{busy && <p role="status">正在处理,请稍候…</p>}
|
|
67
|
+
</div>
|
|
68
|
+
<div className="ftshare-card" aria-live="polite"><h3>验证结果</h3><dl>
|
|
69
|
+
{save && <div className="ftshare-result"><dt>本次保存</dt><dd>{save.message}</dd></div>}
|
|
70
|
+
<div className="ftshare-result"><dt>远端服务</dt><dd>{status?.validation.remote.message ?? '状态待确认'}</dd></div>
|
|
71
|
+
<div className="ftshare-result"><dt>API Key</dt><dd>{status?.validation.apiKey.message ?? '状态待确认'}</dd></div>
|
|
72
|
+
{status?.validation.testedAt && <div className="ftshare-result"><dt>测试时间</dt><dd>{new Date(status.validation.testedAt).toLocaleString()}</dd></div>}
|
|
73
|
+
</dl><small>节假日或收盘后的空数据不影响验证。</small></div>
|
|
74
|
+
{message && <p role="status">{message}</p>}{statusError && <p className="ftshare-error" role="alert">{statusError}</p>}
|
|
75
|
+
</section>
|
|
76
|
+
</>
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ApiKeyPublicApi, ApiKeyMethod } from './contract.js'
|
|
2
|
+
import { callSettingsRpc, type ClientTransport } from '../../core/rpc-core.js'
|
|
3
|
+
/** Browser adapter deliberately has no resolve/createEnvironment method. */
|
|
4
|
+
export function createApiKeyClient(transport: ClientTransport): ApiKeyPublicApi {
|
|
5
|
+
async function call<T>(method: ApiKeyMethod, payload: unknown = {}): Promise<T> {
|
|
6
|
+
return callSettingsRpc<T>(transport, 'apiKey', method, payload)
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
describe: () => call('describe'), set: value => call('set', { value }), clear: () => call('clear'),
|
|
10
|
+
configureAndTest: value => call('configureAndTest', { value }), testConnection: () => call('testConnection'), getValidationStatus: () => call('getValidationStatus'),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key namespace contract: the credential reference and lifecycle events,
|
|
3
|
+
* the status/validation vocabulary, and the two faces the namespace exposes
|
|
4
|
+
* (the browser-safe subset and the host-only superset).
|
|
5
|
+
*
|
|
6
|
+
* It merges its own line into the generic {@link FtshareSettingsNamespaces}
|
|
7
|
+
* merge point, so `ctx.ftshareSettings.apiKey` keeps its existing type for
|
|
8
|
+
* every consumer.
|
|
9
|
+
*/
|
|
10
|
+
export const FTSHARE_API_KEY_REF = 'FTSHARE_API_KEY' as const
|
|
11
|
+
export const FTSHARE_CREDENTIAL_EVENTS = ['credentials/reference-updated'] as const
|
|
12
|
+
export const API_KEY_METHODS = ['describe', 'set', 'clear', 'configureAndTest', 'testConnection', 'getValidationStatus'] as const
|
|
13
|
+
export type ApiKeyMethod = typeof API_KEY_METHODS[number]
|
|
14
|
+
export type SubscribeChanges = (listener: () => void) => () => void
|
|
15
|
+
export interface CredentialStatus { configured: boolean; writable: boolean; source?: string }
|
|
16
|
+
export type ProbeState = 'available' | 'auth_required' | 'auth_failed' | 'permission_denied' | 'quota_exceeded' | 'rate_limited' | 'network_error' | 'timeout' | 'upstream_error' | 'invalid_response' | 'not_run'
|
|
17
|
+
export interface ProbeResult { status: ProbeState; message: string }
|
|
18
|
+
export interface ValidationResult {
|
|
19
|
+
status: 'not_tested' | 'not_run' | 'passed' | 'partial' | 'failed' | 'superseded'
|
|
20
|
+
remote: ProbeResult
|
|
21
|
+
apiKey: ProbeResult
|
|
22
|
+
testedAt?: string
|
|
23
|
+
}
|
|
24
|
+
export interface ApiKeyStatus extends CredentialStatus {
|
|
25
|
+
/** Display only. Fixed mask plus last four characters; never submit as a Key. */
|
|
26
|
+
maskedValue?: string
|
|
27
|
+
revision: number
|
|
28
|
+
validation: ValidationResult
|
|
29
|
+
}
|
|
30
|
+
export interface ConfigureResult {
|
|
31
|
+
save: { status: 'saved' | 'failed'; code?: 'invalid_input' | 'read_only' | 'save_failed'; message: string }
|
|
32
|
+
validation: ValidationResult
|
|
33
|
+
}
|
|
34
|
+
/** Local syntax validation, never an assertion of upstream validity. */
|
|
35
|
+
export function normalizeApiKey(input: string): string {
|
|
36
|
+
const value = input.trim()
|
|
37
|
+
if (!value) throw new Error('请输入 API Key')
|
|
38
|
+
if (value.length > 512 || /[\x00-\x1f\x7f]/.test(value)) throw new Error('API Key 长度不能超过 512 个字符,且不能包含控制字符')
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
export function untested(status: 'not_tested' | 'not_run' | 'superseded' = 'not_tested', message = '尚未测试'): ValidationResult {
|
|
42
|
+
return { status, remote: { status: 'not_run', message }, apiKey: { status: 'not_run', message } }
|
|
43
|
+
}
|
|
44
|
+
/** Everything the browser adapter may call; `resolve`/`createEnvironment` are not here. */
|
|
45
|
+
export interface ApiKeyPublicApi {
|
|
46
|
+
describe(): Promise<ApiKeyStatus>
|
|
47
|
+
set(value: string): Promise<void>
|
|
48
|
+
clear(): Promise<void>
|
|
49
|
+
configureAndTest(value: string): Promise<ConfigureResult>
|
|
50
|
+
testConnection(): Promise<ValidationResult>
|
|
51
|
+
getValidationStatus(): Promise<ValidationResult>
|
|
52
|
+
}
|
|
53
|
+
/** The host namespace object: the public API plus the secret-bearing host methods. */
|
|
54
|
+
export interface ApiKeySettings extends ApiKeyPublicApi {
|
|
55
|
+
readonly credentialRef: typeof FTSHARE_API_KEY_REF
|
|
56
|
+
/** Host only. Re-resolve per operation; never send this value to browsers. */
|
|
57
|
+
resolve(): Promise<string | undefined>
|
|
58
|
+
createEnvironment(base?: Readonly<Record<string, string | undefined>>): Promise<Record<string, string | undefined>>
|
|
59
|
+
subscribe: SubscribeChanges
|
|
60
|
+
}
|
|
61
|
+
declare module '../../core/contract.js' {
|
|
62
|
+
interface FtshareSettingsNamespaces { apiKey: ApiKeySettings }
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials'
|
|
2
|
+
import { FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS, type ApiKeySettings } from './contract.js'
|
|
3
|
+
import type { FeatureLogger } from './logging.js'
|
|
4
|
+
import { createApiKeySettings } from './service.js'
|
|
5
|
+
import { defineSettingsModule } from '../../core/module-registry.js'
|
|
6
|
+
import { createApiKeyRpcMethods } from './rpc.js'
|
|
7
|
+
declare module '@deepseek-ai/cordis' {
|
|
8
|
+
interface Events {
|
|
9
|
+
'credentials/reference-updated'(ref: CredentialRef): void
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build the `apiKey` module, closing over the plugin's named logger.
|
|
14
|
+
*
|
|
15
|
+
* 工厂化与 `createLogsModule` 一致:插件入口在 `apply` 里取一次 `ctx.logger()`
|
|
16
|
+
* (无参 = 插件名)再传进来,因此 feature 不依赖模块级单例,也不认识 cordis。
|
|
17
|
+
* @param logger - 可选具名 logger;省略时本模块不写任何日志(既有调用方不变)。
|
|
18
|
+
* @returns 可交给 `ctx.ftshareSettings.modules.register` 的模块。
|
|
19
|
+
*/
|
|
20
|
+
export function createApiKeyModule(logger?: FeatureLogger) {
|
|
21
|
+
return defineSettingsModule<'apiKey', ApiKeySettings>({
|
|
22
|
+
namespace: 'apiKey', inject: ['credentials'],
|
|
23
|
+
create(ctx) {
|
|
24
|
+
const ref = credentialRef(FTSHARE_API_KEY_REF)
|
|
25
|
+
const owner = createApiKeySettings({
|
|
26
|
+
resolve: async () => (await ctx.credentials.resolve(ref))?.value,
|
|
27
|
+
describe: () => ctx.credentials.describe(ref),
|
|
28
|
+
set: value => ctx.credentials.set(ref, value),
|
|
29
|
+
clear: () => ctx.credentials.unset(ref),
|
|
30
|
+
}, undefined, logger)
|
|
31
|
+
for (const event of FTSHARE_CREDENTIAL_EVENTS) ctx.on(event, changed => { if (changed === ref) owner.invalidate() })
|
|
32
|
+
ctx.effect(() => () => owner.dispose(), 'ftshare-settings: API key lifecycle')
|
|
33
|
+
return { service: owner.api, rpcMethods: createApiKeyRpcMethods(owner.api) }
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the API Key feature: the entry a host assembly imports.
|
|
3
|
+
*
|
|
4
|
+
* The directory is written to be liftable into its own package (§10.4): this
|
|
5
|
+
* file is the host entry, `./contract.js` its contract, and `./card.js` the
|
|
6
|
+
* browser entry that browser code imports directly instead of going through
|
|
7
|
+
* here (a browser half must never pull credential or probe code).
|
|
8
|
+
*/
|
|
9
|
+
export * from './contract.js'
|
|
10
|
+
export { createApiKeyModule } from './host.js'
|
|
11
|
+
export type { FeatureLogger } from './logging.js'
|
|
12
|
+
export { createApiKeySettings, type CredentialStore, type Validator } from './service.js'
|
|
13
|
+
export { createApiKeyRpcMethods, createApiKeyRpcHandler } from './rpc.js'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本 feature 写日志用的最小接口(规格 §6/§7:失败不低于 `warn`,可变值放字段)。
|
|
3
|
+
*
|
|
4
|
+
* 只声明用得到的 `warn`,因此 cordis 的具名 logger(`ctx.logger()` 无参 = 插件名)
|
|
5
|
+
* 可以结构化地传进来,而单元测试直接构造 service 时不传 logger 也就保持静默——
|
|
6
|
+
* 日志是**可选依赖**,不是必需接线。
|
|
7
|
+
*
|
|
8
|
+
* 约定:
|
|
9
|
+
* - 第二参数字段化,绝不放 API Key/token 的值;
|
|
10
|
+
* - 消息文本保持稳定(采集器按 `(插件, 级别, 消息, 稳定字段)` 在 10 秒窗口内合并);
|
|
11
|
+
* - 失败级别不低于 `warn`,不用 `debug`。
|
|
12
|
+
*/
|
|
13
|
+
export interface FeatureLogger {
|
|
14
|
+
warn(message: string, fields?: Record<string, unknown>): void
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { normalizeApiKey, type ApiKeyPublicApi, type ApiKeyStatus, type ConfigureResult, type SubscribeChanges } from './contract.js'
|
|
2
|
+
export interface SettingsState { status?: ApiKeyStatus; busy: boolean; message: string; statusError?: string; save?: ConfigureResult['save'] }
|
|
3
|
+
export class FtshareSettingsModel {
|
|
4
|
+
private state: SettingsState = { busy: false, message: '' }
|
|
5
|
+
private listeners = new Set<() => void>()
|
|
6
|
+
private request = 0
|
|
7
|
+
private lifetime = 0
|
|
8
|
+
constructor(private api: ApiKeyPublicApi) {}
|
|
9
|
+
getSnapshot = (): SettingsState => this.state
|
|
10
|
+
subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener) } }
|
|
11
|
+
private update(patch: Partial<SettingsState>): void { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener() }
|
|
12
|
+
connect(subscribe?: SubscribeChanges): () => void {
|
|
13
|
+
const dispose = subscribe?.(() => { void this.refresh() })
|
|
14
|
+
void this.refresh()
|
|
15
|
+
return () => { this.request++; this.lifetime++; dispose?.() }
|
|
16
|
+
}
|
|
17
|
+
async refresh(): Promise<void> {
|
|
18
|
+
const request = ++this.request
|
|
19
|
+
try {
|
|
20
|
+
const status = await this.api.describe()
|
|
21
|
+
if (!status || typeof status.configured !== 'boolean') throw new Error('missing_status')
|
|
22
|
+
if (request === this.request) this.update({ status, statusError: undefined })
|
|
23
|
+
} catch { if (request === this.request) this.update({ status: undefined, statusError: '无法读取设置状态,请刷新重试' }) }
|
|
24
|
+
}
|
|
25
|
+
async configureAndTest(input: string): Promise<boolean> {
|
|
26
|
+
if (this.state.busy || !this.state.status?.writable) return false
|
|
27
|
+
let value: string
|
|
28
|
+
try { value = normalizeApiKey(input) } catch (error) { this.update({ message: (error as Error).message }); return false }
|
|
29
|
+
const lifetime = this.lifetime
|
|
30
|
+
this.update({ busy: true, save: undefined, message: '正在保存并检查远端服务和 API Key…' })
|
|
31
|
+
let saved = false
|
|
32
|
+
try {
|
|
33
|
+
const result = await this.api.configureAndTest(value)
|
|
34
|
+
saved = result.save.status === 'saved'
|
|
35
|
+
if (lifetime === this.lifetime) this.update({ save: result.save, message: saved ? validationMessage(result.validation.status) : result.save.message })
|
|
36
|
+
} catch { if (lifetime === this.lifetime) this.update({ message: '请求未完成,保存状态待确认;请刷新后测试当前连接' }) }
|
|
37
|
+
if (lifetime === this.lifetime) { await this.refresh(); this.update({ busy: false }) }
|
|
38
|
+
return saved
|
|
39
|
+
}
|
|
40
|
+
async testConnection(): Promise<void> {
|
|
41
|
+
if (this.state.busy || !this.state.status) return
|
|
42
|
+
const lifetime = this.lifetime
|
|
43
|
+
this.update({ busy: true, message: '正在检查远端服务和当前 API Key…' })
|
|
44
|
+
try {
|
|
45
|
+
const result = await this.api.testConnection()
|
|
46
|
+
if (lifetime === this.lifetime) this.update({ message: validationMessage(result.status) })
|
|
47
|
+
} catch { if (lifetime === this.lifetime) this.update({ message: '测试请求失败,请重试' }) }
|
|
48
|
+
if (lifetime === this.lifetime) { await this.refresh(); this.update({ busy: false }) }
|
|
49
|
+
}
|
|
50
|
+
async clear(): Promise<void> {
|
|
51
|
+
if (this.state.busy || !this.state.status?.writable) return
|
|
52
|
+
const lifetime = this.lifetime
|
|
53
|
+
this.update({ busy: true, save: undefined, message: '' })
|
|
54
|
+
try {
|
|
55
|
+
await this.api.clear()
|
|
56
|
+
if (lifetime === this.lifetime) this.update({ message: '已清除 DSH 保存的密钥;若有 .env 配置,下方显示其有效状态' })
|
|
57
|
+
} catch { if (lifetime === this.lifetime) this.update({ message: '清除失败,请刷新状态后重试' }) }
|
|
58
|
+
if (lifetime === this.lifetime) { await this.refresh(); this.update({ busy: false }) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function validationMessage(status: string): string {
|
|
62
|
+
return ({ passed: '远端服务正常,API Key 验证通过', partial: '远端服务正常,请查看 API Key 检查结果', failed: '测试未通过,请查看分项结果', not_run: '未执行测试,请先确认凭据配置', superseded: '凭据已变化,请重新测试', not_tested: '尚未测试' } as Record<string, string>)[status] ?? '请查看分项结果'
|
|
63
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type ProbeResult, type ValidationResult } from './contract.js'
|
|
2
|
+
import type { FeatureLogger } from './logging.js'
|
|
3
|
+
|
|
4
|
+
const ORIGIN = 'https://market.ft.tech/gateway/'
|
|
5
|
+
const MAX_RESPONSE_BYTES = 128 * 1024
|
|
6
|
+
const DAY = 86_400_000
|
|
7
|
+
export const PROBE_TIMEOUT_MS = 8_000
|
|
8
|
+
export const PROBE_CONTRACTS = {
|
|
9
|
+
daily: 'api/v1/market/data/stock-candlesticks',
|
|
10
|
+
historicalMinute: 'api/v2/market/data/stock_minutes',
|
|
11
|
+
} as const
|
|
12
|
+
const result = (status: ProbeResult['status'], message: string): ProbeResult => ({ status, message })
|
|
13
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
14
|
+
|
|
15
|
+
/** Error text is classified internally and never returned verbatim. */
|
|
16
|
+
export function classifyFailure(code: number, text: string): ProbeResult {
|
|
17
|
+
if (code === 400 || code === 422) return result('invalid_response', `检测请求参数被 FTShare 拒绝(错误码 ${code}),请更新插件;这不代表 Key 无效`)
|
|
18
|
+
if (code === 429) return result('rate_limited', '请求受限,请稍后重试')
|
|
19
|
+
if (code >= 500) return result('upstream_error', 'FTShare 服务暂时不可用')
|
|
20
|
+
if (code === 401 || code === 403) {
|
|
21
|
+
if (/quota|额度|配额/i.test(text)) return result('quota_exceeded', '接口额度不足,无法完成 Key 验证')
|
|
22
|
+
if (/permission|权限|订阅|套餐|upgrade|plan/i.test(text)) return result('permission_denied', '接口权限不足,无法完成 Key 验证')
|
|
23
|
+
if (/missing|required|未携带|缺少/i.test(text)) return result('auth_required', 'FTShare 未收到所需凭据')
|
|
24
|
+
if (code === 403 && !/invalid|expired|malformed|无效|过期/i.test(text)) return result('permission_denied', '请求被拒绝,请检查 Key 或接口权限')
|
|
25
|
+
return result('auth_failed', 'API Key 未通过认证')
|
|
26
|
+
}
|
|
27
|
+
return result('invalid_response', `检测接口返回未识别的错误(错误码 ${code}),未能完成检测`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readBoundedJson(response: Response): Promise<unknown> {
|
|
31
|
+
if (!response.body) throw new Error('empty_body')
|
|
32
|
+
const reader = response.body.getReader()
|
|
33
|
+
let size = 0
|
|
34
|
+
const chunks: Uint8Array[] = []
|
|
35
|
+
try {
|
|
36
|
+
while (true) {
|
|
37
|
+
const { done, value } = await reader.read()
|
|
38
|
+
if (done) break
|
|
39
|
+
size += value.byteLength
|
|
40
|
+
if (size > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new Error('oversized_body') }
|
|
41
|
+
chunks.push(value)
|
|
42
|
+
}
|
|
43
|
+
} finally { reader.releaseLock() }
|
|
44
|
+
const bytes = new Uint8Array(size)
|
|
45
|
+
let offset = 0
|
|
46
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
|
|
47
|
+
return JSON.parse(new TextDecoder().decode(bytes))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 一次固定探测。失败(发出过请求且没有通过)写一条 `warn`:
|
|
52
|
+
* 字段固定 `{ target, status, durationMs }` —— `target` 由是否携带 Key 决定;
|
|
53
|
+
* 未发请求的 `not_run` 不是探测失败,成功也不记(规格 §6/§7)。
|
|
54
|
+
*/
|
|
55
|
+
async function probe(path: string, params: Record<string, string>, key: string | undefined, parent: AbortSignal, fetcher: typeof fetch, now: number, logger?: FeatureLogger): Promise<ProbeResult> {
|
|
56
|
+
const url = new URL(path, ORIGIN)
|
|
57
|
+
url.search = new URLSearchParams(params).toString()
|
|
58
|
+
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
59
|
+
const signal = AbortSignal.any([parent, timeout])
|
|
60
|
+
const started = Date.now()
|
|
61
|
+
const finish = (outcome: ProbeResult): ProbeResult => {
|
|
62
|
+
if (outcome.status !== 'available' && outcome.status !== 'not_run') {
|
|
63
|
+
logger?.warn('FTShare 探测未通过', { target: key === undefined ? 'remote' : 'apiKey', status: outcome.status, durationMs: Date.now() - started })
|
|
64
|
+
}
|
|
65
|
+
return outcome
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// Never forward a credential across a redirect.
|
|
69
|
+
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
70
|
+
if (key !== undefined) headers.FTSHARE_API_KEY = key
|
|
71
|
+
const failure = (code: number, text: string) => key === undefined && (code === 401 || code === 403)
|
|
72
|
+
? result('upstream_error', '远端服务拒绝了连通性检查') : classifyFailure(code, text)
|
|
73
|
+
const response = await fetcher(url, { headers, redirect: 'error', signal })
|
|
74
|
+
let payload: unknown
|
|
75
|
+
try { payload = await readBoundedJson(response) } catch (error) {
|
|
76
|
+
if (signal.aborted) throw error
|
|
77
|
+
if (!response.ok) return finish(failure(response.status, ''))
|
|
78
|
+
return finish(result('invalid_response', '接口未返回有效 JSON 数据'))
|
|
79
|
+
}
|
|
80
|
+
const text = isRecord(payload) ? String(payload.message ?? payload.msg ?? '') : ''
|
|
81
|
+
if (!response.ok) return finish(failure(response.status, text))
|
|
82
|
+
if (!isRecord(payload) || typeof payload.code !== 'number') return finish(result('invalid_response', '接口响应格式异常'))
|
|
83
|
+
if (payload.code !== 200) return finish(failure(payload.code, text))
|
|
84
|
+
if (!Array.isArray(payload.data)) return finish(result('invalid_response', '接口未返回 K 线数组'))
|
|
85
|
+
// This checks service/authentication success, not market-data availability.
|
|
86
|
+
// Empty holiday or post-close windows are successful responses too.
|
|
87
|
+
return finish(result('available', key === undefined ? '远端服务正常' : 'API Key 验证通过'))
|
|
88
|
+
} catch {
|
|
89
|
+
if (parent.aborted) return finish(result('not_run', '测试已取消或凭据已变化'))
|
|
90
|
+
if (timeout.aborted) return finish(result('timeout', '连接超时,请稍后重试'))
|
|
91
|
+
return finish(result('network_error', '连接失败,请检查网络后重试'))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Two fixed, bounded requests. No SDK, arbitrary URL, retries or stored rows. */
|
|
96
|
+
export async function probeApiKey(key: string | undefined, signal: AbortSignal, fetcher: typeof fetch = fetch, now: number = Date.now(), logger?: FeatureLogger): Promise<ValidationResult> {
|
|
97
|
+
const remote = await probe(PROBE_CONTRACTS.daily, {
|
|
98
|
+
symbol: '600519.SH', interval_unit: 'day', adjust_kind: 'none',
|
|
99
|
+
since_ts_millis: String(now - 30 * DAY), until_ts_millis: String(now), limit: '2',
|
|
100
|
+
}, undefined, signal, fetcher, now, logger)
|
|
101
|
+
let apiKey: ProbeResult
|
|
102
|
+
if (!key) {
|
|
103
|
+
apiKey = result('auth_required', '未配置 API Key')
|
|
104
|
+
} else if (signal.aborted || remote.status !== 'available') {
|
|
105
|
+
apiKey = result('not_run', '远端服务检查未通过,未验证 Key')
|
|
106
|
+
} else {
|
|
107
|
+
// The server counts inclusive Beijing calendar dates, not elapsed days.
|
|
108
|
+
// A 24-hour window covers at most two dates; empty data still verifies auth.
|
|
109
|
+
apiKey = await probe(PROBE_CONTRACTS.historicalMinute, {
|
|
110
|
+
symbol: '600519.SH', interval_value: '1', adjust_kind: 'none',
|
|
111
|
+
since_ts_millis: String(now - DAY), until_ts_millis: String(now), limit: '1',
|
|
112
|
+
}, key, signal, fetcher, now, logger)
|
|
113
|
+
}
|
|
114
|
+
const passed = [remote, apiKey].filter(item => item.status === 'available').length
|
|
115
|
+
return { status: passed === 2 ? 'passed' : passed === 1 ? 'partial' : 'failed', remote, apiKey, testedAt: new Date(now).toISOString() }
|
|
116
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApiKeyPublicApi } from './contract.js'
|
|
2
|
+
import { createSettingsRpcHandler, type SettingsRpcMethods } from '../../core/rpc-core.js'
|
|
3
|
+
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
4
|
+
const empty = (value: unknown) => record(value) && Object.keys(value).length === 0
|
|
5
|
+
const keyValue = (value: unknown) => record(value) && Object.keys(value).length === 1 && Object.keys(value)[0] === 'value' && typeof value.value === 'string' && value.value.length <= 1024
|
|
6
|
+
export function createApiKeyRpcMethods(api: ApiKeyPublicApi): SettingsRpcMethods {
|
|
7
|
+
return {
|
|
8
|
+
describe: { validate: empty, invoke: () => api.describe() },
|
|
9
|
+
set: { validate: keyValue, invoke: payload => api.set((payload as { value: string }).value) },
|
|
10
|
+
clear: { validate: empty, invoke: () => api.clear() },
|
|
11
|
+
configureAndTest: { validate: keyValue, invoke: payload => api.configureAndTest((payload as { value: string }).value) },
|
|
12
|
+
testConnection: { validate: empty, invoke: () => api.testConnection() },
|
|
13
|
+
getValidationStatus: { validate: empty, invoke: () => api.getValidationStatus() },
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function createApiKeyRpcHandler(api: ApiKeyPublicApi) {
|
|
17
|
+
return createSettingsRpcHandler([{ namespace: 'apiKey', rpcMethods: createApiKeyRpcMethods(api) }])
|
|
18
|
+
}
|