relmio 0.4.1 → 0.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.
package/src/web/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { createServer } from "node:http";
4
+ import packageManifest from "../../package.json" with { type: "json" };
4
5
 
5
6
  import { discoverN8n, discoverNetworks } from "../services/discovery.js";
6
7
  import { installSidecar } from "../services/installer.js";
@@ -18,13 +19,19 @@ import {
18
19
  validatePort,
19
20
  } from "../domain/validation.js";
20
21
  import { SIDECAR_HOSTNAME } from "../domain/templates.js";
21
- import { createLocalDeploymentPlan } from "../domain/local-endpoints.js";
22
22
  import {
23
+ createLocalDeploymentPlan,
24
+ validateLocalTarget,
25
+ } from "../domain/local-endpoints.js";
26
+ import {
27
+ acquireLocalEndpointChangeLock,
28
+ activateLocalClientCredentialRotation,
23
29
  attestLocalCodexInstallation,
24
30
  getLocalDockerStatus,
25
31
  installLocalEndpoint,
26
32
  resolveLocalInstallRoot,
27
33
  restartLocalCodex,
34
+ prepareLocalClientCredentialRotation,
28
35
  } from "../services/local-installer.js";
29
36
  import { startCodexDeviceLogin } from "../services/codex-login.js";
30
37
 
@@ -32,6 +39,34 @@ const MAX_BODY_BYTES = 32 * 1024;
32
39
  const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
40
  const RATE_LIMIT_MAX = 10;
34
41
  const OAUTH_SHUTDOWN_WAIT_MS = 2_000;
42
+ const LOCAL_ROTATION_STAGE_TTL_MS = 2 * 60 * 1000;
43
+ const PACKAGE_VERSION = packageManifest.version;
44
+
45
+ async function getProjectMeta({ fetchImpl = fetch } = {}) {
46
+ let stars = null;
47
+ try {
48
+ const response = await fetchImpl(
49
+ "https://api.github.com/repos/Demonbane18/relmio",
50
+ {
51
+ headers: {
52
+ Accept: "application/vnd.github+json",
53
+ "User-Agent": `relmio/${PACKAGE_VERSION}`,
54
+ },
55
+ redirect: "error",
56
+ signal: AbortSignal.timeout(5_000),
57
+ },
58
+ );
59
+ if (response.ok) {
60
+ const value = (await response.json())?.stargazers_count;
61
+ if (Number.isSafeInteger(value) && value >= 0) {
62
+ stars = value;
63
+ }
64
+ }
65
+ } catch {
66
+ // The local control keeps a visible fallback when GitHub is unavailable.
67
+ }
68
+ return { stars, version: PACKAGE_VERSION };
69
+ }
35
70
 
36
71
  const defaultServices = {
37
72
  getAuthStatus,
@@ -44,7 +79,11 @@ const defaultServices = {
44
79
  installSidecar,
45
80
  attestLocalCodexInstallation,
46
81
  getLocalDockerStatus,
82
+ getProjectMeta,
47
83
  installLocalEndpoint,
84
+ acquireLocalEndpointChangeLock,
85
+ activateLocalClientCredentialRotation,
86
+ prepareLocalClientCredentialRotation,
48
87
  resolveLocalInstallRoot,
49
88
  restartLocalCodex,
50
89
  startCodexDeviceLogin,
@@ -268,7 +307,7 @@ async function loadDefaultUiFiles() {
268
307
 
269
308
  return {
270
309
  "/": files[0],
271
- "/local": files[1],
310
+ "/local": files[1].replaceAll("__RELMIO_PACKAGE_VERSION__", PACKAGE_VERSION),
272
311
  "/app.js": files[2],
273
312
  "/local.js": files[3],
274
313
  "/oauth-popup.js": files[4],
@@ -312,6 +351,38 @@ function createSafeLocalInstallResult(result) {
312
351
  };
313
352
  }
314
353
 
354
+ function createSafeLocalActivationResult(result) {
355
+ return {
356
+ target: result.target,
357
+ endpoint: result.endpoint,
358
+ protocol: result.protocol,
359
+ models: Array.isArray(result.models) ? [...result.models] : [],
360
+ deploymentMode: result.deploymentMode,
361
+ experimental: result.experimental === true,
362
+ browserClients: result.browserClients === true,
363
+ };
364
+ }
365
+
366
+ function createSafeProjectMeta(result) {
367
+ return {
368
+ stars:
369
+ Number.isSafeInteger(result?.stars) && result.stars >= 0
370
+ ? result.stars
371
+ : null,
372
+ version: PACKAGE_VERSION,
373
+ };
374
+ }
375
+
376
+ function getPendingLocalCredentialRotation(state) {
377
+ if (
378
+ state.localCredentialRotationPending &&
379
+ state.localCredentialRotationPending.expiresAt <= Date.now()
380
+ ) {
381
+ state.localCredentialRotationPending = null;
382
+ }
383
+ return state.localCredentialRotationPending;
384
+ }
385
+
315
386
  function createSafeDockerStatus(status, previewMode) {
316
387
  if (previewMode || status?.dockerAvailable !== true) {
317
388
  return {
@@ -381,6 +452,15 @@ async function handleApi(request, response, path, state) {
381
452
  return;
382
453
  }
383
454
 
455
+ if (request.method === "GET" && path === "/api/local/project-meta") {
456
+ sendJson(
457
+ response,
458
+ 200,
459
+ createSafeProjectMeta(await state.services.getProjectMeta()),
460
+ );
461
+ return;
462
+ }
463
+
384
464
  if (
385
465
  request.method === "GET" &&
386
466
  path === "/api/local/codex/login/status"
@@ -568,9 +648,15 @@ async function handleApi(request, response, path, state) {
568
648
  try {
569
649
  requireLiveLocalAction(state, "Local endpoint installation");
570
650
  enforceRateLimit(state, path);
571
- if (state.localInstallInFlight) {
651
+ if (
652
+ state.localInstallInFlight ||
653
+ state.localCredentialRotationInFlight ||
654
+ getPendingLocalCredentialRotation(state) ||
655
+ state.codexLoginStartInFlight ||
656
+ state.codexLogin?.status === "pending"
657
+ ) {
572
658
  throw Object.assign(
573
- new Error("A local endpoint installation is already in progress."),
659
+ new Error("A local endpoint change is already in progress."),
574
660
  { statusCode: 409 },
575
661
  );
576
662
  }
@@ -600,26 +686,105 @@ async function handleApi(request, response, path, state) {
600
686
  return;
601
687
  }
602
688
 
689
+ if (path === "/api/local/client-credential/rotate") {
690
+ requireLiveLocalAction(state, "Local client credential rotation");
691
+ enforceRateLimit(state, path);
692
+ if (
693
+ state.localCredentialRotationInFlight ||
694
+ state.localInstallInFlight ||
695
+ getPendingLocalCredentialRotation(state) ||
696
+ state.codexLoginStartInFlight ||
697
+ state.codexLogin?.status === "pending"
698
+ ) {
699
+ throw Object.assign(
700
+ new Error("A local endpoint change is already in progress."),
701
+ { statusCode: 409 },
702
+ );
703
+ }
704
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
705
+ throw new Error("Choose the installed local endpoint before rotating its credential.");
706
+ }
707
+
708
+ state.localCredentialRotationInFlight = true;
709
+ try {
710
+ const result = await state.services.prepareLocalClientCredentialRotation({
711
+ target: validateLocalTarget(body.target),
712
+ });
713
+ const rotationId = randomUUID();
714
+ state.localCredentialRotationPending = {
715
+ rotationId,
716
+ target: result.target,
717
+ tokenSha256: result.tokenSha256,
718
+ expiresAt: Date.now() + LOCAL_ROTATION_STAGE_TTL_MS,
719
+ };
720
+ sendJson(response, 200, {
721
+ ...createSafeLocalInstallResult(result),
722
+ rotationId,
723
+ });
724
+ } finally {
725
+ state.localCredentialRotationInFlight = false;
726
+ }
727
+ return;
728
+ }
729
+
730
+ if (path === "/api/local/client-credential/activate") {
731
+ requireLiveLocalAction(state, "Local client credential activation");
732
+ enforceRateLimit(state, path);
733
+ if (
734
+ state.localCredentialRotationInFlight ||
735
+ state.localInstallInFlight ||
736
+ state.codexLoginStartInFlight ||
737
+ state.codexLogin?.status === "pending"
738
+ ) {
739
+ throw Object.assign(
740
+ new Error("A local endpoint change is already in progress."),
741
+ { statusCode: 409 },
742
+ );
743
+ }
744
+ const pending = getPendingLocalCredentialRotation(state);
745
+ if (!pending || !tokenMatches(body?.rotationId, pending.rotationId)) {
746
+ throw new Error("Stage a fresh local client credential before activating it.");
747
+ }
748
+
749
+ state.localCredentialRotationPending = null;
750
+ state.localCredentialRotationInFlight = true;
751
+ try {
752
+ const result = await state.services.activateLocalClientCredentialRotation({
753
+ target: pending.target,
754
+ clientCredential: body.clientCredential,
755
+ tokenSha256: pending.tokenSha256,
756
+ });
757
+ sendJson(response, 200, createSafeLocalActivationResult(result));
758
+ } finally {
759
+ state.localCredentialRotationInFlight = false;
760
+ }
761
+ return;
762
+ }
763
+
603
764
  if (path === "/api/local/codex/login") {
604
765
  requireLiveLocalAction(state, "Local Codex sign-in");
605
766
  enforceRateLimit(state, path);
606
- if (state.codexLoginStartInFlight) {
767
+ if (
768
+ state.codexLoginStartInFlight ||
769
+ state.localInstallInFlight ||
770
+ state.localCredentialRotationInFlight ||
771
+ getPendingLocalCredentialRotation(state)
772
+ ) {
607
773
  throw Object.assign(
608
- new Error("A local Codex sign-in start is already in progress."),
774
+ new Error("A local endpoint change is already in progress."),
609
775
  { statusCode: 409 },
610
776
  );
611
777
  }
612
778
 
613
779
  state.codexLoginStartInFlight = true;
780
+ let finishStart;
781
+ const startPromise = new Promise((resolvePromise) => {
782
+ finishStart = resolvePromise;
783
+ });
784
+ state.codexLoginStartPromise = startPromise;
785
+ let releaseChangeLock;
786
+ let lockTransferred = false;
614
787
  try {
615
- const installDirectory = await state.services.resolveLocalInstallRoot({
616
- target: "codex-chatgpt",
617
- });
618
- const { dockerHost, projectName } =
619
- await state.services.attestLocalCodexInstallation({
620
- installDirectory,
621
- });
622
-
623
788
  const previous = state.codexLogin;
624
789
  if (previous?.status === "pending") {
625
790
  state.codexLogin = null;
@@ -630,26 +795,61 @@ async function handleApi(request, response, path, state) {
630
795
  // A fresh attempt intentionally supersedes the old device-code login.
631
796
  }
632
797
  }
798
+ if (state.closing) {
799
+ throw Object.assign(new Error("The local wizard is closing."), {
800
+ statusCode: 409,
801
+ });
802
+ }
803
+
804
+ releaseChangeLock = await state.services.acquireLocalEndpointChangeLock({
805
+ target: "codex-chatgpt",
806
+ });
807
+ if (state.closing) {
808
+ throw Object.assign(new Error("The local wizard is closing."), {
809
+ statusCode: 409,
810
+ });
811
+ }
812
+ const installDirectory = await state.services.resolveLocalInstallRoot({
813
+ target: "codex-chatgpt",
814
+ });
815
+ const { dockerHost, projectName } =
816
+ await state.services.attestLocalCodexInstallation({
817
+ installDirectory,
818
+ });
633
819
 
634
820
  const attempt = await state.services.startCodexDeviceLogin({
635
821
  installDirectory,
636
822
  dockerHost,
637
823
  projectName,
638
824
  });
825
+ if (state.closing) {
826
+ attempt.cancel();
827
+ try {
828
+ await attempt.completion;
829
+ } catch {
830
+ // Shutdown intentionally cancels a helper that finished starting late.
831
+ }
832
+ throw Object.assign(new Error("The local wizard is closing."), {
833
+ statusCode: 409,
834
+ });
835
+ }
639
836
  const login = {
640
837
  cancel: attempt.cancel,
641
- completion: attempt.completion,
838
+ completion: null,
642
839
  error: null,
643
840
  status: "pending",
644
841
  };
645
842
  state.codexLogin = login;
646
- void (async () => {
843
+ login.completion = (async () => {
647
844
  try {
648
- await login.completion;
845
+ await attempt.completion;
649
846
  if (state.codexLogin !== login || state.closing) {
650
847
  return;
651
848
  }
652
- await state.services.restartLocalCodex({ installDirectory });
849
+ await state.services.restartLocalCodex(
850
+ { installDirectory },
851
+ { changeLockHeld: true },
852
+ );
653
853
  if (state.codexLogin === login && !state.closing) {
654
854
  login.status = "success";
655
855
  }
@@ -658,14 +858,24 @@ async function handleApi(request, response, path, state) {
658
858
  login.status = "error";
659
859
  login.error = safeErrorMessage(error);
660
860
  }
861
+ } finally {
862
+ await releaseChangeLock();
661
863
  }
662
864
  })();
865
+ lockTransferred = true;
663
866
  sendJson(response, 200, {
664
867
  verificationUrl: attempt.verificationUrl,
665
868
  userCode: attempt.userCode,
666
869
  });
667
870
  } finally {
871
+ if (!lockTransferred && releaseChangeLock) {
872
+ await releaseChangeLock();
873
+ }
668
874
  state.codexLoginStartInFlight = false;
875
+ finishStart();
876
+ if (state.codexLoginStartPromise === startPromise) {
877
+ state.codexLoginStartPromise = null;
878
+ }
669
879
  }
670
880
  return;
671
881
  }
@@ -877,8 +1087,11 @@ export async function startWizardServer({
877
1087
  oauthLoginStartPromise: null,
878
1088
  localPlan: null,
879
1089
  localInstallInFlight: false,
1090
+ localCredentialRotationInFlight: false,
1091
+ localCredentialRotationPending: null,
880
1092
  codexLogin: null,
881
1093
  codexLoginStartInFlight: false,
1094
+ codexLoginStartPromise: null,
882
1095
  rateLimits: new Map(),
883
1096
  previewMode: previewMode === true,
884
1097
  oauthShutdownWaitMs,
@@ -915,6 +1128,14 @@ export async function startWizardServer({
915
1128
  const codexLogin = state.codexLogin;
916
1129
  state.codexLogin = null;
917
1130
  codexLogin?.cancel();
1131
+ await waitForBoundedResult(
1132
+ state.codexLoginStartPromise,
1133
+ state.oauthShutdownWaitMs,
1134
+ );
1135
+ await waitForBoundedResult(
1136
+ codexLogin?.completion,
1137
+ state.oauthShutdownWaitMs,
1138
+ );
918
1139
  state.connection?.close();
919
1140
  state.connection = null;
920
1141
  await waitForBoundedResult(