@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.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,294 @@
1
+ /**
2
+ * The Device X browser API: four same-origin JSON routes on the DSH Web
3
+ * server, each behind the shipped Connection trust fence (the page's signed
4
+ * session cookie authenticates the caller; foreign origins are rejected by
5
+ * the Host/Origin fence). Route handlers never shell out themselves — they
6
+ * call the shared operation surface, exactly like the agent tools.
7
+ *
8
+ * @module @cfour-hi/dsh-plugin-devicex/host/transport
9
+ */
10
+
11
+ /** URL prefix owned by this plugin. */
12
+ const API_PREFIX = '/devicex/api';
13
+
14
+ /** Action request bodies are tiny JSON objects; anything larger is hostile. */
15
+ const MAX_BODY_BYTES = 256 * 1024;
16
+
17
+ /** Reject malformed or unknown section names before they reach collection. */
18
+ function isSection(value) {
19
+ return typeof value === 'string' && value.length > 0;
20
+ }
21
+
22
+ /** JSON response, always uncached: device facts are live facts. */
23
+ function sendJson(res, status, payload) {
24
+ res.statusCode = status;
25
+ res.setHeader('content-type', 'application/json; charset=utf-8');
26
+ res.setHeader('cache-control', 'no-store');
27
+ res.end(JSON.stringify(payload));
28
+ }
29
+
30
+ /** 405 with the route's one supported method. */
31
+ function sendMethodNotAllowed(res, allow) {
32
+ res.statusCode = 405;
33
+ res.setHeader('allow', allow);
34
+ res.end();
35
+ }
36
+
37
+ /** Collect a bounded request body as UTF-8 text; null past the ceiling. */
38
+ async function readBoundedBody(req) {
39
+ const chunks = [];
40
+ let size = 0;
41
+ for await (const chunk of req) {
42
+ size += chunk.byteLength;
43
+ if (size > MAX_BODY_BYTES) {
44
+ req.resume();
45
+ return null;
46
+ }
47
+ chunks.push(chunk);
48
+ }
49
+ return Buffer.concat(chunks, size).toString('utf8');
50
+ }
51
+
52
+ /** Apply the connection trust fence; true when the request was rejected. */
53
+ function rejected(ctx, req, res) {
54
+ const rejection = ctx.connection.requestRejection(req);
55
+ if (rejection === undefined) return false;
56
+ res.statusCode = rejection;
57
+ res.end();
58
+ return true;
59
+ }
60
+
61
+ /** Parse the request URL against a placeholder origin (only path/query matter). */
62
+ function requestUrl(req) {
63
+ try {
64
+ return new URL(String(req.url), 'http://localhost');
65
+ } catch {
66
+ return new URL('http://localhost/');
67
+ }
68
+ }
69
+
70
+ /** Answer a GET/HEAD route, containing any handler failure as a 500. */
71
+ function guarded(handler) {
72
+ return async (req, res) => {
73
+ try {
74
+ await handler(req, res);
75
+ } catch (error) {
76
+ if (!res.headersSent) {
77
+ sendJson(res, 500, {
78
+ ok: false,
79
+ error: {
80
+ code: 'internal',
81
+ message: error instanceof Error ? error.message : String(error),
82
+ },
83
+ });
84
+ } else {
85
+ res.destroy();
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Register every Device X route as one disposable effect.
93
+ * @param {import('../../types/contracts.js').PluginContext} ctx - the plugin context carrying `webServer` and `connection`.
94
+ * @param {import('../../types/contracts.js').PluginOperations} operations - the shared operation surface.
95
+ * @returns the disposer removing every route.
96
+ */
97
+ export function registerRoutes(ctx, operations) {
98
+ const disposers = [];
99
+
100
+ disposers.push(ctx.webServer.register({
101
+ kind: 'exact',
102
+ path: `${API_PREFIX}/metrics`,
103
+ handler: guarded(async (req, res) => {
104
+ if (rejected(ctx, req, res)) return;
105
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
106
+ const metrics = await operations.collectMetrics();
107
+ sendJson(res, 200, { ok: true, metrics });
108
+ }),
109
+ }));
110
+
111
+ disposers.push(ctx.webServer.register({
112
+ kind: 'exact',
113
+ path: `${API_PREFIX}/inventory`,
114
+ handler: guarded(async (req, res) => {
115
+ if (rejected(ctx, req, res)) return;
116
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
117
+ const url = requestUrl(req);
118
+ const section = url.searchParams.get('section');
119
+ const fresh = url.searchParams.get('fresh') === '1';
120
+ if (section !== null && section !== '') {
121
+ if (!isSection(section) || !operations.SECTIONS.includes(section)) {
122
+ sendJson(res, 400, {
123
+ ok: false,
124
+ error: { code: 'unknown-section', message: `unknown section: ${section}` },
125
+ });
126
+ return;
127
+ }
128
+ const data = await operations.collectSection(section, { fresh });
129
+ sendJson(res, 200, { ok: true, section, data });
130
+ return;
131
+ }
132
+ const data = await operations.collectInventory({ fresh });
133
+ sendJson(res, 200, { ok: true, section: null, data });
134
+ }),
135
+ }));
136
+
137
+ disposers.push(ctx.webServer.register({
138
+ kind: 'exact',
139
+ path: `${API_PREFIX}/actions`,
140
+ handler: guarded(async (req, res) => {
141
+ if (rejected(ctx, req, res)) return;
142
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
143
+ sendJson(res, 200, {
144
+ ok: true,
145
+ platform: operations.PLATFORM,
146
+ sections: operations.SECTIONS,
147
+ actions: operations.actionCatalog(),
148
+ history: operations.actionHistory(),
149
+ });
150
+ }),
151
+ }));
152
+
153
+ disposers.push(ctx.webServer.register({
154
+ kind: 'exact',
155
+ path: `${API_PREFIX}/action`,
156
+ handler: guarded(async (req, res) => {
157
+ if (rejected(ctx, req, res)) return;
158
+ if (req.method !== 'POST') return sendMethodNotAllowed(res, 'POST');
159
+ const contentType = String(req.headers['content-type'] ?? '')
160
+ .split(';', 1)[0]
161
+ .trim()
162
+ .toLowerCase();
163
+ if (contentType !== 'application/json') {
164
+ sendJson(res, 415, {
165
+ ok: false,
166
+ error: { code: 'unsupported-media-type', message: 'content-type must be application/json' },
167
+ });
168
+ return;
169
+ }
170
+ let text;
171
+ try {
172
+ text = await readBoundedBody(req);
173
+ } catch {
174
+ sendJson(res, 400, {
175
+ ok: false,
176
+ error: { code: 'bad-request', message: 'request body unreadable' },
177
+ });
178
+ return;
179
+ }
180
+ if (text === null) {
181
+ sendJson(res, 413, {
182
+ ok: false,
183
+ error: { code: 'payload-too-large', message: 'request body is too large' },
184
+ });
185
+ return;
186
+ }
187
+ let body;
188
+ try {
189
+ body = JSON.parse(text);
190
+ } catch {
191
+ body = undefined;
192
+ }
193
+ if (typeof body !== 'object' || body === null || typeof body.action !== 'string') {
194
+ sendJson(res, 400, {
195
+ ok: false,
196
+ error: {
197
+ code: 'bad-request',
198
+ message: 'body must be a JSON object with a string "action" and optional object "params"',
199
+ },
200
+ });
201
+ return;
202
+ }
203
+ const params = typeof body.params === 'object' && body.params !== null ? body.params : {};
204
+ const result = await operations.runAction(body.action, params);
205
+ sendJson(res, 200, result);
206
+ }),
207
+ }));
208
+
209
+ disposers.push(ctx.webServer.register({
210
+ kind: 'exact',
211
+ path: `${API_PREFIX}/cleanup`,
212
+ handler: guarded(async (req, res) => {
213
+ if (rejected(ctx, req, res)) return;
214
+ if (req.method !== 'GET') return sendMethodNotAllowed(res, 'GET');
215
+ const url = requestUrl(req);
216
+ const fresh = url.searchParams.get('fresh') === '1';
217
+ const cleanup = await operations.scanCleanup({ fresh });
218
+ sendJson(res, 200, { ok: true, cleanup });
219
+ }),
220
+ }));
221
+
222
+ disposers.push(ctx.webServer.register({
223
+ kind: 'exact',
224
+ path: `${API_PREFIX}/benchmark`,
225
+ handler: guarded(async (req, res) => {
226
+ if (rejected(ctx, req, res)) return;
227
+ if (req.method !== 'POST') return sendMethodNotAllowed(res, 'POST');
228
+ const contentType = String(req.headers['content-type'] ?? '')
229
+ .split(';', 1)[0]
230
+ .trim()
231
+ .toLowerCase();
232
+ if (contentType !== 'application/json') {
233
+ sendJson(res, 415, {
234
+ ok: false,
235
+ error: { code: 'unsupported-media-type', message: 'content-type must be application/json' },
236
+ });
237
+ return;
238
+ }
239
+ let text;
240
+ try {
241
+ text = await readBoundedBody(req);
242
+ } catch {
243
+ sendJson(res, 400, {
244
+ ok: false,
245
+ error: { code: 'bad-request', message: 'request body unreadable' },
246
+ });
247
+ return;
248
+ }
249
+ if (text === null) {
250
+ sendJson(res, 413, {
251
+ ok: false,
252
+ error: { code: 'payload-too-large', message: 'request body is too large' },
253
+ });
254
+ return;
255
+ }
256
+ /** @type {any} */
257
+ let body = {};
258
+ if (text.trim() !== '') {
259
+ try {
260
+ body = JSON.parse(text);
261
+ } catch {
262
+ body = undefined;
263
+ }
264
+ }
265
+ if (typeof body !== 'object' || body === null) {
266
+ sendJson(res, 400, {
267
+ ok: false,
268
+ error: { code: 'bad-request', message: 'body must be a JSON object' },
269
+ });
270
+ return;
271
+ }
272
+ const tests = body.tests;
273
+ if (tests !== undefined && (!Array.isArray(tests) || tests.some((id) => typeof id !== 'string'))) {
274
+ sendJson(res, 400, {
275
+ ok: false,
276
+ error: { code: 'bad-request', message: '"tests" must be an array of test ids' },
277
+ });
278
+ return;
279
+ }
280
+ const benchmark = await operations.runBenchmark({ tests, repeat: body.repeat === true });
281
+ sendJson(res, 200, benchmark);
282
+ }),
283
+ }));
284
+
285
+ return () => {
286
+ for (const dispose of disposers.splice(0).reverse()) {
287
+ try {
288
+ dispose();
289
+ } catch {
290
+ // A route disposer that already ran must not block the rest.
291
+ }
292
+ }
293
+ };
294
+ }