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();