@mokuyoaxis/dsh-iris 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/lib/config.js ADDED
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
4
+ * 结构与 ai-paint 工作台兼容(providers 数组同形),支持一键导入。
5
+ * Key 明文落盘(0600);接口层永远不回明文。
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import * as cap from './capability.js';
11
+ import * as models from './models.js';
12
+
13
+ export function irisHome() {
14
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
15
+ return path.join(home, 'iris', 'v1');
16
+ }
17
+
18
+ function storeFile() {
19
+ return path.join(irisHome(), 'providers.json');
20
+ }
21
+
22
+ let cache = null;
23
+
24
+ /* ---------------- 状态变化总线(阶段 4 SSE:供应商/分配落盘即通知) ---------------- */
25
+ const changeListeners = new Set();
26
+ /** 订阅配置变化(upsert/remove/assignment 都会触发);返回退订函数 */
27
+ export function onChange(fn) {
28
+ changeListeners.add(fn);
29
+ return () => changeListeners.delete(fn);
30
+ }
31
+ function emitChange() {
32
+ for (const fn of [...changeListeners]) {
33
+ try { fn(); } catch (_) { /* 单个监听者异常不影响其余 */ }
34
+ }
35
+ }
36
+
37
+ function persist() {
38
+ fs.mkdirSync(path.dirname(storeFile()), { recursive: true });
39
+ const tmp = storeFile() + '.tmp';
40
+ // 0600 从创建时生效(含临时文件);rename 保留 mode,chmod 兜底不依赖 umask
41
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
42
+ fs.renameSync(tmp, storeFile());
43
+ try {
44
+ fs.chmodSync(storeFile(), 0o600);
45
+ } catch (_) {
46
+ /* ignore */
47
+ }
48
+ emitChange(); // 任何配置落盘 = 状态变了 → SSE 推送
49
+ }
50
+
51
+ export function load() {
52
+ if (cache) return cache;
53
+ const file = storeFile();
54
+ if (!fs.existsSync(file)) {
55
+ // 文件不存在 = 首次运行,正常初始化
56
+ cache = { version: 1, providers: [], assignments: {} };
57
+ persist();
58
+ return cache;
59
+ }
60
+ try {
61
+ cache = JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ if (!Array.isArray(cache.providers)) cache.providers = [];
63
+ if (!cache.assignments || typeof cache.assignments !== 'object') cache.assignments = {};
64
+ } catch (err) {
65
+ // 文件存在但损坏:隔离,绝不静默覆盖证据
66
+ const backup = file + '.corrupted-' + Date.now();
67
+ try {
68
+ fs.renameSync(file, backup);
69
+ } catch (_) {
70
+ /* 隔离失败也继续 */
71
+ }
72
+ console.error('[iris] providers.json 已损坏,已隔离为 ' + backup + ':', err && err.message);
73
+ cache = { version: 1, providers: [], assignments: {} };
74
+ persist();
75
+ }
76
+ return cache;
77
+ }
78
+
79
+ /** 测试/重载用:丢弃内存缓存,下次读取重新走盘 */
80
+ export function resetCache() {
81
+ cache = null;
82
+ }
83
+
84
+ /** 首次运行且 Iris 为空时,从工作台配置导入(迁移动作,非耦合) */
85
+ export function importFromWorkbench(workbenchConfigPath) {
86
+ const c = load();
87
+ if (c.providers.length) return { imported: 0, reason: '已有配置,跳过导入' };
88
+ let raw;
89
+ try {
90
+ raw = JSON.parse(fs.readFileSync(workbenchConfigPath, 'utf8'));
91
+ } catch (_) {
92
+ return { imported: 0, reason: '未找到工作台配置' };
93
+ }
94
+ const list = Array.isArray(raw.providers) ? raw.providers : [];
95
+ let n = 0;
96
+ for (const p of list) {
97
+ if (!p || !p.apiKey || !p.baseUrl) continue;
98
+ c.providers.push({
99
+ id: 'iris_' + Math.random().toString(36).slice(2, 8),
100
+ name: p.name || p.id,
101
+ type: p.type === 'anthropic' ? 'anthropic' : 'openai',
102
+ baseUrl: p.baseUrl,
103
+ apiKey: p.apiKey,
104
+ enabled: p.enabled !== false,
105
+ // 媒体生成默认走百炼协议;OpenAI Images 兼容后端由用户按需切换
106
+ mediaProtocol: /dashscope/i.test(p.baseUrl) ? 'dashscope' : 'openai-images'
107
+ });
108
+ n++;
109
+ }
110
+ if (n) persist();
111
+ return { imported: n };
112
+ }
113
+
114
+ export function providers() {
115
+ return load().providers.filter((p) => p.enabled && p.apiKey);
116
+ }
117
+
118
+ export function providerById(id) {
119
+ return providers().find((p) => p.id === id);
120
+ }
121
+
122
+ /** 能力 → 该能力在 provider 上对应的模型字段(models.CAP_FIELD 别名) */
123
+ const FIELD_OF = models.CAP_FIELD;
124
+
125
+ /**
126
+ * 严格选择(阶段 6 模型池):优先用「已分配模型」(有序列表取第一个可用),
127
+ * 否则从全局模型池挑第一个有能力标签的模型。
128
+ * 返回该模型所属 provider 的副本,并把该能力的模型字段设为选中模型。
129
+ * 向后兼容:调用方继续读 provider.apiKey / provider.imageModel 等。
130
+ * 无可用模型 → null(不再兜底到任意 provider)。
131
+ */
132
+ export function pickFor(capability) {
133
+ const pool = models.modelPool(providers());
134
+ let m = null;
135
+ for (const ref of assignmentOrder(capability)) {
136
+ m = pool.find((x) => x.ref === ref && x.capabilities.includes(capability));
137
+ if (m) break;
138
+ }
139
+ if (!m) m = models.pickModel(pool, capability);
140
+ if (!m) return null;
141
+ const p = providerById(m.providerId);
142
+ if (!p) return null;
143
+ const field = FIELD_OF[capability];
144
+ return field ? { ...p, [field]: m.id } : p;
145
+ }
146
+
147
+ /**
148
+ * 同一能力的有序 provider 列表(供 failover 依次尝试):
149
+ * 已分配顺序优先(阶段 6 条目 4),其余按池顺序补齐(去重)。
150
+ * 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
151
+ */
152
+ export function pickAllFor(capability) {
153
+ const pool = models.modelPool(providers());
154
+ const chosen = [];
155
+ const seen = new Set();
156
+ const add = (m) => {
157
+ const k = m.providerId + '\u0000' + m.id;
158
+ if (!seen.has(k)) { seen.add(k); chosen.push(m); }
159
+ };
160
+ for (const ref of assignmentOrder(capability)) {
161
+ for (const m of pool) if (m.ref === ref && m.capabilities.includes(capability)) add(m);
162
+ }
163
+ for (const m of pool) if (m.capabilities.includes(capability)) add(m);
164
+ const out = [];
165
+ for (const m of chosen) {
166
+ const p = providerById(m.providerId);
167
+ if (!p) continue;
168
+ const field = FIELD_OF[capability];
169
+ out.push(field ? { ...p, [field]: m.id } : p);
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** 能力 → 模型复合引用 的分配映射;兼容旧的纯 model id。 */
175
+ export function assignments() {
176
+ return load().assignments || {};
177
+ }
178
+
179
+ function resolveAssignment(pool, raw, capability) {
180
+ let providerId = '';
181
+ let modelId = '';
182
+ if (raw && typeof raw === 'object') {
183
+ providerId = String(raw.providerId || '');
184
+ modelId = String(raw.modelId || raw.id || '');
185
+ } else if (typeof raw === 'string') {
186
+ const parsed = models.parseModelRef(raw);
187
+ if (parsed) ({ providerId, modelId } = parsed);
188
+ else modelId = raw; // v1 旧格式:纯 model id,按池顺序取第一个
189
+ }
190
+ return pool.find((m) => m.id === modelId
191
+ && (!providerId || m.providerId === providerId)
192
+ && m.capabilities.includes(capability)) || null;
193
+ }
194
+
195
+ /** 归一化某能力的有序分配列表 → [providerId::modelId, ...]。 */
196
+ export function assignmentOrder(capability) {
197
+ const a = assignments()[capability];
198
+ if (!a) return [];
199
+ const pool = models.modelPool(providers());
200
+ const refs = [];
201
+ for (const raw of (Array.isArray(a) ? a : [a])) {
202
+ const m = resolveAssignment(pool, raw, capability);
203
+ if (m && !refs.includes(m.ref)) refs.push(m.ref);
204
+ }
205
+ return refs;
206
+ }
207
+
208
+ /** 设置某能力分配的模型 id(需在全局池中存在且具备该能力) */
209
+ export function setAssignment(capability, ref) {
210
+ const c = load();
211
+ const pool = models.modelPool(providers());
212
+ const m = resolveAssignment(pool, ref, capability);
213
+ if (!m) return false;
214
+ if (!c.assignments) c.assignments = {};
215
+ c.assignments[capability] = m.ref;
216
+ persist();
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * 设置某能力的有序 failover 列表(阶段 6 条目 4):每个模型须在全局池中存在且具备该能力。
222
+ * 空数组 = 清除分配(回退池顺序自动选择)。重复 id 自动去重。
223
+ */
224
+ export function setAssignmentOrder(capability, modelRefs) {
225
+ const c = load();
226
+ if (!c.assignments) c.assignments = {};
227
+ if (!Array.isArray(modelRefs) || !modelRefs.length) {
228
+ if (c.assignments[capability]) {
229
+ delete c.assignments[capability];
230
+ persist();
231
+ }
232
+ return true;
233
+ }
234
+ const pool = models.modelPool(providers());
235
+ const refs = [];
236
+ for (const raw of modelRefs) {
237
+ const m = resolveAssignment(pool, raw, capability);
238
+ if (!m) return false;
239
+ if (!refs.includes(m.ref)) refs.push(m.ref);
240
+ }
241
+ if (!refs.length) return false;
242
+ c.assignments[capability] = refs;
243
+ persist();
244
+ return true;
245
+ }
246
+
247
+ /** 清除某个能力的手动分配(回退到池自动选择) */
248
+ export function clearAssignment(capability) {
249
+ const c = load();
250
+ if (c.assignments && c.assignments[capability]) {
251
+ delete c.assignments[capability];
252
+ persist();
253
+ }
254
+ return true;
255
+ }
256
+
257
+ /** 某 provider 的模型池覆盖的能力集合(合并各模型的能力标签) */
258
+ export function capabilitiesOf(p) {
259
+ const caps = new Set();
260
+ for (const m of models.providerModels(p)) {
261
+ for (const c of m.capabilities) caps.add(c);
262
+ }
263
+ return [...caps];
264
+ }
265
+
266
+ export function upsert(provider) {
267
+ const c = load();
268
+ const { id, ...input } = provider;
269
+ // PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
270
+ const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
271
+ if (id) {
272
+ const i = c.providers.findIndex((p) => p.id === id);
273
+ if (i >= 0) {
274
+ c.providers[i] = { ...c.providers[i], ...rest };
275
+ persist();
276
+ return c.providers[i];
277
+ }
278
+ }
279
+ const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
280
+ c.providers.push(fresh);
281
+ persist();
282
+ return fresh;
283
+ }
284
+
285
+ /** 全部供应商原始记录(含停用,管理 GUI 用) */
286
+ export function allProviders() {
287
+ return load().providers;
288
+ }
289
+
290
+ /** 删除一个供应商 */
291
+ export function removeProvider(id) {
292
+ const c = load();
293
+ const i = c.providers.findIndex((p) => p.id === id);
294
+ if (i < 0) return false;
295
+ c.providers.splice(i, 1);
296
+ persist();
297
+ return true;
298
+ }
299
+
300
+ /** 设置一个供应商的模型池(覆盖显式 models 数组;空数组 = 回退到自动发现/旧字段) */
301
+ export function setProviderModels(id, modelEntries) {
302
+ const c = load();
303
+ const p = c.providers.find((x) => x.id === id);
304
+ if (!p) return null;
305
+ if (Array.isArray(modelEntries)) {
306
+ // 保留同名模型已有的 verified/source(重新发现不丢实测结果)
307
+ const prev = {};
308
+ for (const m of p.models || []) if (m && m.id) prev[m.id] = m;
309
+ p.models = modelEntries.map((m) => {
310
+ const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
311
+ const old = prev[entry.id];
312
+ if (old) {
313
+ if (old.verified) entry.verified = old.verified;
314
+ if (old.source) entry.source = old.source;
315
+ }
316
+ if (m && m.verified) entry.verified = m.verified;
317
+ if (m && m.source) entry.source = m.source;
318
+ return entry;
319
+ });
320
+ }
321
+ persist();
322
+ return p;
323
+ }
324
+
325
+ /** 找 provider 里的某个模型条目(可变引用) */
326
+ function findModelEntry(p, modelId) {
327
+ return (p.models || []).find((m) => m && m.id === modelId);
328
+ }
329
+
330
+ /** 手动添加一个模型到池(source=manual,能力由调用方给) */
331
+ export function addProviderModel(id, modelId, capabilities) {
332
+ const c = load();
333
+ const p = c.providers.find((x) => x.id === id);
334
+ if (!p) return null;
335
+ const name = String(modelId || '').trim();
336
+ if (!name) return null;
337
+ if (!Array.isArray(p.models)) p.models = [];
338
+ let entry = findModelEntry(p, name);
339
+ if (!entry) {
340
+ entry = { id: name, capabilities: Array.isArray(capabilities) ? capabilities : [], source: 'manual' };
341
+ p.models.push(entry);
342
+ } else if (Array.isArray(capabilities)) {
343
+ entry.capabilities = capabilities;
344
+ }
345
+ persist();
346
+ return entry;
347
+ }
348
+
349
+ /** 从池移除一个模型 */
350
+ export function removeProviderModel(id, modelId) {
351
+ const c = load();
352
+ const p = c.providers.find((x) => x.id === id);
353
+ if (!p || !Array.isArray(p.models)) return false;
354
+ const i = p.models.findIndex((m) => m && m.id === modelId);
355
+ if (i < 0) return false;
356
+ p.models.splice(i, 1);
357
+ persist();
358
+ return true;
359
+ }
360
+
361
+ /** 设置某模型的能力标签(用户纠正规则误判 / 给未知模型标能力) */
362
+ export function setModelCapabilities(id, modelId, capabilities) {
363
+ const c = load();
364
+ const p = c.providers.find((x) => x.id === id);
365
+ if (!p) return false;
366
+ const entry = findModelEntry(p, modelId);
367
+ if (!entry) return false;
368
+ entry.capabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
369
+ persist();
370
+ return true;
371
+ }
372
+
373
+ /** 记录某模型的实测结果(verified[capability] = {ok, at, note}) */
374
+ export function setModelVerified(id, modelId, capability, result) {
375
+ const c = load();
376
+ const p = c.providers.find((x) => x.id === id);
377
+ if (!p) return false;
378
+ const entry = findModelEntry(p, modelId);
379
+ if (!entry) return false;
380
+ if (!entry.verified) entry.verified = {};
381
+ entry.verified[capability] = { ok: !!result.ok, at: new Date().toISOString(), note: String(result.note || '').slice(0, 200) };
382
+ persist();
383
+ return true;
384
+ }
package/lib/guard.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 请求守卫(O2 授权边界,2026-09-03 修订为"发布安全优先")。
4
+ *
5
+ * 调研宿主结论:DSH 在 `dsh-client-connection` 里有一套权威信任栅栏
6
+ * `isTrustedApiRequest`(回环∪trustedHosts + Sec-Fetch-Site + Origin==Host),
7
+ * 但它**只挂在 `/api` 前缀**。iris 注册的是 `/iris/*` 裸 prefix 路由,在栅栏之外,
8
+ * 所以 iris 需自备一道——但必须**对齐宿主里"永不锁死合法用户"的那半,
9
+ * 舍弃"会锁死 LAN/反代用户"的那半**。
10
+ *
11
+ * 威胁与处置(按发布安全排序):
12
+ * ① 驱动式 CSRF(真实、高价值):恶意网页对 `/iris/api/actions/*` 发跨站 POST,
13
+ * 触发付费生成/写盘。→ **挡**:POST 时 `Sec-Fetch-Site === cross-site` 拒绝;
14
+ * 有 `Origin` 且 `Origin.host !== 请求 Host` 拒绝。合法同源用户(不管在回环/LAN/
15
+ * 反代域名上访问,浏览器 Origin 总与 Host 同源)永不误伤;无 Origin 的 CLI/curl 放行。
16
+ * ② DNS 重绑定读 state / 驱动 POST(低价值):需要 Host 白名单才能挡,但白名单正是
17
+ * 锁死 LAN/反代用户的元凶。用户裁定:iris 开源、反代场景无所谓 → **不挡**,
18
+ * 读接口(GET/HEAD/SSE)完全放开(跨源读本就受 CORS 阻挡,媒体另有 token 能力凭证)。
19
+ *
20
+ * 净效果:**任何部署拓扑都不拦合法用户**,只挡跨站 CSRF 驱动付费动作。
21
+ */
22
+
23
+ /** 请求 Host 头 → host:port 规范化(小写,去 IPv6 方括号保留) */
24
+ function authority(header) {
25
+ const h = String(header || '').trim().toLowerCase();
26
+ if (!h) return '';
27
+ return h;
28
+ }
29
+
30
+ /**
31
+ * 纯函数裁决:{ok:true} 放行;否则 {ok:false, status, error}。
32
+ * 非 POST 一律放行;POST 仅挡跨站/跨源(同源浏览器与无 Origin 的 CLI 放行)。
33
+ * @param {{method?:string, headers?:object}} req node:http IncomingMessage 的最小面
34
+ */
35
+ export function checkRequest(req) {
36
+ const headers = req.headers || {};
37
+ const method = String(req.method || 'GET').toUpperCase();
38
+ if (method !== 'POST') return { ok: true }; // 读接口放开:跨源读受 CORS 挡,媒体有 token
39
+
40
+ const sfs = String(headers['sec-fetch-site'] || '').toLowerCase();
41
+ if (sfs === 'cross-site') return { ok: false, status: 403, error: 'cross-site request denied' };
42
+
43
+ const origin = String(headers.origin || '');
44
+ if (origin) {
45
+ let oh = '';
46
+ try {
47
+ oh = new URL(origin).host.toLowerCase(); // host:port,与宿主 isTrustedApiRequest 同语义
48
+ } catch (_) {
49
+ return { ok: false, status: 403, error: 'bad origin' };
50
+ }
51
+ if (oh !== authority(headers.host)) return { ok: false, status: 403, error: 'cross-origin request denied' };
52
+ }
53
+ return { ok: true }; // 同源浏览器,或无 Origin 的本机 CLI/curl
54
+ }
55
+
56
+ /** 包裹路由 handler:未授权请求统一 403 人话 JSON,不进业务 handler */
57
+ export function guarded(handler) {
58
+ return (req, res, ...rest) => {
59
+ const verdict = checkRequest(req);
60
+ if (!verdict.ok) {
61
+ if (res && !res.headersSent && typeof res.writeHead === 'function') {
62
+ try {
63
+ res.writeHead(verdict.status, { 'Content-Type': 'application/json; charset=utf-8', 'X-Content-Type-Options': 'nosniff' });
64
+ res.end(JSON.stringify({ error: verdict.error }));
65
+ } catch (_) {
66
+ /* 连接已死 */
67
+ }
68
+ }
69
+ return;
70
+ }
71
+ return handler(req, res, ...rest);
72
+ };
73
+ }