@twinsearth/w2m-dsh-plugin 1.1.18 → 1.1.19

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,120 @@
1
+ /**
2
+ * `/api/w2m/chat` — the browser-facing half of fleet chat (v1.1.17 shipped the tool only; this
3
+ * module is the missing host route the composer's chat panel calls).
4
+ *
5
+ * The route proxies the relay's device-gated `/v1/fleet/messages` endpoints with this machine's
6
+ * device token. The browser never sees the token: it calls this same-origin route, which is inside
7
+ * the host's authentication fence, exactly like `/api/w2m/target`.
8
+ *
9
+ * Wire:
10
+ * GET /api/w2m/chat?session_id=…&since_ms=…&limit=… -> relay list shape passed through
11
+ * POST /api/w2m/chat { session_id, text } -> relay send shape passed through
12
+ *
13
+ * Failure is a value here: `{ok:false, error, reason_code, detail}` with a non-2xx status, so the
14
+ * panel can print the host's own words. A relay that predates the fleet-messages routes answers
15
+ * 404, which is reported as such rather than as an empty conversation.
16
+ */
17
+ export const CHAT_ROUTE_PATH = '/api/w2m/chat';
18
+ export const MAX_SESSION_ID_LENGTH = 200;
19
+
20
+ const NO_STORE = { 'cache-control': 'no-store' };
21
+
22
+ /**
23
+ * Attach the route to one host's fetch registry, recording the outcome on `status`.
24
+ *
25
+ * @param {object} ctx the plugin context (a Cordis fiber)
26
+ * @param {{cfg: () => object, request: Function, resolveToken: () => Promise<string|null>}} deps
27
+ * @returns {{mounted: boolean, reason: string|null}}
28
+ */
29
+ export function registerChatRoutes(ctx, deps) {
30
+ const status = { mounted: false, reason: 'the host has no Connection service' };
31
+ if (typeof ctx?.inject !== 'function') return status;
32
+ ctx.inject(['connection'], (webCtx) => {
33
+ const registry = webCtx?.connection?.fetch;
34
+ if (registry === undefined || registry === null || typeof registry.register !== 'function') {
35
+ status.reason = 'the connection service carries no fetch registry';
36
+ return;
37
+ }
38
+ try {
39
+ registry.register({
40
+ path: CHAT_ROUTE_PATH,
41
+ methods: ['GET', 'POST'],
42
+ requestBody: 'buffered',
43
+ fetch: async (request) => {
44
+ const refuse = (httpStatus, reasonCode, error, detail) =>
45
+ Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
46
+
47
+ if (request.method !== 'GET' && request.method !== 'POST') {
48
+ return refuse(405, 'CHAT_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET lists a session; POST sends a message');
49
+ }
50
+
51
+ const url = new URL(request.url);
52
+ const sessionIdOf = (value) => (typeof value === 'string' ? value.trim() : '');
53
+ let sessionId = '';
54
+ let text = null;
55
+ if (request.method === 'GET') {
56
+ sessionId = sessionIdOf(url.searchParams.get('session_id'));
57
+ } else {
58
+ let body;
59
+ try {
60
+ body = await request.json();
61
+ } catch {
62
+ return refuse(400, 'CHAT_BODY_NOT_JSON', 'body is not JSON', 'send {"session_id":"…","text":"…"}');
63
+ }
64
+ sessionId = sessionIdOf(body?.session_id);
65
+ text = typeof body?.text === 'string' ? body.text : null;
66
+ }
67
+ if (sessionId === '') return refuse(400, 'CHAT_SESSION_ID_REQUIRED', '`session_id` is required', 'chat is scoped to one conversation');
68
+ if (sessionId.length > MAX_SESSION_ID_LENGTH) {
69
+ return refuse(400, 'CHAT_SESSION_ID_TOO_LONG', `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters`, 'send the id the host gave this session');
70
+ }
71
+
72
+ const cfg = deps.cfg();
73
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
74
+ if (rabbitUrl === null) return refuse(503, 'CHAT_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
75
+ let token;
76
+ try {
77
+ token = await deps.resolveToken();
78
+ } catch (error) {
79
+ return refuse(503, 'CHAT_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first (autoStartAgent or a pairing code)');
80
+ }
81
+ if (typeof token !== 'string' || token === '') {
82
+ return refuse(503, 'CHAT_UNPAIRED', 'this machine has no device token', 'pair the machine first (autoStartAgent or a pairing code)');
83
+ }
84
+
85
+ try {
86
+ if (request.method === 'GET') {
87
+ const query = { session_id: sessionId };
88
+ const sinceMs = url.searchParams.get('since_ms');
89
+ const limit = url.searchParams.get('limit');
90
+ if (sinceMs !== null && /^\d+$/.test(sinceMs)) query.since_ms = sinceMs;
91
+ if (limit !== null && /^\d+$/.test(limit)) query.limit = limit;
92
+ const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/fleet/messages', query, signal: request.signal });
93
+ if (listed?.ok !== true) {
94
+ const code = listed?.json?.error?.code ?? listed?.error?.code ?? 'CHAT_LIST_REFUSED';
95
+ const message = listed?.json?.error?.message ?? listed?.error?.message ?? `the relay answered HTTP ${listed?.status ?? 0}`;
96
+ return refuse(listed?.status === 404 ? 502 : 502, code, message, 'the relay must run a version with /v1/fleet/messages (>= 1.1.17) and be restarted');
97
+ }
98
+ return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
99
+ }
100
+ const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '' }, signal: request.signal });
101
+ if (posted?.ok !== true) {
102
+ const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
103
+ const message = posted?.json?.error?.message ?? posted?.error?.message ?? `the relay answered HTTP ${posted?.status ?? 0}`;
104
+ return refuse(posted?.status === 404 ? 502 : 400, code, message, 'quota, size and non-text are each refused by name; 404 means the relay predates fleet chat');
105
+ }
106
+ return Response.json({ ...posted.json, ok: true }, { headers: NO_STORE });
107
+ } catch (error) {
108
+ return refuse(502, 'CHAT_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, 'check the relay and this machine token');
109
+ }
110
+ },
111
+ });
112
+ status.mounted = true;
113
+ status.reason = null;
114
+ } catch (error) {
115
+ status.reason = error instanceof Error ? error.message : String(error);
116
+ ctx?.logger?.warn?.(`w2m: the fleet chat route is inactive — ${status.reason}`);
117
+ }
118
+ });
119
+ return status;
120
+ }
@@ -180,6 +180,9 @@ import {
180
180
  sessionKeyFor,
181
181
  TARGET_ROUTE_PATH,
182
182
  } from './fleet-target.mjs';
183
+ // v1.1.17 chat: the host route behind the composer's fleet-chat panel. The relay side and the
184
+ // `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
185
+ import { registerChatRoutes } from './fleet-chat-route.mjs';
183
186
  // v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
184
187
  // module header for why a discovered device is a candidate to *ask* and never an authorisation.
185
188
  import { createDiscoverySource } from './mdns-discover.mjs';
@@ -217,7 +220,7 @@ const MAX_STAGES = 16;
217
220
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
218
221
  * overrides it at runtime for tests and packagers.
219
222
  */
220
- const PLUGIN_VERSION = '1.1.18';
223
+ const PLUGIN_VERSION = '1.1.19';
221
224
 
222
225
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
223
226
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3401,6 +3404,20 @@ export async function apply(ctx, config = {}) {
3401
3404
  resolveToken,
3402
3405
  });
3403
3406
 
3407
+ // The composer's fleet-chat panel: `GET/POST /api/w2m/chat`, proxied to the relay's device-gated
3408
+ // `/v1/fleet/messages`. Optional like the target routes — a host without Connection keeps every
3409
+ // tool and the panel is simply not mounted.
3410
+ const chatRoutes = registerChatRoutes(ctx, {
3411
+ cfg: () => cfg,
3412
+ request,
3413
+ resolveToken: () => resolveToken(cfg),
3414
+ });
3415
+ if (chatRoutes.mounted !== true) {
3416
+ // Named rather than silent: a composition without the fetch registry loses the chat panel while every tool still
3417
+ // works, and "the panel is missing" with no line anywhere is the shape this project keeps paying for.
3418
+ ctx.logger?.warn?.('w2m: the fleet-chat composer route is not mounted (' + (chatRoutes.reason ?? 'unknown reason') + ')');
3419
+ }
3420
+
3404
3421
  // The LAN discovery source (v0.7.0). Constructing it binds nothing: a socket is opened only when a
3405
3422
  // scan is asked for, and only `w2m_devices` asks. A network where multicast is unavailable produces
3406
3423
  // one log line and an `available: false` block in the answer - never a failure of the tool.
@@ -6320,10 +6337,14 @@ export async function apply(ctx, config = {}) {
6320
6337
  const action = typeof args?.action === 'string' ? args.action : 'list';
6321
6338
  const sessionId = typeof args?.session_id === 'string' ? args.session_id.trim() : '';
6322
6339
  if (sessionId === '') throw new W2MError('W2M_CHAT_SESSION', 'W2M_CHAT_SESSION: session_id is required', { hint: 'chat is scoped to a conversation' });
6323
- const device = await readDevice(cfg);
6324
6340
  const rabbitUrl = cfg.rabbitUrl ?? null;
6325
6341
  if (rabbitUrl === null) throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so chat cannot be reached', { hint: 'set rabbitUrl in the profile patch' });
6326
- const token = requireToken(device);
6342
+ // resolveToken, not readDevice: readDevice returns an empty identity whenever `stateDir` is
6343
+ // unset, even though this machine's device.json exists at the default DSH_HOME path — every
6344
+ // other device-authenticated tool goes through resolveToken for exactly that reason, and this
6345
+ // one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
6346
+ const token = await resolveToken(cfg);
6347
+ if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
6327
6348
  if (action === 'send') {
6328
6349
  const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '' } });
6329
6350
  if (posted?.ok !== true) {