// dsh-trusted-page —— 宿主侧入口。 // // 问题:DSH Web 客户端用 ctx.connection.isLoopback 决定是否启用 Host settings // 持久化(@deepseek-ai/dsh-client-connection): // // isLoopback: transport?.ownsHost === true // || pageLocation === void 0 // || isLoopbackHostname(pageLocation.hostname) // // 经反向隧道/自定义域名访问(如 https://dsh.example.com)时页面 hostname 非回环, // persistence 落到 "memory",Models/设置页报 // “加载提供方目录失败: settings are unavailable in this browser”。 // // 本插件提供可视化配置(设置面板 → 通用 → 「远程受信域名」,见 lib/client.js) // 并联动三件事: // // 1. 设置命名空间 trusted-page.hosts —— 用户编辑的受信域名列表; // 2. /api 的 Host/Origin fence —— 把列表并入 connection.trustedHosts // (静态基线 = CLI --trusted-host / cordis.patch.yml,只增不减), // 命名空间 scope.watch 时即时生效,无需重启; // 3. 页面受信判定 —— 每次渲染 index 时注入一段先于插件执行的内联脚本 // (lib/authority.js 生成):location 命中列表才声明 // window.__DSH_TRANSPORT__ = { ownsHost: true }(dsh-client-connection // 预留扩展点,读取处仅此一处)。 // // 安全边界不变:受信域名之外的 /api 仍被 fence 403;页面本身仍需浏览器会话 // Cookie(未授权来源 index 401)。插件只放宽「操作者自己声明的域名」。 // // 零依赖说明:本文件只从相对路径导入纯工具(link: 安装时裸导入解析不到 // dsh 安装树);settings schema 用手写的最小 schemastery 兼容节点 // (callable + toJSON 信封),行为与 z.object({hosts: z.array(String).default([])}) // 一致(见 tests/authority.test.mjs 的信封 round-trip 用例)。 import { buildClassificationScript, extraAuthorities } from './authority.js' export const name = 'dsh-trusted-page' export const NAMESPACE = 'trusted-page' export const HOSTS_FIELD = 'hosts' /** * 手写的 schemastery 兼容 schema 节点(refs 信封形态,与 * z.object({ hosts: z.array(String).default([]) }).toJSON() 同构)。 * @param {unknown} input 分层合并后的原始段(schema defaults → base → user)。 * @returns {{ hosts: string[] }} 规范化后的命名空间值。 * @throws {TypeError} hosts 非数组或元素非字符串(拒绝写入)。 */ const SCHEMA_ENVELOPE = { uid: 3, refs: { '1': { type: 'string', meta: { required: true } }, '2': { type: 'array', meta: { default: [] }, inner: 1 }, '3': { type: 'object', meta: { default: {} }, dict: { hosts: 2 } }, }, } function describeValue(value) { if (value === null) return 'null' if (Array.isArray(value)) return 'array' return typeof value } function TrustedPageSchema(input) { const source = input === undefined || input === null ? {} : input if (typeof source !== 'object' || Array.isArray(source)) { throw new TypeError(`$. expected object but got ${describeValue(source)}`) } if (source.hosts === undefined) return { hosts: [] } if (!Array.isArray(source.hosts)) { throw new TypeError(`$.hosts expected array but got ${describeValue(source.hosts)}`) } for (let index = 0; index < source.hosts.length; index += 1) { if (typeof source.hosts[index] !== 'string') { throw new TypeError(`$.hosts[${String(index)}] expected string but got ${describeValue(source.hosts[index])}`) } } return { hosts: [...source.hosts] } } TrustedPageSchema.toJSON = () => JSON.parse(JSON.stringify(SCHEMA_ENVELOPE)) /** 读取当前受信域名(原始值;规范化发生在合并时)。 */ function readHosts(ctx) { const settings = ctx.get('settings') if (settings === undefined) return [] const section = settings.get(NAMESPACE) const hosts = section?.[HOSTS_FIELD] return Array.isArray(hosts) ? hosts.filter((entry) => typeof entry === 'string') : [] } /** * 装配宿主侧:设置命名空间 + fence 联动 + 页面判定脚本注入。 * @param {import('@deepseek-ai/cordis').Context} ctx */ export function apply(ctx) { // fence 联动体:connection 就绪时装配;settings 注册完成后补一次同步。 let syncFence = () => {} ctx.inject(['settings'], (settingsCtx) => { const scope = settingsCtx.settings.register(NAMESPACE, TrustedPageSchema) // 只监听本命名空间的解析值变化(含 settings.mutate RPC 写入),即时同步 fence const off = scope.watch(() => { syncFence() }) syncFence() return () => { off() } }) ctx.inject(['connection'], (connectionCtx) => { const service = connectionCtx.connection const base = [...service.trustedHosts] syncFence = () => { const extra = extraAuthorities(readHosts(ctx), base) service.trustedHosts = [...base, ...extra] } syncFence() return () => { syncFence = () => {} service.trustedHosts = base } }) ctx.on('webserver/index-inject', (table) => { const extra = extraAuthorities(readHosts(ctx), []) table.push({ kind: 'script', placement: 'head', text: buildClassificationScript(extra), }) }) }