dsh-mosaic 0.2.1 → 1.0.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/CHANGELOG.md +30 -2
- package/README.md +16 -5
- package/SKILL.md +1 -1
- package/docs/publishing.md +1 -1
- package/lib/client.js +359 -50
- package/lib/index.js +37 -10
- package/lib/prompt-store.js +120 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
3
|
+
> 版本规则:插件版本按 npm 语义独立递增,**不再与 DeepSeek Harness 版本号对齐**。
|
|
4
|
+
> 兼容性分两处表达:`package.json` 的 `engines.dsh` 给机器可读的范围,README「安装」一节给「插件版本 ↔ 实测 Harness 版本」对照表;每次在真实 Harness 上验证后更新这两处。
|
|
5
|
+
> `0.2.1` 及各 `0.2.1-<标签>` 是改规则前的历史版本,保留用于追溯。
|
|
6
|
+
|
|
7
|
+
## 1.0.0
|
|
8
|
+
|
|
9
|
+
首个稳定版,也是 npm 上第一次发布的完整版本(`0.2.1` 之后的所有改动都在这里)。
|
|
10
|
+
|
|
11
|
+
- 版本规则变更:不再与 DeepSeek Harness 版本号对齐。此前用 `0.2.1-<标签>` 表示「对齐 0.2.1 的本地改动」,但这类版本在 semver 里是**预发布**(`0.2.1-harden < 0.2.1`),既不会被 `npm i` 选中,发布时还必须显式 `--tag`。兼容范围改由 `engines.dsh` 与 README 的对照表表达。
|
|
12
|
+
- 发布自动化:新增 tag 触发的发布工作流(`.github/workflows/release.yml`),使用 npm trusted publishing(OIDC,无需长期 token),并在发布前校验 tag 与 `package.json` 版本一致、该版本尚未发布。
|
|
13
|
+
|
|
14
|
+
### 功能(此前未发布)
|
|
15
|
+
|
|
16
|
+
- 示意图与图表支持缩放/平移:`−`/`+`/`⟲`/`⤢`、拖动平移、`Ctrl/⌘ + 滚轮`、双击复位、键盘 `+`/`-`/`0`;示意图按原始尺寸渲染,超出部分裁切可拖动。
|
|
17
|
+
|
|
18
|
+
### 安全与状态修正
|
|
19
|
+
|
|
20
|
+
- Widget 沙箱重建:整份文档(`doctype`/`html`/`head`/`body`)由宿主统一生成,模型内容只投放到宿主给出的 head/body 槽位,CSP 因此永远排在模型脚本之前。此前完整 HTML 文档走 `<\/head>` 前插入,模型写在 `<head>` 里的 `<script>` 会在 CSP 生效前执行;模型自带的 CSP `<meta>` 与 `<base>` 一并剥离。
|
|
21
|
+
- Widget 动作校验补上父窗口一侧:nonce 写在 Widget 自己的文档里,模型代码能读出来直接 `postMessage` 伪造,所以除 iframe 内的判断之外,父页面独立校验自己文档的 `navigator.userActivation`;iframe 内的判断同时改为 fail-closed。额度从「每个 Widget 一份」改为**整个会话共享**,payload 上限按 UTF-8 **字节**(16 KB)而不是字符数计算。
|
|
22
|
+
- 自有路由增加 Host 权威校验:`/dsh-mosaic/prompt` 与 `/dsh-mosaic/log` 只接受 loopback、监听地址或 `config.trustedHosts` 声明的授权。仅比较 Origin 与 Host 挡不住 DNS rebinding——被 rebinding 的请求两者都等于攻击者域名。
|
|
23
|
+
- Prompt 状态:`reset` 不再被迟到的 `hydrate` 覆盖(用独立标志区分「还没读过」和「用户刚清空」);`GET` 返回 `revision`,`POST` 可带 `expectedRevision`,对不上返回 409 并附当前内容,跨浏览器编辑不再静默覆盖。
|
|
24
|
+
- 日志路由在 `flush` 之后才回 204,204 变成真正的落盘回执;测试改为等待具体事件而不是「目录里有任意 jsonl」,修掉那条在 CI 上必然失败的竞态。
|
|
25
|
+
- 缩放/平移:触屏改为 `touch-action: pan-y`,手指上下划可以继续滚动聊天页面;ResizeObserver 里不再在 `setZoom` 的 updater 内调用 `setOverflowing`,两个更新都在回调顶层发出。
|
|
26
|
+
- 测试:新增 `test/client.test.mjs`(Widget 文档构造:CSP 顺序、外壳唯一性、剥离模型 CSP/base、bridge 策略),以及 Host 权威、reset/hydrate 竞态、revision 冲突的回归用例。`npm test` 现为 78 项。
|
|
27
|
+
|
|
28
|
+
## 0.2.1-zoom
|
|
29
|
+
|
|
30
|
+
- 示意图与图表可以缩放和拖动:右上角 `−`/`+`/`⟲`/`⤢`,视图内按住拖动平移,`Ctrl/⌘ + 滚轮`缩放,双击或 `⟲` 复位,键盘 `+`/`-`/`0` 同样可用;`⤢` 在遮罩层里打开更大的画布。
|
|
31
|
+
- 示意图保留原始尺寸(不再被压缩到卡片宽度),超出部分在视口里裁切、可拖动查看。
|
|
32
|
+
- 修复外观切换后 Mermaid 重渲染报 `Unsupported color format: "currentColor"`、随后掉到内置布局的问题:主题变量的兜底改用具体颜色。
|
|
5
33
|
|
|
6
34
|
## 0.2.1
|
|
7
35
|
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Mosaic 是一个轻量的 DeepSeek Harness 可视化回答插件。它把模型
|
|
|
39
39
|
|
|
40
40
|
插件向模型贡献一段说明可视化能力的 Prompt,默认是包内文本。
|
|
41
41
|
|
|
42
|
-
**Settings → Mosaic**
|
|
42
|
+
**Settings → Mosaic** 就是这段文本的编辑页:打开即显示当前生效的内容(默认就是内置文案),可以直接改、点「保存」,随时点「恢复默认」回到插件自带文案。保存后从下一轮模型请求开始生效,所有浏览器看到的是同一份,重启也在。两个浏览器同时编辑时,后保存的那个会收到「已在别处被修改」,页面载入最新内容而不覆盖别人的修改。
|
|
43
43
|
|
|
44
44
|
生效顺序是「你保存的覆盖 > 配置文件里的 `config.prompt` > 内置文本」。想在 profile 里批量固定一份基线,仍然可以在 `cordis.patch.yml` 给 mosaic 行加 `config.prompt`;留空、类型不对或超过 128 KB 会回落。
|
|
45
45
|
|
|
@@ -71,7 +71,7 @@ $DSH_HOME/logs/dsh-mosaic/YYYY-MM-DD.jsonl # 未设置 DSH_HOME 时是 ~/.dsh/
|
|
|
71
71
|
- 记录什么:激活/停用、Prompt 的读取与保存(来源、字符数、是否落盘)、工具执行(名称、结果状态、耗时)、路由请求(方法、状态码、耗时)、技能被读取、以及浏览器侧上报的失败(`client.` 前缀:围栏解析失败、Mermaid 失败、Prompt 读写失败、Widget 动作被限流/超限丢弃)。
|
|
72
72
|
- 不记录什么:Prompt 正文、组件 JSON、任何文本字段的内容。字段名为 `prompt`/`text`/`code`/`content`/`secret`/`token`/`password`/`apiKey` 时只写字符数;其它字符串超过 300 字符会被截断。
|
|
73
73
|
- 体积:单文件超过 2 MB 滚动为 `<日期>.1.jsonl`;超过 7 天的文件在下次启动时删除;文件权限 `0600`。写不进去时不会影响插件,只在宿主日志里告警一次。
|
|
74
|
-
- 浏览器侧的失败通过同源 `POST /dsh-mosaic/log` 上报(请求体上限 4 KB
|
|
74
|
+
- 浏览器侧的失败通过同源 `POST /dsh-mosaic/log` 上报(请求体上限 4 KB,非本机权威或跨站一律 403,客户端每分钟最多 20 条);接口在日志落盘之后才回 204,看到成功就是真的写进去了。
|
|
75
75
|
- 默认级别 `info`;想要更细可以给 mosaic 行加 `config.logLevel: debug`(或 `warn` 减少噪音)。
|
|
76
76
|
|
|
77
77
|
排查示例:
|
|
@@ -87,15 +87,17 @@ grep -c '"event":"tool.failed"' "$DSH_HOME"/logs/dsh-mosaic/*.jsonl
|
|
|
87
87
|
|
|
88
88
|
## 已知边界
|
|
89
89
|
|
|
90
|
-
-
|
|
90
|
+
- 本插件的 npm 版本独立递增(当前 `1.0.0`),不再与 Harness 版本号对齐;兼容范围由 `package.json` 的 `engines.dsh` 声明,最近一次实测的 Harness 版本见「安装」一节。
|
|
91
91
|
- 围栏渲染靠扫描页面里的代码块实现:0.2.1 全系列(实测 rc.3 与 rc.5,master 同样)都没有给插件开放「渲染 fenced code block」的扩展点。工具通道 `render_view` + `tool.call.toolview` 是原生做法,围栏是它的补充。
|
|
92
92
|
- 从 0.2.1 起代码块由 Shiki 渲染,`<code>` 上**没有** `language-*` 类;识别靠「代码块位于 assistant 行内 + 正文能解析成合法视图 spec」这条路径,用户消息、工具结果、推理块里的代码块都被排除。
|
|
93
|
-
- `
|
|
93
|
+
- `diagram` 与 `chart` 自带缩放/平移:卡片右上角有 `−`/`+`/`⟲`/`⤢`,放大后按住图面拖动即可平移,`Ctrl/⌘ + 滚轮`缩放,双击复位;示意图按原始尺寸渲染,不会被压缩到卡片宽度。触屏上图表区域只拦横向拖动(`touch-action: pan-y`),手指上下划仍然滚动聊天页面。
|
|
94
|
+
- `widget` 里调用 `window.mosaic.action()` 只有**真实用户手势**之后才会把动作发回模型:Widget 自己也在判断 `navigator.userActivation`,但决定权在宿主——消息的 nonce 就写在 Widget 自己的文档里,模型代码能读出来直接伪造,所以父页面独立再校验一次自己文档的激活状态。确实需要自行提交的 Widget 要在节点上写 `actions: "programmatic"`;无论哪种,整个会话共享「每分钟 10 次、单次 payload 16 KB(按 UTF-8 字节算)」的额度,新开一个 Widget 不会重置。浏览器若不提供 `navigator.userActivation`,非 `programmatic` 的 Widget 会被按「没有手势」处理,按钮不会回模型。
|
|
94
95
|
- `widget` 是 iframe,拿不到宿主的 `ctx.locale`,因此内部文案由调用方自己写;主题靠宿主令牌值传入。围栏是独立的 React root,切换外观不会触发它重渲染,所以 widget 会观察 `body[data-ds-dark-theme]` 这一个外观属性并在变化时重发令牌——这是插件唯一读取的宿主属性。
|
|
95
96
|
- Mermaid 资产走包内私有路由 `/dsh-mosaic/assets`;没有 Web 服务时 diagram 回落到内置 SVG 布局。
|
|
97
|
+
- 自有路由(`/prompt`、`/log`)先验 **Host 必须是本机权威**(loopback、监听地址,或 `config.trustedHosts` 里声明的),再验 `Sec-Fetch-Site` 与 `Origin`。只比 Origin 和 Host 挡不住 DNS rebinding:被 rebinding 的页面这两者都等于攻击者域名,看起来完全同源。宿主把 Web 服务绑到非 loopback 地址时,请把对应主机名写进 `config.trustedHosts`,否则这些路由会拒绝请求。
|
|
96
98
|
- bundle patch 只插入 Mosaic 自己这一行,不改动任何其他插件。若你还装了别的围栏类插件,两者可以对同一段围栏各显一套,按需在 profile 里关掉其中一个即可。
|
|
97
99
|
- 持久化只用「Host 数据目录里的一个 JSON 文件」,没有走 DSH 的 storageDomain。实测(rc.5):`storageDomain` 可用,但记录校验只在**重新打开**时执行(`put` 不校验);`single` 布局遇到版本变化会直接拒绝打开,`compatibleVersions` 也不适用。对一份 Prompt 来说这些能力换不来收益,反而多一个依赖和三条失败分支。
|
|
98
|
-
- Prompt 编辑页是对平台形态的**补充,不是平台标准做法**。DSH 把 Prompt 视为 composition 配置(system-prompt 的 README 明确写着没有终端用户编辑 Prompt 的 API),标准平面是 `Config` + `cordis.patch.yml`,客户端改值应走 `@Remote`,插件自有数据应走存储服务。这里为了「只装一个包、不引入 peer
|
|
100
|
+
- Prompt 编辑页是对平台形态的**补充,不是平台标准做法**。DSH 把 Prompt 视为 composition 配置(system-prompt 的 README 明确写着没有终端用户编辑 Prompt 的 API),标准平面是 `Config` + `cordis.patch.yml`,客户端改值应走 `@Remote`,插件自有数据应走存储服务。这里为了「只装一个包、不引入 peer 依赖」,写入用了自建路由(Host 权威校验 + 同源校验)、持久化用了自建的 `mosaic/prompt.json`。将来宿主提供多行配置表单或标准 Remote 时,这块应当迁移过去。
|
|
99
101
|
|
|
100
102
|
## 本地优先
|
|
101
103
|
|
|
@@ -131,6 +133,15 @@ dsh plugin --profile desktop add link:/absolute/path/to/dsh-mosaic
|
|
|
131
133
|
|
|
132
134
|
安装后重启或刷新 DSH Web,新会话即可使用。bundle patch 只插入本插件一行,不会关闭或覆盖别人的插件。
|
|
133
135
|
|
|
136
|
+
**兼容性**:npm 版本独立于 DeepSeek Harness 版本,`1.0.x` 对应下方实测的 Harness 范围;声明式范围见 `package.json` 的 `engines.dsh`(当前 `>=0.2.1-rc.1 <0.3.0-0`)。
|
|
137
|
+
|
|
138
|
+
| 插件版本 | 实测通过的 DeepSeek Harness | 说明 |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `1.0.0` | `0.2.1-rc.5`(同时复核 rc.3 行为) | 首个稳定版;含 Widget 沙箱、路由权威、Prompt 并发等修正 |
|
|
141
|
+
| `0.2.1` | `0.2.1-rc.3`、`0.2.1-rc.5` | 上一版,npm 上的历史发布 |
|
|
142
|
+
|
|
143
|
+
Harness 升级到 `0.3.x` 后,请先在新版本上验证再放宽 `engines.dsh`,并在此表补一行。
|
|
144
|
+
|
|
134
145
|
## 限制
|
|
135
146
|
|
|
136
147
|
- 80 节点、6 层嵌套、表格 40×8、图表 40 点、diagram 12 节点/20 边、widget 代码 20k。
|
package/SKILL.md
CHANGED
|
@@ -122,7 +122,7 @@ A widget is for content the fixed components cannot express: a game, a physics/m
|
|
|
122
122
|
- CSP: no network; inline scripts/styles and `data:`/`blob:` images or media only
|
|
123
123
|
- no access to the host page, cookies, or local storage
|
|
124
124
|
|
|
125
|
-
`window.mosaic.action(name, payload)` reaches the model only after a real user gesture
|
|
125
|
+
`window.mosaic.action(name, payload)` reaches the model only after a real user gesture; the Host re-checks that gesture, because a widget can read the message nonce out of its own document. Set `actions: "programmatic"` when the widget must submit on its own. Either way the whole session shares a budget of 10 actions a minute and 16 KB per payload, so a widget cannot reset it by re-rendering.
|
|
126
126
|
|
|
127
127
|
Inside the widget:
|
|
128
128
|
|
package/docs/publishing.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
1. Create the GitHub repository. Add the topic `dsh-plugin`; the catalog's automated check looks for it.
|
|
6
6
|
2. Fill `repository`, `homepage`, and `bugs` in `package.json` after the repository URL exists.
|
|
7
|
-
3. Keep at least one version published on npm before submitting to the catalog. The package version
|
|
7
|
+
3. Keep at least one version published on npm before submitting to the catalog. The package version follows plain semver (currently `1.0.0`) and is independent of the DeepSeek Harness version; the verified Harness range lives in `engines.dsh` and in the README compatibility table, and both are updated after each real verification. `npm pack --dry-run` must include `lib/assets/mermaid.min.js`, `screenshots.json`, and `assets/screenshots/`.
|
|
8
8
|
4. Add the repository to the catalog with `docs/catalog-entry.example.yml` copied to `data/plugins/imwhatiam__dsh-mosaic.yml` in `awesome-dsh-plugin/awesome-dsh-plugin`. One entry per PR; the repo must be at least one day old.
|
|
9
9
|
5. Add real screenshots under `assets/screenshots/` and list them in `screenshots.json`. Verified against installed DSH 0.2.1-rc.5: the Plugins page reads only `meta.title`, `meta.description` and the icon, so `screenshots.json` is a **catalog/community** convention, not a DSH one. Keep the paths inside the package and in sync with the filenames.
|
|
10
10
|
6. The catalog `icon` must be a GitHub-hosted raster image. This repo ships `assets/icon-180.png`; SVG is rejected by the catalog.
|
package/lib/client.js
CHANGED
|
@@ -61,6 +61,11 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
reload: '重载',
|
|
62
62
|
fullscreen: '全屏',
|
|
63
63
|
close: '关闭',
|
|
64
|
+
zoomIn: '放大',
|
|
65
|
+
zoomOut: '缩小',
|
|
66
|
+
zoomReset: '恢复 100%',
|
|
67
|
+
zoomFullscreen: '放大查看',
|
|
68
|
+
zoomHint: '按住拖动查看,Ctrl/⌘ + 滚轮缩放,双击复位',
|
|
64
69
|
drawio: '下载 draw.io 文件',
|
|
65
70
|
mermaidFallback: 'Mermaid 渲染失败,已使用内置布局:{message}',
|
|
66
71
|
parseFailed: 'dsh-view 解析失败:{detail}(已保留代码块)',
|
|
@@ -81,6 +86,7 @@ window.__ModuleLoader__.load({
|
|
|
81
86
|
promptSourceBuiltin: '内置默认',
|
|
82
87
|
promptLoadFailed: '读取失败:{reason}',
|
|
83
88
|
promptSaveFailed: '保存失败:{reason}',
|
|
89
|
+
promptConflict: '这份 Prompt 已在别处被修改,已载入最新内容;确认后再保存一次即可。',
|
|
84
90
|
promptMemoryOnly: '未能写入 Host 数据目录,本次仅在进程内生效。',
|
|
85
91
|
promptReadCorrupt: '保存的内容无法解析,已按内置内容运行;原文件已保留,可手工修复。',
|
|
86
92
|
promptReadUnreadable: '保存的内容读取失败(权限或路径问题),已按内置内容运行。',
|
|
@@ -105,6 +111,11 @@ window.__ModuleLoader__.load({
|
|
|
105
111
|
reload: 'Reload',
|
|
106
112
|
fullscreen: 'Fullscreen',
|
|
107
113
|
close: 'Close',
|
|
114
|
+
zoomIn: 'Zoom in',
|
|
115
|
+
zoomOut: 'Zoom out',
|
|
116
|
+
zoomReset: 'Reset to 100%',
|
|
117
|
+
zoomFullscreen: 'Open larger',
|
|
118
|
+
zoomHint: 'Drag to pan, Ctrl/⌘ + wheel to zoom, double-click to reset',
|
|
108
119
|
drawio: 'Download a draw.io file',
|
|
109
120
|
mermaidFallback: 'Mermaid rendering failed; the built-in layout was used: {message}',
|
|
110
121
|
parseFailed: 'dsh-view could not be parsed: {detail} (the code block is kept)',
|
|
@@ -125,6 +136,7 @@ window.__ModuleLoader__.load({
|
|
|
125
136
|
promptSourceBuiltin: 'Built-in default',
|
|
126
137
|
promptLoadFailed: 'Could not read the prompt: {reason}',
|
|
127
138
|
promptSaveFailed: 'Could not save: {reason}',
|
|
139
|
+
promptConflict: 'Another tab changed this prompt. The latest text is loaded; review it and save again.',
|
|
128
140
|
promptMemoryOnly: 'Could not write the Host data directory, so this change only lives in this process.',
|
|
129
141
|
promptReadCorrupt: 'The saved override is not valid JSON, so the built-in text is used. The file was kept for manual repair.',
|
|
130
142
|
promptReadUnreadable: 'The saved override could not be read (permissions or path), so the built-in text is used.',
|
|
@@ -329,10 +341,23 @@ window.__ModuleLoader__.load({
|
|
|
329
341
|
.mosaic-chart .mosaic-dot{stroke:var(--m-bg);stroke-width:1.5}
|
|
330
342
|
.mosaic-chart .mosaic-bar{shape-rendering:geometricPrecision}
|
|
331
343
|
.mosaic-chart .mosaic-donut-label{font-size:11px;fill:var(--m-text)}
|
|
332
|
-
.mosaic-diagram-wrap{max-width:100%;
|
|
344
|
+
.mosaic-diagram-wrap{max-width:100%;overflow:hidden;padding-bottom:2px}
|
|
333
345
|
.mosaic-diagram{display:block;width:auto;max-width:none;height:auto;margin:0 auto;overflow:visible}
|
|
334
|
-
.mosaic-mermaid{max-width:100%;
|
|
335
|
-
.mosaic-mermaid svg{display:block;max-width:
|
|
346
|
+
.mosaic-mermaid{max-width:100%;padding:2px 0}
|
|
347
|
+
.mosaic-mermaid svg{display:block;max-width:none!important;height:auto;margin:0 auto}
|
|
348
|
+
.mosaic-zoom{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
349
|
+
.mosaic-zoom-bar{display:flex;align-items:center;gap:6px;justify-content:flex-end}
|
|
350
|
+
.mosaic-zoom-btn{border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-muted);border-radius:6px;min-width:26px;height:24px;padding:0 6px;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}
|
|
351
|
+
.mosaic-zoom-btn:hover:not(:disabled){color:var(--m-text);border-color:var(--m-accent)}
|
|
352
|
+
.mosaic-zoom-btn:disabled{opacity:.45;cursor:default}
|
|
353
|
+
.mosaic-zoom-value{min-width:46px;text-align:center;font:11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--m-muted);font-variant-numeric:tabular-nums}
|
|
354
|
+
.mosaic-zoom-viewport{position:relative;overflow:hidden;max-height:min(70vh,560px);border-radius:8px;outline:none;touch-action:pan-y}
|
|
355
|
+
.mosaic-zoom-viewport[data-pannable="true"]{cursor:grab}
|
|
356
|
+
.mosaic-zoom-viewport[data-panning="true"]{cursor:grabbing}
|
|
357
|
+
.mosaic-zoom-viewport:focus-visible{box-shadow:0 0 0 2px var(--m-accent)}
|
|
358
|
+
.mosaic-zoom-stage{transform-origin:0 0;display:inline-block;min-width:100%;will-change:transform}
|
|
359
|
+
.mosaic-zoom-stage[data-panning="true"]{user-select:none}
|
|
360
|
+
.mosaic-zoom-full .mosaic-zoom-viewport{max-height:calc(92vh - 96px);min-height:min(70vh,560px)}
|
|
336
361
|
.mosaic-mermaid .labelBkg{background:var(--m-bg)!important;color:var(--m-text)!important;padding:0 3px;border-radius:3px}
|
|
337
362
|
.mosaic-mermaid .edgeLabel{color:var(--m-text)!important}
|
|
338
363
|
.mosaic-diagram-head{display:flex;align-items:center;gap:8px;margin:0 0 6px}
|
|
@@ -427,6 +452,7 @@ window.__ModuleLoader__.load({
|
|
|
427
452
|
.mosaic-overlay-body{position:relative;width:min(1120px,96vw);max-height:92vh;overflow:auto;background:var(--m-bg);color:var(--m-text);border:1px solid var(--m-border);border-radius:14px;padding:16px;box-shadow:0 24px 70px rgba(0,0,0,.35)}
|
|
428
453
|
.mosaic-overlay-close{position:sticky;top:0;float:right;z-index:2;border:1px solid var(--m-border);background:var(--m-bg);color:var(--m-text);border-radius:8px;width:30px;height:30px;font-size:18px;line-height:1;cursor:pointer}
|
|
429
454
|
.mosaic-overlay-close:hover{background:var(--m-bg2)}
|
|
455
|
+
.mosaic-overlay-body[data-figure="true"]{width:min(1440px,97vw);overflow:hidden}
|
|
430
456
|
.mosaic-overlay-image{display:block;max-width:100%;max-height:76vh;margin:0 auto;border-radius:10px;object-fit:contain}
|
|
431
457
|
.mosaic-diagnostic{margin:0 0 6px;padding:6px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:7px;background:color-mix(in srgb,var(--m-danger) 10%,transparent);color:var(--m-danger);font-size:12px;line-height:1.5;white-space:pre-wrap}
|
|
432
458
|
.mosaic-error{margin:8px 0;padding:8px 10px;border:1px solid color-mix(in srgb,var(--m-danger) 40%,transparent);border-radius:8px;background:color-mix(in srgb,var(--m-danger) 8%,var(--m-bg));color:var(--m-danger);font-size:12px}
|
|
@@ -555,6 +581,11 @@ window.__ModuleLoader__.load({
|
|
|
555
581
|
function MosaicSettings({ t }) {
|
|
556
582
|
const alive = useRef(true)
|
|
557
583
|
const [state, setState] = useState({ loading: true, text: '', saved: '', info: null, notice: null, saving: false })
|
|
584
|
+
// The save callback is cached on `t`, so the revision it echoes comes from a ref.
|
|
585
|
+
const infoRef = useRef(null)
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
infoRef.current = state.info
|
|
588
|
+
}, [state.info])
|
|
558
589
|
useEffect(() => () => { alive.current = false }, [])
|
|
559
590
|
const reason = (error) => (error instanceof Error ? error.message : String(error))
|
|
560
591
|
|
|
@@ -575,8 +606,12 @@ window.__ModuleLoader__.load({
|
|
|
575
606
|
return () => { current = false }
|
|
576
607
|
}, [t])
|
|
577
608
|
|
|
578
|
-
const submit = useCallback(async (
|
|
609
|
+
const submit = useCallback(async (payload) => {
|
|
579
610
|
setState((previous) => ({ ...previous, saving: true, notice: null }))
|
|
611
|
+
// Name the version this tab read: another tab may have saved since, and the
|
|
612
|
+
// Host refuses the write instead of silently replacing that edit.
|
|
613
|
+
const revision = infoRef.current?.revision
|
|
614
|
+
const body = revision === undefined ? payload : { ...payload, expectedRevision: revision }
|
|
580
615
|
try {
|
|
581
616
|
const response = await fetch(PROMPT_URL, {
|
|
582
617
|
method: 'POST',
|
|
@@ -584,6 +619,19 @@ window.__ModuleLoader__.load({
|
|
|
584
619
|
body: JSON.stringify(body),
|
|
585
620
|
})
|
|
586
621
|
const data = await response.json().catch(() => ({}))
|
|
622
|
+
if (response.status === 409) {
|
|
623
|
+
if (!alive.current) return
|
|
624
|
+
const text = typeof data.prompt === 'string' ? data.prompt : ''
|
|
625
|
+
setState({
|
|
626
|
+
loading: false,
|
|
627
|
+
text,
|
|
628
|
+
saved: text,
|
|
629
|
+
info: data,
|
|
630
|
+
saving: false,
|
|
631
|
+
notice: { kind: 'error', text: t('promptConflict') },
|
|
632
|
+
})
|
|
633
|
+
return
|
|
634
|
+
}
|
|
587
635
|
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`)
|
|
588
636
|
if (!alive.current) return
|
|
589
637
|
const text = typeof data.prompt === 'string' ? data.prompt : ''
|
|
@@ -1861,7 +1909,7 @@ window.__ModuleLoader__.load({
|
|
|
1861
1909
|
var schedule=function(){requestAnimationFrame(report)};
|
|
1862
1910
|
// A browser-verified gesture, not a widget-supplied flag: userActivation cannot be forged
|
|
1863
1911
|
// by page script, so an auto-firing widget is refused unless it opted into programmatic.
|
|
1864
|
-
var activated=function(){var ua=navigator.userActivation;return ua
|
|
1912
|
+
var activated=function(){var ua=navigator.userActivation;return ua!==undefined&&ua.isActive===true};
|
|
1865
1913
|
window.mosaic={action:function(name,payload){if(policy!=='programmatic'&&!activated())return;send({type:'mosaic:action',action:String(name||'action'),payload:payload&&typeof payload==='object'?payload:{}})},resize:report};
|
|
1866
1914
|
window.addEventListener('message',function(event){var data=event&&event.data;if(!data||typeof data!=='object'||data.type!=='mosaic:theme')return;if(data.nonce!==undefined&&data.nonce!==nonce)return;try{if(data.theme&&typeof data.theme==='object'){Object.keys(data.theme).forEach(function(key){document.documentElement.style.setProperty('--mosaic-'+key,String(data.theme[key]))})}report()}catch(error){}},false);
|
|
1867
1915
|
window.addEventListener('load',function(){schedule();setTimeout(schedule,120);setTimeout(schedule,600)});
|
|
@@ -1872,27 +1920,89 @@ window.__ModuleLoader__.load({
|
|
|
1872
1920
|
})();`
|
|
1873
1921
|
}
|
|
1874
1922
|
|
|
1875
|
-
|
|
1876
|
-
|
|
1923
|
+
/**
|
|
1924
|
+
* Split model-authored HTML into the two places the Host will accept it.
|
|
1925
|
+
*
|
|
1926
|
+
* A complete document keeps its head material (styles, metas, title) and its body
|
|
1927
|
+
* material separate, but neither keeps its own wrapper tags: the Host emits
|
|
1928
|
+
* `html`/`head`/`body` itself, so no model tag can appear before the policy.
|
|
1929
|
+
*
|
|
1930
|
+
* @param {string} code - model-authored widget source.
|
|
1931
|
+
* @returns {{ head: string, body: string }} content for the two Host-owned slots.
|
|
1932
|
+
*/
|
|
1933
|
+
function widgetParts(code) {
|
|
1934
|
+
const text = String(code).slice(0, 20000)
|
|
1877
1935
|
.replace(/<meta\b[^>]*http-equiv\s*=\s*["']?Content-Security-Policy["']?[^>]*>/gi, '')
|
|
1878
1936
|
.replace(/<base\b[^>]*>/gi, '')
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
return safe.replace(/<html[^>]*>/i, (match) => `${match}<head>${head}</head>`)
|
|
1937
|
+
.replace(/<!doctype[^>]*>/gi, '')
|
|
1938
|
+
.replace(/<\/?html\b[^>]*>/gi, '')
|
|
1939
|
+
.replace(/<\/?head\b[^>]*>/gi, '')
|
|
1940
|
+
const document = /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(text)
|
|
1941
|
+
if (document === null) {
|
|
1942
|
+
return /<body\b[^>]*>/i.test(text)
|
|
1943
|
+
? { head: '', body: text.replace(/<\/?body\b[^>]*>/gi, '') }
|
|
1944
|
+
: { head: '', body: text }
|
|
1888
1945
|
}
|
|
1889
|
-
return
|
|
1946
|
+
return { head: text.replace(/<body\b[^>]*>[\s\S]*?<\/body>/i, ''), body: document[1] }
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Build the sandboxed document the widget iframe runs.
|
|
1951
|
+
*
|
|
1952
|
+
* The Host owns the whole shell and the policy comes first, because a `<meta>` CSP
|
|
1953
|
+
* only governs what the parser reaches after it: anything the model put earlier —
|
|
1954
|
+
* a `<script>` in `<head>` is the ordinary case, not an exotic one — would run
|
|
1955
|
+
* before the network ban existed. Model content therefore only ever lands after
|
|
1956
|
+
* the policy, in the head slot or the body slot.
|
|
1957
|
+
*
|
|
1958
|
+
* @param {string} code - model-authored widget source.
|
|
1959
|
+
* @param {string} nonce - per-frame message tag.
|
|
1960
|
+
* @param {Record<string, string>} theme - resolved Host tokens.
|
|
1961
|
+
* @param {'user' | 'programmatic'} policy - whether `mosaic.action` needs a gesture.
|
|
1962
|
+
* @returns {string} the complete iframe document.
|
|
1963
|
+
*/
|
|
1964
|
+
function widgetDocument(code, nonce, theme, policy) {
|
|
1965
|
+
const parts = widgetParts(code)
|
|
1966
|
+
const style = `<style>:root{${Object.keys(theme).map((key) => `--mosaic-${key}:${theme[key]};`).join('')}color-scheme:light dark}html,body{margin:0;padding:0;background:var(--mosaic-bg,transparent);color:var(--mosaic-text,canvastext);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:14px;line-height:1.55}*,*::before,*::after{box-sizing:border-box}a{color:var(--mosaic-accent)}</style>`
|
|
1967
|
+
const lang = (typeof document !== 'undefined' && document.documentElement.lang) || 'en'
|
|
1968
|
+
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><!-- dsh-mosaic sandbox --><meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">${style}<script>${widgetBridge(nonce, policy)}</script>${parts.head}</head><body>${parts.body}</body></html>`
|
|
1890
1969
|
}
|
|
1891
1970
|
|
|
1892
|
-
/**
|
|
1971
|
+
/**
|
|
1972
|
+
* Widget actions are capped per *session*, not per frame: a reopened or newly
|
|
1973
|
+
* rendered widget used to start with a fresh allowance, which is exactly what an
|
|
1974
|
+
* action loop needs.
|
|
1975
|
+
*/
|
|
1893
1976
|
const WIDGET_ACTIONS_PER_WINDOW = 10
|
|
1894
1977
|
const WIDGET_ACTION_WINDOW_MS = 60000
|
|
1895
|
-
const
|
|
1978
|
+
const WIDGET_ACTION_MAX_BYTES = 16 * 1024
|
|
1979
|
+
let widgetActionTimes = []
|
|
1980
|
+
|
|
1981
|
+
/** Whether one widget action fits this window's allowance; consumes it when it does. */
|
|
1982
|
+
function admitWidgetAction() {
|
|
1983
|
+
const now = Date.now()
|
|
1984
|
+
widgetActionTimes = widgetActionTimes.filter((at) => now - at < WIDGET_ACTION_WINDOW_MS)
|
|
1985
|
+
if (widgetActionTimes.length >= WIDGET_ACTIONS_PER_WINDOW) return false
|
|
1986
|
+
widgetActionTimes.push(now)
|
|
1987
|
+
return true
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Whether this document currently holds transient user activation.
|
|
1992
|
+
*
|
|
1993
|
+
* The widget's own copy of this check cannot be trusted: the policy tag, the bridge
|
|
1994
|
+
* and model code share one document, so a widget can read the nonce and post the
|
|
1995
|
+
* same shape itself. The parent answers about its own document instead, and user
|
|
1996
|
+
* activation propagates from a focused subframe to its ancestors, so a real click
|
|
1997
|
+
* inside the iframe still counts here.
|
|
1998
|
+
*/
|
|
1999
|
+
function hasUserGesture() {
|
|
2000
|
+
try {
|
|
2001
|
+
return window.navigator.userActivation?.isActive === true
|
|
2002
|
+
} catch {
|
|
2003
|
+
return false
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
1896
2006
|
|
|
1897
2007
|
function WidgetFrame({ node, onAction, onFullscreen, fullscreen = false }) {
|
|
1898
2008
|
const [height, setHeight] = useState(node.height ?? 320)
|
|
@@ -1905,7 +2015,6 @@ window.__ModuleLoader__.load({
|
|
|
1905
2015
|
const initialTheme = useMemo(() => themeSnapshot(iframeRef.current?.parentElement), [])
|
|
1906
2016
|
const policy = node.actions === 'programmatic' ? 'programmatic' : 'user'
|
|
1907
2017
|
const html = useMemo(() => widgetDocument(node.code, nonce, initialTheme, policy), [node.code, nonce, initialTheme, policy])
|
|
1908
|
-
const actionTimes = useRef([])
|
|
1909
2018
|
const sendTheme = useCallback(() => {
|
|
1910
2019
|
const next = themeSnapshot(iframeRef.current?.parentElement)
|
|
1911
2020
|
iframeRef.current?.contentWindow?.postMessage({ type: 'mosaic:theme', nonce, theme: next }, '*')
|
|
@@ -1918,11 +2027,16 @@ window.__ModuleLoader__.load({
|
|
|
1918
2027
|
if (data.type === 'mosaic:height') {
|
|
1919
2028
|
setHeight(clamp(Number(data.height) || 320, 120, 900))
|
|
1920
2029
|
} else if (data.type === 'mosaic:action') {
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2030
|
+
const action = String(data.action || 'action').slice(0, 64)
|
|
2031
|
+
// The nonce proves which frame spoke, not that a person acted: a widget can
|
|
2032
|
+
// read it out of its own document. A non-programmatic widget therefore still
|
|
2033
|
+
// needs activation in *this* document, which model code cannot fake.
|
|
2034
|
+
if (policy !== 'programmatic' && !hasUserGesture()) {
|
|
2035
|
+
reportLog('warn', 'widget.action-dropped', { reason: 'gesture', action })
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
if (!admitWidgetAction()) {
|
|
2039
|
+
reportLog('warn', 'widget.action-dropped', { reason: 'rate', action })
|
|
1926
2040
|
return
|
|
1927
2041
|
}
|
|
1928
2042
|
const payload = data.payload && typeof data.payload === 'object' ? data.payload : {}
|
|
@@ -1932,12 +2046,14 @@ window.__ModuleLoader__.load({
|
|
|
1932
2046
|
} catch {
|
|
1933
2047
|
return
|
|
1934
2048
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
2049
|
+
const bytes = typeof TextEncoder === 'function'
|
|
2050
|
+
? new TextEncoder().encode(serialized).byteLength
|
|
2051
|
+
: serialized.length
|
|
2052
|
+
if (bytes > WIDGET_ACTION_MAX_BYTES) {
|
|
2053
|
+
reportLog('warn', 'widget.action-dropped', { reason: 'size', bytes })
|
|
1937
2054
|
return
|
|
1938
2055
|
}
|
|
1939
|
-
|
|
1940
|
-
onAction?.(String(data.action || 'action').slice(0, 64), payload)
|
|
2056
|
+
onAction?.(action, payload)
|
|
1941
2057
|
}
|
|
1942
2058
|
}
|
|
1943
2059
|
window.addEventListener('message', onMessage)
|
|
@@ -2072,14 +2188,21 @@ window.__ModuleLoader__.load({
|
|
|
2072
2188
|
/** Resolved host tokens mapped onto Mermaid's base theme. */
|
|
2073
2189
|
function mermaidThemeVariables(host) {
|
|
2074
2190
|
const theme = themeSnapshot(host)
|
|
2191
|
+
// Mermaid parses every value as a colour and rejects `currentColor`, so a token
|
|
2192
|
+
// that is briefly unreadable (an appearance switch mid-render) must still fall
|
|
2193
|
+
// back to a concrete colour instead of failing the whole diagram.
|
|
2194
|
+
const background = theme.bg ?? '#ffffff'
|
|
2195
|
+
const card = theme.card ?? background
|
|
2196
|
+
const text = theme.text ?? '#111111'
|
|
2197
|
+
const border = theme.border ?? '#8a8a8a'
|
|
2075
2198
|
return {
|
|
2076
|
-
background
|
|
2077
|
-
primaryColor:
|
|
2078
|
-
primaryTextColor:
|
|
2079
|
-
primaryBorderColor:
|
|
2080
|
-
lineColor: theme.muted ??
|
|
2081
|
-
secondaryColor:
|
|
2082
|
-
tertiaryColor:
|
|
2199
|
+
background,
|
|
2200
|
+
primaryColor: card,
|
|
2201
|
+
primaryTextColor: text,
|
|
2202
|
+
primaryBorderColor: border,
|
|
2203
|
+
lineColor: theme.muted ?? border,
|
|
2204
|
+
secondaryColor: card,
|
|
2205
|
+
tertiaryColor: background,
|
|
2083
2206
|
fontFamily: 'inherit',
|
|
2084
2207
|
fontSize: '12px',
|
|
2085
2208
|
}
|
|
@@ -2369,6 +2492,181 @@ window.__ModuleLoader__.load({
|
|
|
2369
2492
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
2370
2493
|
}
|
|
2371
2494
|
|
|
2495
|
+
const ZOOM_MIN = 1
|
|
2496
|
+
const ZOOM_MAX = 4
|
|
2497
|
+
const ZOOM_STEP = 1.25
|
|
2498
|
+
|
|
2499
|
+
/**
|
|
2500
|
+
* Zoom and pan for an SVG or rich figure.
|
|
2501
|
+
*
|
|
2502
|
+
* The figure keeps its natural layout size; only a transform changes, so text stays
|
|
2503
|
+
* crisp and the card never reflows. Dragging pans the clipped viewport, and plain
|
|
2504
|
+
* wheel scrolling is left alone so the page still scrolls over the card.
|
|
2505
|
+
*/
|
|
2506
|
+
function ZoomPan({ children, label, fullscreen = false }) {
|
|
2507
|
+
const view = useView()
|
|
2508
|
+
const [zoom, setZoom] = useState({ scale: 1, x: 0, y: 0 })
|
|
2509
|
+
const [panning, setPanning] = useState(false)
|
|
2510
|
+
const [overflowing, setOverflowing] = useState(false)
|
|
2511
|
+
const viewportRef = useRef(null)
|
|
2512
|
+
const stageRef = useRef(null)
|
|
2513
|
+
const drag = useRef(null)
|
|
2514
|
+
// The observer below reads the current view without re-subscribing per frame.
|
|
2515
|
+
const zoomRef = useRef(zoom)
|
|
2516
|
+
useEffect(() => {
|
|
2517
|
+
zoomRef.current = zoom
|
|
2518
|
+
}, [zoom])
|
|
2519
|
+
|
|
2520
|
+
const sizes = () => {
|
|
2521
|
+
const stage = stageRef.current
|
|
2522
|
+
const viewport = viewportRef.current
|
|
2523
|
+
return {
|
|
2524
|
+
width: stage?.offsetWidth ?? 0,
|
|
2525
|
+
height: stage?.offsetHeight ?? 0,
|
|
2526
|
+
viewW: viewport?.clientWidth ?? 0,
|
|
2527
|
+
viewH: viewport?.clientHeight ?? 0,
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
const clampOffset = (next, scale) => {
|
|
2531
|
+
const box = sizes()
|
|
2532
|
+
const limitX = Math.max(0, box.width * scale - box.viewW)
|
|
2533
|
+
const limitY = Math.max(0, box.height * scale - box.viewH)
|
|
2534
|
+
return {
|
|
2535
|
+
x: Math.min(0, Math.max(-limitX, next.x)),
|
|
2536
|
+
y: Math.min(0, Math.max(-limitY, next.y)),
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
const zoomBy = useCallback((factor) => {
|
|
2540
|
+
setZoom((current) => {
|
|
2541
|
+
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, current.scale * factor))
|
|
2542
|
+
if (Math.abs(next - current.scale) < 0.001) return current
|
|
2543
|
+
if (next === ZOOM_MIN) return { scale: ZOOM_MIN, x: 0, y: 0 }
|
|
2544
|
+
const box = sizes()
|
|
2545
|
+
const ratio = next / current.scale
|
|
2546
|
+
// Keep the viewport center fixed while the figure grows around it.
|
|
2547
|
+
const centered = {
|
|
2548
|
+
x: (current.x - box.viewW / 2) * ratio + box.viewW / 2,
|
|
2549
|
+
y: (current.y - box.viewH / 2) * ratio + box.viewH / 2,
|
|
2550
|
+
}
|
|
2551
|
+
return { scale: next, ...clampOffset(centered, next) }
|
|
2552
|
+
})
|
|
2553
|
+
}, [])
|
|
2554
|
+
const reset = useCallback(() => setZoom({ scale: ZOOM_MIN, x: 0, y: 0 }), [])
|
|
2555
|
+
|
|
2556
|
+
useEffect(() => {
|
|
2557
|
+
const element = viewportRef.current
|
|
2558
|
+
if (!element) return undefined
|
|
2559
|
+
const onWheel = (event) => {
|
|
2560
|
+
if (!(event.ctrlKey || event.metaKey)) return
|
|
2561
|
+
event.preventDefault()
|
|
2562
|
+
zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP)
|
|
2563
|
+
}
|
|
2564
|
+
element.addEventListener('wheel', onWheel, { passive: false })
|
|
2565
|
+
return () => element.removeEventListener('wheel', onWheel)
|
|
2566
|
+
}, [zoomBy])
|
|
2567
|
+
|
|
2568
|
+
// A redraw or a card resize changes the pannable area; keep the offset in range.
|
|
2569
|
+
useEffect(() => {
|
|
2570
|
+
const stage = stageRef.current
|
|
2571
|
+
const viewport = viewportRef.current
|
|
2572
|
+
if (typeof ResizeObserver !== 'function' || !stage || !viewport) return undefined
|
|
2573
|
+
const observer = new ResizeObserver(() => {
|
|
2574
|
+
const current = zoomRef.current
|
|
2575
|
+
const box = sizes()
|
|
2576
|
+
// Both updates are issued here rather than inside a state updater: an updater
|
|
2577
|
+
// must stay pure, and React may run it twice while detecting impurity.
|
|
2578
|
+
setOverflowing(box.width * current.scale > box.viewW + 1 || box.height * current.scale > box.viewH + 1)
|
|
2579
|
+
if (current.scale === ZOOM_MIN && current.x === 0 && current.y === 0) return
|
|
2580
|
+
const clamped = clampOffset({ x: current.x, y: current.y }, current.scale)
|
|
2581
|
+
if (clamped.x !== current.x || clamped.y !== current.y) setZoom({ ...current, ...clamped })
|
|
2582
|
+
})
|
|
2583
|
+
observer.observe(stage)
|
|
2584
|
+
observer.observe(viewport)
|
|
2585
|
+
return () => observer.disconnect()
|
|
2586
|
+
}, [])
|
|
2587
|
+
|
|
2588
|
+
useEffect(() => {
|
|
2589
|
+
if (!fullscreen) return undefined
|
|
2590
|
+
const body = viewportRef.current?.closest('.mosaic-overlay-body')
|
|
2591
|
+
if (!body) return undefined
|
|
2592
|
+
body.setAttribute('data-figure', 'true')
|
|
2593
|
+
return () => body.removeAttribute('data-figure')
|
|
2594
|
+
}, [fullscreen])
|
|
2595
|
+
|
|
2596
|
+
const pannable = zoom.scale > ZOOM_MIN || overflowing
|
|
2597
|
+
const onPointerDown = (event) => {
|
|
2598
|
+
if (event.button !== 0 || !pannable) return
|
|
2599
|
+
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, origin: { x: zoom.x, y: zoom.y } }
|
|
2600
|
+
try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch {}
|
|
2601
|
+
setPanning(true)
|
|
2602
|
+
}
|
|
2603
|
+
const onPointerMove = (event) => {
|
|
2604
|
+
const current = drag.current
|
|
2605
|
+
if (!current || current.pointerId !== event.pointerId) return
|
|
2606
|
+
const next = clampOffset({
|
|
2607
|
+
x: current.origin.x + (event.clientX - current.x),
|
|
2608
|
+
y: current.origin.y + (event.clientY - current.y),
|
|
2609
|
+
}, zoom.scale)
|
|
2610
|
+
setZoom((state) => ({ ...state, ...next }))
|
|
2611
|
+
}
|
|
2612
|
+
const endDrag = (event) => {
|
|
2613
|
+
if (!drag.current || drag.current.pointerId !== event.pointerId) return
|
|
2614
|
+
drag.current = null
|
|
2615
|
+
setPanning(false)
|
|
2616
|
+
try { event.currentTarget.releasePointerCapture?.(event.pointerId) } catch {}
|
|
2617
|
+
}
|
|
2618
|
+
const onKeyDown = (event) => {
|
|
2619
|
+
if (event.key === '+' || event.key === '=') { event.preventDefault(); zoomBy(ZOOM_STEP) }
|
|
2620
|
+
else if (event.key === '-') { event.preventDefault(); zoomBy(1 / ZOOM_STEP) }
|
|
2621
|
+
else if (event.key === '0') { event.preventDefault(); reset() }
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
return h('div', {
|
|
2625
|
+
className: `mosaic-zoom${fullscreen ? ' mosaic-zoom-full' : ''}`,
|
|
2626
|
+
'data-mosaic-zoom': '',
|
|
2627
|
+
role: 'group',
|
|
2628
|
+
'aria-label': label ?? translate('zoomIn'),
|
|
2629
|
+
},
|
|
2630
|
+
h('div', { className: 'mosaic-zoom-bar' },
|
|
2631
|
+
h('button', {
|
|
2632
|
+
type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale <= ZOOM_MIN,
|
|
2633
|
+
title: translate('zoomOut'), 'aria-label': translate('zoomOut'), onClick: () => zoomBy(1 / ZOOM_STEP),
|
|
2634
|
+
}, '−'),
|
|
2635
|
+
h('span', { className: 'mosaic-zoom-value', 'aria-live': 'polite' }, `${Math.round(zoom.scale * 100)}%`),
|
|
2636
|
+
h('button', {
|
|
2637
|
+
type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale >= ZOOM_MAX,
|
|
2638
|
+
title: translate('zoomIn'), 'aria-label': translate('zoomIn'), onClick: () => zoomBy(ZOOM_STEP),
|
|
2639
|
+
}, '+'),
|
|
2640
|
+
h('button', {
|
|
2641
|
+
type: 'button', className: 'mosaic-zoom-btn', disabled: zoom.scale === ZOOM_MIN && zoom.x === 0 && zoom.y === 0,
|
|
2642
|
+
title: translate('zoomReset'), 'aria-label': translate('zoomReset'), onClick: reset,
|
|
2643
|
+
}, '⟲'),
|
|
2644
|
+
!fullscreen && view ? h('button', {
|
|
2645
|
+
type: 'button', className: 'mosaic-zoom-btn', title: translate('zoomFullscreen'), 'aria-label': translate('zoomFullscreen'),
|
|
2646
|
+
onClick: () => view.openOverlay(h(ZoomPan, { key: 'zoom-fullscreen', label, fullscreen: true }, children)),
|
|
2647
|
+
}, '⤢') : null),
|
|
2648
|
+
h('div', {
|
|
2649
|
+
ref: viewportRef,
|
|
2650
|
+
className: 'mosaic-zoom-viewport',
|
|
2651
|
+
tabIndex: 0,
|
|
2652
|
+
'data-pannable': String(pannable),
|
|
2653
|
+
'data-panning': String(panning),
|
|
2654
|
+
title: translate('zoomHint'),
|
|
2655
|
+
onPointerDown,
|
|
2656
|
+
onPointerMove,
|
|
2657
|
+
onPointerUp: endDrag,
|
|
2658
|
+
onPointerCancel: endDrag,
|
|
2659
|
+
onDoubleClick: reset,
|
|
2660
|
+
onKeyDown,
|
|
2661
|
+
},
|
|
2662
|
+
h('div', {
|
|
2663
|
+
ref: stageRef,
|
|
2664
|
+
className: 'mosaic-zoom-stage',
|
|
2665
|
+
'data-panning': String(panning),
|
|
2666
|
+
style: { transform: `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.scale})` },
|
|
2667
|
+
}, children)))
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2372
2670
|
function MermaidDiagram({ node, fallback }) {
|
|
2373
2671
|
const containerRef = useRef(null)
|
|
2374
2672
|
const [state, setState] = useState({ status: 'loading', svg: '' })
|
|
@@ -2391,14 +2689,15 @@ window.__ModuleLoader__.load({
|
|
|
2391
2689
|
return () => { alive = false }
|
|
2392
2690
|
}, [node, revision])
|
|
2393
2691
|
if (state.status === 'ready') {
|
|
2394
|
-
return h('
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2692
|
+
return h(ZoomPan, { label: node.title ?? 'diagram' },
|
|
2693
|
+
h('div', {
|
|
2694
|
+
ref: containerRef,
|
|
2695
|
+
className: 'mosaic-mermaid',
|
|
2696
|
+
'data-diagram-engine': 'mermaid',
|
|
2697
|
+
// The string already passed sanitizeMermaidSvg; Mermaid itself runs with
|
|
2698
|
+
// securityLevel 'strict' and htmlLabels off.
|
|
2699
|
+
dangerouslySetInnerHTML: { __html: state.svg },
|
|
2700
|
+
}))
|
|
2402
2701
|
}
|
|
2403
2702
|
if (state.status === 'error') {
|
|
2404
2703
|
return h('div', null, fallback, h('div', { className: 'mosaic-caption-line' }, translate('mermaidFallback', { message: state.message })))
|
|
@@ -2694,14 +2993,20 @@ window.__ModuleLoader__.load({
|
|
|
2694
2993
|
if (node.kind === 'sequence') {
|
|
2695
2994
|
return h('div', null, head, h(MermaidDiagram, {
|
|
2696
2995
|
node,
|
|
2697
|
-
fallback: h(
|
|
2996
|
+
fallback: h(ZoomPan, { label: node.title ?? 'diagram' },
|
|
2997
|
+
h('div', { className: 'mosaic-diagram-wrap' }, h(SequenceDiagram, { node }))),
|
|
2698
2998
|
}))
|
|
2699
2999
|
}
|
|
2700
|
-
if (node.kind === 'timeline')
|
|
2701
|
-
|
|
3000
|
+
if (node.kind === 'timeline') {
|
|
3001
|
+
return h('div', null, head, h(ZoomPan, { label: node.title ?? 'diagram' }, h(TimelineDiagram, { node })))
|
|
3002
|
+
}
|
|
3003
|
+
if (node.kind === 'layers') {
|
|
3004
|
+
return h('div', null, head, h(ZoomPan, { label: node.title ?? 'diagram' }, h(LayersDiagram, { node })))
|
|
3005
|
+
}
|
|
2702
3006
|
return h('div', null, head, h(MermaidDiagram, {
|
|
2703
3007
|
node,
|
|
2704
|
-
fallback: h(
|
|
3008
|
+
fallback: h(ZoomPan, { label: node.title ?? 'diagram' },
|
|
3009
|
+
h('div', { className: 'mosaic-diagram-wrap' }, h(FlowchartDiagram, { node }))),
|
|
2705
3010
|
}))
|
|
2706
3011
|
}
|
|
2707
3012
|
|
|
@@ -2752,7 +3057,7 @@ window.__ModuleLoader__.load({
|
|
|
2752
3057
|
case 'callout': return h(CalloutNode, { node })
|
|
2753
3058
|
case 'code': return h(CodeNode, { node })
|
|
2754
3059
|
case 'diff': return h(DiffNode, { node })
|
|
2755
|
-
case 'chart': return h(ChartNode, { node })
|
|
3060
|
+
case 'chart': return h(ZoomPan, { label: node.title ?? `chart` }, h(ChartNode, { node }))
|
|
2756
3061
|
case 'diagram': return h(DiagramNode, { node })
|
|
2757
3062
|
case 'image': return h(ImageNode, { node })
|
|
2758
3063
|
case 'video': return h(VideoNode, { node })
|
|
@@ -3186,6 +3491,10 @@ window.__ModuleLoader__.load({
|
|
|
3186
3491
|
console.info('[mosaic] client active; fence=dsh-view')
|
|
3187
3492
|
}
|
|
3188
3493
|
|
|
3189
|
-
|
|
3494
|
+
// Pure helpers the Node test suite can reach; the loader reads only `apply` and
|
|
3495
|
+
// `inject`, so this extra key is inert at runtime.
|
|
3496
|
+
const __test = { widgetDocument, widgetParts, WIDGET_CSP, widgetBridge }
|
|
3497
|
+
|
|
3498
|
+
return { inject: ['slots', 'sessions', 'locale'], apply, __test }
|
|
3190
3499
|
},
|
|
3191
3500
|
})
|
package/lib/index.js
CHANGED
|
@@ -312,18 +312,25 @@ export function apply(ctx, config) {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
/**
|
|
316
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Accept one bounded diagnostic line from the browser half.
|
|
317
|
+
*
|
|
318
|
+
* @param {import('node:http').IncomingMessage} req - request.
|
|
319
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
320
|
+
* @param {{ bindHost?: string, trustedHosts?: readonly string[] }} policy - the
|
|
321
|
+
* authorities this listener answers.
|
|
322
|
+
*/
|
|
323
|
+
async function handleClientLog(req, res, policy) {
|
|
317
324
|
if (req.method !== 'POST') {
|
|
318
325
|
res.writeHead(405)
|
|
319
326
|
res.end()
|
|
320
327
|
return
|
|
321
328
|
}
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
-
log.warn('client.log-rejected', { reason:
|
|
329
|
+
const rejection = crossSiteError(req, policy)
|
|
330
|
+
if (rejection !== undefined) {
|
|
331
|
+
log.warn('client.log-rejected', { reason: rejection })
|
|
325
332
|
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
|
|
326
|
-
res.end(JSON.stringify({ error:
|
|
333
|
+
res.end(JSON.stringify({ error: rejection }))
|
|
327
334
|
return
|
|
328
335
|
}
|
|
329
336
|
let body
|
|
@@ -339,6 +346,11 @@ export function apply(ctx, config) {
|
|
|
339
346
|
const fields = { source: 'client', ...detail }
|
|
340
347
|
if (body?.level === 'error') log.error(`client.${event}`, body?.message === undefined ? undefined : new Error(String(body.message)), fields)
|
|
341
348
|
else log.warn(`client.${event}`, fields)
|
|
349
|
+
// Answer only once the line is durable, so 204 is a receipt rather than a promise
|
|
350
|
+
// the caller has to confirm by polling the file.
|
|
351
|
+
try {
|
|
352
|
+
await log.flush()
|
|
353
|
+
} catch {}
|
|
342
354
|
res.writeHead(204)
|
|
343
355
|
res.end()
|
|
344
356
|
}
|
|
@@ -347,18 +359,33 @@ export function apply(ctx, config) {
|
|
|
347
359
|
// A host without a webserver keeps the built-in SVG fallback.
|
|
348
360
|
ctx.inject(['webServer'], (webCtx) => {
|
|
349
361
|
const webServer = webCtx.webServer
|
|
362
|
+
// A request authority is ours when it names loopback, this listener's own bind
|
|
363
|
+
// address, or a host the deployment declared. Comparing Origin to Host is not
|
|
364
|
+
// enough: under DNS rebinding both carry the attacker's name.
|
|
365
|
+
const authority = {
|
|
366
|
+
...typeof webServer.host === 'string' && webServer.host !== '' ? { bindHost: webServer.host } : {},
|
|
367
|
+
...Array.isArray(config?.trustedHosts) ? { trustedHosts: config.trustedHosts } : {},
|
|
368
|
+
}
|
|
350
369
|
webCtx.effect(
|
|
351
370
|
() => webServer.register({ kind: 'prefix', path: ASSET_ROUTE_PATH, handler: tracedRoute('assets', serveMosaicAsset) }),
|
|
352
371
|
'dsh-mosaic: asset route',
|
|
353
372
|
)
|
|
354
|
-
// The settings page reads and writes the prompt here.
|
|
355
|
-
//
|
|
373
|
+
// The settings page reads and writes the prompt here. Requests must name this
|
|
374
|
+
// listener and come from its own origin; see requestPolicyError for the checks.
|
|
356
375
|
webCtx.effect(
|
|
357
|
-
() => webServer.register({
|
|
376
|
+
() => webServer.register({
|
|
377
|
+
kind: 'exact',
|
|
378
|
+
path: PROMPT_ROUTE,
|
|
379
|
+
handler: tracedRoute('prompt', (req, res) => prompts.handle(req, res, authority)),
|
|
380
|
+
}),
|
|
358
381
|
'dsh-mosaic: prompt route',
|
|
359
382
|
)
|
|
360
383
|
webCtx.effect(
|
|
361
|
-
() => webServer.register({
|
|
384
|
+
() => webServer.register({
|
|
385
|
+
kind: 'exact',
|
|
386
|
+
path: LOG_ROUTE,
|
|
387
|
+
handler: tracedRoute('log', (req, res) => handleClientLog(req, res, authority)),
|
|
388
|
+
}),
|
|
362
389
|
'dsh-mosaic: log route',
|
|
363
390
|
)
|
|
364
391
|
})
|
package/lib/prompt-store.js
CHANGED
|
@@ -120,17 +120,90 @@ export function parsePromptBody(body) {
|
|
|
120
120
|
return { ok: true, prompt }
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Parse one bare `Host` authority into a lowercase hostname, or `undefined` when the
|
|
125
|
+
* value is not a plain `host[:port]`.
|
|
126
|
+
*
|
|
127
|
+
* @param {unknown} authority - the raw header value.
|
|
128
|
+
* @returns {string | undefined} the hostname, without IPv6 brackets.
|
|
129
|
+
*/
|
|
130
|
+
function hostnameOf(authority) {
|
|
131
|
+
if (typeof authority !== 'string' || authority.trim() === '') return undefined
|
|
132
|
+
let url
|
|
133
|
+
try {
|
|
134
|
+
url = new URL(`http://${authority}`)
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
// A bare authority cannot carry a path, query or fragment.
|
|
139
|
+
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') return undefined
|
|
140
|
+
return url.hostname.replace(/^\[/u, '').replace(/\]$/u, '').toLowerCase()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The port one authority names, or `undefined` when it omits one. */
|
|
144
|
+
function portOf(authority) {
|
|
145
|
+
try {
|
|
146
|
+
const url = new URL(`http://${authority}`)
|
|
147
|
+
return url.port === '' ? undefined : url.port
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a hostname names this machine's loopback interface. */
|
|
154
|
+
function isLoopbackHostname(name) {
|
|
155
|
+
if (name === 'localhost' || name === '::1') return true
|
|
156
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(name)) return true
|
|
157
|
+
return /^::ffff:127\./u.test(name)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reject a request whose `Host` this deployment does not answer.
|
|
162
|
+
*
|
|
163
|
+
* The `Origin`/`Host` comparison alone cannot stop DNS rebinding: an attacker page at
|
|
164
|
+
* `evil.example` that resolves to this listener sends `Host: evil.example`,
|
|
165
|
+
* `Origin: http://evil.example` and `Sec-Fetch-Site: same-origin`, so every one of those
|
|
166
|
+
* agrees while the request is foreign. The authority itself therefore has to be one this
|
|
167
|
+
* listener owns — loopback, the configured bind address, or a declared trusted host —
|
|
168
|
+
* which is the fence `dsh-client-connection` already applies to `/api`.
|
|
169
|
+
*
|
|
170
|
+
* @param {import('node:http').IncomingMessage} req - the request under test.
|
|
171
|
+
* @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [options] - the
|
|
172
|
+
* authorities this deployment answers beyond loopback.
|
|
173
|
+
* @returns {string | undefined} a rejection reason, or `undefined` when it is ours.
|
|
174
|
+
*/
|
|
175
|
+
export function foreignHostError(req, options = {}) {
|
|
176
|
+
const raw = req.headers.host
|
|
177
|
+
const name = hostnameOf(raw)
|
|
178
|
+
if (name === undefined) return 'invalid_host'
|
|
179
|
+
if (isLoopbackHostname(name)) return undefined
|
|
180
|
+
const bind = hostnameOf(options.bindHost)
|
|
181
|
+
if (bind !== undefined && name === bind) return undefined
|
|
182
|
+
for (const entry of options.trustedHosts ?? []) {
|
|
183
|
+
const trusted = hostnameOf(entry)
|
|
184
|
+
if (trusted === undefined || trusted !== name) continue
|
|
185
|
+
const port = portOf(entry)
|
|
186
|
+
if (port === undefined || port === portOf(raw)) return undefined
|
|
187
|
+
}
|
|
188
|
+
return 'foreign_host'
|
|
189
|
+
}
|
|
190
|
+
|
|
123
191
|
/**
|
|
124
192
|
* Reject a state-changing request that a foreign page could have triggered.
|
|
125
193
|
*
|
|
126
194
|
* `Sec-Fetch-Site` is sent by the browser and cannot be forged by page script; a
|
|
127
195
|
* foreign origin also fails the `Origin` comparison, and a JSON content type forces a
|
|
128
|
-
* preflight that this route never approves.
|
|
196
|
+
* preflight that this route never approves. The `Host` fence runs first, because every
|
|
197
|
+
* other check agrees with itself once the browser has been rebound to this listener.
|
|
129
198
|
*
|
|
130
199
|
* @param {import('node:http').IncomingMessage} req - the request under test.
|
|
200
|
+
* @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [options] - the
|
|
201
|
+
* authorities this deployment answers beyond loopback.
|
|
131
202
|
* @returns {string | undefined} a rejection reason, or `undefined` when allowed.
|
|
132
203
|
*/
|
|
133
|
-
export function crossSiteError(req) {
|
|
204
|
+
export function crossSiteError(req, options = {}) {
|
|
205
|
+
const foreign = foreignHostError(req, options)
|
|
206
|
+
if (foreign !== undefined) return foreign
|
|
134
207
|
const site = req.headers['sec-fetch-site']
|
|
135
208
|
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return 'cross_site'
|
|
136
209
|
const origin = req.headers.origin
|
|
@@ -147,8 +220,8 @@ export function crossSiteError(req) {
|
|
|
147
220
|
}
|
|
148
221
|
|
|
149
222
|
/** Policy for a state-changing request: same-origin plus a JSON body. */
|
|
150
|
-
export function requestPolicyError(req) {
|
|
151
|
-
const crossSite = crossSiteError(req)
|
|
223
|
+
export function requestPolicyError(req, options = {}) {
|
|
224
|
+
const crossSite = crossSiteError(req, options)
|
|
152
225
|
if (crossSite !== undefined) return crossSite
|
|
153
226
|
const type = req.headers['content-type']
|
|
154
227
|
if (typeof type !== 'string' || !type.toLowerCase().startsWith('application/json')) return 'json_required'
|
|
@@ -190,6 +263,10 @@ function sendJson(res, status, body) {
|
|
|
190
263
|
* @param {(ctx: object, text: string) => () => void} options.registerSection - registers
|
|
191
264
|
* the prompt section and returns its disposer.
|
|
192
265
|
* @param {string} [options.path] - override file path; defaults to the Host data dir.
|
|
266
|
+
* @param {string} [options.bindHost] - the listener's own bind address, accepted as a
|
|
267
|
+
* request authority in addition to loopback.
|
|
268
|
+
* @param {readonly string[]} [options.trustedHosts] - further authorities this
|
|
269
|
+
* deployment answers.
|
|
193
270
|
*/
|
|
194
271
|
export function createPromptController(options) {
|
|
195
272
|
const { ctx, config, resolveConfigPrompt, builtin, registerSection } = options
|
|
@@ -200,9 +277,23 @@ export function createPromptController(options) {
|
|
|
200
277
|
// browser half working after a route change.
|
|
201
278
|
const assets = options.assets ?? {}
|
|
202
279
|
|
|
280
|
+
// The authorities this listener answers beyond loopback; both routes share them.
|
|
281
|
+
const policy = {
|
|
282
|
+
...options.bindHost === undefined ? {} : { bindHost: options.bindHost },
|
|
283
|
+
...Array.isArray(options.trustedHosts) ? { trustedHosts: options.trustedHosts } : {},
|
|
284
|
+
}
|
|
285
|
+
|
|
203
286
|
const state = {
|
|
204
287
|
/** Saved by the user in this profile, or `undefined`. */
|
|
205
288
|
user: undefined,
|
|
289
|
+
/**
|
|
290
|
+
* Whether a user action — a save *or* a reset — has landed in this process.
|
|
291
|
+
* `user === undefined` cannot carry that meaning, because a reset sets it too,
|
|
292
|
+
* so a hydrate that resolves after a reset would otherwise restore the old text.
|
|
293
|
+
*/
|
|
294
|
+
userModified: false,
|
|
295
|
+
/** Monotonic version of the effective prompt; clients echo it back to save. */
|
|
296
|
+
revision: 0,
|
|
206
297
|
/** From the row's `config.prompt`, or `undefined`. */
|
|
207
298
|
configured: resolveConfigPrompt(config) === builtin ? undefined : resolveConfigPrompt(config),
|
|
208
299
|
/** Whether the last write reached disk. */
|
|
@@ -231,6 +322,7 @@ export function createPromptController(options) {
|
|
|
231
322
|
configuredPrompt: state.configured,
|
|
232
323
|
customized: source() !== 'builtin',
|
|
233
324
|
source: source(),
|
|
325
|
+
revision: state.revision,
|
|
234
326
|
persisted: state.persisted,
|
|
235
327
|
...state.persistenceError === undefined ? {} : { persistenceError: state.persistenceError },
|
|
236
328
|
...state.readError === undefined ? {} : { readError: state.readError },
|
|
@@ -270,8 +362,10 @@ export function createPromptController(options) {
|
|
|
270
362
|
/** Pick up a saved override on activation. */
|
|
271
363
|
async hydrate() {
|
|
272
364
|
const stored = await readPromptFile(state.path)
|
|
273
|
-
// A `start()` may have been revoked, or
|
|
274
|
-
|
|
365
|
+
// A `start()` may have been revoked, or the user may have saved or reset while
|
|
366
|
+
// this read was in flight. A reset leaves `user` undefined, so only the flag can
|
|
367
|
+
// tell "nothing saved yet" apart from "the user cleared it".
|
|
368
|
+
if (!state.active || state.userModified) return snapshot()
|
|
275
369
|
if (stored.error !== undefined) {
|
|
276
370
|
// Keep the file: the user may want to recover it, and a later save overwrites it.
|
|
277
371
|
state.readError = stored.error
|
|
@@ -284,6 +378,7 @@ export function createPromptController(options) {
|
|
|
284
378
|
}
|
|
285
379
|
state.user = stored.prompt
|
|
286
380
|
state.persisted = true
|
|
381
|
+
state.revision += 1
|
|
287
382
|
applySection()
|
|
288
383
|
log.info('prompt.hydrate', { source: source(), stored: true, chars: stored.prompt.length })
|
|
289
384
|
return snapshot()
|
|
@@ -299,8 +394,19 @@ export function createPromptController(options) {
|
|
|
299
394
|
log.warn('prompt.update-rejected', { reason: parsed.error })
|
|
300
395
|
return { status: 400, body: { error: parsed.error } }
|
|
301
396
|
}
|
|
397
|
+
// Two browsers share this one file. A save that names the revision it read is
|
|
398
|
+
// refused when another tab has moved on, instead of silently overwriting it.
|
|
399
|
+
const expected = body !== null && typeof body === 'object' && Number.isFinite(body.expectedRevision)
|
|
400
|
+
? body.expectedRevision
|
|
401
|
+
: undefined
|
|
402
|
+
if (expected !== undefined && expected !== state.revision) {
|
|
403
|
+
log.warn('prompt.update-rejected', { reason: 'revision_conflict' })
|
|
404
|
+
return { status: 409, body: { error: 'revision_conflict', ...snapshot() } }
|
|
405
|
+
}
|
|
302
406
|
const next = parsed.reset ? undefined : parsed.prompt
|
|
407
|
+
state.userModified = true
|
|
303
408
|
state.user = next
|
|
409
|
+
state.revision += 1
|
|
304
410
|
const write = await (writes = writes.then(() => writeStoredPrompt(state.path, next)))
|
|
305
411
|
if (!state.active) return { status: 503, body: { error: 'inactive' } }
|
|
306
412
|
state.persisted = write.persisted
|
|
@@ -319,10 +425,13 @@ export function createPromptController(options) {
|
|
|
319
425
|
*
|
|
320
426
|
* @param {import('node:http').IncomingMessage} req - request.
|
|
321
427
|
* @param {import('node:http').ServerResponse} res - response.
|
|
428
|
+
* @param {{ bindHost?: string, trustedHosts?: readonly string[] }} [overrides] -
|
|
429
|
+
* authorities known only once the route is registered.
|
|
322
430
|
*/
|
|
323
|
-
async handle(req, res) {
|
|
431
|
+
async handle(req, res, overrides) {
|
|
432
|
+
const active = overrides === undefined ? policy : { ...policy, ...overrides }
|
|
324
433
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
325
|
-
const readPolicy = crossSiteError(req)
|
|
434
|
+
const readPolicy = crossSiteError(req, active)
|
|
326
435
|
if (readPolicy !== undefined) {
|
|
327
436
|
sendJson(res, 403, { error: readPolicy })
|
|
328
437
|
return
|
|
@@ -334,9 +443,9 @@ export function createPromptController(options) {
|
|
|
334
443
|
sendJson(res, 405, { error: 'method_not_allowed' })
|
|
335
444
|
return
|
|
336
445
|
}
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
sendJson(res, 403, { error:
|
|
446
|
+
const rejection = requestPolicyError(req, active)
|
|
447
|
+
if (rejection !== undefined) {
|
|
448
|
+
sendJson(res, 403, { error: rejection })
|
|
340
449
|
return
|
|
341
450
|
}
|
|
342
451
|
let body
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mosaic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lightweight visual answers for DeepSeek Harness: one compact visual style, an editable model prompt, dsh-view components, Mermaid diagrams, a sandboxed HTML/JS escape hatch, local-first interactions, and link-only media cards.",
|
|
6
6
|
"author": "imwhatiam",
|