dsh-github-router 0.1.0 → 0.2.1
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 +13 -1
- package/README.md +10 -9
- package/README.zh.md +7 -6
- package/docs/design.md +40 -34
- package/lib/client.js +156 -157
- package/lib/config.js +4 -3
- package/lib/index.js +8 -8
- package/lib/settings.js +38 -0
- package/package.json +7 -1
- package/lib/remote.js +0 -169
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,19 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.1] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Declare compatibility for dsh 0.1.1-rc.2
|
|
13
|
+
|
|
14
|
+
## [0.2.0] - 2026-08-21
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Migrated plugin configuration to the framework's plugin-settings mechanism** (requires DSH ≥ 0.1.0-rc.7, which serves every registered settings namespace and dispatches plugin-owned configuration cards). The browser half now registers a `settings.plugin.item` card keyed by the `dsh-github-router` namespace inside Settings → Plugins, bound through `ctx.settingsScope` (shared describe mirror, revision fencing, recovery reads, reconnect invalidation); the plugin-owned `/dsh-github-router/config` web routes and the standalone `settings.section` page are removed. The Host keeps the same `settings.register` seam and adds `applies: 'live'`.
|
|
19
|
+
- The token control is now a true write-only field: the configured state comes from the describe mirror's secret slot list (badge: "已配置/configured"), a typed value writes the token, and a blank save clears a configured one.
|
|
20
|
+
- Runtime requirements: the settings card needs DSH ≥ 0.1.0-rc.7; the Host-side settings seam alone still works on older compositions (tools resolve the composition config as before).
|
|
9
21
|
|
|
10
22
|
## [0.1.0] - 2026-08-19
|
|
11
23
|
|
package/README.md
CHANGED
|
@@ -76,13 +76,14 @@ Behavior notes:
|
|
|
76
76
|
|
|
77
77
|
## Configuration
|
|
78
78
|
|
|
79
|
-
Settings → **GitHub Router**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
overridden by the user are badged, and blank fields
|
|
83
|
-
defaults below. The
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
Settings → **Plugins** shows the **GitHub Router** card on the configurable
|
|
80
|
+
tab (the framework's `settings.plugin.item` card slot keyed by the settings
|
|
81
|
+
namespace; requires DSH ≥ 0.1.0-rc.7): edits are staged locally and written
|
|
82
|
+
only on save, fields overridden by the user are badged, and blank fields
|
|
83
|
+
fall back to the defaults below. The token is a write-only field — a blank
|
|
84
|
+
save clears a configured token. The same values can be set in the
|
|
85
|
+
composition (profile `cordis.patch.yml`) as the plugin's base config; the
|
|
86
|
+
Settings UI overrides per user.
|
|
86
87
|
|
|
87
88
|
| Field | Default | Meaning |
|
|
88
89
|
| --- | --- | --- |
|
|
@@ -112,8 +113,8 @@ per user.
|
|
|
112
113
|
| Path | Purpose |
|
|
113
114
|
| ---- | ------- |
|
|
114
115
|
| `cordis.patch.yml` | Profile patch layer inserting the `dsh-github-router` row |
|
|
115
|
-
| `lib/index.js` | Host plugin: settings
|
|
116
|
-
| `lib/client.js` | Browser half: the
|
|
116
|
+
| `lib/index.js` | Host plugin: settings namespace, five tools, skill, guidance |
|
|
117
|
+
| `lib/client.js` | Browser half: the settings card (hand-written factory bundle, no build step) |
|
|
117
118
|
| `lib/config.js` | Settings schema, defaults, runtime option resolution |
|
|
118
119
|
| `lib/net.js`, `lib/tunnel.js` | Route-aware HTTP layer; zero-dependency CONNECT proxy tunnel |
|
|
119
120
|
| `lib/routes/` | One module per route: `api` (GET-only REST), `gh` (CLI), `git` (protocol), `html` (page parse), `mirror` (raw mirrors) |
|
package/README.zh.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- **一次调用、结构化结果** —— PR 一次返回元数据、讨论、评审、提交、变更文件与 diff,每个部件标注来源路由;失败时返回路由矩阵,而不是十几条重试过的终端命令。
|
|
16
16
|
- **连通性探针** —— `github_probe` 一次调用报告宿主侧哪些路由存活,附耗时与推荐路由链。
|
|
17
17
|
- **零运行时依赖** —— peer 依赖由 DSH profile 解析;代理请求走插件自带的 CONNECT 隧道(不依赖第三方 HTTP 栈),因此可以完全离线安装。
|
|
18
|
-
-
|
|
18
|
+
- **框架级插件设置卡片** —— 设置 → **Plugins(插件)** 的可配置页中出现「GitHub 路由」卡片(框架的 `settings.plugin.item` 机制,按设置命名空间配对,需 DSH ≥ 0.1.0-rc.7):`dsh-github-router` 设置命名空间(secret token、代理、路由开关、缓存 TTL、字节上限),编辑暂存、保存/放弃、覆盖徽标、token 已配置徽标。
|
|
19
19
|
- **上下文效率** —— 按类别 TTL 的响应缓存、全局字节上限、带截断注记的列表上限,以及限流余量提示。
|
|
20
20
|
|
|
21
21
|
## 环境要求
|
|
@@ -78,11 +78,12 @@ github_api { path: "/repos/o/r/commits", query: { per_page: 5 } }
|
|
|
78
78
|
|
|
79
79
|
## 配置
|
|
80
80
|
|
|
81
|
-
设置 → **
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
设置 → **Plugins(插件)** → 可配置页中的「GitHub 路由」卡片(注册进
|
|
82
|
+
`settings.plugin.item` 槽、以设置命名空间为 key 的框架插件卡片,需要
|
|
83
|
+
DSH ≥ 0.1.0-rc.7):编辑内容先本地暂存、点保存才落盘,被用户覆盖的
|
|
84
|
+
字段带标记,留空字段回退到下方默认值;token 为只写字段,留空保存会
|
|
85
|
+
清除已配置的 token。同样的值也可以在组合(profile 的
|
|
86
|
+
`cordis.patch.yml`)中作为插件基础配置写入;设置 UI 按用户覆盖。
|
|
86
87
|
|
|
87
88
|
| 字段 | 默认 | 含义 |
|
|
88
89
|
| --- | --- | --- |
|
package/docs/design.md
CHANGED
|
@@ -107,44 +107,50 @@ substitute bytes. When enabled, each configured base yields two candidates
|
|
|
107
107
|
per file (raw passthrough and the `github.com` `/raw/` route), tried in
|
|
108
108
|
order.
|
|
109
109
|
|
|
110
|
-
## Settings
|
|
110
|
+
## Settings card
|
|
111
111
|
|
|
112
112
|
The Host registers the `dsh-github-router` settings namespace on the
|
|
113
113
|
official settings seam (durable document, schema validation, revision
|
|
114
|
-
fencing)
|
|
115
|
-
factory bundle with no build step) registers
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
the
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
114
|
+
fencing, `applies: 'live'`). The browser half (`lib/client.js`, a
|
|
115
|
+
hand-written ModuleLoader factory bundle with no build step) registers one
|
|
116
|
+
plugin configuration CARD into the `settings.plugin.item` slot keyed by the
|
|
117
|
+
namespace — the framework's mechanism for plugins distributed outside the
|
|
118
|
+
repository (DSH ≥ 0.1.0-rc.7): the Settings → Plugins configurable tab
|
|
119
|
+
reads which namespaces the Host serves and dispatches the intersection of
|
|
120
|
+
that ledger and the registered cards, so the card appears only when the
|
|
121
|
+
namespace is registered and served.
|
|
122
|
+
|
|
123
|
+
### Framework transport
|
|
124
|
+
|
|
125
|
+
Since DSH 0.1.0-rc.7 the api-proxy serves every registered settings
|
|
126
|
+
namespace (the earlier `WEB_SETTINGS_NAMESPACES` allowlist and its
|
|
127
|
+
`settings-not-exposed` answer retired in PR #2404), so the card binds the
|
|
128
|
+
namespace through the framework settings transport:
|
|
129
|
+
`ctx.settingsScope.bind({ namespace })` returns a `SettingsScope` whose
|
|
130
|
+
snapshot carries the redacted resolved section
|
|
131
|
+
(`status`/`value`/`base`/`user`/`revision`/`writable`). All consumers
|
|
132
|
+
derive from one shared describe mirror, so reads never block activation,
|
|
133
|
+
writes carry revision fencing and recovery re-reads, and the mirror
|
|
134
|
+
refreshes on `settings/document-updated` and `connection/reset` — a Host
|
|
135
|
+
restart no longer strands the form in a failed state. No plugin-owned HTTP
|
|
136
|
+
route exists.
|
|
137
|
+
|
|
138
|
+
### Card form
|
|
139
|
+
|
|
140
|
+
Edits are staged locally and written only on save, one field per
|
|
141
|
+
`scope.set`/`scope.unset`; after each write the card verifies the user
|
|
142
|
+
layer (JSON-shaped deep equality) and keeps drafts that did not land.
|
|
143
|
+
Secret fields never ride a response, so the token control is write-only:
|
|
144
|
+
its configured state comes from the describe mirror's secret slot list
|
|
145
|
+
(the snapshot itself redacts it from every layer), a typed value writes the
|
|
146
|
+
token through `settings.mutate` (the one direction secrets cross the wire),
|
|
147
|
+
and a blank draft clears a configured one.
|
|
148
|
+
|
|
149
|
+
Because the scope writes **scalar fields by name**, the settings schema is
|
|
150
|
+
deliberately flat (`routesApi`, `cacheTtlMeta`, …); the Host projects it
|
|
151
|
+
into the nested runtime shape (`routes.api`, `cacheTtlSeconds.meta`) in
|
|
146
152
|
`resolveOptions`. The composition layer can still carry the same flat
|
|
147
|
-
keys. The
|
|
153
|
+
keys. The card shows the common fields up top (token, proxy, main route
|
|
148
154
|
switches) and the long tail (timeouts, retries, cache TTLs, mirrors,
|
|
149
155
|
repos, git cache dir) in a collapsed "Advanced settings" disclosure.
|
|
150
156
|
|
package/lib/client.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-github-router — client half (hand-written factory bundle, no build step).
|
|
3
3
|
*
|
|
4
|
-
* One
|
|
5
|
-
* the `settings.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* correctly.
|
|
4
|
+
* One plugin configuration CARD inside the framework's Settings → Plugins →
|
|
5
|
+
* configurable tab, registered into the `settings.plugin.item` slot keyed by
|
|
6
|
+
* the settings namespace — the framework mechanism for plugins distributed
|
|
7
|
+
* outside the repository (DSH ≥ 0.1.0-rc.7): the Host registers the
|
|
8
|
+
* namespace, the browser registers the card under the same key, and the tab
|
|
9
|
+
* pairs the two. No plugin-owned HTTP route exists anymore; reads and writes
|
|
10
|
+
* ride the framework settings transport (`ctx.settingsScope`).
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The form keeps the staged-editing model: edits are staged locally and
|
|
13
|
+
* written only on save; each write goes through the bound settings scope
|
|
14
|
+
* (revision fencing, recovery reads on failure) and the card verifies the
|
|
15
|
+
* user layer afterwards, keeping drafts that did not land. Secret fields
|
|
16
|
+
* (the GitHub token) never ride a response, so the token control is
|
|
17
|
+
* write-only: it starts blank, a typed value writes the token, and a blank
|
|
18
|
+
* draft clears a configured one — the configured state comes from the
|
|
19
|
+
* describe mirror's secret slot list.
|
|
16
20
|
*
|
|
17
21
|
* The module requires only `react` and
|
|
18
22
|
* `@deepseek-ai/dsh-client-runtime/client` (the browser subpath — the bare
|
|
@@ -27,10 +31,10 @@ window.__ModuleLoader__.load({
|
|
|
27
31
|
var React = require('react');
|
|
28
32
|
var runtime = require('@deepseek-ai/dsh-client-runtime/client');
|
|
29
33
|
|
|
34
|
+
var NAMESPACE = 'dsh-github-router';
|
|
35
|
+
var NS = 'dsh-github-router';
|
|
36
|
+
|
|
30
37
|
// ------------------------------------------------------------ specs
|
|
31
|
-
// Flat scalar fields only: the client settings scope writes one field
|
|
32
|
-
// per call (`scope.set(field, value)`), so nested schema objects are
|
|
33
|
-
// deliberately absent from this page.
|
|
34
38
|
function textSpec(field) {
|
|
35
39
|
return {
|
|
36
40
|
field: field,
|
|
@@ -83,7 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
83
87
|
|
|
84
88
|
// ------------------------------------------------------ field catalog
|
|
85
89
|
var PRIMARY_FIELDS = [
|
|
86
|
-
{ field: 'token', kind: '
|
|
90
|
+
{ field: 'token', kind: 'secret', spec: textSpec('token'), labelKey: 'field.token', hintKey: 'hint.token' },
|
|
87
91
|
{ field: 'tokenEnv', kind: 'text', spec: textSpec('tokenEnv'), labelKey: 'field.tokenEnv', hintKey: 'hint.tokenEnv' },
|
|
88
92
|
{ field: 'proxy', kind: 'text', spec: textSpec('proxy'), labelKey: 'field.proxy', hintKey: 'hint.proxy' },
|
|
89
93
|
{ field: 'routesApi', kind: 'bool', spec: boolSpec('routesApi'), labelKey: 'field.routesApi', hintKey: null },
|
|
@@ -108,12 +112,10 @@ window.__ModuleLoader__.load({
|
|
|
108
112
|
FIELDS.forEach(function (f) { SPECS[f.field] = f.spec; });
|
|
109
113
|
|
|
110
114
|
// ------------------------------------------------------------- i18n
|
|
111
|
-
var NS = 'dsh-github-router';
|
|
112
115
|
var zhDict = {
|
|
113
116
|
'nav': 'GitHub 路由',
|
|
114
117
|
'description': 'GitHub 读取的路由与缓存配置。',
|
|
115
118
|
'advanced': '高级设置',
|
|
116
|
-
'unavailable': '无法从宿主加载配置。',
|
|
117
119
|
'save': '保存',
|
|
118
120
|
'saving': '保存中…',
|
|
119
121
|
'discard': '放弃修改',
|
|
@@ -121,6 +123,7 @@ window.__ModuleLoader__.load({
|
|
|
121
123
|
'readOnly': '当前部署不允许修改设置。',
|
|
122
124
|
'invalid': '无效值',
|
|
123
125
|
'overridden': '已覆盖',
|
|
126
|
+
'configured': '已配置',
|
|
124
127
|
'field.token': 'GitHub Token',
|
|
125
128
|
'field.tokenEnv': 'Token 环境变量',
|
|
126
129
|
'field.proxy': '代理地址',
|
|
@@ -138,7 +141,7 @@ window.__ModuleLoader__.load({
|
|
|
138
141
|
'field.mirrors': '镜像基址(逗号分隔)',
|
|
139
142
|
'field.repos': '本地仓库白名单(逗号分隔)',
|
|
140
143
|
'field.gitCacheDir': 'git 缓存目录',
|
|
141
|
-
'hint.token': '
|
|
144
|
+
'hint.token': '仅写入:留空并保存 = 清除已配置的 token;优先使用 tokenEnv。',
|
|
142
145
|
'hint.tokenEnv': '指向 token 的环境变量名,例如 GITHUB_TOKEN。',
|
|
143
146
|
'hint.proxy': '空 = 继承环境代理;direct = 永不代理;其余为代理 URL。',
|
|
144
147
|
'hint.maxBytes': '每个响应体的字节上限(16384..8388608)。',
|
|
@@ -153,7 +156,6 @@ window.__ModuleLoader__.load({
|
|
|
153
156
|
'nav': 'GitHub Router',
|
|
154
157
|
'description': 'Routing and cache configuration for GitHub reads.',
|
|
155
158
|
'advanced': 'Advanced settings',
|
|
156
|
-
'unavailable': 'The configuration could not be loaded from the host.',
|
|
157
159
|
'save': 'Save',
|
|
158
160
|
'saving': 'Saving…',
|
|
159
161
|
'discard': 'Discard',
|
|
@@ -161,6 +163,7 @@ window.__ModuleLoader__.load({
|
|
|
161
163
|
'readOnly': 'This deployment does not allow settings edits.',
|
|
162
164
|
'invalid': 'invalid',
|
|
163
165
|
'overridden': 'overridden',
|
|
166
|
+
'configured': 'configured',
|
|
164
167
|
'field.token': 'GitHub token',
|
|
165
168
|
'field.tokenEnv': 'Token env var',
|
|
166
169
|
'field.proxy': 'Proxy URL',
|
|
@@ -178,7 +181,7 @@ window.__ModuleLoader__.load({
|
|
|
178
181
|
'field.mirrors': 'Mirror bases (comma-separated)',
|
|
179
182
|
'field.repos': 'Local repo allowlist (comma-separated)',
|
|
180
183
|
'field.gitCacheDir': 'Git cache directory',
|
|
181
|
-
'hint.token': '
|
|
184
|
+
'hint.token': 'Write-only: a blank save clears the configured token; prefer tokenEnv.',
|
|
182
185
|
'hint.tokenEnv': 'Environment variable naming the token, e.g. GITHUB_TOKEN.',
|
|
183
186
|
'hint.proxy': "Empty = inherit ambient proxy env; 'direct' = never proxy; anything else is a proxy URL.",
|
|
184
187
|
'hint.maxBytes': 'Byte cap per response body (16384..8388608).',
|
|
@@ -191,100 +194,22 @@ window.__ModuleLoader__.load({
|
|
|
191
194
|
};
|
|
192
195
|
|
|
193
196
|
// ------------------------------------------------------------- form
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
* uses plain same-origin fetch (GET = redacted view, POST = writes).
|
|
198
|
-
* The framework's settings scope is NOT used — its api-proxy serves
|
|
199
|
-
* settings namespaces through a hardcoded allowlist that third-party
|
|
200
|
-
* plugins cannot join.
|
|
201
|
-
*/
|
|
202
|
-
|
|
203
|
-
/** One `/dsh-github-router/config` call (GET or POST). */
|
|
204
|
-
function configCall(payload) {
|
|
205
|
-
var init = payload === undefined
|
|
206
|
-
? { method: 'GET', cache: 'no-store' }
|
|
207
|
-
: {
|
|
208
|
-
method: 'POST',
|
|
209
|
-
cache: 'no-store',
|
|
210
|
-
headers: { 'content-type': 'application/json' },
|
|
211
|
-
body: JSON.stringify(payload),
|
|
212
|
-
};
|
|
213
|
-
return fetch('/dsh-github-router/config', init).then(function (response) {
|
|
214
|
-
return response.json().then(function (envelope) {
|
|
215
|
-
if (!envelope || envelope.ok !== true) {
|
|
216
|
-
var message = envelope && envelope.error !== undefined ? String(envelope.error) : 'config call failed (HTTP ' + response.status + ')';
|
|
217
|
-
throw new Error('dsh-github-router: ' + message);
|
|
218
|
-
}
|
|
219
|
-
return envelope.value;
|
|
220
|
-
});
|
|
221
|
-
});
|
|
197
|
+
/** JSON-shaped deep equality for verifying writes against the user layer. */
|
|
198
|
+
function sameJson(a, b) {
|
|
199
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
222
200
|
}
|
|
223
201
|
|
|
224
|
-
/**
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
value: view && typeof view.value === 'object' ? view.value : {},
|
|
236
|
-
base: view && typeof view.base === 'object' ? view.base : {},
|
|
237
|
-
user: view && typeof view.user === 'object' ? view.user : {},
|
|
238
|
-
revision: view && view.revision,
|
|
239
|
-
writable: !!(view && view.writable),
|
|
240
|
-
mode: 'host',
|
|
241
|
-
};
|
|
242
|
-
publish();
|
|
243
|
-
}
|
|
244
|
-
function fail(message) {
|
|
245
|
-
snapshot = { status: 'unavailable', value: {}, base: {}, user: {}, revision: undefined, writable: false, mode: 'memory' };
|
|
246
|
-
console.error('[dsh-github-router] config load failed', message);
|
|
247
|
-
publish();
|
|
248
|
-
}
|
|
249
|
-
function load() {
|
|
250
|
-
return configCall().then(accept, fail);
|
|
251
|
-
}
|
|
252
|
-
function write(op) {
|
|
253
|
-
return configCall({ ops: [op], expectedRevision: snapshot.revision }).then(accept, function (error) {
|
|
254
|
-
// A rejected write may mean the document moved (revision conflict)
|
|
255
|
-
// or storage failed — re-read so the next save fences correctly.
|
|
256
|
-
return load().then(function () { throw error; });
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
load();
|
|
261
|
-
return {
|
|
262
|
-
getSnapshot: function () { return snapshot; },
|
|
263
|
-
subscribe: function (fn) {
|
|
264
|
-
listeners.add(fn);
|
|
265
|
-
return function () { listeners.delete(fn); };
|
|
266
|
-
},
|
|
267
|
-
set: function (field, value) {
|
|
268
|
-
return write({ op: 'set', path: [field], value: value });
|
|
269
|
-
},
|
|
270
|
-
unset: function (field) {
|
|
271
|
-
return write({ op: 'unset', path: [field] });
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** A static scope stub for environments without fetch. */
|
|
277
|
-
function unavailableScope() {
|
|
278
|
-
var snapshot = { status: 'unavailable', value: {}, base: {}, user: {}, revision: undefined, writable: false, mode: 'memory' };
|
|
279
|
-
return {
|
|
280
|
-
getSnapshot: function () { return snapshot; },
|
|
281
|
-
subscribe: function () { return function () {}; },
|
|
282
|
-
set: function () { return Promise.resolve(); },
|
|
283
|
-
unset: function () { return Promise.resolve(); },
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function GithubRouterForm(scope) {
|
|
202
|
+
/**
|
|
203
|
+
* Stages one card's edits over the plugin's settings namespace and writes
|
|
204
|
+
* them on save. The scope is the framework-bound settings scope
|
|
205
|
+
* (`ctx.settingsScope.bind({ namespace })`): its snapshot carries the
|
|
206
|
+
* redacted resolved section (`status`/`value`/`base`/`user`/`revision`/
|
|
207
|
+
* `writable`), and `set`/`unset` write one field each with revision
|
|
208
|
+
* fencing and recovery reads. The describe mirror supplements the
|
|
209
|
+
* snapshot with the secret slot list, the only signal that a redacted
|
|
210
|
+
* field (the token) is configured.
|
|
211
|
+
*/
|
|
212
|
+
function GithubRouterForm(scope, mirror) {
|
|
288
213
|
var staged = new Map(); // field -> { text, clear }
|
|
289
214
|
var saving = false;
|
|
290
215
|
var failed = false;
|
|
@@ -311,6 +236,17 @@ window.__ModuleLoader__.load({
|
|
|
311
236
|
var s = snap();
|
|
312
237
|
return !!(s.user && typeof s.user === 'object' && Object.prototype.hasOwnProperty.call(s.user, field));
|
|
313
238
|
}
|
|
239
|
+
/** Whether a redacted secret slot holds a value (from the describe mirror). */
|
|
240
|
+
function secretSet(field) {
|
|
241
|
+
if (mirror === null || typeof mirror.namespace !== 'function') return false;
|
|
242
|
+
var row = mirror.namespace(NAMESPACE);
|
|
243
|
+
if (row === undefined || !Array.isArray(row.secrets)) return false;
|
|
244
|
+
for (var i = 0; i < row.secrets.length; i++) {
|
|
245
|
+
var slot = row.secrets[i];
|
|
246
|
+
if (Array.isArray(slot.path) && slot.path.length === 1 && slot.path[0] === field) return slot.set === true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
314
250
|
|
|
315
251
|
function publish() {
|
|
316
252
|
var s = snap();
|
|
@@ -323,14 +259,26 @@ window.__ModuleLoader__.load({
|
|
|
323
259
|
var invalidField = false;
|
|
324
260
|
if (stagedEntry !== undefined) {
|
|
325
261
|
text = stagedEntry.text;
|
|
326
|
-
|
|
327
|
-
|
|
262
|
+
if (stagedEntry.clear) {
|
|
263
|
+
overridden = false;
|
|
264
|
+
} else {
|
|
265
|
+
var parsedStaged = f.spec.parse(stagedEntry.text);
|
|
266
|
+
// The badge previews the save outcome: a staged blank that
|
|
267
|
+
// parses to a clear is not an override anymore.
|
|
268
|
+
overridden = parsedStaged !== undefined && parsedStaged.kind === 'set';
|
|
269
|
+
invalidField = parsedStaged === undefined;
|
|
270
|
+
}
|
|
328
271
|
} else {
|
|
329
|
-
text = f.spec.format(sectionValue(f.field));
|
|
330
|
-
overridden = stored(f.field);
|
|
272
|
+
text = f.kind === 'secret' ? '' : f.spec.format(sectionValue(f.field));
|
|
273
|
+
overridden = f.kind === 'secret' ? secretSet(f.field) : stored(f.field);
|
|
331
274
|
}
|
|
332
275
|
if (invalidField) invalid = true;
|
|
333
|
-
fields[f.field] = {
|
|
276
|
+
fields[f.field] = {
|
|
277
|
+
text: text,
|
|
278
|
+
overridden: overridden,
|
|
279
|
+
invalid: invalidField,
|
|
280
|
+
configured: f.kind === 'secret' && stagedEntry === undefined ? secretSet(f.field) : undefined,
|
|
281
|
+
};
|
|
334
282
|
});
|
|
335
283
|
store.set({
|
|
336
284
|
available: s.status === 'ready',
|
|
@@ -343,6 +291,7 @@ window.__ModuleLoader__.load({
|
|
|
343
291
|
});
|
|
344
292
|
}
|
|
345
293
|
scope.subscribe(publish);
|
|
294
|
+
if (mirror !== null && typeof mirror.subscribe === 'function') mirror.subscribe(publish);
|
|
346
295
|
publish();
|
|
347
296
|
|
|
348
297
|
function edit(field, text) {
|
|
@@ -368,9 +317,42 @@ window.__ModuleLoader__.load({
|
|
|
368
317
|
staged.forEach(function (stagedEntry, field) {
|
|
369
318
|
var spec = SPECS[field];
|
|
370
319
|
if (stagedEntry.clear) {
|
|
320
|
+
if (field === 'token') {
|
|
321
|
+
// A blank draft clears the token only when one is configured.
|
|
322
|
+
if (secretSet(field)) {
|
|
323
|
+
writes.push({
|
|
324
|
+
field: field,
|
|
325
|
+
run: function () { return scope.unset(field).then(function () { return !secretSet(field); }); },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
371
330
|
if (stored(field)) {
|
|
372
|
-
writes.push({
|
|
331
|
+
writes.push({
|
|
332
|
+
field: field,
|
|
333
|
+
run: function () { return scope.unset(field).then(function () { return !stored(field); }); },
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (field === 'token') {
|
|
339
|
+
var tokenText = stagedEntry.text.trim();
|
|
340
|
+
if (tokenText === '') {
|
|
341
|
+
// A blank draft clears the token when one is configured.
|
|
342
|
+
if (secretSet(field)) {
|
|
343
|
+
writes.push({
|
|
344
|
+
field: field,
|
|
345
|
+
run: function () { return scope.unset(field).then(function () { return !secretSet(field); }); },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
373
349
|
}
|
|
350
|
+
writes.push({
|
|
351
|
+
field: field,
|
|
352
|
+
run: (function (value) {
|
|
353
|
+
return function () { return scope.set(field, value).then(function () { return secretSet(field); }); };
|
|
354
|
+
})(tokenText),
|
|
355
|
+
});
|
|
374
356
|
return;
|
|
375
357
|
}
|
|
376
358
|
if (stagedEntry.text === spec.format(sectionValue(field))) return;
|
|
@@ -380,9 +362,22 @@ window.__ModuleLoader__.load({
|
|
|
380
362
|
return;
|
|
381
363
|
}
|
|
382
364
|
if (write.kind === 'clear') {
|
|
383
|
-
writes.push({
|
|
365
|
+
writes.push({
|
|
366
|
+
field: field,
|
|
367
|
+
run: function () { return scope.unset(field).then(function () { return !stored(field); }); },
|
|
368
|
+
});
|
|
384
369
|
} else {
|
|
385
|
-
writes.push({
|
|
370
|
+
writes.push({
|
|
371
|
+
field: field,
|
|
372
|
+
run: (function (value) {
|
|
373
|
+
return function () {
|
|
374
|
+
return scope.set(field, value).then(function () {
|
|
375
|
+
var current = snap().user;
|
|
376
|
+
return !!(current && typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, field) && sameJson(current[field], value));
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
})(write.value),
|
|
380
|
+
});
|
|
386
381
|
}
|
|
387
382
|
});
|
|
388
383
|
if (blocked || writes.length === 0) return;
|
|
@@ -393,7 +388,7 @@ window.__ModuleLoader__.load({
|
|
|
393
388
|
var chain = Promise.resolve();
|
|
394
389
|
writes.forEach(function (w) {
|
|
395
390
|
chain = chain.then(function () {
|
|
396
|
-
return w.run().
|
|
391
|
+
return w.run().then(function (ok) { if (ok !== true) landed = false; }, function () { landed = false; });
|
|
397
392
|
});
|
|
398
393
|
});
|
|
399
394
|
chain.then(function () {
|
|
@@ -406,9 +401,9 @@ window.__ModuleLoader__.load({
|
|
|
406
401
|
|
|
407
402
|
function inject(t) {
|
|
408
403
|
return {
|
|
409
|
-
// The
|
|
410
|
-
//
|
|
411
|
-
// `
|
|
404
|
+
// The slots runtime synthesizes a `use<Key>` hook for every entry
|
|
405
|
+
// of this `hooks` object (the shipped plugin cards' `hooks` →
|
|
406
|
+
// `useBashCard` contract).
|
|
412
407
|
hooks: { settings: store },
|
|
413
408
|
t: t,
|
|
414
409
|
edit: edit,
|
|
@@ -420,8 +415,8 @@ window.__ModuleLoader__.load({
|
|
|
420
415
|
return { inject: inject, store: store, save: save, edit: edit, discard: discard, resetField: resetField };
|
|
421
416
|
}
|
|
422
417
|
|
|
423
|
-
// -------------------------------------------------------------
|
|
424
|
-
var
|
|
418
|
+
// ------------------------------------------------------------- card
|
|
419
|
+
var cardStyle = { padding: '4px 0 16px' };
|
|
425
420
|
var gridStyle = {
|
|
426
421
|
display: 'grid',
|
|
427
422
|
gridTemplateColumns: 'minmax(180px, 1fr) minmax(180px, 1fr)',
|
|
@@ -447,12 +442,16 @@ window.__ModuleLoader__.load({
|
|
|
447
442
|
cursor: 'pointer',
|
|
448
443
|
};
|
|
449
444
|
|
|
450
|
-
function
|
|
445
|
+
function GithubRouterCard(props) {
|
|
451
446
|
var state = props.useSettings(function (s) { return s; });
|
|
452
447
|
var t = props.t;
|
|
453
448
|
|
|
449
|
+
// The framework cards render nothing until their namespace is served;
|
|
450
|
+
// this card follows the same posture.
|
|
451
|
+
if (!state.available) return null;
|
|
452
|
+
|
|
454
453
|
function renderField(f) {
|
|
455
|
-
var fieldState = state.fields[f.field] || { text: '', overridden: false, invalid: false };
|
|
454
|
+
var fieldState = state.fields[f.field] || { text: '', overridden: false, invalid: false, configured: false };
|
|
456
455
|
if (f.kind === 'bool') {
|
|
457
456
|
return React.createElement(
|
|
458
457
|
'label',
|
|
@@ -466,15 +465,22 @@ window.__ModuleLoader__.load({
|
|
|
466
465
|
t(f.labelKey),
|
|
467
466
|
);
|
|
468
467
|
}
|
|
468
|
+
var marker;
|
|
469
|
+
if (f.kind === 'secret') {
|
|
470
|
+
marker = fieldState.configured === true
|
|
471
|
+
? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('configured'))
|
|
472
|
+
: null;
|
|
473
|
+
} else {
|
|
474
|
+
marker = fieldState.overridden
|
|
475
|
+
? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('overridden'))
|
|
476
|
+
: null;
|
|
477
|
+
}
|
|
469
478
|
return React.createElement(
|
|
470
479
|
'label',
|
|
471
480
|
{ key: f.field, style: rowStyle, title: f.hintKey ? t(f.hintKey) : undefined },
|
|
472
|
-
React.createElement('span', { style: labelStyle },
|
|
473
|
-
t(f.labelKey),
|
|
474
|
-
fieldState.overridden ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('overridden')) : null,
|
|
475
|
-
),
|
|
481
|
+
React.createElement('span', { style: labelStyle }, t(f.labelKey), marker),
|
|
476
482
|
React.createElement('input', {
|
|
477
|
-
type: f.kind === 'number' ? 'number' : 'text',
|
|
483
|
+
type: f.kind === 'number' ? 'number' : f.kind === 'secret' ? 'password' : 'text',
|
|
478
484
|
value: fieldState.text,
|
|
479
485
|
disabled: !state.writable,
|
|
480
486
|
style: inputStyle,
|
|
@@ -486,18 +492,17 @@ window.__ModuleLoader__.load({
|
|
|
486
492
|
|
|
487
493
|
return React.createElement(
|
|
488
494
|
'section',
|
|
489
|
-
{ style:
|
|
495
|
+
{ style: cardStyle },
|
|
490
496
|
React.createElement('p', { style: { margin: '0 0 12px', opacity: 0.75, fontSize: '13px' } }, t('description')),
|
|
491
|
-
!state.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
state.available ? React.createElement(
|
|
497
|
+
!state.writable ? React.createElement('div', { style: { color: '#c00', fontSize: '13px', marginBottom: '8px' } }, t('readOnly')) : null,
|
|
498
|
+
React.createElement('div', { style: gridStyle }, PRIMARY_FIELDS.map(renderField)),
|
|
499
|
+
React.createElement(
|
|
495
500
|
'details',
|
|
496
501
|
{ style: { marginTop: '12px' } },
|
|
497
502
|
React.createElement('summary', { style: { cursor: 'pointer', fontSize: '13px', opacity: 0.85 } }, t('advanced')),
|
|
498
503
|
React.createElement('div', { style: Object.assign({ marginTop: '8px' }, gridStyle) }, ADVANCED_FIELDS.map(renderField)),
|
|
499
|
-
)
|
|
500
|
-
|
|
504
|
+
),
|
|
505
|
+
React.createElement('footer', { style: footerStyle },
|
|
501
506
|
React.createElement('button', {
|
|
502
507
|
style: buttonStyle,
|
|
503
508
|
disabled: !state.dirty || state.invalid || state.saving,
|
|
@@ -509,13 +514,13 @@ window.__ModuleLoader__.load({
|
|
|
509
514
|
onClick: props.discard,
|
|
510
515
|
}, t('discard')),
|
|
511
516
|
state.failed ? React.createElement('span', { style: { color: '#c00', fontSize: '13px' } }, t('failed')) : null,
|
|
512
|
-
)
|
|
517
|
+
),
|
|
513
518
|
);
|
|
514
519
|
}
|
|
515
520
|
|
|
516
521
|
// ------------------------------------------------------------- plugin
|
|
517
522
|
var name = 'dsh-github-router';
|
|
518
|
-
var inject = ['slots', 'locale'];
|
|
523
|
+
var inject = ['slots', 'locale', 'settingsScope', 'connection'];
|
|
519
524
|
function apply(ctx) {
|
|
520
525
|
console.info('[dsh-github-router] client bundle loaded');
|
|
521
526
|
try {
|
|
@@ -523,24 +528,18 @@ window.__ModuleLoader__.load({
|
|
|
523
528
|
ctx.effect(function () {
|
|
524
529
|
ctx.locale.register(NS, { zh: zhDict, en: enDict });
|
|
525
530
|
}, 'dsh-github-router: locale dictionaries');
|
|
526
|
-
var
|
|
527
|
-
ctx.
|
|
531
|
+
var scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
|
|
532
|
+
var mirror = typeof ctx.settingsScope.describe === 'function' ? ctx.settingsScope.describe() : null;
|
|
533
|
+
var form = new GithubRouterForm(scope, mirror);
|
|
534
|
+
ctx.slots.inject('settings.plugin.item', function* () {
|
|
528
535
|
yield ctx.slots.register({
|
|
529
|
-
name: 'settings.
|
|
530
|
-
|
|
531
|
-
order: 65,
|
|
532
|
-
label: function () { return t('nav'); },
|
|
536
|
+
name: 'settings.plugin.item',
|
|
537
|
+
key: NAMESPACE,
|
|
533
538
|
locale: NS,
|
|
534
|
-
inject: function () {
|
|
535
|
-
|
|
536
|
-
var scope = typeof fetch === 'function' ? new RemoteScope() : unavailableScope();
|
|
537
|
-
form = new GithubRouterForm(scope);
|
|
538
|
-
}
|
|
539
|
-
return form.inject(t);
|
|
540
|
-
},
|
|
541
|
-
}, GithubRouterSection);
|
|
539
|
+
inject: function () { return form.inject(t); },
|
|
540
|
+
}, GithubRouterCard);
|
|
542
541
|
});
|
|
543
|
-
console.info('[dsh-github-router] settings
|
|
542
|
+
console.info('[dsh-github-router] settings card registered');
|
|
544
543
|
} catch (error) {
|
|
545
544
|
console.error('[dsh-github-router] client apply failed', error);
|
|
546
545
|
}
|
package/lib/config.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Settings schema and runtime-option resolution for dsh-github-router.
|
|
3
3
|
*
|
|
4
4
|
* The settings section (`dsh-github-router` namespace) is registered through
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* the official settings seam (`ctx.settings.register`, see `lib/settings.js`);
|
|
6
|
+
* the Settings UI renders a plugin card for it (Settings → Plugins, DSH ≥
|
|
7
|
+
* 0.1.0-rc.7). Secrets are declared with `.role('secret')` so they are
|
|
8
|
+
* redacted on every wire boundary and rendered as write-only inputs.
|
|
8
9
|
*
|
|
9
10
|
* The schema is deliberately FLAT: the client settings scope writes scalar
|
|
10
11
|
* fields by name (`scope.set(field, value)`), so nested objects cannot be
|
package/lib/index.js
CHANGED
|
@@ -19,14 +19,15 @@
|
|
|
19
19
|
* 4. Plugin-owned state (fetch cache, response cache) lives under
|
|
20
20
|
* <DSH_HOME>/storages/dsh-github-router; user repositories are only ever
|
|
21
21
|
* read (log/diff/show) and only when explicitly granted.
|
|
22
|
-
* 5. Configuration rides the official settings seam
|
|
23
|
-
* namespace: durable document, schema validation,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* 5. Configuration rides the official settings seam end to end
|
|
23
|
+
* (`dsh-github-router` namespace: durable document, schema validation,
|
|
24
|
+
* revision fencing). Since DSH 0.1.0-rc.7 the framework serves every
|
|
25
|
+
* registered settings namespace to the browser and renders the plugin's
|
|
26
|
+
* configuration card in Settings → Plugins, so no plugin-owned HTTP
|
|
27
|
+
* route exists.
|
|
27
28
|
* @module dsh-github-router
|
|
28
29
|
*/
|
|
29
|
-
import {
|
|
30
|
+
import { installSettings } from './settings.js'
|
|
30
31
|
import { registerGuidance } from './guidance.js'
|
|
31
32
|
import { registerSkill } from './skill.js'
|
|
32
33
|
import { registerApiTool } from './tools/api.js'
|
|
@@ -39,8 +40,7 @@ export const name = 'dsh-github-router'
|
|
|
39
40
|
export const inject = ['tools', 'subprocess', 'skills', 'systemPrompt']
|
|
40
41
|
|
|
41
42
|
export function apply(ctx, config = {}) {
|
|
42
|
-
const
|
|
43
|
-
const options = remote.options
|
|
43
|
+
const options = installSettings(ctx, config).options
|
|
44
44
|
|
|
45
45
|
registerSkill(ctx)
|
|
46
46
|
registerGuidance(ctx)
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side settings wiring for dsh-github-router.
|
|
3
|
+
*
|
|
4
|
+
* The `dsh-github-router` namespace rides the official settings seam
|
|
5
|
+
* (`ctx.settings.register`): the framework persists the section in the user
|
|
6
|
+
* settings document, validates it against the schema, fences writes with
|
|
7
|
+
* revisions, and redacts secret-role fields on every wire boundary.
|
|
8
|
+
*
|
|
9
|
+
* Since DSH 0.1.0-rc.7 the api-proxy serves every registered settings
|
|
10
|
+
* namespace to the (loopback) browser and the Settings → Plugins section
|
|
11
|
+
* dispatches a plugin-owned card for it, so the client half binds the same
|
|
12
|
+
* namespace through `ctx.settingsScope` and the plugin needs no HTTP routes
|
|
13
|
+
* of its own. This module only registers the namespace and exposes the
|
|
14
|
+
* runtime-options thunk the tools consume; a settings change therefore
|
|
15
|
+
* applies to the NEXT tool call.
|
|
16
|
+
* @module dsh-github-router/settings
|
|
17
|
+
*/
|
|
18
|
+
import { Config, NAMESPACE, resolveOptions } from './config.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register the settings namespace and return the runtime-options thunk.
|
|
22
|
+
* @param ctx - the plugin context.
|
|
23
|
+
* @param config - the composition-layer config subset (the `base` layer).
|
|
24
|
+
*/
|
|
25
|
+
export function installSettings(ctx, config) {
|
|
26
|
+
const holder = { scope: null }
|
|
27
|
+
|
|
28
|
+
ctx.inject(['settings'], (sctx) => {
|
|
29
|
+
holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config, applies: 'live' })
|
|
30
|
+
sctx.effect(() => () => {
|
|
31
|
+
holder.scope = null
|
|
32
|
+
}, 'dsh-github-router: settings scope teardown')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
|
|
37
|
+
}
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-github-router",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin: read-only GitHub access for agents (github_probe / github_pr / github_issue / github_file / github_api). Routes every request inside the tool — api.github.com (direct or proxy), gh CLI, git protocol (plugin-owned fetch cache plus read-only local repo reads), PR/issue page HTML with strict JSON embeddedData extraction, and optional user-configured raw mirrors — so agents never burn turns fighting sandbox TLS/proxy failures in a shell. No write/push capability exists anywhere in the plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -46,6 +46,12 @@
|
|
|
46
46
|
"docs"
|
|
47
47
|
],
|
|
48
48
|
"dsh": {
|
|
49
|
+
"compatibility": {
|
|
50
|
+
"node": ">=20",
|
|
51
|
+
"dshReleases": {
|
|
52
|
+
"0.1.1-rc.2": "compatible"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
49
55
|
"bundle": {
|
|
50
56
|
"patch": "./cordis.patch.yml"
|
|
51
57
|
},
|
package/lib/remote.js
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-side configuration routes for the browser settings page.
|
|
3
|
-
*
|
|
4
|
-
* The channel is the dshmarket pattern: the plugin mounts its own HTTP
|
|
5
|
-
* routes on the shared web server (`ctx.webServer.register`, prefix
|
|
6
|
-
* `/dsh-github-router`) and the browser page talks to them with plain
|
|
7
|
-
* same-origin fetch — no typert, no gateway, no framework allowlist. The
|
|
8
|
-
* routes are backed by the SAME official settings seam: the namespace
|
|
9
|
-
* stays durable in the settings document, with schema validation,
|
|
10
|
-
* revision fencing, and secret redaction.
|
|
11
|
-
*
|
|
12
|
-
* Mutating requests are POST-only, same-origin-checked (Origin === Host)
|
|
13
|
-
* and body-capped, mirroring dsh-market's route security posture.
|
|
14
|
-
* @module dsh-github-router/remote
|
|
15
|
-
*/
|
|
16
|
-
import { Config, NAMESPACE, resolveOptions } from './config.js'
|
|
17
|
-
|
|
18
|
-
const ROUTE_PREFIX = '/dsh-github-router'
|
|
19
|
-
const MAX_BODY_BYTES = 16384
|
|
20
|
-
|
|
21
|
-
function sendJson(response, status, payload) {
|
|
22
|
-
response.writeHead(status, {
|
|
23
|
-
'cache-control': 'no-store',
|
|
24
|
-
'content-type': 'application/json; charset=utf-8',
|
|
25
|
-
})
|
|
26
|
-
response.end(JSON.stringify(payload))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Same-origin enforcement for mutating routes (dsh-market posture). */
|
|
30
|
-
function sameOrigin(request) {
|
|
31
|
-
const origin = request.headers.origin
|
|
32
|
-
const host = request.headers.host
|
|
33
|
-
if (origin === undefined || host === undefined) return false
|
|
34
|
-
try {
|
|
35
|
-
return new URL(origin).host === host
|
|
36
|
-
} catch {
|
|
37
|
-
return false
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Read and parse a JSON request body with a hard byte cap. */
|
|
42
|
-
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
43
|
-
const chunks = []
|
|
44
|
-
let size = 0
|
|
45
|
-
for await (const chunk of request) {
|
|
46
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
47
|
-
size += buffer.length
|
|
48
|
-
if (size > maxBytes) throw new Error('request body too large')
|
|
49
|
-
chunks.push(buffer)
|
|
50
|
-
}
|
|
51
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Install the settings-namespace registration, the options thunk, and the
|
|
56
|
-
* `/dsh-github-router/config` routes in one wiring. Returns the
|
|
57
|
-
* runtime-options thunk the tools consume.
|
|
58
|
-
*/
|
|
59
|
-
export function installRemote(ctx, config) {
|
|
60
|
-
const holder = { scope: null, settings: null }
|
|
61
|
-
|
|
62
|
-
ctx.inject(['settings'], (sctx) => {
|
|
63
|
-
holder.settings = sctx.settings
|
|
64
|
-
holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config })
|
|
65
|
-
sctx.effect(() => () => {
|
|
66
|
-
holder.scope = null
|
|
67
|
-
holder.settings = null
|
|
68
|
-
}, 'dsh-github-router: settings scope teardown')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
/** Redacted wire view of this namespace (value/base/user/revision/writable). */
|
|
72
|
-
function viewOf() {
|
|
73
|
-
if (holder.scope === null || holder.settings === null) return null
|
|
74
|
-
const descriptors = holder.settings.describe({ redactSecrets: true })
|
|
75
|
-
const descriptor = descriptors.find((d) => String(d.ns) === NAMESPACE)
|
|
76
|
-
if (descriptor === undefined) return null
|
|
77
|
-
return {
|
|
78
|
-
value: descriptor.value ?? {},
|
|
79
|
-
base: descriptor.base ?? {},
|
|
80
|
-
user: descriptor.user ?? {},
|
|
81
|
-
revision: descriptor.revision,
|
|
82
|
-
writable: holder.settings.writable === true,
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function validateOps(payload) {
|
|
87
|
-
if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.ops) || payload.ops.length === 0) {
|
|
88
|
-
return 'payload.ops must be a non-empty array'
|
|
89
|
-
}
|
|
90
|
-
if (payload.ops.length > 40) return 'too many ops in one write'
|
|
91
|
-
for (const op of payload.ops) {
|
|
92
|
-
if (op === null || typeof op !== 'object' || (op.op !== 'set' && op.op !== 'unset')) {
|
|
93
|
-
return 'each op must be { op: "set" | "unset", path: [field] }'
|
|
94
|
-
}
|
|
95
|
-
if (!Array.isArray(op.path) || op.path.length !== 1 || typeof op.path[0] !== 'string' || op.path[0].length === 0 || op.path[0].length > 64) {
|
|
96
|
-
return 'op.path must be a single field name'
|
|
97
|
-
}
|
|
98
|
-
if (op.op === 'set' && op.value === undefined) return 'set ops require a value'
|
|
99
|
-
}
|
|
100
|
-
return null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const routeHandler = async (request, response) => {
|
|
104
|
-
const pathname = new URL(request.url ?? '/', 'http://x').pathname
|
|
105
|
-
if (pathname !== ROUTE_PREFIX + '/config') {
|
|
106
|
-
sendJson(response, 404, { ok: false, error: 'unknown dsh-github-router route' })
|
|
107
|
-
return
|
|
108
|
-
}
|
|
109
|
-
if (request.method === 'GET') {
|
|
110
|
-
if (holder.scope === null || holder.settings === null) {
|
|
111
|
-
sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
const view = viewOf()
|
|
115
|
-
if (view === null) {
|
|
116
|
-
sendJson(response, 503, { ok: false, error: 'settings namespace is not registered' })
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
sendJson(response, 200, { ok: true, value: view })
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
if (request.method === 'POST') {
|
|
123
|
-
if (!sameOrigin(request)) {
|
|
124
|
-
sendJson(response, 403, { ok: false, error: 'configuration writes are limited to same-origin requests' })
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
if (holder.scope === null || holder.settings === null) {
|
|
128
|
-
sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
let payload
|
|
132
|
-
try {
|
|
133
|
-
payload = await readJsonBody(request)
|
|
134
|
-
} catch (error) {
|
|
135
|
-
sendJson(response, 400, { ok: false, error: `unreadable request body: ${String(error && error.message ? error.message : error)}` })
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
const invalid = validateOps(payload)
|
|
139
|
-
if (invalid !== null) {
|
|
140
|
-
sendJson(response, 400, { ok: false, error: invalid })
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
const expectedRevision = Number.isInteger(payload.expectedRevision) ? payload.expectedRevision : undefined
|
|
144
|
-
try {
|
|
145
|
-
await holder.scope.mutate(payload.ops, expectedRevision)
|
|
146
|
-
} catch (error) {
|
|
147
|
-
sendJson(response, 409, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
const view = viewOf()
|
|
151
|
-
if (view === null) {
|
|
152
|
-
sendJson(response, 503, { ok: false, error: 'settings namespace disappeared after the write' })
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
sendJson(response, 200, { ok: true, value: view })
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
response.writeHead(405, { allow: 'GET, POST' })
|
|
159
|
-
response.end()
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
ctx.inject(['webServer'], (hctx) => {
|
|
163
|
-
hctx.effect(() => hctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: routeHandler }), 'dsh-github-router: settings routes')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
|
|
168
|
-
}
|
|
169
|
-
}
|