From fc37490471d248634c841cfb81fd6f34183c3b79 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 10 Oct 2026 21:42:35 +0000 Subject: [PATCH] fix(team-vault): never show a secret the server refused as saved (#608) Saving a port-knock sequence on a team-vault host against a server older than its knock support failed with a bare "400", yet the port reappeared when the editor was reopened: the value was cached before the upload and never taken back. The Port Knocking switch, stored on the host itself, saved fine, so the host could end up knocking with no ports and fail to connect for the whole team. The client now reads the secret types a server advertises in /v1/meta and never uploads one that is missing. A server too old to advertise anything is still tried; a 400 on the knock sequence is read as "too old" and remembered until the next check. Either way the cache goes back to what the server holds, the error says the server needs an update, and the refusal is logged with the secret type and client version. On such a server the knock panel of a team-vault host explains why instead of offering ports, the switch can only be turned off, and a switch whose sequence was just refused is turned back off. A secret queued for retry after a move into a team vault keeps its local copy as before and uploads once the server is updated. --- src/components/connections/ConnectionForm.tsx | 14 +++- .../connections/PortKnockPanel.test.tsx | 13 +++ src/components/connections/PortKnockPanel.tsx | 15 +++- .../connectionForms.characterisation.test.tsx | 32 ++++++++ .../shared/VaultUnavailableNote.tsx | 2 +- src/i18n/locales/cs/common.json | 2 + src/i18n/locales/cs/connections.json | 1 + src/i18n/locales/en/common.json | 2 + src/i18n/locales/en/connections.json | 1 + src/i18n/locales/fr/common.json | 2 + src/i18n/locales/fr/connections.json | 1 + src/i18n/locales/ru/common.json | 2 + src/i18n/locales/ru/connections.json | 1 + src/i18n/locales/tr/common.json | 2 + src/i18n/locales/tr/connections.json | 1 + src/i18n/locales/zh/common.json | 2 + src/i18n/locales/zh/connections.json | 1 + src/services/secretRouting.test.ts | 70 +++++++++++++++- src/services/secretRouting.ts | 47 +++++++++-- src/services/serverCapabilities.test.ts | 82 +++++++++++++++++++ src/services/serverCapabilities.ts | 67 +++++++++++++++ src/services/teamDataManager.ts | 2 + src/services/teamObjects.ts | 10 +-- 23 files changed, 352 insertions(+), 20 deletions(-) create mode 100644 src/services/serverCapabilities.test.ts create mode 100644 src/services/serverCapabilities.ts diff --git a/src/components/connections/ConnectionForm.tsx b/src/components/connections/ConnectionForm.tsx index 3d54f1d69..bc745e9ca 100644 --- a/src/components/connections/ConnectionForm.tsx +++ b/src/components/connections/ConnectionForm.tsx @@ -15,6 +15,8 @@ import { getSecret } from "@/services/vault"; import { sshExecCommand } from "@/services/ssh"; import { isCustomProxyMode, resolveDirectHop } from "@/services/proxy"; import { useStoredSecrets } from "@/hooks/useStoredSecrets"; +import { useServerLacksSecretType } from "@/services/serverCapabilities"; +import { resolveTeamIdForVaultId } from "@/services/teamVaultSecrets"; import { StoredSecretsNote } from "@/components/shared/VaultUnavailableNote"; import { useAutosave } from "@/hooks/useAutosave"; import { auditContextForVaultId } from "@/services/auditContextResolver"; @@ -208,8 +210,10 @@ const ConnectionFormEditor = forwardRef i.id === identityId) ?? null; + const knockServerTooOld = useServerLacksSecretType("connection_knock_sequence") && resolveTeamIdForVaultId(vaultId) !== null; + const editedKnockSequence = () => { - if (!knockDirty.current) return null; + if (!knockDirty.current || knockServerTooOld) return null; const issues = knockPanelIssues({ enabled: true }, knockSteps, "direct"); if (issues.includes("port") || (portKnock.enabled && issues.includes("empty"))) return null; return formatKnockSequence(knockSteps); @@ -299,6 +303,13 @@ const ConnectionFormEditor = forwardRef schedule(), [name, host, port, username, protocol, ftpSecure, webdavUrl, password, privateKey, passphrase, identityId, keyId, folderId, tags, vaultId, jumpHosts, envVars, agentForwarding, legacyAlgorithms, hostCommands.preCommand, hostCommands.postCommand, hostCommands.preSnippetId, hostCommands.postSnippetId, hostCommands.askVarsEachTime, hostCommands.terminalEncoding, distro, icon, pingDisabled, shellIntegration, keepalivePreset, persistSession, proxyOverride, proxyPassword, portKnock, knockSteps, notes]); + // Left on, a switch whose sequence the server refused fails every teammate's connect. + useEffect(() => { + if (!knockServerTooOld || !knockDirty.current) return; + markDirty(); + setPortKnock((current) => (current.enabled ? { ...current, enabled: false } : current)); + }, [knockServerTooOld, markDirty]); + useImperativeHandle(ref, () => ({ flush, isDirty: () => userEditedRef.current }), [flush]); const handleClose = () => flushAndClose(onClose); @@ -790,6 +801,7 @@ const ConnectionFormEditor = forwardRef { markDirty(); setPortKnock(next); }} onStepsChange={(next) => { markDirty(); knockDirty.current = true; setKnockSteps(next); }} effectiveProxyMode={proxyOverride?.mode ?? globalProxy.mode} diff --git a/src/components/connections/PortKnockPanel.test.tsx b/src/components/connections/PortKnockPanel.test.tsx index 1d248136a..0dad3f42c 100644 --- a/src/components/connections/PortKnockPanel.test.tsx +++ b/src/components/connections/PortKnockPanel.test.tsx @@ -103,3 +103,16 @@ test("timing inputs cap their length", () => { expect(screen.getByLabelText("connections.knock.settle").getAttribute("maxlength")).toBe("6"); expect(screen.getByLabelText("connections.knock.window").getAttribute("maxlength")).toBe("5"); }); + +test("a server too old for knock sequences shows why and offers nothing to fill in", () => { + renderPanel({ settings: { enabled: false }, steps: [s(666)], serverTooOld: true }); + expect(screen.getByText("connections.knock.serverTooOld")).toBeTruthy(); + expect(screen.queryByText("connections.knock.addPort")).toBeNull(); + expect((screen.getByRole("switch") as HTMLButtonElement).disabled).toBe(true); +}); + +test("a switch left on against a server too old for knock sequences can still be turned off", () => { + const { onSettingsChange } = renderPanel({ settings: { enabled: true }, serverTooOld: true }); + fireEvent.click(screen.getByRole("switch")); + expect(onSettingsChange).toHaveBeenCalledWith({ enabled: false }); +}); diff --git a/src/components/connections/PortKnockPanel.tsx b/src/components/connections/PortKnockPanel.tsx index 1f60f0712..6f5db94a7 100644 --- a/src/components/connections/PortKnockPanel.tsx +++ b/src/components/connections/PortKnockPanel.tsx @@ -6,7 +6,7 @@ import type { StoredSecretsState } from "@/hooks/useStoredSecrets"; import { useListReorder } from "@/hooks/useListReorder"; import { ReorderableRow } from "@/components/shared/reorder"; import { SlideOverHeader, DashedAddButton } from "@/components/shared/slideOver"; -import { StoredSecretsNote } from "@/components/shared/VaultUnavailableNote"; +import { StoredSecretsNote, WarningNote } from "@/components/shared/VaultUnavailableNote"; import { Toggle } from "@/components/shared/Toggle"; import { FormSelect } from "@/components/shared/FormSelect"; import { formInputClass, formInputStyle } from "@/components/shared/Panel"; @@ -45,6 +45,7 @@ interface Props { settings: PortKnockSettings; steps: KnockStepRow[]; sequenceState: StoredSecretsState; + serverTooOld?: boolean; onSettingsChange: (next: PortKnockSettings) => void; onStepsChange: (next: KnockStepRow[]) => void; effectiveProxyMode: KnockProxyMode; @@ -52,10 +53,10 @@ interface Props { onBack: () => void; } -export default function PortKnockPanel({ settings, steps, sequenceState, onSettingsChange, onStepsChange, effectiveProxyMode, bastionName, onBack }: Props) { +export default function PortKnockPanel({ settings, steps, sequenceState, serverTooOld = false, onSettingsChange, onStepsChange, effectiveProxyMode, bastionName, onBack }: Props) { const { t } = useTranslation(); const dnd = useListReorder(steps, onStepsChange); - const sequenceShown = sequenceState === "ok"; + const sequenceShown = sequenceState === "ok" && !serverTooOld; const issues = sequenceShown ? knockPanelIssues(settings, steps, effectiveProxyMode) : []; const updateStep = (id: string, patch: Partial) => onStepsChange(steps.map((s) => (s.id === id ? { ...s, ...patch } : s))); @@ -66,7 +67,11 @@ export default function PortKnockPanel({ settings, steps, sequenceState, onSetti
- onSettingsChange({ ...settings, enabled })} /> + onSettingsChange({ ...settings, enabled })} + /> {t("connections.knock.hint")} {bastionName && {t("connections.knock.viaBastion", { name: bastionName })}} @@ -110,6 +115,8 @@ export default function PortKnockPanel({ settings, steps, sequenceState, onSetti {t("connections.knock.addPort")} + ) : serverTooOld ? ( + ) : ( )} diff --git a/src/components/connections/connectionForms.characterisation.test.tsx b/src/components/connections/connectionForms.characterisation.test.tsx index fe2815d10..53012bea2 100644 --- a/src/components/connections/connectionForms.characterisation.test.tsx +++ b/src/components/connections/connectionForms.characterisation.test.tsx @@ -605,6 +605,38 @@ test("without View secrets the knock ports stay hidden and a save leaves the seq useTeamObjectAccessStore.getState().clearAll(); })); +test("a team host on a server too old for knock sequences withholds the sequence and switches knocking back off", async () => { + const { noteRefusedSecretType, resetServerCapabilities } = await import("@/services/serverCapabilities"); + h.teams = [{ id: "team-1" }]; + grantOnC1(PERM_BITS.VIEW | PERM_BITS.VIEW_SECRETS | PERM_BITS.EDIT_CONNECTIONS); + const { onSubmit, ref } = renderSsh({ initial: conn({ vault_id: "team-1", port_knock: { enabled: true } }) }); + await act(async () => { await Promise.resolve(); }); + fireEvent.click(screen.getByText("connections.knock.addPort")); + fireEvent.change(screen.getByPlaceholderText("connections.knock.port"), { target: { value: "666" } }); + await act(async () => { ref.current!.flush(); }); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ port_knock: { enabled: true } }); + expect(onSubmit.mock.calls[0][1].knock_sequence).toBe("666/tcp"); + + act(() => { noteRefusedSecretType("connection_knock_sequence"); }); + + expect(screen.getByText("connections.knock.serverTooOld")).toBeTruthy(); + expect(screen.queryByDisplayValue("666")).toBeNull(); + await act(async () => { ref.current!.flush(); }); + expect(onSubmit.mock.calls[1][0]).toMatchObject({ port_knock: { enabled: false } }); + expect(onSubmit.mock.calls[1][1].knock_sequence).toBeNull(); + resetServerCapabilities(); + useTeamObjectAccessStore.getState().clearAll(); +}); + +test("a personal host keeps its knock ports whatever the server supports", async () => { + const { noteRefusedSecretType, resetServerCapabilities } = await import("@/services/serverCapabilities"); + act(() => { noteRefusedSecretType("connection_knock_sequence"); }); + renderSsh({ initial: conn({ port_knock: { enabled: true } }) }); + expect(screen.getByText("connections.knock.addPort")).toBeTruthy(); + expect(screen.queryByText("connections.knock.serverTooOld")).toBeNull(); + resetServerCapabilities(); +}); + test("a new ssh host keeps its secret fields in a vault whose secrets the caller cannot view", () => { h.teams = [{ id: "team-1" }]; h.defaultVaultId = "team-1"; diff --git a/src/components/shared/VaultUnavailableNote.tsx b/src/components/shared/VaultUnavailableNote.tsx index 8c58283cd..aaecd1203 100644 --- a/src/components/shared/VaultUnavailableNote.tsx +++ b/src/components/shared/VaultUnavailableNote.tsx @@ -5,7 +5,7 @@ import type { StoredSecretsState } from "@/hooks/useStoredSecrets"; import type { TeamCredentialsReason } from "@/hooks/useBlockedTeamVault"; /** One warning row: an icon and a sentence on the shared warning surface. */ -function WarningNote({ icon, text, className }: { icon: string; text: string; className?: string }) { +export function WarningNote({ icon, text, className }: { icon: string; text: string; className?: string }) { return (
({ saveTeamVaultSecret: vi.fn(), resolveTeamIdForVaultId: vi.fn(), deleteTeamSecret: vi.fn(), + fetchServerMeta: vi.fn(), logged: vi.fn(), + warned: vi.fn(), })); vi.mock("@/services/vault", () => ({ getLocalSecret: h.getLocalSecret, @@ -18,19 +20,24 @@ vi.mock("@/services/teamVaultSecrets", () => ({ saveTeamVaultSecret: h.saveTeamVaultSecret, resolveTeamIdForVaultId: h.resolveTeamIdForVaultId, })); -vi.mock("@/services/teamObjects", () => ({ deleteTeamSecret: h.deleteTeamSecret })); -vi.mock("@/lib/logger", () => ({ logFailure: () => h.logged })); +vi.mock("@/services/teamObjects", () => ({ deleteTeamSecret: h.deleteTeamSecret, fetchServerMeta: h.fetchServerMeta })); +vi.mock("@/lib/logger", () => ({ logFailure: () => h.logged, log: { warn: h.warned } })); +vi.mock("@/services/clientHeaders", () => ({ clientVersion: async () => "9.9.9" })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); import { - readSecretAt, writeSecretAt, removeSecretAt, keepCachedOnUploadFailure, TeamSecretUploadError, + readSecretAt, writeSecretAt, removeSecretAt, keepCachedOnUploadFailure, TeamSecretUploadError, TeamSecretUnsupportedError, } from "./secretRouting"; import { teamSecretCache } from "./teamSecretCache"; +import { resetServerCapabilities, serverLacksSecretType } from "./serverCapabilities"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; beforeEach(() => { Object.values(h).forEach((m) => m.mockReset()); teamSecretCache.clearAll(); usePendingTeamSecretUploadStore.getState().clearAll(); + resetServerCapabilities(); + h.fetchServerMeta.mockResolvedValue({}); }); test("a team-owned key is never read from the local store, even when it has a value", async () => { @@ -129,3 +136,60 @@ test("a failed upload of a key not queued for retry leaves the local store alone expect(h.storeLocalSecret).not.toHaveBeenCalled(); }); + +const refused400 = () => Object.assign(new Error("Failed to save team secret: 400"), { status: 400 }); + +test("a type the server does not advertise is never uploaded, and the cache keeps what the server holds", async () => { + h.fetchServerMeta.mockResolvedValue({ team_secret_types: ["connection_password"] }); + teamSecretCache.set("t1", "knock_sequence:c1", "1/tcp"); + + const err = await writeSecretAt("t1", "knock_sequence:c1", "666/tcp").catch((e) => e); + + expect(err).toBeInstanceOf(TeamSecretUnsupportedError); + expect(err).toBeInstanceOf(TeamSecretUploadError); + expect(err.message).toBe("common.error.serverLacksTeamSecretType"); + expect(h.saveTeamVaultSecret).not.toHaveBeenCalled(); + expect(teamSecretCache.get("t1", "knock_sequence:c1")).toBe("1/tcp"); + expect(h.warned.mock.calls[0].join(" ")).toContain("connection_knock_sequence"); + expect(h.warned.mock.calls[0].join(" ")).toContain("9.9.9"); +}); + +test("a 400 from a server too old to advertise its types drops the unsaved value and is remembered", async () => { + h.saveTeamVaultSecret.mockRejectedValue(refused400()); + + await expect(writeSecretAt("t1", "knock_sequence:c1", "666/tcp")).rejects.toBeInstanceOf(TeamSecretUnsupportedError); + + expect(teamSecretCache.get("t1", "knock_sequence:c1")).toBeUndefined(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); +}); + +test("a 400 for a type every server stores stays an ordinary upload failure", async () => { + h.saveTeamVaultSecret.mockRejectedValue(refused400()); + + const err = await writeSecretAt("t1", "password:c1", "pw").catch((e) => e); + + expect(err).toBeInstanceOf(TeamSecretUploadError); + expect(err).not.toBeInstanceOf(TeamSecretUnsupportedError); + expect(teamSecretCache.get("t1", "password:c1")).toBe("pw"); +}); + +test("an unsupported type queued for retry stays readable here and keeps its local copy current", async () => { + usePendingTeamSecretUploadStore.getState().enqueue("t1", ["knock_sequence:c1"]); + h.saveTeamVaultSecret.mockRejectedValue(refused400()); + h.storeLocalSecret.mockResolvedValue(undefined); + + await expect(writeSecretAt("t1", "knock_sequence:c1", "666/tcp")).rejects.toBeInstanceOf(TeamSecretUnsupportedError); + + expect(teamSecretCache.get("t1", "knock_sequence:c1")).toBe("666/tcp"); + expect(h.storeLocalSecret).toHaveBeenCalledWith("knock_sequence:c1", "666/tcp"); + expect(pendingIn("t1")).toEqual(["knock_sequence:c1"]); +}); + +test("an unreachable meta endpoint never blocks an upload", async () => { + h.fetchServerMeta.mockRejectedValue(new Error("offline")); + h.saveTeamVaultSecret.mockResolvedValue(undefined); + + await writeSecretAt("t1", "knock_sequence:c1", "666/tcp"); + + expect(h.saveTeamVaultSecret).toHaveBeenCalledWith("t1", "knock_sequence:c1", "666/tcp"); +}); diff --git a/src/services/secretRouting.ts b/src/services/secretRouting.ts index 6d466466a..99a8c44cb 100644 --- a/src/services/secretRouting.ts +++ b/src/services/secretRouting.ts @@ -1,36 +1,71 @@ +import i18n from "@/i18n"; import { getLocalSecret, storeLocalSecret, deleteLocalSecret } from "@/services/vault"; import { teamSecretCache } from "@/services/teamSecretCache"; -import { teamSecretFromLocalKey } from "@/services/teamVaultSecretKeys"; +import { teamSecretFromLocalKey, type TeamSecretType } from "@/services/teamVaultSecretKeys"; import { resolveTeamIdForVaultId, saveTeamVaultSecret } from "@/services/teamVaultSecrets"; import { deleteTeamSecret } from "@/services/teamObjects"; -import { logFailure } from "@/lib/logger"; +import { refreshServerCapabilities, serverLacksSecretType, noteRefusedSecretType } from "@/services/serverCapabilities"; +import { clientVersion } from "@/services/clientHeaders"; +import { log, logFailure } from "@/lib/logger"; import { usePendingTeamSecretUploadStore } from "@/stores/pendingTeamSecretUploadStore"; export class TeamSecretUploadError extends Error { - constructor(readonly localKey: string, readonly reason: unknown) { - super(`team secret upload failed for ${localKey}: ${reason instanceof Error ? reason.message : String(reason)}`); + constructor( + readonly localKey: string, + readonly reason: unknown, + message = `team secret upload failed for ${localKey}: ${reason instanceof Error ? reason.message : String(reason)}`, + ) { + super(message); this.name = "TeamSecretUploadError"; } } +export class TeamSecretUnsupportedError extends TeamSecretUploadError { + constructor(localKey: string, readonly secretType: TeamSecretType, reason: unknown = null) { + super(localKey, reason, i18n.t("common.error.serverLacksTeamSecretType")); + this.name = "TeamSecretUnsupportedError"; + } +} + export const teamIdOfVault = (vaultId: string | null | undefined): string | null => resolveTeamIdForVaultId(vaultId); export async function readSecretAt(teamId: string | null, localKey: string): Promise { return teamId ? teamSecretCache.get(teamId, localKey) ?? null : getLocalSecret(localKey); } +async function uploadSupported(teamId: string, localKey: string, value: string): Promise { + const secretType = teamSecretFromLocalKey(localKey)?.secretType; + if (!secretType) return saveTeamVaultSecret(teamId, localKey, value); + await refreshServerCapabilities(); + let refusal: unknown = null; + if (!serverLacksSecretType(secretType)) { + try { + return await saveTeamVaultSecret(teamId, localKey, value); + } catch (e) { + if ((e as { status?: number } | null)?.status !== 400 || !noteRefusedSecretType(secretType)) throw e; + refusal = e; + } + } + log.warn(`team secret not uploaded: server does not support secret_type=${secretType} client_version=${await clientVersion()}`); + throw new TeamSecretUnsupportedError(localKey, secretType, refusal); +} + export async function writeSecretAt(teamId: string | null, localKey: string, value: string): Promise { if (!teamId) return storeLocalSecret(localKey, value); + const held = teamSecretCache.get(teamId, localKey); teamSecretCache.set(teamId, localKey, value); const uploads = () => usePendingTeamSecretUploadStore.getState(); try { - await saveTeamVaultSecret(teamId, localKey, value); + await uploadSupported(teamId, localKey, value); } catch (e) { // A queued retry uploads the local copy, so it must hold the newest value. if (uploads().keysByTeamId[teamId]?.includes(localKey)) { await storeLocalSecret(localKey, value).catch(logFailure(`refresh queued team secret ${localKey}`)); + } else if (e instanceof TeamSecretUnsupportedError) { + if (held === undefined) teamSecretCache.delete(teamId, localKey); + else teamSecretCache.set(teamId, localKey, held); } - throw new TeamSecretUploadError(localKey, e); + throw e instanceof TeamSecretUploadError ? e : new TeamSecretUploadError(localKey, e); } uploads().resolve(teamId, [localKey]); } diff --git a/src/services/serverCapabilities.test.ts b/src/services/serverCapabilities.test.ts new file mode 100644 index 000000000..522c04933 --- /dev/null +++ b/src/services/serverCapabilities.test.ts @@ -0,0 +1,82 @@ +import { test, expect, vi, beforeEach, afterEach } from "vitest"; + +const h = vi.hoisted(() => ({ fetchServerMeta: vi.fn(), logged: vi.fn() })); +vi.mock("@/services/teamObjects", () => ({ fetchServerMeta: h.fetchServerMeta })); +vi.mock("@/lib/logger", () => ({ logFailure: () => h.logged })); + +import { + refreshServerCapabilities, resetServerCapabilities, serverLacksSecretType, noteRefusedSecretType, +} from "./serverCapabilities"; + +beforeEach(() => { + vi.useFakeTimers(); + Object.values(h).forEach((m) => m.mockReset()); + resetServerCapabilities(); +}); +afterEach(() => vi.useRealTimers()); + +test("a type missing from the advertised list is unsupported, a listed one is not", async () => { + h.fetchServerMeta.mockResolvedValue({ team_secret_types: ["connection_password"] }); + await refreshServerCapabilities(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); + expect(serverLacksSecretType("connection_password")).toBe(false); +}); + +test("a server that advertises nothing is not assumed to lack anything", async () => { + h.fetchServerMeta.mockResolvedValue({ self_hosted: true }); + await refreshServerCapabilities(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(false); +}); + +test("a refusal marks only a type that shipped before servers advertised their list", async () => { + h.fetchServerMeta.mockResolvedValue({ self_hosted: true }); + await refreshServerCapabilities(); + expect(noteRefusedSecretType("connection_password")).toBe(false); + expect(serverLacksSecretType("connection_password")).toBe(false); + expect(noteRefusedSecretType("connection_knock_sequence")).toBe(true); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); +}); + +test("an advertised list outranks a refusal", async () => { + h.fetchServerMeta.mockResolvedValue({ team_secret_types: ["connection_knock_sequence"] }); + await refreshServerCapabilities(); + expect(noteRefusedSecretType("connection_knock_sequence")).toBe(false); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(false); +}); + +test("an upgraded server clears what an older one refused", async () => { + h.fetchServerMeta.mockResolvedValueOnce({}).mockResolvedValueOnce({ team_secret_types: ["connection_knock_sequence"] }); + await refreshServerCapabilities(); + noteRefusedSecretType("connection_knock_sequence"); + vi.advanceTimersByTime(5 * 60_000); + await refreshServerCapabilities(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(false); +}); + +test("meta is fetched once per interval, and concurrent callers share the request", async () => { + h.fetchServerMeta.mockResolvedValue({}); + await Promise.all([refreshServerCapabilities(), refreshServerCapabilities()]); + await refreshServerCapabilities(); + expect(h.fetchServerMeta).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(5 * 60_000); + await refreshServerCapabilities(); + expect(h.fetchServerMeta).toHaveBeenCalledTimes(2); +}); + +test("a failed fetch is logged, leaves support unknown, and is retried on the next call", async () => { + h.fetchServerMeta.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ team_secret_types: [] }); + await expect(refreshServerCapabilities()).resolves.toBeUndefined(); + expect(h.logged).toHaveBeenCalled(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(false); + await refreshServerCapabilities(); + expect(serverLacksSecretType("connection_knock_sequence")).toBe(true); +}); + +test("a reset forgets the previous server", async () => { + h.fetchServerMeta.mockResolvedValue({ team_secret_types: [] }); + await refreshServerCapabilities(); + resetServerCapabilities(); + expect(serverLacksSecretType("connection_password")).toBe(false); + await refreshServerCapabilities(); + expect(h.fetchServerMeta).toHaveBeenCalledTimes(2); +}); diff --git a/src/services/serverCapabilities.ts b/src/services/serverCapabilities.ts new file mode 100644 index 000000000..14b23fcb7 --- /dev/null +++ b/src/services/serverCapabilities.ts @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import { fetchServerMeta } from "@/services/teamObjects"; +import { logFailure } from "@/lib/logger"; +import type { TeamSecretType } from "@/services/teamVaultSecretKeys"; + +// Shipped before servers advertised `team_secret_types`: only a refused upload reveals a server too old for one. +const UNADVERTISED_SECRET_TYPES: ReadonlySet = new Set(["connection_knock_sequence"]); +const REFRESH_INTERVAL_MS = 5 * 60_000; + +interface ServerCapabilities { + advertisedSecretTypes: string[] | null; + refusedSecretTypes: TeamSecretType[]; +} + +const UNKNOWN: ServerCapabilities = { advertisedSecretTypes: null, refusedSecretTypes: [] }; +const useServerCapabilities = create(() => UNKNOWN); + +let fetchedAt: number | null = null; +let inFlight: Promise | null = null; + +async function loadCapabilities(): Promise { + try { + const { team_secret_types: types } = await fetchServerMeta("common.error.failedToLoadServerCapabilities"); + fetchedAt = Date.now(); + useServerCapabilities.setState( + Array.isArray(types) + ? { advertisedSecretTypes: types.filter((t): t is string => typeof t === "string"), refusedSecretTypes: [] } + : { advertisedSecretTypes: null }, + ); + } catch (e) { + logFailure("server capabilities")(e); + } finally { + inFlight = null; + } +} + +export function refreshServerCapabilities(): Promise { + if (fetchedAt !== null && Date.now() - fetchedAt < REFRESH_INTERVAL_MS) return Promise.resolve(); + inFlight ??= loadCapabilities(); + return inFlight; +} + +export function resetServerCapabilities(): void { + fetchedAt = null; + useServerCapabilities.setState(UNKNOWN); +} + +const lacks = (s: ServerCapabilities, type: TeamSecretType): boolean => + s.advertisedSecretTypes ? !s.advertisedSecretTypes.includes(type) : s.refusedSecretTypes.includes(type); + +export const serverLacksSecretType = (type: TeamSecretType): boolean => lacks(useServerCapabilities.getState(), type); + +/** True when the refusal means the server is too old for `type`, which is then remembered. */ +export function noteRefusedSecretType(type: TeamSecretType): boolean { + const s = useServerCapabilities.getState(); + if (s.advertisedSecretTypes || !UNADVERTISED_SECRET_TYPES.has(type)) return false; + if (!s.refusedSecretTypes.includes(type)) { + useServerCapabilities.setState({ refusedSecretTypes: [...s.refusedSecretTypes, type] }); + } + return true; +} + +export function useServerLacksSecretType(type: TeamSecretType): boolean { + useEffect(() => void refreshServerCapabilities(), []); + return useServerCapabilities((s) => lacks(s, type)); +} diff --git a/src/services/teamDataManager.ts b/src/services/teamDataManager.ts index ad062ac56..1693ebba1 100644 --- a/src/services/teamDataManager.ts +++ b/src/services/teamDataManager.ts @@ -26,6 +26,7 @@ import { useIdentityPickStore } from "@/stores/identityPickStore"; import { fetchTeamData, clearTeamKeyCache, reconcileTeamVaultKeys, drainPendingSecretWipes } from "@/services/teamVaultSync"; import { checkAndRotateTeamKey } from "@/services/teamKeyRotation"; import { teamSecretCache } from "@/services/teamSecretCache"; +import { resetServerCapabilities } from "@/services/serverCapabilities"; import { logFailure } from "@/lib/logger"; // Statuses that warrant a retry (transient — key not yet distributed) @@ -187,6 +188,7 @@ export async function refreshAwaitingKeyTeams(): Promise { export function onSessionEnd(): void { clearTeamKeyCache(); teamSecretCache.clearAll(); + resetServerCapabilities(); useHistoryStore.getState().clear(); useConnectionStore.getState().clearTeamConnections(); useIdentityStore.getState().clearTeamIdentities(); diff --git a/src/services/teamObjects.ts b/src/services/teamObjects.ts index 39b808bf5..e1e571577 100644 --- a/src/services/teamObjects.ts +++ b/src/services/teamObjects.ts @@ -231,15 +231,15 @@ export interface IdentityPicksRecord { defaults: { team_id: string; identity_id: string; updated_at: string }[]; } -async function serverOffersIdentityPicks(): Promise { +export async function fetchServerMeta(messageKey: string): Promise> { const res = await appFetch(`${await requireServerUrl()}/v1/meta`, { method: "GET" }); - await ensureOk(res, "common.error.failedToListIdentityPicks"); - const meta: { identity_picks?: unknown } = await res.json(); - return meta.identity_picks === true; + await ensureOk(res, messageKey); + return res.json(); } export async function listIdentityPicks(): Promise { - if (!(await serverOffersIdentityPicks())) return null; + const meta = await fetchServerMeta("common.error.failedToListIdentityPicks"); + if (meta.identity_picks !== true) return null; const res = await fetchTeamApi("/v1/my/identity-picks", { method: "GET" }); await ensureOk(res, "common.error.failedToListIdentityPicks"); return res.json();