@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.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.
@@ -34,6 +34,12 @@
34
34
  * GET /api/w2m/target?session_id=… the fleet and this session's selection, as one snapshot
35
35
  * POST /api/w2m/target { session_id, machine_id|null } → the same snapshot
36
36
  *
37
+ * A refusal is `{ok: false, error, reason_code, detail}` with a `{@link TARGET_REFUSAL}` code and a
38
+ * `detail` that says what would change it; a `PUT`/`DELETE`/`PATCH` is a `405` rather than a write
39
+ * (only exactly `GET` and `POST` are answered), a machine the relay does not list is a `409` exactly
40
+ * like one it lists as offline, and `session_id` is capped at {@link MAX_SESSION_ID_LENGTH} characters
41
+ * on both verbs because the store keys on the whole string.
42
+ *
37
43
  * Plain `Response.json`, no Typert descriptor: this package is dependency-free, and a generated
38
44
  * strict Remote contribution is neither reachable from plain JavaScript nor worth a codec for one
39
45
  * object. Nothing else in the protocol changes — no new relay endpoint, no new tool parameter, no
@@ -76,6 +82,49 @@ export const NO_SESSION = '(no-session)';
76
82
  /** Session buckets retained before the oldest is evicted; a browse session cannot grow this. */
77
83
  export const MAX_SELECTIONS = 512;
78
84
 
85
+ /**
86
+ * Longest `session_id` this route accepts, in characters (UTF-16 code units).
87
+ *
88
+ * `MAX_SELECTIONS` bounds how many buckets exist; it says nothing about how large one key is, and the
89
+ * store keeps the whole string. A caller that sends a 200,000-character id therefore allocates as much
90
+ * as it likes per request and gets it echoed back in the snapshot — the count cap bounds the *number*
91
+ * of sessions, never the *bytes*. 200 is a bound with room to spare: a DSH session id is a UUID (36
92
+ * characters), and 200 leaves room for a ULID, a path-shaped id or a test's own naming without
93
+ * inviting an unbounded key.
94
+ */
95
+ export const MAX_SESSION_ID_LENGTH = 200;
96
+
97
+ /**
98
+ * The `/api/w2m/target` route's refusals, in the house `{reason_code, detail}` shape.
99
+ *
100
+ * Deliberately a map of its own rather than a widening of `tools.mjs`'s `REFUSAL_CODE`: those codes name
101
+ * refusals on the *dispatch* path (which the eight tools report), while these name refusals at the
102
+ * route the browser calls, and `fleet-target.mjs` is imported *by* `tools.mjs` — importing back would
103
+ * be a cycle. `reason_code` is the greppable, stable identifier and `detail` says what would change it.
104
+ */
105
+ export const TARGET_REFUSAL = Object.freeze({
106
+ /** A verb this route does not answer. Only exactly `GET` and `POST` are read or written. */
107
+ METHOD_NOT_ALLOWED: 'TARGET_METHOD_NOT_ALLOWED',
108
+ /** `GET` carried no `session_id`, so there is no session to answer for. */
109
+ SESSION_ID_REQUIRED: 'TARGET_SESSION_ID_REQUIRED',
110
+ /** `session_id` is longer than {@link MAX_SESSION_ID_LENGTH}. */
111
+ SESSION_ID_TOO_LONG: 'TARGET_SESSION_ID_TOO_LONG',
112
+ /** A `POST` body that could not be parsed as JSON at all. */
113
+ BODY_NOT_JSON: 'TARGET_BODY_NOT_JSON',
114
+ /** A `POST` body that is not a JSON object. */
115
+ BODY_NOT_AN_OBJECT: 'TARGET_BODY_NOT_AN_OBJECT',
116
+ /** `machine_id` was neither a string nor null. */
117
+ MACHINE_ID_NOT_A_STRING: 'TARGET_MACHINE_ID_NOT_A_STRING',
118
+ /** `machine_name` was neither a string nor null. */
119
+ MACHINE_NAME_NOT_A_STRING: 'TARGET_MACHINE_NAME_NOT_A_STRING',
120
+ /** The named machine is not one the relay lists at all. */
121
+ MACHINE_UNKNOWN: 'TARGET_MACHINE_UNKNOWN',
122
+ /** The named machine exists, and the relay does not call it online. */
123
+ MACHINE_OFFLINE: 'TARGET_MACHINE_OFFLINE',
124
+ /** The fleet could not be read, so no selection could be checked against it. */
125
+ FLEET_UNAVAILABLE: 'TARGET_FLEET_UNAVAILABLE',
126
+ });
127
+
79
128
  /** How long the fleet roster may be reused by the control before it is asked for again. */
80
129
  export const FLEET_CACHE_MS = 10_000;
81
130
 
@@ -415,25 +464,55 @@ export function buildUiState(deps) {
415
464
  /**
416
465
  * Parse the JSON body of a selection write, refusing anything outside the declared shape.
417
466
  *
467
+ * Every refusal is a *value with a name*: `reason_code` is stable and greppable, and `detail` says what
468
+ * would have to change for the same request to be accepted. The route turns both into the response body
469
+ * verbatim, so the browser can show the reason without re-deriving it.
470
+ *
418
471
  * @param {unknown} body
419
- * @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}|{ok: false, error: string}}
472
+ * @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
473
+ * |{ok: false, error: string, reason_code: string, detail: string}}
420
474
  */
421
475
  export function parseSelectionWrite(body) {
476
+ const refuse = (error, reason_code, detail) => ({ ok: false, error, reason_code, detail });
422
477
  if (body === null || typeof body !== 'object' || Array.isArray(body)) {
423
- return { ok: false, error: 'body must be a JSON object' };
478
+ return refuse(
479
+ 'body must be a JSON object',
480
+ TARGET_REFUSAL.BODY_NOT_AN_OBJECT,
481
+ 'send `{"session_id": "<id>", "machine_id": "<id>|null"}`',
482
+ );
424
483
  }
425
484
  const record = /** @type {Record<string, unknown>} */ (body);
426
485
  const sessionId = record.session_id;
427
486
  if (typeof sessionId !== 'string' || sessionId === '') {
428
- return { ok: false, error: '`session_id` must be a non-empty string' };
487
+ return refuse(
488
+ '`session_id` must be a non-empty string',
489
+ TARGET_REFUSAL.SESSION_ID_REQUIRED,
490
+ 'send the id of the session this selection belongs to',
491
+ );
492
+ }
493
+ if (sessionId.length > MAX_SESSION_ID_LENGTH) {
494
+ return refuse(
495
+ `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
496
+ TARGET_REFUSAL.SESSION_ID_TOO_LONG,
497
+ `send the id the host gave this session (a UUID is 36 characters); anything longer than `
498
+ + `${MAX_SESSION_ID_LENGTH} characters is not a session id`,
499
+ );
429
500
  }
430
501
  const machineId = record.machine_id;
431
502
  if (machineId !== null && machineId !== undefined && typeof machineId !== 'string') {
432
- return { ok: false, error: '`machine_id` must be a string or null' };
503
+ return refuse(
504
+ '`machine_id` must be a string or null',
505
+ TARGET_REFUSAL.MACHINE_ID_NOT_A_STRING,
506
+ 'send a `machine_id` from the roster this route reports, or null to clear the selection',
507
+ );
433
508
  }
434
509
  const machineName = record.machine_name;
435
510
  if (machineName !== null && machineName !== undefined && typeof machineName !== 'string') {
436
- return { ok: false, error: '`machine_name` must be a string or null' };
511
+ return refuse(
512
+ '`machine_name` must be a string or null',
513
+ TARGET_REFUSAL.MACHINE_NAME_NOT_A_STRING,
514
+ 'send the machine\'s display name as a string, or leave it out and the relay\'s name is used',
515
+ );
437
516
  }
438
517
  return {
439
518
  ok: true,
@@ -603,11 +682,51 @@ function mountTargetRoutes(connection, deps, status, logger) {
603
682
  fetch: async (request) => {
604
683
  const url = new URL(request.url);
605
684
  const noStore = { 'cache-control': 'no-store' };
685
+ /**
686
+ * One refusal shape for the whole route: `{ok:false, error, reason_code, detail}`.
687
+ *
688
+ * @param {number} status
689
+ * @param {string} reasonCode A {@link TARGET_REFUSAL} value.
690
+ * @param {string} error What happened, in the host's own words (the control prints this).
691
+ * @param {string} detail What would change it.
692
+ * @returns {Response}
693
+ */
694
+ const refuse = (status, reasonCode, error, detail) =>
695
+ Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status, headers: noStore });
696
+
697
+ // Defence in depth, and the one place it is cheap. The registration above declares
698
+ // `methods: ['GET', 'POST']`, so the host should never route a `PUT`/`DELETE`/`PATCH` here at all
699
+ // — but the handler used to treat *anything* that was not a GET as a write, which means a
700
+ // registration mistake, a host that forwards other verbs, or a future `methods` change would
701
+ // silently turn `DELETE /api/w2m/target` into "clear this selection". A route that writes must
702
+ // say which verbs write; everything else is refused by name rather than guessed at.
703
+ if (request.method !== 'GET' && request.method !== 'POST') {
704
+ return refuse(
705
+ 405,
706
+ TARGET_REFUSAL.METHOD_NOT_ALLOWED,
707
+ `${request.method} is not a method this route answers`,
708
+ 'GET reads the fleet and this session\'s selection and POST writes one; any other verb is refused '
709
+ + 'rather than treated as a write',
710
+ );
711
+ }
606
712
 
607
713
  if (request.method === 'GET') {
608
714
  const sessionId = url.searchParams.get('session_id');
609
715
  if (typeof sessionId !== 'string' || sessionId === '') {
610
- return Response.json({ ok: false, error: '`session_id` query parameter is required' }, { status: 400 });
716
+ return refuse(
717
+ 400,
718
+ TARGET_REFUSAL.SESSION_ID_REQUIRED,
719
+ '`session_id` query parameter is required',
720
+ 'add `?session_id=<the session the control belongs to>`',
721
+ );
722
+ }
723
+ if (sessionId.length > MAX_SESSION_ID_LENGTH) {
724
+ return refuse(
725
+ 400,
726
+ TARGET_REFUSAL.SESSION_ID_TOO_LONG,
727
+ `\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
728
+ `send the id the host gave this session (a UUID is 36 characters)`,
729
+ );
611
730
  }
612
731
  try {
613
732
  return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
@@ -623,10 +742,17 @@ function mountTargetRoutes(connection, deps, status, logger) {
623
742
  try {
624
743
  body = await request.json();
625
744
  } catch {
626
- return Response.json({ ok: false, error: 'body is not JSON' }, { status: 400 });
745
+ return refuse(
746
+ 400,
747
+ TARGET_REFUSAL.BODY_NOT_JSON,
748
+ 'body is not JSON',
749
+ 'send `{"session_id": "<id>", "machine_id": "<id>|null"}` as a JSON object',
750
+ );
627
751
  }
628
752
  const parsed = parseSelectionWrite(body);
629
- if (!parsed.ok) return Response.json({ ok: false, error: parsed.error }, { status: 400 });
753
+ if (!parsed.ok) {
754
+ return refuse(400, parsed.reason_code, parsed.error, parsed.detail);
755
+ }
630
756
  const { session_id: sessionId, machine_id: machineId, machine_name: machineName } = parsed.value;
631
757
 
632
758
  if (machineId === null) {
@@ -642,16 +768,35 @@ function mountTargetRoutes(connection, deps, status, logger) {
642
768
  const cfg = deps.cfg();
643
769
  const fleet = await fleetFor(request.signal);
644
770
  if (fleet.ok !== true) {
645
- return Response.json(
646
- { ok: false, error: `the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}` },
647
- { status: 503 },
771
+ return refuse(
772
+ 503,
773
+ TARGET_REFUSAL.FLEET_UNAVAILABLE,
774
+ `the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}`,
775
+ 'bring the relay back (or fix `rabbitUrl`) and choose again; nothing was selected in the meantime',
648
776
  );
649
777
  }
650
778
  const machine = fleet.machines.find((entry) => entry.machine_id === machineId) ?? null;
651
- if (machine !== null && machine.online !== true) {
652
- return Response.json(
653
- { ok: false, error: `${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected` },
654
- { status: 409 },
779
+ // A machine the relay does not list *at all* is refused in exactly the same breath as one it lists
780
+ // as offline. The comment above always claimed this and the code did not do it: `machine === null`
781
+ // fell through to `store.set`, so `{"machine_id":"NOT-A-MACHINE"}` was accepted with a 200 and a
782
+ // selection the fleet could never honour. Dispatch re-validates, which is what bounded the impact
783
+ // — but a control whose whole promise is "you cannot select what the fleet cannot run" has to
784
+ // refuse the id it has never heard of too.
785
+ if (machine === null) {
786
+ return refuse(
787
+ 409,
788
+ TARGET_REFUSAL.MACHINE_UNKNOWN,
789
+ `${machineId} is not a machine this relay lists`,
790
+ 'pick a machine from the roster this route reports; if it has just paired, reload the list and '
791
+ + 'choose it again',
792
+ );
793
+ }
794
+ if (machine.online !== true) {
795
+ return refuse(
796
+ 409,
797
+ TARGET_REFUSAL.MACHINE_OFFLINE,
798
+ `${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected`,
799
+ 'the machine must be online: wait for its next heartbeat, or select another machine',
655
800
  );
656
801
  }
657
802
  deps.store.set(sessionId, {