@240xu/dsh-websearch 2.8.2 → 2.8.4

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,277 @@
1
+ /**
2
+ * Fenced settings bridge for the browser settings card.
3
+ *
4
+ * The card needs three things from the host: the resolved value of every
5
+ * field, the revision that guards concurrent saves, and a way to write path
6
+ * ops back into the same profile entry the provider reads. On the 0.2.x
7
+ * settings line there is no `settingsScope` client service and the settings
8
+ * shell renders client slot sections only — so this module servers that data
9
+ * over the plugin's own HTTP surface instead.
10
+ *
11
+ * Data flow: the host keeps plugin config in its `settings` service
12
+ * (`SettingsForms.describe` / `mutate`, keyed by the profile entry id). This
13
+ * route is a thin bridge to it: writes land in the volatile config entry the
14
+ * provider resolves from on the next call, so there is no second store and no
15
+ * out-of-band state. Mutations are validated against the plugin's own schema
16
+ * here because the host only checks the volatile path, not the value shape.
17
+ *
18
+ * Both routes sit behind the host connection admission fence (the same
19
+ * decision the kernel applies to its own `/api` routes, including the browser
20
+ * auth cookie), so a LAN or tunnel page holding a valid session can save
21
+ * settings, and nothing weaker than the app can.
22
+ *
23
+ * GET /api/unified-search/settings -> { ok, namespace, writable, revision,
24
+ * value, base, user, reason? }
25
+ * POST /api/unified-search/settings -> { ok } + fresh payload on success,
26
+ * 409 { code: 'SETTINGS_CONFLICT' },
27
+ * 400 invalid op value/field,
28
+ * 503 settings service unavailable.
29
+ *
30
+ * Headless / CI profiles never mount `webServer`, so registration is skipped
31
+ * and nothing here runs.
32
+ *
33
+ * @module lib/settings-api.js
34
+ */
35
+ import { rejectionFor } from "./trust.js";
36
+
37
+ const SETTINGS_PATH = "/api/unified-search/settings";
38
+ const MAX_BODY_BYTES = 64 * 1024;
39
+ const MAX_OPS = 64;
40
+ /**
41
+ * Field names the card owns. Used only to find the profile entry in
42
+ * `describe()` output — never as a whitelist; the whitelist is the live
43
+ * `Config.dict`, so schema growth is picked up without touching this file.
44
+ */
45
+ const DISCOVERY_FIELDS = ["numResults", "backendTimeoutMs", "enabledBackends", "enableExa"];
46
+
47
+ /**
48
+ * Register the (exact) settings route on the late-mounted webServer.
49
+ * @param {{ register?: (route: { kind: string, path: string, handler: Function }) => void }} ws
50
+ * @param {{ getSettings: () => unknown, getConfig: () => unknown, getConnection?: () => unknown }} deps
51
+ */
52
+ export function registerSettingsRoutes(ws, deps) {
53
+ if (!ws || typeof ws.register !== "function") return;
54
+ ws.register({
55
+ kind: "exact",
56
+ path: SETTINGS_PATH,
57
+ handler: guarded(deps, (req, res) => {
58
+ const url = new URL(String(req.url ?? "/"), "http://localhost");
59
+ if (url.pathname !== SETTINGS_PATH) {
60
+ sendJson(res, 404, { ok: false, error: "not found" });
61
+ return;
62
+ }
63
+ if (req.method === "GET" || req.method === "HEAD") {
64
+ const payload = describe(deps);
65
+ sendJson(res, payload.ok ? 200 : 503, payload);
66
+ return;
67
+ }
68
+ if (req.method === "POST") {
69
+ void write(req, res, deps);
70
+ return;
71
+ }
72
+ res.setHeader("allow", "GET, POST");
73
+ sendJson(res, 405, { ok: false, error: "method not allowed (GET/POST)" });
74
+ }),
75
+ });
76
+ }
77
+
78
+ /* ------------------------------------------------------------------ */
79
+ /* handlers */
80
+ /* ------------------------------------------------------------------ */
81
+
82
+ function guarded(deps, handler) {
83
+ return (req, res) => {
84
+ const rejection = rejectionFor(req, typeof deps.getConnection === "function" ? deps.getConnection() : undefined);
85
+ if (rejection !== undefined) {
86
+ sendJson(res, rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
87
+ return;
88
+ }
89
+ Promise.resolve()
90
+ .then(() => handler(req, res))
91
+ .catch((err) => {
92
+ const status = err && Number.isInteger(err.status) ? err.status : 500;
93
+ sendJson(res, status, { ok: false, error: sanitize(err) });
94
+ });
95
+ };
96
+ }
97
+
98
+ /** Read the plugin's volatile form snapshot from the host settings service. */
99
+ function describe(deps) {
100
+ const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
101
+ if (settings === undefined || settings === null || typeof settings.describe !== "function") {
102
+ return { ok: false, writable: false, reason: "settings-unavailable" };
103
+ }
104
+ let descriptors;
105
+ try {
106
+ descriptors = settings.describe();
107
+ } catch {
108
+ return { ok: false, writable: false, reason: "settings-unavailable" };
109
+ }
110
+ const view = findNamespace(descriptors);
111
+ if (view === undefined) return { ok: false, writable: false, reason: "namespace-not-found" };
112
+ return {
113
+ ok: true,
114
+ namespace: view.ns,
115
+ writable: true,
116
+ applies: view.applies,
117
+ revision: view.revision,
118
+ value: view.value,
119
+ base: view.base,
120
+ user: view.user,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Locate this plugin's descriptor: first by shape (the card's own fields
126
+ * present in the resolved value), then by the profile entry id this package's
127
+ * bundle patch inserts. Shape-first survives a renamed insert id; the literal
128
+ * survives a host that reports an empty value.
129
+ */
130
+ function findNamespace(descriptors) {
131
+ if (!Array.isArray(descriptors)) return undefined;
132
+ const rows = descriptors.filter((row) => row !== null && typeof row === "object" && typeof row.ns === "string");
133
+ return rows.find((row) => row.value !== undefined && row.value !== null
134
+ && typeof row.value === "object" && DISCOVERY_FIELDS.some((key) => Object.hasOwn(row.value, key)))
135
+ ?? rows.find((row) => row.ns === "dsh-websearch");
136
+ }
137
+
138
+ async function write(req, res, deps) {
139
+ const type = String(req.headers["content-type"] ?? "").toLowerCase();
140
+ if (!type.includes("application/json")) {
141
+ sendJson(res, 400, { ok: false, error: "content-type must be application/json" });
142
+ return;
143
+ }
144
+ let body;
145
+ try {
146
+ body = JSON.parse(await readBody(req));
147
+ } catch (err) {
148
+ sendJson(res, err && Number.isInteger(err.status) ? err.status : 400, { ok: false, error: sanitize(err) || "request body is not valid JSON" });
149
+ return;
150
+ }
151
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
152
+ sendJson(res, 400, { ok: false, error: "request body must be a JSON object" });
153
+ return;
154
+ }
155
+ const wanted = Array.isArray(body.ops) ? body.ops : undefined;
156
+ if (wanted === undefined || wanted.length === 0 || wanted.length > MAX_OPS) {
157
+ sendJson(res, 400, { ok: false, error: `ops must be a non-empty array of at most ${MAX_OPS} entries` });
158
+ return;
159
+ }
160
+ const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
161
+ if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
162
+ sendJson(res, 503, { ok: false, error: "settings service unavailable" });
163
+ return;
164
+ }
165
+ const config = typeof deps.getConfig === "function" ? deps.getConfig() : undefined;
166
+ const fields = config !== undefined && config !== null && config.dict !== undefined && config.dict !== null
167
+ ? config.dict
168
+ : {};
169
+ const ops = [];
170
+ for (const raw of wanted) {
171
+ const parsed = validateOp(raw, fields);
172
+ if (typeof parsed === "string") {
173
+ sendJson(res, 400, { ok: false, error: parsed });
174
+ return;
175
+ }
176
+ ops.push(parsed);
177
+ }
178
+ const view = findNamespace(safeDescribe(settings));
179
+ if (view === undefined) {
180
+ sendJson(res, 503, { ok: false, error: "settings namespace unavailable" });
181
+ return;
182
+ }
183
+ const expected = Number.isInteger(body.expectedRevision) ? body.expectedRevision : undefined;
184
+ try {
185
+ await settings.mutate(view.ns, ops, expected);
186
+ } catch (err) {
187
+ if (isConflict(err)) {
188
+ sendJson(res, 409, { ok: false, code: "SETTINGS_CONFLICT", error: "settings changed elsewhere; reload and retry" });
189
+ return;
190
+ }
191
+ sendJson(res, 400, { ok: false, error: sanitize(err) || "settings write failed" });
192
+ return;
193
+ }
194
+ const payload = describe(deps);
195
+ sendJson(res, payload.ok ? 200 : 503, payload);
196
+ }
197
+
198
+ /**
199
+ * Shape-check one path op against the live schema. The host validates only
200
+ * that the path is inside the volatile subtree; a wrong-typed value would be
201
+ * written into the profile patch and break the entry's next reload, so the
202
+ * value must be validated here.
203
+ * @returns {object|string} the op to forward, or a fixed reason string
204
+ */
205
+ function validateOp(raw, fields) {
206
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return "ops entries must be objects";
207
+ const op = raw.op === "set" || raw.op === "unset" ? raw.op : undefined;
208
+ if (op === undefined) return "unsupported op (expected set|unset)";
209
+ const path = Array.isArray(raw.path) ? raw.path : undefined;
210
+ if (path === undefined || path.length !== 1 || typeof path[0] !== "string" || path[0] === "") {
211
+ return "path must be a one-element array holding a field name";
212
+ }
213
+ const field = path[0];
214
+ if (!Object.hasOwn(fields, field)) return `unknown settings field "${field}"`;
215
+ if (op === "unset") return { op, path: [field] };
216
+ if (!Object.hasOwn(raw, "value")) return `op "set" on "${field}" requires a value`;
217
+ const schema = fields[field];
218
+ if (typeof schema !== "function") return `field "${field}" is not writable`;
219
+ let checked;
220
+ try {
221
+ checked = schema(raw.value);
222
+ } catch {
223
+ return `invalid value for "${field}"`;
224
+ }
225
+ return { op, path: [field], value: checked };
226
+ }
227
+
228
+ function isConflict(err) {
229
+ if (err === null || err === undefined) return false;
230
+ if (typeof err === "object" && typeof err.code === "string" && err.code === "SETTINGS_CONFLICT") return true;
231
+ const message = typeof err === "object" && "message" in err ? String(err.message) : String(err);
232
+ return /SETTINGS_CONFLICT/.test(message);
233
+ }
234
+
235
+ function safeDescribe(settings) {
236
+ try {
237
+ return settings.describe();
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+
243
+ /* ------------------------------------------------------------------ */
244
+ /* plumbing */
245
+ /* ------------------------------------------------------------------ */
246
+
247
+ function readBody(req) {
248
+ return new Promise((resolveBody, rejectBody) => {
249
+ const chunks = [];
250
+ let size = 0;
251
+ req.on("data", (chunk) => {
252
+ size += chunk.length;
253
+ if (size > MAX_BODY_BYTES) {
254
+ const oversize = new Error("request body too large");
255
+ oversize.status = 413;
256
+ rejectBody(oversize);
257
+ req.removeAllListeners("data");
258
+ return;
259
+ }
260
+ chunks.push(chunk);
261
+ });
262
+ req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
263
+ req.on("error", rejectBody);
264
+ });
265
+ }
266
+
267
+ function sendJson(res, status, payload) {
268
+ if (res.writableEnded === true) return;
269
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
270
+ res.end(JSON.stringify(payload));
271
+ }
272
+
273
+ /** Fixed-shape diagnostics only: never echo header text or raw error details. */
274
+ function sanitize(err) {
275
+ const raw = err === null || err === undefined ? "" : String(err && err.message ? err.message : err);
276
+ return raw.replace(/\s+/g, " ").trim().slice(0, 160);
277
+ }
package/lib/trust.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Request trust fence for the plugin's HTTP surface.
3
+ *
4
+ * The plugin registers a prefix *longer* than the kernel's `/api`, and webServer
5
+ * dispatch is longest-prefix-wins — so these routes run before the connection
6
+ * service's own admission check and would otherwise answer any loopback caller.
7
+ * That was measured and documented as an open boundary; this module closes it.
8
+ *
9
+ * Two layers, tried in order:
10
+ *
11
+ * 1. the composition's own `connection` service when present — the exact
12
+ * admission decision the kernel applies to its `/api` routes (trust fence
13
+ * plus browser-auth cookie), so the plugin is never weaker than the app;
14
+ * 2. a structural replica of that fence for compositions without the service:
15
+ * loopback host only, no cross-site fetches, and an `Origin`/`Referer` that
16
+ * matches the `Host` authority whenever the client supplies one, with a
17
+ * narrow exception for the desktop's dsh-app://app relay Referer.
18
+ *
19
+ * @module src/trust.js
20
+ */
21
+
22
+ /** Hostnames a same-machine caller can legitimately use. */
23
+ const LOOPBACK_NAMES = new Set(['127.0.0.1', '[::1]', '::1', 'localhost'])
24
+
25
+ /**
26
+ * Is this address one a same-machine caller can reach — i.e. may the forward
27
+ * listener bind it? The lanes are keyed to this machine's egress and priced
28
+ * against whoever shares it, so binding a routable interface would hand the
29
+ * quota to the whole subnet on the strength of a string in a settings file.
30
+ */
31
+ export function isLoopbackHost(value) {
32
+ const authority = authorityOf(String(value ?? ''), 'http')
33
+ return authority !== null && LOOPBACK_NAMES.has(authority.hostname)
34
+ }
35
+
36
+ /**
37
+ * The fence's view of one connection service, keyed under `admit` (issue #89).
38
+ *
39
+ * The composition may publish `connection` long after plugins have loaded, so
40
+ * index.js reads it per request through this late-binding view. Hosts before
41
+ * dsh 0.1.7 carry the same decision as `requestRejection`: the view maps that
42
+ * method onto `admit` instead of wrapping a property that does not exist —
43
+ * the wrapping call used to throw inside the fence and read as 503 admission
44
+ * unavailable on every request. A bare status travels as the `{rejection}`
45
+ * envelope `rejectionFor` unwraps: handed through bare, an object-shaped
46
+ * return reads as "no rejection" and switches the host's authentication off.
47
+ *
48
+ * Exported so the tests exercise the real view rather than a replica that can
49
+ * drift from it.
50
+ *
51
+ * @param {object|(() => object|undefined)} service - the harness `connection`
52
+ * service, or a thunk resolving it per request (the composition may publish
53
+ * it long after plugins have loaded)
54
+ * @returns {{admit: (req: object) => ({rejection: number}|undefined)|undefined}|{admit: undefined}}
55
+ */
56
+ export function connectionAdmissionView(service) {
57
+ return {
58
+ get admit() {
59
+ const current = typeof service === 'function' ? service() : service
60
+ if (current === undefined) return undefined
61
+ if (typeof current.admit !== 'function' && typeof current.requestRejection === 'function') {
62
+ return req => {
63
+ const status = current.requestRejection(req)
64
+ return status === undefined ? undefined : { rejection: status }
65
+ }
66
+ }
67
+ return req => current.admit(req)
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Decide one request. Returns an HTTP status to reject with, or `undefined` to
74
+ * let the handler run.
75
+ *
76
+ * @param {import('node:http').IncomingMessage} req
77
+ * @param {object|undefined} connection - the harness `connection` service, when the composition mounts one
78
+ * @param {(decision: {status: number, source: string, reason: string}) => void} [onRejection]
79
+ * fixed diagnostic fields only; never request headers or error text
80
+ * @returns {number|undefined}
81
+ */
82
+ export function rejectionFor(req, connection, onRejection) {
83
+ let decision
84
+ try {
85
+ // Hosts before dsh 0.1.7 publish the same decision under the older name
86
+ // `requestRejection` (issue #89): `admit` is undefined there, and the
87
+ // service *exists*, so an empty read must fall through to that method —
88
+ // treating it as a fault turned every request into 503 admission
89
+ // unavailable. The return value is a bare status on that spelling and an
90
+ // `{rejection}` envelope on `admit`.
91
+ const admit = connection?.admit
92
+ if (typeof admit === 'function') {
93
+ const admission = admit.call(connection, req)
94
+ const status = admission && typeof admission === 'object' ? admission.rejection : undefined
95
+ if (status === undefined) return undefined
96
+ decision = { status, source: 'connection', reason: 'host-rejected' }
97
+ } else {
98
+ const reject = connection?.requestRejection
99
+ if (typeof reject === 'function') {
100
+ const status = reject.call(connection, req)
101
+ if (status === undefined) return undefined
102
+ decision = { status, source: 'connection', reason: 'host-rejected' }
103
+ }
104
+ }
105
+ } catch {
106
+ // Never bypass browser authentication when a mounted Host service fails.
107
+ decision = { status: 503, source: 'connection', reason: 'admission-error' }
108
+ }
109
+ if (decision === undefined) {
110
+ const reason = structuralReason(req)
111
+ if (reason === undefined) return undefined
112
+ decision = { status: 403, source: 'structural', reason }
113
+ }
114
+ try {
115
+ onRejection?.(decision)
116
+ } catch {
117
+ // Diagnostics are observational; a logger cannot change the HTTP decision.
118
+ }
119
+ return decision.status
120
+ }
121
+
122
+ /**
123
+ * The replica fence. Same shape as the kernel's `isTrustedApiRequest`: DNS
124
+ * rebinding defence via the Host header, cross-site fetch refusal, and an
125
+ * Origin/Referer authority match.
126
+ */
127
+ export function structuralRejection(req) {
128
+ return structuralReason(req) === undefined ? undefined : 403
129
+ }
130
+
131
+ /** Return a fixed reason without exposing caller-supplied strings. */
132
+ function structuralReason(req) {
133
+ const host = authorityOf(req.headers.host, 'http')
134
+ if (host === null || !LOOPBACK_NAMES.has(host.hostname)) return 'host-not-loopback'
135
+ const site = String(req.headers['sec-fetch-site'] ?? '').toLowerCase()
136
+ if (site === 'cross-site') return 'cross-site'
137
+ for (const header of ['origin', 'referer']) {
138
+ const raw = req.headers[header]
139
+ if (typeof raw !== 'string' || raw.trim() === '') continue
140
+ if (header === 'referer' && isDesktopRelayReferer(raw)) {
141
+ // Desktop forwardWebRequest strips both browser markers before relaying
142
+ // to HTTP loopback, but retains this Referer. It is not an Origin grant
143
+ // and never overrides a mounted Host's authentication decision.
144
+ if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) return 'desktop-relay-markers'
145
+ continue
146
+ }
147
+ const authority = authorityOf(raw.trim())
148
+ if (authority === null) return `${header}-invalid`
149
+ // The web server speaks plain http on a loopback bind, so an Origin that
150
+ // claims https — or any other scheme — is not this page.
151
+ if (authority.scheme !== host.scheme || authority.hostname !== host.hostname || authority.port !== host.port) return `${header}-mismatch`
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ /** Only the owned application page, never arbitrary custom-protocol pages. */
157
+ function isDesktopRelayReferer(value) {
158
+ try {
159
+ const url = new URL(value)
160
+ return url.protocol === 'dsh-app:' && url.hostname === 'app' && url.port === ''
161
+ && url.username === '' && url.password === ''
162
+ } catch {
163
+ return false
164
+ }
165
+ }
166
+
167
+ /** Split a Host/Origin/Referer value into {scheme, hostname, port}, defaulting the port. */
168
+ function authorityOf(value, defaultScheme) {
169
+ if (typeof value !== 'string' || value.trim() === '') return null
170
+ let url
171
+ try {
172
+ url = new URL(value.includes('://') ? value.trim() : `${defaultScheme ?? 'http'}://${value.trim()}`)
173
+ } catch {
174
+ return null
175
+ }
176
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
177
+ const port = url.port === '' ? (url.protocol === 'https:' ? '443' : '80') : url.port
178
+ return { scheme: url.protocol.replace(':', ''), hostname: url.hostname.toLowerCase(), port }
179
+ }