mulmoclaude 2.3.0 → 2.4.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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
  3. package/client/assets/{index-hiEYYDcg.js → index-ijQN8Kty.js} +396 -396
  4. package/client/assets/{marp-WnOHAJUy.js → marp-DbrRRh1n.js} +1 -1
  5. package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
  6. package/client/index.html +2 -2
  7. package/package.json +10 -10
  8. package/server/api/auth/bearerAuth.ts +10 -24
  9. package/server/api/auth/viewToken.ts +5 -12
  10. package/server/api/routes/files.ts +34 -17
  11. package/server/build/mcp-server.mjs +502 -505
  12. package/server/events/pub-sub/handshakeAuth.ts +9 -0
  13. package/server/events/pub-sub/index.ts +47 -14
  14. package/server/index.ts +1 -1
  15. package/server/plugins/dev-watcher.ts +1 -1
  16. package/server/utils/launcher/preflight.d.mts +8 -5
  17. package/server/utils/launcher/preflight.mjs +34 -8
  18. package/src/App.vue +5 -0
  19. package/src/components/AuthTokenStaleBanner.vue +27 -0
  20. package/src/components/BackendOfflineBanner.vue +13 -24
  21. package/src/components/RemoteHostOfflineBanner.vue +12 -21
  22. package/src/components/StatusBanner.vue +65 -0
  23. package/src/composables/usePubSub.ts +32 -0
  24. package/src/lang/de.ts +5 -0
  25. package/src/lang/en.ts +5 -0
  26. package/src/lang/es.ts +5 -0
  27. package/src/lang/fr.ts +5 -0
  28. package/src/lang/ja.ts +5 -0
  29. package/src/lang/ko.ts +5 -0
  30. package/src/lang/pt-BR.ts +5 -0
  31. package/src/lang/zh.ts +5 -0
  32. package/src/main.ts +1 -1
  33. package/src/utils/api.ts +45 -22
  34. package/src/utils/auth/authToken.ts +14 -0
  35. package/src/utils/auth/authTokenHtml.ts +19 -0
  36. package/src/utils/auth/authTokenRecovery.ts +73 -0
  37. package/src/utils/dom/authTokenMeta.ts +3 -1
  38. package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
  39. package/server/system/logs/aaa +0 -737
  40. package/server/system/logs/bb +0 -446
@@ -0,0 +1,9 @@
1
+ import { isAuthorizedToken } from "@mulmoclaude/common/token-guard";
2
+ import { isRecord } from "../../utils/types.js";
3
+
4
+ // `auth` is whatever the socket.io client put in `io({ auth })`. Only a record
5
+ // whose `token` passes the shared bearer rule connects; a server with no token
6
+ // yet refuses everything, as `bearerAuth` does for HTTP.
7
+ export function isAuthorizedPubSubHandshake(auth: unknown, expectedToken: string | null): boolean {
8
+ return isRecord(auth) && isAuthorizedToken(auth.token, expectedToken);
9
+ }
@@ -1,26 +1,59 @@
1
1
  import http from "http";
2
- import { Server as IOServer } from "socket.io";
2
+ import { Server as IOServer, type Socket } from "socket.io";
3
3
 
4
+ import { UNAUTHORIZED_MESSAGE } from "@mulmoclaude/common/token-guard";
4
5
  import { log } from "../../system/logger/index.js";
6
+ import { isAuthorizedPubSubHandshake } from "./handshakeAuth.js";
5
7
 
6
8
  export interface IPubSub {
7
9
  /** Publish data to all clients subscribed to this channel. */
8
10
  publish: (channel: string, data: unknown) => void;
9
11
  }
10
12
 
13
+ export interface PubSubOptions {
14
+ /** The bearer token a handshake must present. `null` while the server
15
+ * has none yet, which refuses every handshake. */
16
+ tokenProvider: () => string | null;
17
+ }
18
+
11
19
  const logRoomError =
12
20
  (action: string, channel: string) =>
13
21
  (err: unknown): void => {
14
22
  log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
15
23
  };
16
24
 
25
+ // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
26
+ // synchronously and returns undefined, while a clustered adapter goes over
27
+ // the wire. Nothing here depends on the result, but the promise branch
28
+ // still needs a terminal handler — a bare `void` would turn an adapter
29
+ // failure into an unhandled rejection. `Promise.resolve` normalises both.
30
+ function bindRoomEvents(socket: Socket): void {
31
+ socket.on("subscribe", (channel: unknown) => {
32
+ if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
33
+ });
34
+ socket.on("unsubscribe", (channel: unknown) => {
35
+ if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
36
+ });
37
+ }
38
+
39
+ // A refused handshake leaves the engine.io connection open, and that one
40
+ // connection can keep sending CONNECT packets; one line per connection keeps
41
+ // a misconfigured relay diagnosable without letting a loop fill the log.
42
+ const warnedConnections = new WeakSet<object>();
43
+
44
+ function warnRefusedOnce(socket: Socket): void {
45
+ if (warnedConnections.has(socket.conn)) return;
46
+ warnedConnections.add(socket.conn);
47
+ log.warn("pubsub", "handshake refused: bearer token missing or invalid", { address: socket.handshake.address });
48
+ }
49
+
17
50
  // Channel names are treated as socket.io rooms — one room per
18
51
  // channel. Subscribe/unsubscribe is plain `socket.join` /
19
52
  // `socket.leave`. Publish broadcasts to the room. Reconnect /
20
53
  // heartbeat / multi-transport fallback are handled by socket.io
21
54
  // itself, which is why we switched off raw ws.
22
55
 
23
- export function createPubSub(server: http.Server): IPubSub {
56
+ export function createPubSub(server: http.Server, options: PubSubOptions): IPubSub {
24
57
  const ioServer = new IOServer(server, {
25
58
  path: "/ws/pubsub",
26
59
  // Server binds to 127.0.0.1 only, so CORS is moot — but
@@ -36,20 +69,20 @@ export function createPubSub(server: http.Server): IPubSub {
36
69
  transports: ["websocket"],
37
70
  });
38
71
 
39
- ioServer.on("connection", (socket) => {
40
- // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
41
- // synchronously and returns undefined, while a clustered adapter goes over
42
- // the wire. Nothing here depends on the result, but the promise branch
43
- // still needs a terminal handler — a bare `void` would turn an adapter
44
- // failure into an unhandled rejection. `Promise.resolve` normalises both.
45
- socket.on("subscribe", (channel: unknown) => {
46
- if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
47
- });
48
- socket.on("unsubscribe", (channel: unknown) => {
49
- if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
50
- });
72
+ // Same token every `/api/*` call presents (#3433). A refused handshake
73
+ // destroys the client socket, so socket.io does not retry it — the SPA
74
+ // reads that as "reload for a fresh token" rather than spinning.
75
+ ioServer.use((socket, next) => {
76
+ if (isAuthorizedPubSubHandshake(socket.handshake.auth, options.tokenProvider())) {
77
+ next();
78
+ return;
79
+ }
80
+ warnRefusedOnce(socket);
81
+ next(new Error(UNAUTHORIZED_MESSAGE));
51
82
  });
52
83
 
84
+ ioServer.on("connection", bindRoomEvents);
85
+
53
86
  return {
54
87
  publish(channel: string, data: unknown): void {
55
88
  ioServer.to(channel).emit("data", { channel, data });
package/server/index.ts CHANGED
@@ -1548,7 +1548,7 @@ process.on("SIGTERM", () => {
1548
1548
  // (CodeRabbit review on PR #1196). Both `createPubSub` and
1549
1549
  // `initNotifier` are sync, so wiring them up here costs nothing
1550
1550
  // and closes the window.
1551
- const earlyPubsub = createPubSub(httpServer);
1551
+ const earlyPubsub = createPubSub(httpServer, { tokenProvider: getCurrentToken });
1552
1552
  initNotifier({
1553
1553
  publish: (channel, payload) => earlyPubsub.publish(channel, payload),
1554
1554
  });
@@ -6,7 +6,7 @@
6
6
  // chokidar to avoid pulling another runtime dependency. Recursive
7
7
  // watching is reliable on macOS / Linux / Windows starting from Node
8
8
  // 20.12 (Linux had race conditions and crash-on-delete bugs in earlier
9
- // 20.x patches), which engines.node (>=22.19) clears comfortably.
9
+ // 20.x patches), which engines.node clears comfortably.
10
10
  //
11
11
  // The publish + warnServerSideChange callbacks are injected so tests
12
12
  // can exercise the debounce + classification logic without booting the
@@ -1,17 +1,20 @@
1
1
  // Type declarations for preflight.mjs.
2
2
 
3
- export interface NodeVersionRequirement {
3
+ export interface NodeVersion {
4
4
  major: number;
5
5
  minor: number;
6
+ patch: number;
6
7
  }
7
8
 
8
- export const REQUIRED_NODE: NodeVersionRequirement;
9
+ export const REQUIRED_NODE: readonly NodeVersion[];
9
10
 
10
- export function parseNodeVersion(raw: string): NodeVersionRequirement | null;
11
+ export function parseNodeVersion(raw: string): NodeVersion | null;
11
12
 
12
- export function isNodeVersionSupported(raw: string, required?: NodeVersionRequirement): boolean;
13
+ export function isNodeVersionSupported(raw: string, required?: readonly NodeVersion[]): boolean;
13
14
 
14
- export function formatRequiredNode(required?: NodeVersionRequirement): string;
15
+ export function formatRequiredNode(required?: readonly NodeVersion[]): string;
16
+
17
+ export function formatEnginesRange(required?: readonly NodeVersion[]): string;
15
18
 
16
19
  export interface CommandProbeOptions {
17
20
  env?: Record<string, string | undefined>;
@@ -9,7 +9,13 @@ import { execFileSync } from "node:child_process";
9
9
 
10
10
  // Mirrors `engines.node` in packages/mulmoclaude/package.json.
11
11
  // test/utils/launcher/test_preflight.ts fails if the two drift.
12
- export const REQUIRED_NODE = { major: 22, minor: 19 };
12
+ // One floor per supported major; the last also admits every later major,
13
+ // and a major with no floor of its own (23, 25) is unsupported.
14
+ export const REQUIRED_NODE = [
15
+ { major: 22, minor: 22, patch: 2 },
16
+ { major: 24, minor: 15, patch: 0 },
17
+ { major: 26, minor: 0, patch: 0 },
18
+ ];
13
19
 
14
20
  // These probes run a local binary that reads its own package metadata;
15
21
  // anything past a few seconds means it is wedged, not slow.
@@ -22,30 +28,50 @@ const COMMAND_PROBE_TIMEOUT_MS = 5000;
22
28
  * Returns null for anything unparseable rather than guessing — an
23
29
  * unknown version must not silently pass the requirement.
24
30
  * @param {string} raw
25
- * @returns {{ major: number, minor: number } | null}
31
+ * @returns {import("./preflight.d.mts").NodeVersion | null}
26
32
  */
27
33
  export function parseNodeVersion(raw) {
28
34
  if (typeof raw !== "string") return null;
29
- const match = /^v?(\d+)\.(\d+)\./.exec(raw.trim());
35
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
30
36
  if (!match) return null;
31
- return { major: Number(match[1]), minor: Number(match[2]) };
37
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
38
+ }
39
+
40
+ function compareVersions(left, right) {
41
+ return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
42
+ }
43
+
44
+ function floorFor(found, required) {
45
+ const newest = required[required.length - 1];
46
+ if (found.major > newest.major) return newest;
47
+ return required.find((floor) => floor.major === found.major);
32
48
  }
33
49
 
34
50
  /**
35
51
  * @param {string} raw
36
- * @param {{ major: number, minor: number }} [required]
52
+ * @param {readonly import("./preflight.d.mts").NodeVersion[]} [required]
37
53
  * @returns {boolean}
38
54
  */
39
55
  export function isNodeVersionSupported(raw, required = REQUIRED_NODE) {
40
56
  const found = parseNodeVersion(raw);
41
57
  if (found === null) return false;
42
- if (found.major !== required.major) return found.major > required.major;
43
- return found.minor >= required.minor;
58
+ const floor = floorFor(found, required);
59
+ return floor !== undefined && compareVersions(found, floor) >= 0;
60
+ }
61
+
62
+ function formatVersion({ major, minor, patch }) {
63
+ return `${major}.${minor}.${patch}`;
44
64
  }
45
65
 
46
66
  /** Human-readable form of a requirement, for the {required} placeholder. */
47
67
  export function formatRequiredNode(required = REQUIRED_NODE) {
48
- return `${required.major}.${required.minor}`;
68
+ return required.map(formatVersion).join(" / ");
69
+ }
70
+
71
+ /** The same requirement as an `engines.node` range. */
72
+ export function formatEnginesRange(required = REQUIRED_NODE) {
73
+ const carets = required.slice(0, -1).map((floor) => `^${formatVersion(floor)}`);
74
+ return [...carets, `>=${formatVersion(required[required.length - 1])}`].join(" || ");
49
75
  }
50
76
 
51
77
  /**
package/src/App.vue CHANGED
@@ -4,6 +4,10 @@
4
4
  it's the first thing the user sees when the server isn't
5
5
  reachable. Self-hiding when fetchHealth succeeds. -->
6
6
  <BackendOfflineBanner :on-retry="fetchHealth" />
7
+ <!-- Stale-token notice (#3438) — the server refused this page's token and
8
+ fetching index.html again did not produce one it accepts. Recovery is
9
+ the normal path after a restart; this is its last resort. -->
10
+ <AuthTokenStaleBanner />
7
11
  <!-- Remote-host disconnect notice (#2535) — shown when the host was meant to
8
12
  be connected (a session is parked) but dropped and a silent reconnect
9
13
  couldn't restore it. Offers a one-click re-login. -->
@@ -361,6 +365,7 @@ import { ref, computed, watch, nextTick, onMounted, onScopeDispose, reactive } f
361
365
  import { useI18n } from "vue-i18n";
362
366
  import { getPlugin } from "./tools";
363
367
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
368
+ import AuthTokenStaleBanner from "./components/AuthTokenStaleBanner.vue";
364
369
  import BackendOfflineBanner from "./components/BackendOfflineBanner.vue";
365
370
  import RemoteHostOfflineBanner from "./components/RemoteHostOfflineBanner.vue";
366
371
  import RightSidebar from "./components/RightSidebar.vue";
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <StatusBanner
3
+ v-if="authTokenStale"
4
+ tone="warning"
5
+ icon="sync_disabled"
6
+ testid="auth-token-stale-banner"
7
+ action-testid="auth-token-stale-reload"
8
+ :title="t('authTokenStale.title')"
9
+ :body="t('authTokenStale.body')"
10
+ :action-label="t('authTokenStale.reload')"
11
+ @action="reload"
12
+ />
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ // Shown once the server refused this page's token and fetching index.html
17
+ // again did not produce one it accepts (#3438). Auto-recovery is the normal
18
+ // path after a server restart; this is its last resort, and a reload is the
19
+ // only thing left that fetches a fresh token.
20
+ import { useI18n } from "vue-i18n";
21
+ import StatusBanner from "./StatusBanner.vue";
22
+ import { authTokenStale } from "../utils/auth/authTokenRecovery";
23
+
24
+ const { t } = useI18n();
25
+
26
+ const reload = (): void => window.location.reload();
27
+ </script>
@@ -1,34 +1,23 @@
1
1
  <template>
2
- <div
2
+ <StatusBanner
3
3
  v-if="!backendReachable"
4
- data-testid="backend-offline-banner"
5
- role="alert"
6
- class="flex items-center gap-2 px-3 py-2 bg-red-50 border-b border-red-200 text-red-700 text-sm"
7
- >
8
- <span class="material-icons text-base" aria-hidden="true">cloud_off</span>
9
- <div class="flex-1 min-w-0">
10
- <div class="font-medium">{{ t("backendOffline.title") }}</div>
11
- <div class="text-xs text-red-600 truncate">
12
- <span>{{ t("backendOffline.body") }}</span>
13
- <span v-if="lastBackendError" class="opacity-70">{{ ` — ${lastBackendError}` }}</span>
14
- </div>
15
- </div>
16
- <button
17
- type="button"
18
- data-testid="backend-offline-retry"
19
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-red-300 text-red-700 hover:bg-red-100 disabled:opacity-40"
20
- :disabled="retrying"
21
- @click="retry"
22
- >
23
- <span class="material-icons text-sm" :class="retrying ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
24
- {{ t("backendOffline.retry") }}
25
- </button>
26
- </div>
4
+ tone="error"
5
+ icon="cloud_off"
6
+ testid="backend-offline-banner"
7
+ action-testid="backend-offline-retry"
8
+ :title="t('backendOffline.title')"
9
+ :body="t('backendOffline.body')"
10
+ :detail="lastBackendError"
11
+ :action-label="t('backendOffline.retry')"
12
+ :busy="retrying"
13
+ @action="retry"
14
+ />
27
15
  </template>
28
16
 
29
17
  <script setup lang="ts">
30
18
  import { ref } from "vue";
31
19
  import { useI18n } from "vue-i18n";
20
+ import StatusBanner from "./StatusBanner.vue";
32
21
  import { backendReachable, lastBackendError } from "../utils/api";
33
22
 
34
23
  const { t } = useI18n();
@@ -1,26 +1,16 @@
1
1
  <template>
2
- <div
2
+ <StatusBanner
3
3
  v-if="needsReconnect"
4
- data-testid="remote-host-offline-banner"
5
- role="alert"
6
- class="flex items-center gap-2 px-3 py-2 bg-amber-50 border-b border-amber-200 text-amber-800 text-sm"
7
- >
8
- <span class="material-icons text-base" aria-hidden="true">phonelink_off</span>
9
- <div class="flex-1 min-w-0">
10
- <div class="font-medium">{{ t("remoteHostOffline.title") }}</div>
11
- <div class="text-xs text-amber-700 truncate">{{ t("remoteHostOffline.body") }}</div>
12
- </div>
13
- <button
14
- type="button"
15
- data-testid="remote-host-offline-reconnect"
16
- class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-amber-300 text-amber-800 hover:bg-amber-100 disabled:opacity-40"
17
- :disabled="busy"
18
- @click="reconnect"
19
- >
20
- <span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
21
- {{ t("remoteHostOffline.reconnect") }}
22
- </button>
23
- </div>
4
+ tone="warning"
5
+ icon="phonelink_off"
6
+ testid="remote-host-offline-banner"
7
+ action-testid="remote-host-offline-reconnect"
8
+ :title="t('remoteHostOffline.title')"
9
+ :body="t('remoteHostOffline.body')"
10
+ :action-label="t('remoteHostOffline.reconnect')"
11
+ :busy="busy"
12
+ @action="reconnect"
13
+ />
24
14
  </template>
25
15
 
26
16
  <script setup lang="ts">
@@ -29,6 +19,7 @@
29
19
  // session expired). Reads the shared `useRemoteHost` store; Reconnect re-runs the
30
20
  // Google sign-in popup from this click (a user gesture, so the popup isn't blocked).
31
21
  import { useI18n } from "vue-i18n";
22
+ import StatusBanner from "./StatusBanner.vue";
32
23
  import { useRemoteHost } from "../composables/useRemoteHost";
33
24
 
34
25
  const { t } = useI18n();
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div :data-testid="testid" role="alert" class="flex items-center gap-2 px-3 py-2 border-b text-sm" :class="palette.root">
3
+ <span class="material-icons text-base" aria-hidden="true">{{ icon }}</span>
4
+ <div class="flex-1 min-w-0">
5
+ <div class="font-medium">{{ title }}</div>
6
+ <div class="text-xs truncate" :class="palette.body">
7
+ <span>{{ body }}</span>
8
+ <span v-if="detail" class="opacity-70">{{ ` — ${detail}` }}</span>
9
+ </div>
10
+ </div>
11
+ <button
12
+ type="button"
13
+ :data-testid="actionTestid"
14
+ class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border disabled:opacity-40"
15
+ :class="palette.button"
16
+ :disabled="busy"
17
+ @click="emit('action')"
18
+ >
19
+ <span class="material-icons text-sm" :class="busy ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
20
+ {{ actionLabel }}
21
+ </button>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ // The one chrome for a page-wide status notice pinned above the top bar: an
27
+ // icon, a title, one line of body with optional small print, and one action.
28
+ // The wrapper that mounts it owns the signal (`v-if`) and the action.
29
+ import { computed } from "vue";
30
+
31
+ const PALETTES = {
32
+ error: {
33
+ root: "bg-red-50 border-red-200 text-red-700",
34
+ body: "text-red-600",
35
+ button: "border-red-300 text-red-700 hover:bg-red-100",
36
+ },
37
+ warning: {
38
+ root: "bg-amber-50 border-amber-200 text-amber-800",
39
+ body: "text-amber-700",
40
+ button: "border-amber-300 text-amber-800 hover:bg-amber-100",
41
+ },
42
+ } as const;
43
+
44
+ const props = withDefaults(
45
+ defineProps<{
46
+ tone: keyof typeof PALETTES;
47
+ /** A Material Icons name. */
48
+ icon: string;
49
+ title: string;
50
+ body: string;
51
+ /** Small print after the body, rendered dimmer. */
52
+ detail?: string | null;
53
+ actionLabel: string;
54
+ /** Disables the action and spins its icon. */
55
+ busy?: boolean;
56
+ testid: string;
57
+ actionTestid: string;
58
+ }>(),
59
+ { detail: null, busy: false },
60
+ );
61
+
62
+ const emit = defineEmits<{ action: [] }>();
63
+
64
+ const palette = computed(() => PALETTES[props.tone]);
65
+ </script>
@@ -1,4 +1,6 @@
1
1
  import { io, type Socket } from "socket.io-client";
2
+ import { getAuthToken } from "../utils/auth/authToken";
3
+ import { authTokenStale, recoverAuthToken } from "../utils/auth/authTokenRecovery";
2
4
 
3
5
  interface PubSubMessage {
4
6
  channel: string;
@@ -35,6 +37,23 @@ function fireReconnectHandlers(): void {
35
37
  }
36
38
  }
37
39
 
40
+ /** The server refused the handshake. The token is regenerated at every server
41
+ * start, so this is what a restart looks like from a page that stayed open;
42
+ * index.html carries the new token, and `auth` re-reads it on the next
43
+ * CONNECT. socket.io does not retry a refusal on its own (#3433), so the
44
+ * reconnect is explicit — and only with a token the server has not refused
45
+ * yet, so a second refusal ends with the banner rather than a loop (#3438). */
46
+ async function reconnectWithFreshToken(refusedSocket: Socket): Promise<void> {
47
+ const recovery = await recoverAuthToken();
48
+ // `maybeDisconnect` may have let go of this socket while index.html was fetched.
49
+ if (socket !== refusedSocket) return;
50
+ if (recovery === "installed") {
51
+ refusedSocket.connect();
52
+ return;
53
+ }
54
+ authTokenStale.value = true;
55
+ }
56
+
38
57
  function connect(): Socket {
39
58
  if (socket) return socket;
40
59
 
@@ -42,9 +61,13 @@ function connect(): Socket {
42
61
  path: "/ws/pubsub",
43
62
  // Server refuses long-polling fallback, so fail fast here too if the WS upgrade doesn't go through.
44
63
  transports: ["websocket"],
64
+ // The same bearer token every `apiCall` attaches (#3433). A function, so
65
+ // each CONNECT re-reads it instead of re-sending the first value.
66
+ auth: (provide) => provide({ token: getAuthToken() }),
45
67
  });
46
68
 
47
69
  sock.on("connect", () => {
70
+ authTokenStale.value = false;
48
71
  resendSubscriptions(sock);
49
72
  if (hasConnectedOnce) {
50
73
  fireReconnectHandlers();
@@ -53,6 +76,15 @@ function connect(): Socket {
53
76
  }
54
77
  });
55
78
 
79
+ sock.on("connect_error", (err: Error) => {
80
+ // A transport failure leaves `active` true and socket.io retries it on its
81
+ // own. A refusal from the server middleware destroys the socket (`active`
82
+ // false) and nothing else will retry it.
83
+ if (sock.active) return;
84
+ console.error("[usePubSub] handshake refused by the server:", err.message);
85
+ void reconnectWithFreshToken(sock);
86
+ });
87
+
56
88
  sock.on("data", (msg: PubSubMessage) => {
57
89
  const cbs = listeners.get(msg.channel);
58
90
  if (cbs) {
package/src/lang/de.ts CHANGED
@@ -139,6 +139,11 @@ const deMessages = {
139
139
  body: "Ihr Telefon kann erst nach dem erneuten Verbinden an dieses Gerät senden.",
140
140
  reconnect: "Erneut verbinden",
141
141
  },
142
+ authTokenStale: {
143
+ title: "Diese Seite muss neu geladen werden",
144
+ body: "Der Server hat das Token dieser Seite abgelehnt, und ein neues konnte nicht abgerufen werden. Laden Sie die Seite neu, um fortzufahren.",
145
+ reload: "Neu laden",
146
+ },
142
147
  remoteHost: {
143
148
  title: "Remote-Host",
144
149
  online: "Remote-Host online",
package/src/lang/en.ts CHANGED
@@ -157,6 +157,11 @@ const enMessages = {
157
157
  body: "Your phone can't send to this device until you reconnect.",
158
158
  reconnect: "Reconnect",
159
159
  },
160
+ authTokenStale: {
161
+ title: "This page needs a reload",
162
+ body: "The server refused this page's token and a new one could not be fetched. Reload to continue.",
163
+ reload: "Reload",
164
+ },
160
165
  remoteHost: {
161
166
  title: "Remote host",
162
167
  online: "Remote host online",
package/src/lang/es.ts CHANGED
@@ -142,6 +142,11 @@ const esMessages = {
142
142
  body: "Tu teléfono no podrá enviar a este dispositivo hasta que vuelvas a conectarte.",
143
143
  reconnect: "Reconectar",
144
144
  },
145
+ authTokenStale: {
146
+ title: "Esta página necesita recargarse",
147
+ body: "El servidor rechazó el token de esta página y no se pudo obtener uno nuevo. Recarga para continuar.",
148
+ reload: "Recargar",
149
+ },
145
150
  remoteHost: {
146
151
  title: "Host remoto",
147
152
  online: "Host remoto en línea",
package/src/lang/fr.ts CHANGED
@@ -137,6 +137,11 @@ const frMessages = {
137
137
  body: "Votre téléphone ne pourra pas envoyer vers cet appareil tant que vous n'êtes pas reconnecté.",
138
138
  reconnect: "Se reconnecter",
139
139
  },
140
+ authTokenStale: {
141
+ title: "Cette page doit être rechargée",
142
+ body: "Le serveur a refusé le jeton de cette page et un nouveau n'a pas pu être récupéré. Rechargez pour continuer.",
143
+ reload: "Recharger",
144
+ },
140
145
  remoteHost: {
141
146
  title: "Hôte distant",
142
147
  online: "Hôte distant en ligne",
package/src/lang/ja.ts CHANGED
@@ -142,6 +142,11 @@ const jaMessages = {
142
142
  body: "再接続するまで、スマホからの送信はこの端末に届きません。",
143
143
  reconnect: "再接続",
144
144
  },
145
+ authTokenStale: {
146
+ title: "ページの再読み込みが必要です",
147
+ body: "サーバーがこのページのトークンを拒否し、新しいトークンを取得できませんでした。続けるには再読み込みしてください。",
148
+ reload: "再読み込み",
149
+ },
145
150
  remoteHost: {
146
151
  title: "リモートホスト",
147
152
  online: "リモートホスト: オンライン",
package/src/lang/ko.ts CHANGED
@@ -143,6 +143,11 @@ const koMessages = {
143
143
  body: "다시 연결하기 전에는 휴대폰에서 이 기기로 보낼 수 없습니다.",
144
144
  reconnect: "다시 연결",
145
145
  },
146
+ authTokenStale: {
147
+ title: "페이지를 새로 고쳐야 합니다",
148
+ body: "서버가 이 페이지의 토큰을 거부했고 새 토큰을 가져오지 못했습니다. 계속하려면 새로 고침하세요.",
149
+ reload: "새로 고침",
150
+ },
146
151
  remoteHost: {
147
152
  title: "원격 호스트",
148
153
  online: "원격 호스트 온라인",
package/src/lang/pt-BR.ts CHANGED
@@ -137,6 +137,11 @@ const ptBRMessages = {
137
137
  body: "Seu telefone não conseguirá enviar para este dispositivo até você reconectar.",
138
138
  reconnect: "Reconectar",
139
139
  },
140
+ authTokenStale: {
141
+ title: "Esta página precisa ser recarregada",
142
+ body: "O servidor recusou o token desta página e não foi possível obter um novo. Recarregue para continuar.",
143
+ reload: "Recarregar",
144
+ },
140
145
  remoteHost: {
141
146
  title: "Host remoto",
142
147
  online: "Host remoto on-line",
package/src/lang/zh.ts CHANGED
@@ -138,6 +138,11 @@ const zhMessages = {
138
138
  body: "重新连接之前,手机将无法发送到此设备。",
139
139
  reconnect: "重新连接",
140
140
  },
141
+ authTokenStale: {
142
+ title: "此页面需要重新加载",
143
+ body: "服务器拒绝了此页面的令牌,且无法获取新令牌。请重新加载以继续。",
144
+ reload: "重新加载",
145
+ },
141
146
  remoteHost: {
142
147
  title: "远程主机",
143
148
  online: "远程主机在线",
package/src/main.ts CHANGED
@@ -9,7 +9,7 @@ import App from "./App.vue";
9
9
  import router from "./router/index";
10
10
  import { installGuards } from "./router/guards";
11
11
  import i18n from "./lib/vue-i18n";
12
- import { setAuthToken } from "./utils/api";
12
+ import { setAuthToken } from "./utils/auth/authToken";
13
13
  import { readAuthTokenFromMeta } from "./utils/dom/authTokenMeta";
14
14
  import { loadRuntimePlugins } from "./tools/runtimeLoader";
15
15
  import { startDevPluginReloadListener } from "./composables/useDevPluginReload";