From 710235ceac1cec83925ae730394e2ab2586f4850 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Mon, 17 Aug 2026 09:31:32 +0000
Subject: [PATCH 1/3] refactor(multiplayer): collapse the repeated server+JWT
preamble
Six call sites resolved the server URL and JWT with the same four lines and the
same two error keys. listActiveSessions keeps its own copy: it degrades to an
empty list instead of throwing.
---
src/services/multiplayerService.ts | 93 ++++++++++++++++++++++--------
1 file changed, 69 insertions(+), 24 deletions(-)
diff --git a/src/services/multiplayerService.ts b/src/services/multiplayerService.ts
index b16582a95..07c4c8e5a 100644
--- a/src/services/multiplayerService.ts
+++ b/src/services/multiplayerService.ts
@@ -4,6 +4,7 @@ import { getVaultKey } from "@/services/vault";
import * as teamService from "@/services/teamService";
import { freshPublicKeys, type InviteTarget } from "@/services/teamSharing";
import { appFetch } from "@/services/http";
+import { normalizeShortCode } from "@/services/shortCode";
import { openXChaCha20Poly1305, sealXChaCha20Poly1305 } from "@/services/crypto/xchacha";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -118,6 +119,18 @@ export async function unwrapSessionKey(
// ─── Server API ───────────────────────────────────────────────────────────────
+/**
+ * Server URL and JWT, or a throw naming whichever half is missing. Callers that
+ * degrade instead of failing (`listActiveSessions`) read the two values directly.
+ */
+async function requireServer(): Promise<{ serverUrl: string; jwt: string }> {
+ const serverUrl = await teamService.getServerUrlValue();
+ if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
+ const jwt = await teamService.getJwtToken();
+ if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ return { serverUrl, jwt };
+}
+
export async function listActiveSessions(): Promise {
const serverUrl = await teamService.getServerUrlValue();
if (!serverUrl) return [];
@@ -196,10 +209,7 @@ export async function createVaultSession(
): Promise<{ sessionId: string; sessionKey: SessionKey; sessionKeyBytes: Uint8Array }> {
const { sessionKey, sessionKeyBytes, wrappedKeys } = await prepareWrappedSessionKey(members);
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
const res = await appFetch(`${serverUrl}/v1/terminal-sessions`, {
method: "POST",
@@ -231,10 +241,7 @@ export async function createDirectSession(
): Promise<{ sessionId: string; sessionKey: SessionKey; sessionKeyBytes: Uint8Array }> {
const { sessionKey, sessionKeyBytes, wrappedKeys } = await prepareWrappedSessionKey(invitees);
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
const res = await appFetch(`${serverUrl}/v1/terminal-sessions`, {
method: "POST",
@@ -268,10 +275,7 @@ export async function inviteUserToSession(
// wrapping to a cached key fails with aead::Error on the recipient (#66).
const wrappedKey = await wrapSessionKeyForUser(sessionKeyBytes, await resolveStrangerPublicKey(target.user_id));
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
const res = await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}/invitees`, {
method: "POST",
@@ -291,10 +295,7 @@ export async function inviteUserToSession(
export async function createInviteLinkSession(
connectionName: string,
): Promise<{ sessionId: string; sessionKey: SessionKey; inviteToken: string }> {
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
const sessionKeyBytes = crypto.getRandomValues(new Uint8Array(32));
const sessionKey = await importSessionKey(sessionKeyBytes);
@@ -318,14 +319,61 @@ export async function createInviteLinkSession(
return { sessionId: session_id, sessionKey, inviteToken: invite_token as string };
}
+/**
+ * Host: mint a short code for an already-live invite-link session. Minting revokes
+ * any previous code server-side, so the value returned here is the only live one.
+ */
+export async function mintSessionCode(
+ sessionId: string,
+): Promise<{ code: string; expiresAt: string }> {
+ const { serverUrl, jwt } = await requireServer();
+
+ const res = await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}/code`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ if (!res.ok) throw new Error(i18n.t("common.error.failedToMintInviteCode", { status: res.status }));
+ const { code, expires_at } = await res.json();
+
+ return { code: code as string, expiresAt: expires_at as string };
+}
+
+/**
+ * Guest: exchange a short code for the session it belongs to and a join secret of
+ * this guest's own. The secret is what every later request presents; the code is
+ * never sent again.
+ */
+export async function redeemSessionCode(
+ code: string,
+): Promise<{ sessionId: string; inviteToken: string }> {
+ const normalized = normalizeShortCode(code);
+ if (!normalized) throw new Error(i18n.t("common.error.inviteCodeMalformed"));
+
+ const { serverUrl, jwt } = await requireServer();
+
+ const res = await appFetch(`${serverUrl}/v1/terminal-sessions/redeem`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ code: normalized }),
+ });
+ // The server answers 404 for unknown, expired and revoked alike — deliberately,
+ // so a wrong code reveals nothing. Do not invent a distinction here.
+ if (res.status === 404) throw new Error(i18n.t("common.error.inviteCodeNotFound"));
+ if (res.status === 429) throw new Error(i18n.t("common.error.inviteCodeTooManyAttempts"));
+ if (!res.ok) throw new Error(i18n.t("common.error.failedToRedeemInviteCode", { status: res.status }));
+ const { session_id, invite_token } = await res.json();
+
+ return { sessionId: session_id as string, inviteToken: invite_token as string };
+}
+
export async function getMySessionKey(
sessionId: string,
inviteToken?: string,
): Promise<{ sessionKey: SessionKey; hostPublicKey: string }> {
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
const url = inviteToken
? `${serverUrl}/v1/terminal-sessions/${sessionId}/my-key?invite_token=${encodeURIComponent(inviteToken)}`
@@ -352,10 +400,7 @@ export async function getMySessionKey(
}
export async function endMultiplayerSession(sessionId: string): Promise {
- const serverUrl = await teamService.getServerUrlValue();
- if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer"));
- const jwt = await teamService.getJwtToken();
- if (!jwt) throw new Error(i18n.t("common.error.notAuthenticated"));
+ const { serverUrl, jwt } = await requireServer();
await appFetch(`${serverUrl}/v1/terminal-sessions/${sessionId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${jwt}` },
From df6f6c5ef7e7ec29a581a65df61d69fad2d3bbc2 Mon Sep 17 00:00:00 2001
From: kipavy
Date: Mon, 17 Aug 2026 09:31:41 +0000
Subject: [PATCH 2/3] fix(share): keep an invite link reachable after the menu
closes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The server returns invite_token once, at creation, and it was held only in
ShareMenu's local state — so reopening the menu on a session that was already
sharing showed an empty Link tab, with no way back to the link short of
stopping and restarting the share. Retain it on the connection instead.
---
src/components/terminal/ShareMenu.tsx | 15 ++++++++++++---
src/stores/teamSessionStore.ts | 4 ++++
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/src/components/terminal/ShareMenu.tsx b/src/components/terminal/ShareMenu.tsx
index f3d503a60..98164e71c 100644
--- a/src/components/terminal/ShareMenu.tsx
+++ b/src/components/terminal/ShareMenu.tsx
@@ -10,6 +10,7 @@ import { uninviteFromSession } from "@/services/teamService";
import { guestCapFor, highestOwnerTier, inviteSessionOf, membersOfTeams, seatUsage, type InviteSession, type InviteTarget, type ShareTier } from "@/services/teamSharing";
import { useDelayedUnmount } from "@/hooks/useDelayedUnmount";
import { InviteCodeField } from "./InviteCodeField";
+import { SpokenCodeRow } from "./SpokenCodeRow";
import { PeopleTab } from "./PeopleTab";
import { ParticipantsRatioNotice } from "./ParticipantsRatioNotice";
@@ -59,6 +60,10 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
const activeMp = mpConnections[activeSessionId];
const isSharing = !!activeMp && !activeMp.ended;
+ // The store's copy outlives this menu's local state, so reopening a sharing
+ // session shows the link it already has instead of an empty tab.
+ const linkToken = activeMp?.inviteToken ?? inviteLinkToken;
+
// The server's record of this session, if one exists yet — the source of truth for
// vault scope and per-invitee grants (#66). Empty until this local session has a
// multiplayer counterpart the server has told us about.
@@ -313,7 +318,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
connectionName={connectionName}
loading={loading}
guestCap={guestCap}
- inviteLinkToken={inviteLinkToken}
+ inviteLinkToken={linkToken}
autoCopied={autoCopied}
tier={tier}
inviteSession={inviteSession}
@@ -394,7 +399,7 @@ export function ShareMenu({ anchorRef, open, onClose, activeSessionId, connectio
) : (
+
+
)}
@@ -673,6 +679,9 @@ function InviteLinkTab({
{t("terminal.share.shareCodeDescription")}
+
+
+
>
) : (
<>
diff --git a/src/stores/teamSessionStore.ts b/src/stores/teamSessionStore.ts
index 3914e013e..afc6fbc86 100644
--- a/src/stores/teamSessionStore.ts
+++ b/src/stores/teamSessionStore.ts
@@ -79,6 +79,9 @@ export interface MultiplayerSessionState {
vaultOwnerTier?: string;
// Raw session key bytes, retained so a live E2EE session can invite more members later (#66).
sessionKeyBytes?: Uint8Array;
+ // Invite-link sessions only. Retained because the server returns it once, at
+ // creation: without it a host who reopens ShareMenu can never see the link again.
+ inviteToken?: string;
// Runtime-only wiring between the terminal view and store; never persisted.
_termWrite?: (data: Uint8Array) => void;
_pendingOutput?: Uint8Array;
@@ -162,6 +165,7 @@ async function attachAsHost(
[localSessionId]: {
multiplayerSessionId: sessionId, role: "host", myUserId, participants: [], controlHolder: "", controlRequester: null,
connection: conn, vaultOwnerTier: extra.vaultOwnerTier, sessionKeyBytes: extra.sessionKeyBytes,
+ inviteToken: extra.inviteToken,
},
},
}));
From 4b73c64d42addc51b2ae1667018d270c6ad3a29c Mon Sep 17 00:00:00 2001
From: kipavy
Date: Mon, 17 Aug 2026 09:31:52 +0000
Subject: [PATCH 3/3] feat(share): short spoken codes for live session invites
Hosts can mint a 10-character Crockford code for an invite-link session and read
it down a phone line; the server kills it after ten minutes, so what lands in
chat scrollback or a clipboard manager is a dead credential rather than a
session-lifetime token.
Minted on demand, not alongside the link, so the window starts when the host
needs it. Guests can paste a code anywhere the other invite shapes already work:
detection stays synchronous on shape, and only activating a join costs a request.
Joining by a bare sessionId:token now requires a real session id. TeamSessions
previously accepted any colon-separated pair, unlike OmniSearch, which had always
been strict; one existing test carried the looser contract and moved to a real
UUID.
---
src/components/hosts/TeamSessions.test.tsx | 8 +-
src/components/hosts/TeamSessions.tsx | 11 +-
src/components/omni/OmniSearch.tsx | 27 ++-
.../terminal/SpokenCodeRow.test.tsx | 163 ++++++++++++++++++
src/components/terminal/SpokenCodeRow.tsx | 108 ++++++++++++
src/i18n/locales/en/common.json | 7 +-
src/i18n/locales/en/terminal.json | 8 +-
src/i18n/locales/fr/common.json | 7 +-
src/i18n/locales/fr/terminal.json | 8 +-
src/i18n/locales/ru/common.json | 7 +-
src/i18n/locales/ru/terminal.json | 8 +-
src/i18n/locales/zh/common.json | 7 +-
src/i18n/locales/zh/terminal.json | 8 +-
.../multiplayerService.shortCode.test.ts | 78 +++++++++
src/services/resolveJoinInput.test.ts | 62 +++++++
src/services/resolveJoinInput.ts | 33 ++++
src/services/shortCode.test.ts | 53 ++++++
src/services/shortCode.ts | 32 ++++
18 files changed, 605 insertions(+), 30 deletions(-)
create mode 100644 src/components/terminal/SpokenCodeRow.test.tsx
create mode 100644 src/components/terminal/SpokenCodeRow.tsx
create mode 100644 src/services/multiplayerService.shortCode.test.ts
create mode 100644 src/services/resolveJoinInput.test.ts
create mode 100644 src/services/resolveJoinInput.ts
create mode 100644 src/services/shortCode.test.ts
create mode 100644 src/services/shortCode.ts
diff --git a/src/components/hosts/TeamSessions.test.tsx b/src/components/hosts/TeamSessions.test.tsx
index 1b6f0ff3e..03cf9effd 100644
--- a/src/components/hosts/TeamSessions.test.tsx
+++ b/src/components/hosts/TeamSessions.test.tsx
@@ -79,6 +79,8 @@ const { teamState, sessionState, uiState, getMyUserId, accessibleVaultIds } = h;
import { TeamSessions } from "./TeamSessions";
+const SESSION_ID = "8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90";
+
const active = (o: Partial<{
id: string; connection_name: string; host_user_id: string;
participant_count: number; participants: { user_id: string; handle: string }[]; vault_ids: string[];
@@ -178,10 +180,12 @@ test("valid code calls joinSession with sessionId + token", async () => {
render();
fireEvent.click(screen.getByText("hosts.teamSessions.joinByCode"));
const input = screen.getByPlaceholderText("hosts.teamSessions.inviteCodePlaceholder");
- fireEvent.change(input, { target: { value: "sess-9:tok-9" } });
+ // A real session id: the field now rejects shapes that only look like one, so
+ // `host:22` and friends can no longer reach the join call.
+ fireEvent.change(input, { target: { value: `${SESSION_ID}:tok-9` } });
fireEvent.click(screen.getByText("hosts.teamSessions.join"));
await waitFor(() =>
- expect(teamState.joinSession).toHaveBeenCalledWith("sess-9", expect.any(Function), "tok-9"),
+ expect(teamState.joinSession).toHaveBeenCalledWith(SESSION_ID, expect.any(Function), "tok-9"),
);
});
diff --git a/src/components/hosts/TeamSessions.tsx b/src/components/hosts/TeamSessions.tsx
index 1a09c2508..33cad7c75 100644
--- a/src/components/hosts/TeamSessions.tsx
+++ b/src/components/hosts/TeamSessions.tsx
@@ -10,7 +10,7 @@ import { useAccessibleVaultIds } from "@/hooks/useAccessibleVaultIds";
import { AvatarStack } from "@/components/shared/AvatarStack";
import { AvatarTile } from "@/components/shared/AvatarTile";
import { BaseCard } from "@/components/shared/BaseCard";
-import { parseInviteCode } from "@/services/inviteCode";
+import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput";
import { joinTeamSessionAndOpenTab } from "@/services/teamSessionJoin";
import { sessionDisplayName } from "@/services/teamSharing";
@@ -110,17 +110,18 @@ export function TeamSessions() {
const code = inviteCode.trim();
if (!code) return;
- const parsed = parseInviteCode(code);
- if (!parsed) {
+ if (!isJoinInput(code)) {
setJoinError(t("hosts.teamSessions.invalidCodeFormat"));
return;
}
- const { sessionId, token } = parsed;
setJoinLoading(true);
setJoinError(null);
try {
- await doJoinSession(sessionId, token);
+ // A short code is exchanged for a session and a secret here; the other two
+ // shapes already carry theirs.
+ const { sessionId, inviteToken } = await resolveJoinInput(code);
+ await doJoinSession(sessionId, inviteToken);
setShowJoinModal(false);
} catch (err) {
setJoinError(err instanceof Error ? err.message : t("hosts.teamSessions.failedToJoinSession"));
diff --git a/src/components/omni/OmniSearch.tsx b/src/components/omni/OmniSearch.tsx
index bb2e8ac4f..051b0f3dd 100644
--- a/src/components/omni/OmniSearch.tsx
+++ b/src/components/omni/OmniSearch.tsx
@@ -34,7 +34,7 @@ import { useToggleSettings } from "@/hooks/useToggleSettings";
import { parseQuickConnect, type QuickConnectIntent } from "@/services/quickConnect";
import { launchHost, launchQuickConnect, launchLocalShell } from "@/services/launch";
import { sessionDisplayName } from "@/services/teamSharing";
-import { isInviteCode, parseInviteCode } from "@/services/inviteCode";
+import { isJoinInput, resolveJoinInput } from "@/services/resolveJoinInput";
import { computeSectionBoundaries } from "./omniSections";
import {
selectRecentHosts,
@@ -226,7 +226,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
}
if (category === "marketplace") return [];
if (category === "join") {
- if (isInviteCode(q)) {
+ if (isJoinInput(q)) {
return [{ kind: "join-code", id: "", label: "", icon: "", code: query.trim() }];
}
const sessionItems = teamSessions
@@ -238,7 +238,7 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
const result: OmniItem[] = [];
// A valid invite code cannot also be a host, so it outranks quick-connect.
- if (isInviteCode(query)) {
+ if (isJoinInput(query)) {
result.push({ kind: "join-code", id: "", label: "", icon: "", code: query.trim() });
}
@@ -494,18 +494,15 @@ export default function OmniSearch({ onClose }: OmniSearchProps) {
}
}, 0);
} else if (item.kind === "join-code") {
- const parsed = parseInviteCode(item.code);
- if (parsed) {
- const { sessionId, token } = parsed;
- (async () => {
- await joinTeamSessionAndOpenTab({
- sessionId,
- connectionName: "Shared Terminal",
- inviteToken: token,
- });
- setSidebarOpen(false);
- })().catch(console.error);
- }
+ (async () => {
+ const { sessionId, inviteToken } = await resolveJoinInput(item.code);
+ await joinTeamSessionAndOpenTab({
+ sessionId,
+ connectionName: "Shared Terminal",
+ inviteToken,
+ });
+ setSidebarOpen(false);
+ })().catch(console.error);
onClose();
} else if (item.kind === "local-shell") {
launchLocalShell(item.shell?.path);
diff --git a/src/components/terminal/SpokenCodeRow.test.tsx b/src/components/terminal/SpokenCodeRow.test.tsx
new file mode 100644
index 000000000..4fe10fb05
--- /dev/null
+++ b/src/components/terminal/SpokenCodeRow.test.tsx
@@ -0,0 +1,163 @@
+import { test, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react";
+
+vi.mock("react-i18next", () => ({
+ // Interpolates, because the countdown assertions read the substituted value.
+ useTranslation: () => ({
+ t: (k: string, opts?: Record) => (opts?.time ? `${k} ${opts.time}` : k),
+ }),
+}));
+vi.mock("@iconify/react", () => ({ Icon: () => null }));
+
+const writeClipboard = vi.hoisted(() => vi.fn(async () => {}));
+vi.mock("@/utils/clipboard", () => ({ writeClipboard }));
+
+const mintSessionCode = vi.hoisted(() => vi.fn());
+vi.mock("@/services/multiplayerService", () => ({ mintSessionCode }));
+
+import { SpokenCodeRow } from "./SpokenCodeRow";
+
+function mintsIn(seconds: number, code = "K7M2-P9QX-3B") {
+ mintSessionCode.mockResolvedValue({
+ code,
+ expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
+ });
+}
+
+const T0 = Date.parse("2026-08-17T09:00:00.000Z");
+
+/**
+ * Fakes the clock the component reads along with the timers it schedules, and
+ * advances only when told to. `shouldAdvanceTime` must stay off: combined with a
+ * fixed expiry it let real wall time leak into the arithmetic, so the first tick
+ * read 0:00 once real UTC passed the fixture's expiry.
+ */
+function withControlledClock(startMs = T0) {
+ vi.useFakeTimers({
+ toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"],
+ });
+ vi.setSystemTime(startMs);
+ return {
+ async advance(ms: number) {
+ await act(async () => { await vi.advanceTimersByTimeAsync(ms); });
+ },
+ };
+}
+
+/** Flushes the mint promise without waitFor, which would poll on faked timers. */
+async function mintWithFakeTimers() {
+ fireEvent.click(screen.getByRole("button"));
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+}
+
+function expiringAt(offsetMs: number, code = "K7M2-P9QX-3B") {
+ mintSessionCode.mockResolvedValue({
+ code,
+ expiresAt: new Date(T0 + offsetMs).toISOString(),
+ });
+}
+
+async function mint() {
+ fireEvent.click(screen.getByRole("button"));
+ await waitFor(() => expect(screen.getByRole("textbox")).toBeTruthy());
+}
+
+beforeEach(() => {
+ writeClipboard.mockClear();
+ mintSessionCode.mockReset();
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+test("mints only when asked, so opening the tab does not spend a code", () => {
+ mintsIn(600);
+ render();
+
+ expect(mintSessionCode).not.toHaveBeenCalled();
+ expect(screen.queryByRole("textbox")).toBeNull();
+});
+
+test("shows the minted code and copies exactly what it displays", async () => {
+ mintsIn(600);
+ render();
+ await mint();
+
+ const field = screen.getByRole("textbox") as HTMLInputElement;
+ expect(field.value).toBe("K7M2-P9QX-3B");
+ expect(mintSessionCode).toHaveBeenCalledWith("sess-1");
+
+ fireEvent.click(screen.getByText("common.action.copy"));
+ await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith("K7M2-P9QX-3B"));
+});
+
+test("groups a code the server sent unformatted", async () => {
+ mintsIn(600, "K7M2P9QX3B");
+ render();
+ await mint();
+
+ expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("K7M2-P9QX-3B");
+});
+
+test("counts down toward expiry", async () => {
+ const clock = withControlledClock();
+ expiringAt(600_000);
+ render();
+ await mintWithFakeTimers();
+
+ expect(screen.getByText(/expiresIn 10:00/)).toBeTruthy();
+ await clock.advance(62_000);
+ expect(screen.getByText(/expiresIn 8:58/)).toBeTruthy();
+});
+
+// An expired code is worse than no code: it looks usable and fails at the guest's end.
+test("drops an expired code and offers a fresh one", async () => {
+ const clock = withControlledClock();
+ expiringAt(5_000);
+ render();
+ await mintWithFakeTimers();
+
+ await clock.advance(6_000);
+
+ expect(screen.queryByRole("textbox")).toBeNull();
+ expect(screen.getByText("terminal.share.codeExpired")).toBeTruthy();
+});
+
+test("regenerating replaces the code, matching the server revoking the old one", async () => {
+ mintsIn(600, "K7M2-P9QX-3B");
+ render();
+ await mint();
+
+ mintsIn(600, "AAAA-BBBB-CC");
+ fireEvent.click(screen.getByText("terminal.share.newCode"));
+
+ await waitFor(() =>
+ expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe("AAAA-BBBB-CC"),
+ );
+});
+
+test("surfaces a mint failure instead of showing a stale code", async () => {
+ mintSessionCode.mockRejectedValue(new Error("boom"));
+ render();
+
+ fireEvent.click(screen.getByRole("button"));
+
+ await waitFor(() => expect(screen.getByText("boom")).toBeTruthy());
+ expect(screen.queryByRole("textbox")).toBeNull();
+});
+
+// The interval must die with the component; a leaked one ticks against an unmounted
+// tree for the rest of the session (the leak PR #128 had to fix).
+test("clears its countdown on unmount", async () => {
+ withControlledClock();
+ expiringAt(600_000);
+ const view = render();
+ await mintWithFakeTimers();
+
+ const clearSpy = vi.spyOn(globalThis, "clearInterval");
+ view.unmount();
+
+ expect(clearSpy).toHaveBeenCalled();
+});
diff --git a/src/components/terminal/SpokenCodeRow.tsx b/src/components/terminal/SpokenCodeRow.tsx
new file mode 100644
index 000000000..a9dc7cca3
--- /dev/null
+++ b/src/components/terminal/SpokenCodeRow.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Icon } from "@iconify/react";
+import { mintSessionCode } from "@/services/multiplayerService";
+import { formatShortCode } from "@/services/shortCode";
+import { InviteCodeField } from "./InviteCodeField";
+
+function mmss(secondsLeft: number): string {
+ const m = Math.floor(secondsLeft / 60);
+ const s = secondsLeft % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+/**
+ * The read-aloud half of an invite: a short code the server kills after ten
+ * minutes. Minted on demand rather than alongside the link, so its window starts
+ * when the host actually needs to say it.
+ */
+export function SpokenCodeRow({ sessionId }: { sessionId: string }) {
+ const { t } = useTranslation();
+ const [code, setCode] = useState(null);
+ const [expiresAt, setExpiresAt] = useState(null);
+ const [remaining, setRemaining] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [expired, setExpired] = useState(false);
+ const intervalRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (expiresAt === null) return;
+ const tick = () => {
+ const left = Math.max(0, Math.round((expiresAt - Date.now()) / 1000));
+ setRemaining(left);
+ if (left === 0) {
+ setCode(null);
+ setExpiresAt(null);
+ setExpired(true);
+ }
+ };
+ tick();
+ intervalRef.current = setInterval(tick, 1000);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [expiresAt]);
+
+ const handleMint = async () => {
+ setLoading(true);
+ setError(null);
+ setExpired(false);
+ try {
+ const minted = await mintSessionCode(sessionId);
+ setCode(formatShortCode(minted.code));
+ setExpiresAt(new Date(minted.expiresAt).getTime());
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t("terminal.share.failedToMintCode"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {code ? (
+ <>
+
+
+
+ {t("terminal.share.readAloudHint")}
+ {" · "}
+
+ {t("terminal.share.expiresIn", { time: mmss(remaining) })}
+
+
+
+
+ >
+ ) : (
+ <>
+
+ {error && (
+
{error}
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 441bf8c64..d9e07ee0d 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -163,7 +163,12 @@
"termiusDumpInvalidJson": "Termius dump must be valid JSON",
"legacyTermiusUnsupported": "Legacy Termius dump format is no longer supported; use Auto Extract.",
"termiusExtractionFormat": "Termius extraction must return { version: 2, records: [...] }",
- "failedToFetchSource": "Failed to fetch source: HTTP {{status}}"
+ "failedToFetchSource": "Failed to fetch source: HTTP {{status}}",
+ "failedToMintInviteCode": "Failed to create invite code: {{status}}",
+ "failedToRedeemInviteCode": "Failed to use invite code: {{status}}",
+ "inviteCodeMalformed": "That code is not in the right format",
+ "inviteCodeNotFound": "That code has expired or is incorrect",
+ "inviteCodeTooManyAttempts": "Too many attempts — wait a moment and try again"
},
"clipboard": {
"cut_one": "{{count}} item cut",
diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json
index 118240346..11188d320 100644
--- a/src/i18n/locales/en/terminal.json
+++ b/src/i18n/locales/en/terminal.json
@@ -164,7 +164,13 @@
"deepLinkJoinBody": "Someone shared a live terminal session with you. Joining connects you to it now.",
"deepLinkJoinUnknownHost": "Voltius can't identify who shared it until you join.",
"deepLinkJoinAction": "Join session",
- "deepLinkJoinFailed": "Couldn't join that session — the link may have expired."
+ "deepLinkJoinFailed": "Couldn't join that session — the link may have expired.",
+ "getSpokenCode": "Get a code to read aloud",
+ "codeExpired": "Code expired — get a new one",
+ "newCode": "New code",
+ "readAloudHint": "Say it out loud",
+ "expiresIn": "expires in {{time}}",
+ "failedToMintCode": "Failed to create code"
},
"snippetVariableModal": {
"on": "On",
diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json
index 5943f51c7..282da9c05 100644
--- a/src/i18n/locales/fr/common.json
+++ b/src/i18n/locales/fr/common.json
@@ -163,7 +163,12 @@
"termiusDumpInvalidJson": "Le fichier Termius doit être un JSON valide",
"legacyTermiusUnsupported": "L'ancien format d'export Termius n'est plus pris en charge ; utilisez l'extraction automatique.",
"termiusExtractionFormat": "L'extraction Termius doit renvoyer { version: 2, records: [...] }",
- "failedToFetchSource": "Échec de la récupération de la source : HTTP {{status}}"
+ "failedToFetchSource": "Échec de la récupération de la source : HTTP {{status}}",
+ "failedToMintInviteCode": "Échec de la création du code d'invitation : {{status}}",
+ "failedToRedeemInviteCode": "Échec de l'utilisation du code d'invitation : {{status}}",
+ "inviteCodeMalformed": "Ce code n'a pas le bon format",
+ "inviteCodeNotFound": "Ce code a expiré ou est incorrect",
+ "inviteCodeTooManyAttempts": "Trop de tentatives — patientez un instant et réessayez"
},
"clipboard": {
"cut_one": "{{count}} élément coupé",
diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json
index bb71b4140..437f5445a 100644
--- a/src/i18n/locales/fr/terminal.json
+++ b/src/i18n/locales/fr/terminal.json
@@ -164,7 +164,13 @@
"deepLinkJoinBody": "Quelqu'un a partagé une session de terminal en direct avec vous. En rejoignant, vous vous y connectez immédiatement.",
"deepLinkJoinUnknownHost": "Voltius ne peut pas identifier l'expéditeur avant que vous rejoigniez la session.",
"deepLinkJoinAction": "Rejoindre la session",
- "deepLinkJoinFailed": "Impossible de rejoindre cette session — le lien a peut-être expiré."
+ "deepLinkJoinFailed": "Impossible de rejoindre cette session — le lien a peut-être expiré.",
+ "getSpokenCode": "Obtenir un code à dicter",
+ "codeExpired": "Code expiré — en obtenir un nouveau",
+ "newCode": "Nouveau code",
+ "readAloudHint": "Dictez-le à voix haute",
+ "expiresIn": "expire dans {{time}}",
+ "failedToMintCode": "Échec de la création du code"
},
"snippetVariableModal": {
"on": "Activé",
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
index d45254e42..eda02a7cc 100644
--- a/src/i18n/locales/ru/common.json
+++ b/src/i18n/locales/ru/common.json
@@ -163,7 +163,12 @@
"termiusDumpInvalidJson": "Дамп Termius должен быть корректным JSON",
"legacyTermiusUnsupported": "Старый формат дампа Termius больше не поддерживается; используйте автоэкспорт.",
"termiusExtractionFormat": "Экспорт Termius должен возвращать { version: 2, records: [...] }",
- "failedToFetchSource": "Не удалось получить источник: HTTP {{status}}"
+ "failedToFetchSource": "Не удалось получить источник: HTTP {{status}}",
+ "failedToMintInviteCode": "Не удалось создать код приглашения: {{status}}",
+ "failedToRedeemInviteCode": "Не удалось использовать код приглашения: {{status}}",
+ "inviteCodeMalformed": "Код указан в неверном формате",
+ "inviteCodeNotFound": "Код истёк или указан неверно",
+ "inviteCodeTooManyAttempts": "Слишком много попыток — подождите и попробуйте снова"
},
"clipboard": {
"cut_one": "Вырезан {{count}} элемент",
diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json
index 88b49b229..d41d764ba 100644
--- a/src/i18n/locales/ru/terminal.json
+++ b/src/i18n/locales/ru/terminal.json
@@ -174,7 +174,13 @@
"deepLinkJoinBody": "Кто-то поделился с вами активной сессией терминала. Присоединение подключит вас к ней сейчас.",
"deepLinkJoinUnknownHost": "Voltius не сможет определить отправителя, пока вы не присоединитесь.",
"deepLinkJoinAction": "Присоединиться",
- "deepLinkJoinFailed": "Не удалось присоединиться к сессии — возможно, срок действия ссылки истёк."
+ "deepLinkJoinFailed": "Не удалось присоединиться к сессии — возможно, срок действия ссылки истёк.",
+ "getSpokenCode": "Получить код для передачи голосом",
+ "codeExpired": "Код истёк — получите новый",
+ "newCode": "Новый код",
+ "readAloudHint": "Продиктуйте его",
+ "expiresIn": "истекает через {{time}}",
+ "failedToMintCode": "Не удалось создать код"
},
"snippetVariableModal": {
"on": "Вкл",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index 363099db5..29a67f06f 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -163,7 +163,12 @@
"termiusDumpInvalidJson": "Termius 导出必须是有效的 JSON",
"legacyTermiusUnsupported": "不再支持旧版 Termius 导出格式;请使用自动提取。",
"termiusExtractionFormat": "Termius 提取必须返回 { version: 2, records: [...] }",
- "failedToFetchSource": "获取源失败:HTTP {{status}}"
+ "failedToFetchSource": "获取源失败:HTTP {{status}}",
+ "failedToMintInviteCode": "创建邀请码失败:{{status}}",
+ "failedToRedeemInviteCode": "使用邀请码失败:{{status}}",
+ "inviteCodeMalformed": "该邀请码格式不正确",
+ "inviteCodeNotFound": "该邀请码已过期或不正确",
+ "inviteCodeTooManyAttempts": "尝试次数过多 — 请稍后重试"
},
"clipboard": {
"cut_one": "已剪切 {{count}} 个项目",
diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json
index ef93474d4..79c857cfe 100644
--- a/src/i18n/locales/zh/terminal.json
+++ b/src/i18n/locales/zh/terminal.json
@@ -164,7 +164,13 @@
"deepLinkJoinBody": "有人与你共享了一个实时终端会话。加入后将立即连接。",
"deepLinkJoinUnknownHost": "在你加入之前,Voltius 无法识别共享者。",
"deepLinkJoinAction": "加入会话",
- "deepLinkJoinFailed": "无法加入该会话——链接可能已失效。"
+ "deepLinkJoinFailed": "无法加入该会话——链接可能已失效。",
+ "getSpokenCode": "获取口述验证码",
+ "codeExpired": "验证码已过期 — 请获取新的",
+ "newCode": "新验证码",
+ "readAloudHint": "可直接读出",
+ "expiresIn": "{{time}} 后过期",
+ "failedToMintCode": "创建验证码失败"
},
"snippetVariableModal": {
"on": "开",
diff --git a/src/services/multiplayerService.shortCode.test.ts b/src/services/multiplayerService.shortCode.test.ts
new file mode 100644
index 000000000..16ac51bbf
--- /dev/null
+++ b/src/services/multiplayerService.shortCode.test.ts
@@ -0,0 +1,78 @@
+import { test, expect, vi, beforeEach } from "vitest";
+
+const h = vi.hoisted(() => ({
+ invoke: vi.fn(),
+ appFetch: vi.fn(),
+ getServerUrlValue: vi.fn(),
+ getJwtToken: vi.fn(),
+ getVaultKey: vi.fn(),
+ getUserPublicKey: vi.fn(),
+ freshPublicKeys: vi.fn(),
+}));
+vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke }));
+vi.mock("@/services/http", () => ({ appFetch: h.appFetch }));
+vi.mock("@/services/vault", () => ({ getVaultKey: h.getVaultKey }));
+vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } }));
+vi.mock("@/services/teamService", () => ({
+ getServerUrlValue: h.getServerUrlValue,
+ getJwtToken: h.getJwtToken,
+ getUserPublicKey: h.getUserPublicKey,
+}));
+vi.mock("@/services/teamSharing", () => ({ freshPublicKeys: h.freshPublicKeys }));
+
+import { mintSessionCode, redeemSessionCode } from "./multiplayerService";
+
+function mockAppFetch(body: unknown, status = 200) {
+ h.appFetch.mockResolvedValue({ ok: status < 300, status, json: async () => body });
+}
+
+beforeEach(() => {
+ Object.values(h).forEach((m) => m.mockReset());
+ h.getServerUrlValue.mockResolvedValue("https://srv.test");
+ h.getJwtToken.mockResolvedValue("jwt");
+});
+
+test("mintSessionCode posts to the session's code route and maps the response", async () => {
+ mockAppFetch({ code: "K7M2-P9QX-3B", expires_at: "2026-08-17T09:00:00Z" }, 201);
+
+ const minted = await mintSessionCode("sess-1");
+
+ expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/sess-1/code");
+ expect(h.appFetch.mock.calls[0][1]).toMatchObject({ method: "POST" });
+ expect(minted).toEqual({ code: "K7M2-P9QX-3B", expiresAt: "2026-08-17T09:00:00Z" });
+});
+
+test("redeemSessionCode sends the normalized code, not what the guest typed", async () => {
+ mockAppFetch({ session_id: "sess-1", invite_token: "fake-guest-secret" });
+
+ await redeemSessionCode(" k7m2-p9qx-3b\n");
+
+ expect(h.appFetch.mock.calls[0][0]).toBe("https://srv.test/v1/terminal-sessions/redeem");
+ expect(JSON.parse(h.appFetch.mock.calls[0][1].body)).toEqual({ code: "K7M2P9QX3B" });
+});
+
+test("redeemSessionCode returns the session and the guest secret to join with", async () => {
+ mockAppFetch({ session_id: "sess-1", invite_token: "fake-guest-secret" });
+
+ expect(await redeemSessionCode("K7M2-P9QX-3B")).toEqual({
+ sessionId: "sess-1",
+ inviteToken: "fake-guest-secret",
+ });
+});
+
+// The server answers 404 for unknown, malformed, expired and revoked alike, so the
+// client must not invent a distinction it cannot know.
+test("redeemSessionCode reports an unknown or expired code as one outcome", async () => {
+ mockAppFetch({}, 404);
+ await expect(redeemSessionCode("K7M2-P9QX-3B")).rejects.toThrow("common.error.inviteCodeNotFound");
+});
+
+test("redeemSessionCode reports rate limiting separately", async () => {
+ mockAppFetch({}, 429);
+ await expect(redeemSessionCode("K7M2-P9QX-3B")).rejects.toThrow("common.error.inviteCodeTooManyAttempts");
+});
+
+test("redeemSessionCode refuses a code the server would reject anyway", async () => {
+ await expect(redeemSessionCode("nonsense")).rejects.toThrow("common.error.inviteCodeMalformed");
+ expect(h.appFetch).not.toHaveBeenCalled();
+});
diff --git a/src/services/resolveJoinInput.test.ts b/src/services/resolveJoinInput.test.ts
new file mode 100644
index 000000000..487d2d78f
--- /dev/null
+++ b/src/services/resolveJoinInput.test.ts
@@ -0,0 +1,62 @@
+import { test, expect, vi, beforeEach } from "vitest";
+
+const redeemSessionCode = vi.hoisted(() => vi.fn());
+vi.mock("@/services/multiplayerService", () => ({ redeemSessionCode }));
+vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } }));
+
+import { resolveJoinInput } from "./resolveJoinInput";
+
+const SESSION = "8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90";
+
+// Braces matter: a hook that returns the mock hands vitest the mock as its
+// teardown callback, which then calls it after the test.
+beforeEach(() => {
+ redeemSessionCode.mockReset();
+});
+
+test("a bare sessionId:token needs no server round-trip", async () => {
+ expect(await resolveJoinInput(`${SESSION}:faketoken`)).toEqual({
+ sessionId: SESSION,
+ inviteToken: "faketoken",
+ });
+ expect(redeemSessionCode).not.toHaveBeenCalled();
+});
+
+test("a deep link needs no server round-trip", async () => {
+ expect(await resolveJoinInput(`voltius://join?s=${SESSION}&t=faketoken`)).toEqual({
+ sessionId: SESSION,
+ inviteToken: "faketoken",
+ });
+ expect(redeemSessionCode).not.toHaveBeenCalled();
+});
+
+test("a short code is redeemed for a session and a guest secret", async () => {
+ redeemSessionCode.mockResolvedValue({ sessionId: SESSION, inviteToken: "fake-guest-secret" });
+
+ expect(await resolveJoinInput("K7M2-P9QX-3B")).toEqual({
+ sessionId: SESSION,
+ inviteToken: "fake-guest-secret",
+ });
+ expect(redeemSessionCode).toHaveBeenCalledWith("K7M2-P9QX-3B");
+});
+
+test("redemption failures reach the caller unchanged", async () => {
+ redeemSessionCode.mockImplementation(async () => {
+ throw new Error("common.error.inviteCodeNotFound");
+ });
+
+ const caught = await resolveJoinInput("K7M2-P9QX-3B").catch((e: unknown) => e);
+ expect((caught as Error).message).toBe("common.error.inviteCodeNotFound");
+});
+
+test("input that is neither shape is rejected without a request", async () => {
+ await expect(resolveJoinInput("nonsense")).rejects.toThrow("common.error.inviteCodeMalformed");
+ expect(redeemSessionCode).not.toHaveBeenCalled();
+});
+
+// Quick-connect targets share the colon shape, and mistaking one for an invite
+// would fire a redeem for every `host:22` a user types.
+test("quick-connect shapes are not treated as invites", async () => {
+ await expect(resolveJoinInput("host:22")).rejects.toThrow("common.error.inviteCodeMalformed");
+ expect(redeemSessionCode).not.toHaveBeenCalled();
+});
diff --git a/src/services/resolveJoinInput.ts b/src/services/resolveJoinInput.ts
new file mode 100644
index 000000000..4f5cb1325
--- /dev/null
+++ b/src/services/resolveJoinInput.ts
@@ -0,0 +1,33 @@
+import i18n from "@/i18n";
+import { isInviteCode, parseInviteCode } from "@/services/inviteCode";
+import { redeemSessionCode } from "@/services/multiplayerService";
+import { isShortCode } from "@/services/shortCode";
+
+/**
+ * Turns anything a guest can paste or type — a bare `sessionId:token`, a
+ * `voltius://join` link, or a short spoken code — into the pair every join needs.
+ *
+ * Only the short code costs a request: the other two already carry their token, so
+ * detection stays synchronous and callers can decide what to offer before asking
+ * the server anything.
+ */
+export async function resolveJoinInput(
+ input: string,
+): Promise<{ sessionId: string; inviteToken: string }> {
+ const trimmed = input.trim();
+
+ if (isInviteCode(trimmed)) {
+ const parsed = parseInviteCode(trimmed);
+ if (parsed) return { sessionId: parsed.sessionId, inviteToken: parsed.token };
+ }
+
+ if (isShortCode(trimmed)) return redeemSessionCode(trimmed);
+
+ throw new Error(i18n.t("common.error.inviteCodeMalformed"));
+}
+
+/** True for anything `resolveJoinInput` can act on, without contacting the server. */
+export function isJoinInput(input: string): boolean {
+ const trimmed = input.trim();
+ return isInviteCode(trimmed) || isShortCode(trimmed);
+}
diff --git a/src/services/shortCode.test.ts b/src/services/shortCode.test.ts
new file mode 100644
index 000000000..e6a23f830
--- /dev/null
+++ b/src/services/shortCode.test.ts
@@ -0,0 +1,53 @@
+import { test, expect } from "vitest";
+import { formatShortCode, isShortCode, normalizeShortCode } from "./shortCode";
+
+test("normalizeShortCode folds spelling variants to one value", () => {
+ const canonical = normalizeShortCode("K7M2-P9QX-3B");
+ expect(canonical).toBe("K7M2P9QX3B");
+ for (const variant of ["k7m2p9qx3b", "K7M2 P9QX 3B", " k7m2-p9qx-3b\n", "K7M2--P9QX--3B"]) {
+ expect(normalizeShortCode(variant)).toBe(canonical);
+ }
+});
+
+// Crockford treats these as digits, so a guest who hears "oh" and types O still gets in.
+test("normalizeShortCode maps the confusable letters onto digits", () => {
+ expect(normalizeShortCode("O1IL-2345-67")).toBe("0111234567");
+});
+
+test("normalizeShortCode rejects U, which the alphabet excludes", () => {
+ expect(normalizeShortCode("K7M2-P9QU-3B")).toBeNull();
+});
+
+test("normalizeShortCode rejects wrong lengths and foreign symbols", () => {
+ expect(normalizeShortCode("K7M2-P9QX")).toBeNull();
+ expect(normalizeShortCode("K7M2-P9QX-3B4")).toBeNull();
+ expect(normalizeShortCode("K7M2-P9QX-3$")).toBeNull();
+ expect(normalizeShortCode("")).toBeNull();
+});
+
+test("isShortCode accepts every spelling the server would accept", () => {
+ expect(isShortCode("K7M2-P9QX-3B")).toBe(true);
+ expect(isShortCode("k7m2p9qx3b")).toBe(true);
+ expect(isShortCode("nonsense")).toBe(false);
+});
+
+// A session id and a bare `sessionId:token` must never be mistaken for a code,
+// or the join paths would redeem instead of using the token they already have.
+test("isShortCode rejects the other invite shapes", () => {
+ expect(isShortCode("8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90")).toBe(false);
+ expect(isShortCode("8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90:faketoken")).toBe(false);
+ expect(isShortCode("voltius://join?s=8f3c1e0a-4b2d-47aa-9e11-2c6d5a7b8f90&t=faketoken")).toBe(false);
+ expect(isShortCode("host:22")).toBe(false);
+});
+
+test("formatShortCode groups a canonical code 4-4-2", () => {
+ expect(formatShortCode("K7M2P9QX3B")).toBe("K7M2-P9QX-3B");
+});
+
+test("formatShortCode normalizes before grouping", () => {
+ expect(formatShortCode("k7m2-p9qx-3b")).toBe("K7M2-P9QX-3B");
+});
+
+test("formatShortCode returns the input unchanged when it is not a code", () => {
+ expect(formatShortCode("nonsense")).toBe("nonsense");
+});
diff --git a/src/services/shortCode.ts b/src/services/shortCode.ts
new file mode 100644
index 000000000..39ed8bff8
--- /dev/null
+++ b/src/services/shortCode.ts
@@ -0,0 +1,32 @@
+/**
+ * Server-resolved short invite codes. This mirrors the server's normalizer
+ * (`src/session_grants.rs`) exactly — a code the server accepts must normalize
+ * to the same 10 symbols here, or a guest who typed it correctly is refused.
+ */
+
+const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+const CODE_LENGTH = 10;
+
+export function normalizeShortCode(input: string): string | null {
+ let normalized = "";
+ for (const raw of input) {
+ if (/\s/.test(raw) || raw === "-") continue;
+ const upper = raw.toUpperCase();
+ normalized += upper === "I" || upper === "L" ? "1" : upper === "O" ? "0" : upper;
+ }
+ if (normalized.length !== CODE_LENGTH) return null;
+ for (const symbol of normalized) {
+ if (!ALPHABET.includes(symbol)) return null;
+ }
+ return normalized;
+}
+
+export function isShortCode(value: string): boolean {
+ return normalizeShortCode(value) !== null;
+}
+
+export function formatShortCode(code: string): string {
+ const normalized = normalizeShortCode(code);
+ if (!normalized) return code;
+ return `${normalized.slice(0, 4)}-${normalized.slice(4, 8)}-${normalized.slice(8)}`;
+}