@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,32 @@
|
|
|
1
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { FTSHARE_API_KEY_REF, FTSHARE_CREDENTIAL_EVENTS } from './contract.js';
|
|
3
|
+
import { createApiKeySettings } from './service.js';
|
|
4
|
+
import { defineSettingsModule } from '../../core/module-registry.js';
|
|
5
|
+
import { createApiKeyRpcMethods } from './rpc.js';
|
|
6
|
+
/**
|
|
7
|
+
* Build the `apiKey` module, closing over the plugin's named logger.
|
|
8
|
+
*
|
|
9
|
+
* 工厂化与 `createLogsModule` 一致:插件入口在 `apply` 里取一次 `ctx.logger()`
|
|
10
|
+
* (无参 = 插件名)再传进来,因此 feature 不依赖模块级单例,也不认识 cordis。
|
|
11
|
+
* @param logger - 可选具名 logger;省略时本模块不写任何日志(既有调用方不变)。
|
|
12
|
+
* @returns 可交给 `ctx.ftshareSettings.modules.register` 的模块。
|
|
13
|
+
*/
|
|
14
|
+
export function createApiKeyModule(logger) {
|
|
15
|
+
return defineSettingsModule({
|
|
16
|
+
namespace: 'apiKey', inject: ['credentials'],
|
|
17
|
+
create(ctx) {
|
|
18
|
+
const ref = credentialRef(FTSHARE_API_KEY_REF);
|
|
19
|
+
const owner = createApiKeySettings({
|
|
20
|
+
resolve: async () => (await ctx.credentials.resolve(ref))?.value,
|
|
21
|
+
describe: () => ctx.credentials.describe(ref),
|
|
22
|
+
set: value => ctx.credentials.set(ref, value),
|
|
23
|
+
clear: () => ctx.credentials.unset(ref),
|
|
24
|
+
}, undefined, logger);
|
|
25
|
+
for (const event of FTSHARE_CREDENTIAL_EVENTS)
|
|
26
|
+
ctx.on(event, changed => { if (changed === ref)
|
|
27
|
+
owner.invalidate(); });
|
|
28
|
+
ctx.effect(() => () => owner.dispose(), 'ftshare-settings: API key lifecycle');
|
|
29
|
+
return { service: owner.api, rpcMethods: createApiKeyRpcMethods(owner.api) };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -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,12 @@
|
|
|
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 { createApiKeySettings } from './service.js';
|
|
12
|
+
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ApiKeyPublicApi, type ApiKeyStatus, type ConfigureResult, type SubscribeChanges } from './contract.js';
|
|
2
|
+
export interface SettingsState {
|
|
3
|
+
status?: ApiKeyStatus;
|
|
4
|
+
busy: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
statusError?: string;
|
|
7
|
+
save?: ConfigureResult['save'];
|
|
8
|
+
}
|
|
9
|
+
export declare class FtshareSettingsModel {
|
|
10
|
+
private api;
|
|
11
|
+
private state;
|
|
12
|
+
private listeners;
|
|
13
|
+
private request;
|
|
14
|
+
private lifetime;
|
|
15
|
+
constructor(api: ApiKeyPublicApi);
|
|
16
|
+
getSnapshot: () => SettingsState;
|
|
17
|
+
subscribe: (listener: () => void) => (() => void);
|
|
18
|
+
private update;
|
|
19
|
+
connect(subscribe?: SubscribeChanges): () => void;
|
|
20
|
+
refresh(): Promise<void>;
|
|
21
|
+
configureAndTest(input: string): Promise<boolean>;
|
|
22
|
+
testConnection(): Promise<void>;
|
|
23
|
+
clear(): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { normalizeApiKey } from './contract.js';
|
|
2
|
+
export class FtshareSettingsModel {
|
|
3
|
+
api;
|
|
4
|
+
state = { busy: false, message: '' };
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
request = 0;
|
|
7
|
+
lifetime = 0;
|
|
8
|
+
constructor(api) {
|
|
9
|
+
this.api = api;
|
|
10
|
+
}
|
|
11
|
+
getSnapshot = () => this.state;
|
|
12
|
+
subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
13
|
+
update(patch) { this.state = { ...this.state, ...patch }; for (const listener of this.listeners)
|
|
14
|
+
listener(); }
|
|
15
|
+
connect(subscribe) {
|
|
16
|
+
const dispose = subscribe?.(() => { void this.refresh(); });
|
|
17
|
+
void this.refresh();
|
|
18
|
+
return () => { this.request++; this.lifetime++; dispose?.(); };
|
|
19
|
+
}
|
|
20
|
+
async refresh() {
|
|
21
|
+
const request = ++this.request;
|
|
22
|
+
try {
|
|
23
|
+
const status = await this.api.describe();
|
|
24
|
+
if (!status || typeof status.configured !== 'boolean')
|
|
25
|
+
throw new Error('missing_status');
|
|
26
|
+
if (request === this.request)
|
|
27
|
+
this.update({ status, statusError: undefined });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
if (request === this.request)
|
|
31
|
+
this.update({ status: undefined, statusError: '无法读取设置状态,请刷新重试' });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async configureAndTest(input) {
|
|
35
|
+
if (this.state.busy || !this.state.status?.writable)
|
|
36
|
+
return false;
|
|
37
|
+
let value;
|
|
38
|
+
try {
|
|
39
|
+
value = normalizeApiKey(input);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
this.update({ message: error.message });
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const lifetime = this.lifetime;
|
|
46
|
+
this.update({ busy: true, save: undefined, message: '正在保存并检查远端服务和 API Key…' });
|
|
47
|
+
let saved = false;
|
|
48
|
+
try {
|
|
49
|
+
const result = await this.api.configureAndTest(value);
|
|
50
|
+
saved = result.save.status === 'saved';
|
|
51
|
+
if (lifetime === this.lifetime)
|
|
52
|
+
this.update({ save: result.save, message: saved ? validationMessage(result.validation.status) : result.save.message });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
if (lifetime === this.lifetime)
|
|
56
|
+
this.update({ message: '请求未完成,保存状态待确认;请刷新后测试当前连接' });
|
|
57
|
+
}
|
|
58
|
+
if (lifetime === this.lifetime) {
|
|
59
|
+
await this.refresh();
|
|
60
|
+
this.update({ busy: false });
|
|
61
|
+
}
|
|
62
|
+
return saved;
|
|
63
|
+
}
|
|
64
|
+
async testConnection() {
|
|
65
|
+
if (this.state.busy || !this.state.status)
|
|
66
|
+
return;
|
|
67
|
+
const lifetime = this.lifetime;
|
|
68
|
+
this.update({ busy: true, message: '正在检查远端服务和当前 API Key…' });
|
|
69
|
+
try {
|
|
70
|
+
const result = await this.api.testConnection();
|
|
71
|
+
if (lifetime === this.lifetime)
|
|
72
|
+
this.update({ message: validationMessage(result.status) });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
if (lifetime === this.lifetime)
|
|
76
|
+
this.update({ message: '测试请求失败,请重试' });
|
|
77
|
+
}
|
|
78
|
+
if (lifetime === this.lifetime) {
|
|
79
|
+
await this.refresh();
|
|
80
|
+
this.update({ busy: false });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async clear() {
|
|
84
|
+
if (this.state.busy || !this.state.status?.writable)
|
|
85
|
+
return;
|
|
86
|
+
const lifetime = this.lifetime;
|
|
87
|
+
this.update({ busy: true, save: undefined, message: '' });
|
|
88
|
+
try {
|
|
89
|
+
await this.api.clear();
|
|
90
|
+
if (lifetime === this.lifetime)
|
|
91
|
+
this.update({ message: '已清除 DSH 保存的密钥;若有 .env 配置,下方显示其有效状态' });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
if (lifetime === this.lifetime)
|
|
95
|
+
this.update({ message: '清除失败,请刷新状态后重试' });
|
|
96
|
+
}
|
|
97
|
+
if (lifetime === this.lifetime) {
|
|
98
|
+
await this.refresh();
|
|
99
|
+
this.update({ busy: false });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function validationMessage(status) {
|
|
104
|
+
return { passed: '远端服务正常,API Key 验证通过', partial: '远端服务正常,请查看 API Key 检查结果', failed: '测试未通过,请查看分项结果', not_run: '未执行测试,请先确认凭据配置', superseded: '凭据已变化,请重新测试', not_tested: '尚未测试' }[status] ?? '请查看分项结果';
|
|
105
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ProbeResult, type ValidationResult } from './contract.js';
|
|
2
|
+
import type { FeatureLogger } from './logging.js';
|
|
3
|
+
export declare const PROBE_TIMEOUT_MS = 8000;
|
|
4
|
+
export declare const PROBE_CONTRACTS: {
|
|
5
|
+
readonly daily: "api/v1/market/data/stock-candlesticks";
|
|
6
|
+
readonly historicalMinute: "api/v2/market/data/stock_minutes";
|
|
7
|
+
};
|
|
8
|
+
/** Error text is classified internally and never returned verbatim. */
|
|
9
|
+
export declare function classifyFailure(code: number, text: string): ProbeResult;
|
|
10
|
+
/** Two fixed, bounded requests. No SDK, arbitrary URL, retries or stored rows. */
|
|
11
|
+
export declare function probeApiKey(key: string | undefined, signal: AbortSignal, fetcher?: typeof fetch, now?: number, logger?: FeatureLogger): Promise<ValidationResult>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const ORIGIN = 'https://market.ft.tech/gateway/';
|
|
2
|
+
const MAX_RESPONSE_BYTES = 128 * 1024;
|
|
3
|
+
const DAY = 86_400_000;
|
|
4
|
+
export const PROBE_TIMEOUT_MS = 8_000;
|
|
5
|
+
export const PROBE_CONTRACTS = {
|
|
6
|
+
daily: 'api/v1/market/data/stock-candlesticks',
|
|
7
|
+
historicalMinute: 'api/v2/market/data/stock_minutes',
|
|
8
|
+
};
|
|
9
|
+
const result = (status, message) => ({ status, message });
|
|
10
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
11
|
+
/** Error text is classified internally and never returned verbatim. */
|
|
12
|
+
export function classifyFailure(code, text) {
|
|
13
|
+
if (code === 400 || code === 422)
|
|
14
|
+
return result('invalid_response', `检测请求参数被 FTShare 拒绝(错误码 ${code}),请更新插件;这不代表 Key 无效`);
|
|
15
|
+
if (code === 429)
|
|
16
|
+
return result('rate_limited', '请求受限,请稍后重试');
|
|
17
|
+
if (code >= 500)
|
|
18
|
+
return result('upstream_error', 'FTShare 服务暂时不可用');
|
|
19
|
+
if (code === 401 || code === 403) {
|
|
20
|
+
if (/quota|额度|配额/i.test(text))
|
|
21
|
+
return result('quota_exceeded', '接口额度不足,无法完成 Key 验证');
|
|
22
|
+
if (/permission|权限|订阅|套餐|upgrade|plan/i.test(text))
|
|
23
|
+
return result('permission_denied', '接口权限不足,无法完成 Key 验证');
|
|
24
|
+
if (/missing|required|未携带|缺少/i.test(text))
|
|
25
|
+
return result('auth_required', 'FTShare 未收到所需凭据');
|
|
26
|
+
if (code === 403 && !/invalid|expired|malformed|无效|过期/i.test(text))
|
|
27
|
+
return result('permission_denied', '请求被拒绝,请检查 Key 或接口权限');
|
|
28
|
+
return result('auth_failed', 'API Key 未通过认证');
|
|
29
|
+
}
|
|
30
|
+
return result('invalid_response', `检测接口返回未识别的错误(错误码 ${code}),未能完成检测`);
|
|
31
|
+
}
|
|
32
|
+
async function readBoundedJson(response) {
|
|
33
|
+
if (!response.body)
|
|
34
|
+
throw new Error('empty_body');
|
|
35
|
+
const reader = response.body.getReader();
|
|
36
|
+
let size = 0;
|
|
37
|
+
const chunks = [];
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done)
|
|
42
|
+
break;
|
|
43
|
+
size += value.byteLength;
|
|
44
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
45
|
+
await reader.cancel();
|
|
46
|
+
throw new Error('oversized_body');
|
|
47
|
+
}
|
|
48
|
+
chunks.push(value);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
reader.releaseLock();
|
|
53
|
+
}
|
|
54
|
+
const bytes = new Uint8Array(size);
|
|
55
|
+
let offset = 0;
|
|
56
|
+
for (const chunk of chunks) {
|
|
57
|
+
bytes.set(chunk, offset);
|
|
58
|
+
offset += chunk.byteLength;
|
|
59
|
+
}
|
|
60
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 一次固定探测。失败(发出过请求且没有通过)写一条 `warn`:
|
|
64
|
+
* 字段固定 `{ target, status, durationMs }` —— `target` 由是否携带 Key 决定;
|
|
65
|
+
* 未发请求的 `not_run` 不是探测失败,成功也不记(规格 §6/§7)。
|
|
66
|
+
*/
|
|
67
|
+
async function probe(path, params, key, parent, fetcher, now, logger) {
|
|
68
|
+
const url = new URL(path, ORIGIN);
|
|
69
|
+
url.search = new URLSearchParams(params).toString();
|
|
70
|
+
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS);
|
|
71
|
+
const signal = AbortSignal.any([parent, timeout]);
|
|
72
|
+
const started = Date.now();
|
|
73
|
+
const finish = (outcome) => {
|
|
74
|
+
if (outcome.status !== 'available' && outcome.status !== 'not_run') {
|
|
75
|
+
logger?.warn('FTShare 探测未通过', { target: key === undefined ? 'remote' : 'apiKey', status: outcome.status, durationMs: Date.now() - started });
|
|
76
|
+
}
|
|
77
|
+
return outcome;
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
// Never forward a credential across a redirect.
|
|
81
|
+
const headers = { Accept: 'application/json' };
|
|
82
|
+
if (key !== undefined)
|
|
83
|
+
headers.FTSHARE_API_KEY = key;
|
|
84
|
+
const failure = (code, text) => key === undefined && (code === 401 || code === 403)
|
|
85
|
+
? result('upstream_error', '远端服务拒绝了连通性检查') : classifyFailure(code, text);
|
|
86
|
+
const response = await fetcher(url, { headers, redirect: 'error', signal });
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = await readBoundedJson(response);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (signal.aborted)
|
|
93
|
+
throw error;
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
return finish(failure(response.status, ''));
|
|
96
|
+
return finish(result('invalid_response', '接口未返回有效 JSON 数据'));
|
|
97
|
+
}
|
|
98
|
+
const text = isRecord(payload) ? String(payload.message ?? payload.msg ?? '') : '';
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
return finish(failure(response.status, text));
|
|
101
|
+
if (!isRecord(payload) || typeof payload.code !== 'number')
|
|
102
|
+
return finish(result('invalid_response', '接口响应格式异常'));
|
|
103
|
+
if (payload.code !== 200)
|
|
104
|
+
return finish(failure(payload.code, text));
|
|
105
|
+
if (!Array.isArray(payload.data))
|
|
106
|
+
return finish(result('invalid_response', '接口未返回 K 线数组'));
|
|
107
|
+
// This checks service/authentication success, not market-data availability.
|
|
108
|
+
// Empty holiday or post-close windows are successful responses too.
|
|
109
|
+
return finish(result('available', key === undefined ? '远端服务正常' : 'API Key 验证通过'));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
if (parent.aborted)
|
|
113
|
+
return finish(result('not_run', '测试已取消或凭据已变化'));
|
|
114
|
+
if (timeout.aborted)
|
|
115
|
+
return finish(result('timeout', '连接超时,请稍后重试'));
|
|
116
|
+
return finish(result('network_error', '连接失败,请检查网络后重试'));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Two fixed, bounded requests. No SDK, arbitrary URL, retries or stored rows. */
|
|
120
|
+
export async function probeApiKey(key, signal, fetcher = fetch, now = Date.now(), logger) {
|
|
121
|
+
const remote = await probe(PROBE_CONTRACTS.daily, {
|
|
122
|
+
symbol: '600519.SH', interval_unit: 'day', adjust_kind: 'none',
|
|
123
|
+
since_ts_millis: String(now - 30 * DAY), until_ts_millis: String(now), limit: '2',
|
|
124
|
+
}, undefined, signal, fetcher, now, logger);
|
|
125
|
+
let apiKey;
|
|
126
|
+
if (!key) {
|
|
127
|
+
apiKey = result('auth_required', '未配置 API Key');
|
|
128
|
+
}
|
|
129
|
+
else if (signal.aborted || remote.status !== 'available') {
|
|
130
|
+
apiKey = result('not_run', '远端服务检查未通过,未验证 Key');
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// The server counts inclusive Beijing calendar dates, not elapsed days.
|
|
134
|
+
// A 24-hour window covers at most two dates; empty data still verifies auth.
|
|
135
|
+
apiKey = await probe(PROBE_CONTRACTS.historicalMinute, {
|
|
136
|
+
symbol: '600519.SH', interval_value: '1', adjust_kind: 'none',
|
|
137
|
+
since_ts_millis: String(now - DAY), until_ts_millis: String(now), limit: '1',
|
|
138
|
+
}, key, signal, fetcher, now, logger);
|
|
139
|
+
}
|
|
140
|
+
const passed = [remote, apiKey].filter(item => item.status === 'available').length;
|
|
141
|
+
return { status: passed === 2 ? 'passed' : passed === 1 ? 'partial' : 'failed', remote, apiKey, testedAt: new Date(now).toISOString() };
|
|
142
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ApiKeyPublicApi } from './contract.js';
|
|
2
|
+
import { type SettingsRpcMethods } from '../../core/rpc-core.js';
|
|
3
|
+
export declare function createApiKeyRpcMethods(api: ApiKeyPublicApi): SettingsRpcMethods;
|
|
4
|
+
export declare function createApiKeyRpcHandler(api: ApiKeyPublicApi): import("../../core/rpc-core.js").SettingsRpcHandler;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createSettingsRpcHandler } from '../../core/rpc-core.js';
|
|
2
|
+
const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
const empty = (value) => record(value) && Object.keys(value).length === 0;
|
|
4
|
+
const keyValue = (value) => record(value) && Object.keys(value).length === 1 && Object.keys(value)[0] === 'value' && typeof value.value === 'string' && value.value.length <= 1024;
|
|
5
|
+
export function createApiKeyRpcMethods(api) {
|
|
6
|
+
return {
|
|
7
|
+
describe: { validate: empty, invoke: () => api.describe() },
|
|
8
|
+
set: { validate: keyValue, invoke: payload => api.set(payload.value) },
|
|
9
|
+
clear: { validate: empty, invoke: () => api.clear() },
|
|
10
|
+
configureAndTest: { validate: keyValue, invoke: payload => api.configureAndTest(payload.value) },
|
|
11
|
+
testConnection: { validate: empty, invoke: () => api.testConnection() },
|
|
12
|
+
getValidationStatus: { validate: empty, invoke: () => api.getValidationStatus() },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function createApiKeyRpcHandler(api) {
|
|
16
|
+
return createSettingsRpcHandler([{ namespace: 'apiKey', rpcMethods: createApiKeyRpcMethods(api) }]);
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ApiKeySettings, type CredentialStatus, type ValidationResult } from './contract.js';
|
|
2
|
+
import type { FeatureLogger } from './logging.js';
|
|
3
|
+
export interface CredentialStore {
|
|
4
|
+
resolve(): Promise<string | undefined>;
|
|
5
|
+
describe(): Promise<CredentialStatus>;
|
|
6
|
+
set(value: string): Promise<void>;
|
|
7
|
+
clear(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export type Validator = (key: string | undefined, signal: AbortSignal) => Promise<ValidationResult>;
|
|
10
|
+
/**
|
|
11
|
+
* One Host instance owns validation state; the DSH provider owns all secrets.
|
|
12
|
+
*
|
|
13
|
+
* `logger` is optional so a directly constructed instance (tests, embeddings)
|
|
14
|
+
* stays silent: the plugin entry passes `ctx.logger()` in through
|
|
15
|
+
* `createApiKeyModule`. Fields never carry the Key or any token value.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createApiKeySettings(store: CredentialStore, validator?: Validator, logger?: FeatureLogger): {
|
|
18
|
+
api: ApiKeySettings;
|
|
19
|
+
invalidate: () => void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { normalizeApiKey, untested, FTSHARE_API_KEY_REF } from './contract.js';
|
|
2
|
+
import { probeApiKey } from './probe.js';
|
|
3
|
+
/**
|
|
4
|
+
* One Host instance owns validation state; the DSH provider owns all secrets.
|
|
5
|
+
*
|
|
6
|
+
* `logger` is optional so a directly constructed instance (tests, embeddings)
|
|
7
|
+
* stays silent: the plugin entry passes `ctx.logger()` in through
|
|
8
|
+
* `createApiKeyModule`. Fields never carry the Key or any token value.
|
|
9
|
+
*/
|
|
10
|
+
export function createApiKeySettings(store, validator, logger) {
|
|
11
|
+
// 默认校验器 = 真实探测:只有这条路径才需要把 logger 传进 probe 记请求级失败。
|
|
12
|
+
const validate = validator ?? ((key, signal) => probeApiKey(key, signal, fetch, Date.now(), logger));
|
|
13
|
+
let revision = 0;
|
|
14
|
+
let fingerprint;
|
|
15
|
+
let validation = untested();
|
|
16
|
+
let active;
|
|
17
|
+
let writes = Promise.resolve();
|
|
18
|
+
let reads = Promise.resolve();
|
|
19
|
+
let disposed = false;
|
|
20
|
+
const listeners = new Set();
|
|
21
|
+
function invalidate() {
|
|
22
|
+
revision++;
|
|
23
|
+
fingerprint = undefined;
|
|
24
|
+
validation = untested();
|
|
25
|
+
active?.controller.abort();
|
|
26
|
+
active = undefined;
|
|
27
|
+
for (const listener of listeners) {
|
|
28
|
+
try {
|
|
29
|
+
listener();
|
|
30
|
+
}
|
|
31
|
+
catch { /* Observers cannot turn a committed write into a failure. */ }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function current() {
|
|
35
|
+
const read = reads.then(async () => {
|
|
36
|
+
while (true) {
|
|
37
|
+
if (disposed)
|
|
38
|
+
throw new Error('FTShare settings service is disposed');
|
|
39
|
+
const started = revision;
|
|
40
|
+
const key = await store.resolve();
|
|
41
|
+
const hash = key === undefined ? 'absent' : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key))), b => b.toString(16).padStart(2, '0')).join('');
|
|
42
|
+
if (started !== revision)
|
|
43
|
+
continue;
|
|
44
|
+
if (fingerprint !== undefined && fingerprint !== hash)
|
|
45
|
+
invalidate();
|
|
46
|
+
fingerprint = hash;
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
reads = read.catch(() => { });
|
|
51
|
+
return read;
|
|
52
|
+
}
|
|
53
|
+
function enqueue(operation) {
|
|
54
|
+
const promise = writes.then(() => {
|
|
55
|
+
if (disposed)
|
|
56
|
+
throw new Error('FTShare settings service is disposed');
|
|
57
|
+
return operation();
|
|
58
|
+
});
|
|
59
|
+
writes = promise.catch(() => { });
|
|
60
|
+
return promise;
|
|
61
|
+
}
|
|
62
|
+
async function write(value) {
|
|
63
|
+
if (!(await store.describe()).writable)
|
|
64
|
+
throw new Error('当前配置来源为只读');
|
|
65
|
+
const started = revision;
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
await store.clear();
|
|
68
|
+
else
|
|
69
|
+
await store.set(value);
|
|
70
|
+
// Also covers providers without update events.
|
|
71
|
+
if (started === revision)
|
|
72
|
+
invalidate();
|
|
73
|
+
}
|
|
74
|
+
function testSnapshot(key, expectedRevision) {
|
|
75
|
+
if (disposed || revision !== expectedRevision)
|
|
76
|
+
return Promise.resolve(untested('superseded', '凭据已变化,请重新测试'));
|
|
77
|
+
if (active?.revision === revision)
|
|
78
|
+
return active.promise.then(value => structuredClone(value));
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const run = { revision, controller, promise: Promise.resolve(untested()) };
|
|
81
|
+
run.promise = (async () => {
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await validate(key, controller.signal);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
logger?.warn('FTShare 验证服务异常', { stage: 'validate', code: 'validator_failed' });
|
|
88
|
+
result = { ...untested('not_run', '验证服务异常,请重试'), status: 'failed' };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
if (!disposed)
|
|
92
|
+
await current();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
logger?.warn('FTShare 凭据不可确认', { stage: 'resolve', code: 'credential_unavailable' });
|
|
96
|
+
return { ...untested('not_run', '无法确认当前凭据,请重新测试'), status: 'failed' };
|
|
97
|
+
}
|
|
98
|
+
if (disposed || controller.signal.aborted || revision !== run.revision)
|
|
99
|
+
return untested('superseded', '凭据已变化,旧测试结果已丢弃');
|
|
100
|
+
validation = structuredClone(result);
|
|
101
|
+
return structuredClone(result);
|
|
102
|
+
})().finally(() => { if (active === run)
|
|
103
|
+
active = undefined; });
|
|
104
|
+
active = run;
|
|
105
|
+
return run.promise.then(value => structuredClone(value));
|
|
106
|
+
}
|
|
107
|
+
const api = {
|
|
108
|
+
credentialRef: FTSHARE_API_KEY_REF,
|
|
109
|
+
async describe() {
|
|
110
|
+
while (true) {
|
|
111
|
+
const key = await current();
|
|
112
|
+
const started = revision;
|
|
113
|
+
const { configured, writable, source } = await store.describe();
|
|
114
|
+
if (started !== revision)
|
|
115
|
+
continue;
|
|
116
|
+
const characters = Array.from(key ?? '');
|
|
117
|
+
// Short/invalid keys must not be exposed in full by the suffix preview.
|
|
118
|
+
const maskedValue = configured ? '••••••••' + (characters.length > 4 ? characters.slice(-4).join('') : '') : undefined;
|
|
119
|
+
return { configured, writable, ...(source === undefined ? {} : { source }), ...(maskedValue === undefined ? {} : { maskedValue }), revision, validation: structuredClone(validation) };
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
resolve: current,
|
|
123
|
+
async set(input) { const value = normalizeApiKey(input); await enqueue(() => write(value)); },
|
|
124
|
+
async clear() { await enqueue(() => write()); },
|
|
125
|
+
async createEnvironment(base = {}) {
|
|
126
|
+
const env = { ...base };
|
|
127
|
+
delete env[FTSHARE_API_KEY_REF];
|
|
128
|
+
const key = await current();
|
|
129
|
+
if (key !== undefined)
|
|
130
|
+
env[FTSHARE_API_KEY_REF] = key;
|
|
131
|
+
return env;
|
|
132
|
+
},
|
|
133
|
+
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
134
|
+
async getValidationStatus() { await current(); return structuredClone(validation); },
|
|
135
|
+
async testConnection() {
|
|
136
|
+
await writes;
|
|
137
|
+
const key = await current();
|
|
138
|
+
return testSnapshot(key, revision);
|
|
139
|
+
},
|
|
140
|
+
async configureAndTest(input) {
|
|
141
|
+
let value;
|
|
142
|
+
try {
|
|
143
|
+
value = normalizeApiKey(input);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return { save: { status: 'failed', code: 'invalid_input', message: 'API Key 格式不正确' }, validation: untested('not_run', '保存失败,未执行测试') };
|
|
147
|
+
}
|
|
148
|
+
const saved = await enqueue(async () => {
|
|
149
|
+
try {
|
|
150
|
+
if (!(await store.describe()).writable)
|
|
151
|
+
return { code: 'read_only' };
|
|
152
|
+
await write(value);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
logger?.warn('FTShare API Key 保存失败', { code: 'save_failed' });
|
|
156
|
+
return { code: 'save_failed' };
|
|
157
|
+
}
|
|
158
|
+
// Reading/testing can fail after a committed save. Preserve that fact.
|
|
159
|
+
try {
|
|
160
|
+
const key = await current();
|
|
161
|
+
return { revision: key === value ? revision : -1, key };
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { unavailable: true };
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
if ('code' in saved)
|
|
168
|
+
return {
|
|
169
|
+
save: { status: 'failed', code: saved.code, message: saved.code === 'read_only' ? '当前配置来源为只读' : '保存失败,请重试' },
|
|
170
|
+
validation: untested('not_run', '保存失败,未执行测试'),
|
|
171
|
+
};
|
|
172
|
+
if ('unavailable' in saved)
|
|
173
|
+
return { save: { status: 'saved', message: '已保存到 DSH 凭据存储' }, validation: untested('not_run', '已保存,但无法读取生效凭据,请重试') };
|
|
174
|
+
return { save: { status: 'saved', message: '已保存到 DSH 凭据存储' }, validation: await testSnapshot(saved.key, saved.revision) };
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
api, invalidate,
|
|
179
|
+
dispose() { disposed = true; active?.controller.abort(); active = undefined; listeners.clear(); },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API Key card's own styles, rendered by the card component's `<style>`.
|
|
3
|
+
*
|
|
4
|
+
* A card owns everything past the section shell: the core declares the
|
|
5
|
+
* `--ftshare-*` tokens and the section container, never card internals.
|
|
6
|
+
* Every selector stays scoped under `.ftshare-settings`, so a card never
|
|
7
|
+
* styles another card's markup.
|
|
8
|
+
*/
|
|
9
|
+
export declare const API_KEY_CARD_CSS = "\n.ftshare-settings .ftshare-eyebrow { color:var(--ftshare-accent-on-surface); font-size:11px; letter-spacing:.12em; }\n.ftshare-settings .ftshare-card { padding:22px; background:var(--ftshare-panel); border:0.5px solid var(--ftshare-border); border-radius:var(--dsw-radius-xl); margin-top:16px; }\n.ftshare-settings label { display:block; font-weight:500; }\n.ftshare-settings input { width:100%; height:32px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 12px; background:var(--ftshare-input); color:inherit; font:inherit; }\n.ftshare-settings input::placeholder { color:var(--ftshare-muted); opacity:1; }\n.ftshare-settings .ftshare-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:16px; }\n.ftshare-settings button { min-height:36px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 14px; font:inherit; background:transparent; color:inherit; cursor:pointer; }\n.ftshare-settings button.primary { background:var(--ftshare-accent); color:var(--ftshare-accent-ink); border-color:var(--ftshare-accent); font-weight:500; }\n.ftshare-settings button:disabled { opacity:.5; cursor:not-allowed; }\n.ftshare-settings dl { margin:0; }\n.ftshare-settings .ftshare-result { display:grid; grid-template-columns:130px 1fr; gap:12px; padding:10px 0; border-top:0.5px solid var(--ftshare-border); }\n.ftshare-settings dd { margin:0; overflow-wrap:anywhere; }\n.ftshare-settings .ftshare-error { color:var(--ftshare-error); }\n@media(max-width:540px) { .ftshare-settings .ftshare-card { padding:16px; } .ftshare-settings .ftshare-result { grid-template-columns:1fr; gap:4px; } }\n";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API Key card's own styles, rendered by the card component's `<style>`.
|
|
3
|
+
*
|
|
4
|
+
* A card owns everything past the section shell: the core declares the
|
|
5
|
+
* `--ftshare-*` tokens and the section container, never card internals.
|
|
6
|
+
* Every selector stays scoped under `.ftshare-settings`, so a card never
|
|
7
|
+
* styles another card's markup.
|
|
8
|
+
*/
|
|
9
|
+
export const API_KEY_CARD_CSS = `
|
|
10
|
+
.ftshare-settings .ftshare-eyebrow { color:var(--ftshare-accent-on-surface); font-size:11px; letter-spacing:.12em; }
|
|
11
|
+
.ftshare-settings .ftshare-card { padding:22px; background:var(--ftshare-panel); border:0.5px solid var(--ftshare-border); border-radius:var(--dsw-radius-xl); margin-top:16px; }
|
|
12
|
+
.ftshare-settings label { display:block; font-weight:500; }
|
|
13
|
+
.ftshare-settings input { width:100%; height:32px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 12px; background:var(--ftshare-input); color:inherit; font:inherit; }
|
|
14
|
+
.ftshare-settings input::placeholder { color:var(--ftshare-muted); opacity:1; }
|
|
15
|
+
.ftshare-settings .ftshare-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:16px; }
|
|
16
|
+
.ftshare-settings button { min-height:36px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 14px; font:inherit; background:transparent; color:inherit; cursor:pointer; }
|
|
17
|
+
.ftshare-settings button.primary { background:var(--ftshare-accent); color:var(--ftshare-accent-ink); border-color:var(--ftshare-accent); font-weight:500; }
|
|
18
|
+
.ftshare-settings button:disabled { opacity:.5; cursor:not-allowed; }
|
|
19
|
+
.ftshare-settings dl { margin:0; }
|
|
20
|
+
.ftshare-settings .ftshare-result { display:grid; grid-template-columns:130px 1fr; gap:12px; padding:10px 0; border-top:0.5px solid var(--ftshare-border); }
|
|
21
|
+
.ftshare-settings dd { margin:0; overflow-wrap:anywhere; }
|
|
22
|
+
.ftshare-settings .ftshare-error { color:var(--ftshare-error); }
|
|
23
|
+
@media(max-width:540px) { .ftshare-settings .ftshare-card { padding:16px; } .ftshare-settings .ftshare-result { grid-template-columns:1fr; gap:4px; } }
|
|
24
|
+
`;
|