@xlight-oss/visionary-dsh 0.6.0 → 0.7.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.
@@ -0,0 +1,215 @@
1
+ // Fenced settings HTTP routes for the visionary settings-card panel.
2
+ //
3
+ // The DSH settings RPC domain (dsh-host-apiproxy) only serves an explicit
4
+ // allowlist of namespaces to Web configuration clients — a third-party
5
+ // plugin's namespace never appears in `connection.api.settings.describe()`
6
+ // no matter how correctly it is registered in-process. The established
7
+ // pattern for a third-party settings page (see dsh-better-sidebar,
8
+ // dsh-at-file) is a private, fenced HTTP/Remote route that calls
9
+ // `ctx.settings` in-process instead. This module mounts that route for the
10
+ // settings-card client half (`../settings-card/client.js`).
11
+ //
12
+ // The route is namespace-aware: every method accepts an optional `ns` body
13
+ // field naming a registered visionary namespace to read/write. The caller
14
+ // (settings-card host) passes the namespace list; the default when `ns` is
15
+ // absent is the first entry (image-bridge) for backward compatibility with
16
+ // older deployed clients. Unknown namespaces are rejected (400), so the route
17
+ // can never mutate a namespace it does not own.
18
+ //
19
+ // The route lives on the settings-card host row (not on either feature
20
+ // plugin) so the panel keeps working when a feature plugin row is disabled:
21
+ // each plugin registers its own settings namespace via installSettingsSection
22
+ // (loading is independent of the panel UI).
23
+ //
24
+ // webServer exists only in Web profiles. It is NOT a hard dependency of the
25
+ // settings-card row (headless compositions would never load the panel), so
26
+ // the route rides the same optional-service pattern installSettingsSection
27
+ // uses for `settings`: inject-wait for the service, register inside the
28
+ // nested fiber, dispose with it.
29
+
30
+ import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
31
+ import { isTrustedApiRequest } from "./image-bridge/trust-fence.mjs";
32
+
33
+ const readJsonBody = (req) =>
34
+ new Promise((resolve, reject) => {
35
+ let raw = "";
36
+ req.on("data", (chunk) => {
37
+ raw += chunk;
38
+ if (raw.length > 1_000_000) {
39
+ reject(new Error("request body too large"));
40
+ req.destroy();
41
+ }
42
+ });
43
+ req.on("end", () => {
44
+ if (raw === "") {
45
+ resolve({});
46
+ return;
47
+ }
48
+ try {
49
+ resolve(JSON.parse(raw));
50
+ } catch (err) {
51
+ reject(new Error(`invalid JSON body: ${err?.message ?? err}`));
52
+ }
53
+ });
54
+ req.on("error", reject);
55
+ });
56
+
57
+ const writeJson = (res, status, body) => {
58
+ res.writeHead(status, { "content-type": "application/json" });
59
+ res.end(JSON.stringify(body));
60
+ };
61
+
62
+ /** Namespace resolution state, bound per mount (default = first namespace). */
63
+ function makeNamespaceResolver(nsList) {
64
+ return function resolveNamespace(body) {
65
+ const ns = body?.ns;
66
+ if (ns === undefined || ns === null || ns === "") return nsList[0];
67
+ return nsList.includes(ns) ? ns : null;
68
+ };
69
+ }
70
+
71
+ /** Redacted-free view: these namespaces carry no secrets, so the settings
72
+ * document itself is returned verbatim (no redactSecrets pass needed).
73
+ * `base`/`user` ride along so the client can mark which fields the user
74
+ * explicitly overrode (the settings-card "overridden" badge). */
75
+ function currentView(settings, ns) {
76
+ if (settings === undefined) {
77
+ return { value: undefined, base: undefined, user: undefined, revision: undefined, writable: false };
78
+ }
79
+ const descriptor = settings
80
+ .describe()
81
+ .find((candidate) => candidate.ns === ns);
82
+ return descriptor === undefined
83
+ ? { value: undefined, base: undefined, user: undefined, revision: undefined, writable: settings.writable }
84
+ : {
85
+ value: descriptor.value,
86
+ base: descriptor.base,
87
+ user: descriptor.user,
88
+ revision: descriptor.revision,
89
+ writable: settings.writable,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Mount the `/visionary/api` settings route on a webServer-equipped fiber.
95
+ * @param webCtx - nested fiber that already resolved `webServer`.
96
+ * @param namespaces - the settings namespaces this plugin family may serve,
97
+ * in the order shown to clients. The first entry (image-bridge) is the
98
+ * default when a client sends no `ns` (backward compatibility).
99
+ */
100
+ export function mountVisionaryApi(webCtx, namespaces) {
101
+ const webServer = webCtx.webServer;
102
+ const resolveNamespace = makeNamespaceResolver(namespaces);
103
+ const trustedHosts = () => {
104
+ const webRuntime = webCtx.get("webRuntime");
105
+ return Array.isArray(webRuntime?.trustedHosts) ? webRuntime.trustedHosts : [];
106
+ };
107
+ const fence = (req) => isTrustedApiRequest(req, trustedHosts());
108
+
109
+ webCtx.effect(
110
+ () =>
111
+ webServer.register({
112
+ kind: "prefix",
113
+ path: "/visionary/api",
114
+ handler: async (req, res) => {
115
+ if (!fence(req)) {
116
+ writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } });
117
+ return;
118
+ }
119
+ if (req.method !== "POST") {
120
+ writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } });
121
+ return;
122
+ }
123
+ const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
124
+ const method = pathname.startsWith("/visionary/api/")
125
+ ? pathname.slice("/visionary/api/".length)
126
+ : undefined;
127
+ try {
128
+ const settings = webCtx.get("settings");
129
+ if (method === "settings.get") {
130
+ const body = await readJsonBody(req);
131
+ const ns = resolveNamespace(body);
132
+ if (ns === null) {
133
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: `unknown namespace "${body?.ns}"` } });
134
+ return;
135
+ }
136
+ writeJson(res, 200, { ok: true, value: currentView(settings, ns) });
137
+ return;
138
+ }
139
+ if (settings === undefined) {
140
+ writeJson(res, 503, {
141
+ ok: false,
142
+ error: { code: "settings-rejected", message: "the settings service is not mounted in this deployment" },
143
+ });
144
+ return;
145
+ }
146
+ if (method === "settings.update") {
147
+ const body = await readJsonBody(req);
148
+ const ns = resolveNamespace(body);
149
+ if (ns === null) {
150
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: `unknown namespace "${body?.ns}"` } });
151
+ return;
152
+ }
153
+ const patch = body?.patch;
154
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
155
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "patch must be a plain object" } });
156
+ return;
157
+ }
158
+ const expectedRevision = typeof body?.expectedRevision === "number" ? body.expectedRevision : undefined;
159
+ try {
160
+ await settings.update(ns, patch, expectedRevision);
161
+ } catch (err) {
162
+ if (err instanceof SettingsConflictError) {
163
+ writeJson(res, 409, { ok: false, error: { code: "settings-conflict", message: err.message } });
164
+ return;
165
+ }
166
+ writeJson(res, 400, {
167
+ ok: false,
168
+ error: { code: "settings-rejected", message: err instanceof Error ? err.message : String(err) },
169
+ });
170
+ return;
171
+ }
172
+ writeJson(res, 200, { ok: true, value: currentView(settings, ns) });
173
+ return;
174
+ }
175
+ if (method === "settings.mutate") {
176
+ const body = await readJsonBody(req);
177
+ const ns = resolveNamespace(body);
178
+ if (ns === null) {
179
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: `unknown namespace "${body?.ns}"` } });
180
+ return;
181
+ }
182
+ const ops = body?.ops;
183
+ if (!Array.isArray(ops)) {
184
+ writeJson(res, 400, { ok: false, error: { code: "bad-request", message: "ops must be an array" } });
185
+ return;
186
+ }
187
+ const expectedRevision = typeof body?.expectedRevision === "number" ? body.expectedRevision : undefined;
188
+ try {
189
+ await settings.mutate(ns, ops, expectedRevision);
190
+ } catch (err) {
191
+ if (err instanceof SettingsConflictError) {
192
+ writeJson(res, 409, { ok: false, error: { code: "settings-conflict", message: err.message } });
193
+ return;
194
+ }
195
+ writeJson(res, 400, {
196
+ ok: false,
197
+ error: { code: "settings-rejected", message: err instanceof Error ? err.message : String(err) },
198
+ });
199
+ return;
200
+ }
201
+ writeJson(res, 200, { ok: true, value: currentView(settings, ns) });
202
+ return;
203
+ }
204
+ writeJson(res, 404, { ok: false, error: { code: "not-found", message: `unknown visionary API method "${method}"` } });
205
+ } catch (err) {
206
+ writeJson(res, 400, {
207
+ ok: false,
208
+ error: { code: "bad-request", message: err instanceof Error ? err.message : String(err) },
209
+ });
210
+ }
211
+ },
212
+ }),
213
+ "visionary-settings-card: /visionary/api settings route",
214
+ );
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xlight-oss/visionary-dsh",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DeepSeek Visionary native plugin for DeepSeek Harness: deepseek_vision / status / login / logout native tools plus the text-model image bridge, all backed by the visionary-server CLI (DeepSeek web vision model, no API key).",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",
@@ -11,6 +11,10 @@
11
11
  "./image-bridge": {
12
12
  "default": "./lib/image-bridge/index.mjs"
13
13
  },
14
+ "./settings-card": {
15
+ "default": "./lib/settings-card/index.mjs"
16
+ },
17
+ "./settings-card/package.json": "./lib/settings-card/package.json",
14
18
  "./cordis.patch.yml": "./cordis.patch.yml",
15
19
  "./package.json": "./package.json"
16
20
  },