diff --git a/.env.example b/.env.example
index fcf54701..594a3045 100644
--- a/.env.example
+++ b/.env.example
@@ -53,9 +53,9 @@ VAULT_STORE_KEY=""
# HEADSCALE_USER=officer
# ── Bitcoin wallet (officer-wallet) ─────────────────────────────────────────────────────────────
-# Chain data source for the self-custodial on-chain wallet. Any Esplora-compatible API works —
-# mempool.space by default, or point it at your own node's esplora/electrs when you run one.
-# WALLET_ESPLORA_URL=https://mempool.space/api
+# The chain data source is NOT here — it is configured from the app, at Wallet → Settings → Chain
+# source, and stored per owner. Any Esplora-compatible API works (electrs, esplora, mempool.space);
+# it defaults to the public mempool.space until you set one.
# WALLET_NETWORK=bitcoin # bitcoin | testnet | signet | regtest
#
# How long an unlocked wallet stays unlocked, in seconds. Default 900 (15 min). The root key is held
diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts
index ea69b71d..a05a68f9 100644
--- a/src/databases/officer_db/src/queries/service-connections.ts
+++ b/src/databases/officer_db/src/queries/service-connections.ts
@@ -13,7 +13,7 @@ import { encryptSecret, decryptSecret } from '../crypto';
// moment someone forgot to strip it.
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
-export type ServiceName = 'transmission' | 'slskd';
+export type ServiceName = 'transmission' | 'slskd' | 'esplora';
export type ServiceConnection = {
id: number;
diff --git a/src/servers/sidecar/wallet/config.ts b/src/servers/sidecar/wallet/config.ts
new file mode 100644
index 00000000..4b7322a5
--- /dev/null
+++ b/src/servers/sidecar/wallet/config.ts
@@ -0,0 +1,107 @@
+import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
+import { invalidateAll } from './resolve';
+import { getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
+
+// `/_config` — which Esplora endpoint this owner's on-chain wallets read the chain from.
+//
+// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
+// on-chain wallet the owner has, and it holds no secret, because Esplora is an unauthenticated read API.
+// It went from an env var to a row for one reason — it is the setting most likely to need changing, and
+// the reason to change it (a public explorer rate-limiting you, or seeing every address you own) shows
+// up long after the deployment that set it.
+//
+// PRIVACY, NOT CUSTODY. An Esplora endpoint learns which addresses you ask about, so pointing this at
+// your own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and
+// a hostile one can lie about balances but cannot move a coin.
+
+export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };
+
+/**
+ * Is there an Esplora API at this URL, and does it know the tip?
+ *
+ * `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
+ * bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
+ * nginx default page) fails here rather than at the first address scan, which is the whole point: the
+ * owner finds out while looking at the form.
+ */
+export async function probe(baseUrl: string): Promise {
+ const started = Date.now();
+ try {
+ const res = await fetch(`${baseUrl}/blocks/tip/height`, {
+ headers: { Accept: 'text/plain' },
+ signal: AbortSignal.timeout(8_000),
+ redirect: 'manual',
+ });
+ const body = (await res.text()).trim();
+ if (!res.ok) {
+ return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
+ }
+ const height = Number(body);
+ if (!Number.isFinite(height) || height <= 0) {
+ return {
+ ok: false,
+ error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`,
+ ms: Date.now() - started,
+ };
+ }
+ return { ok: true, blockHeight: height, ms: Date.now() - started };
+ } catch (err) {
+ return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
+ }
+}
+
+const bad = (error: string, status = 400) => Response.json({ error }, { status });
+
+const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
+
+/** The URL in use plus whether it is the owner's or the built-in default. No secrets exist here at all. */
+async function state(userId: number): Promise {
+ const connection = await getServiceConnection(userId, 'esplora');
+ const source = await getChainSource(userId);
+ return Response.json({
+ configured: !!connection,
+ connection,
+ esploraUrl: source.baseUrl,
+ custom: source.custom,
+ defaultUrl: getConfig().esploraUrl,
+ network: getConfig().network,
+ });
+}
+
+type ConfigBody = { url?: unknown };
+
+async function save(req: Request, userId: number): Promise {
+ const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
+ const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
+
+ if (!url) return bad('url is required');
+ if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
+
+ const result = await probe(url);
+ if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');
+
+ await saveServiceConnection({ userId, service: 'esplora', url });
+ invalidateChainSource(userId);
+ // Every on-chain backend holds an EsploraChain built from the old URL, and a cached gap-limit scan
+ // taken through it. Both are now wrong, so drop the instances rather than trying to patch them.
+ invalidateAll();
+
+ return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
+}
+
+/** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */
+export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise {
+ if (subpath && subpath !== '/') return bad('not found', 404);
+
+ if (req.method === 'GET') return state(userId);
+ if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
+
+ if (req.method === 'DELETE') {
+ await deleteServiceConnection(userId, 'esplora');
+ invalidateChainSource(userId);
+ invalidateAll();
+ return state(userId);
+ }
+
+ return bad('method not allowed', 405);
+}
diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts
index 5fd3f3d0..cba512e6 100644
--- a/src/servers/sidecar/wallet/index.ts
+++ b/src/servers/sidecar/wallet/index.ts
@@ -1,7 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
+import { handleConfigRoute } from './config';
import { handleOfficerRoute } from './routes';
-import { getConfig, hasStoreKey } from './upstream';
+import { getChainSource, getConfig, hasStoreKey } from './upstream';
import { lockAll } from './keys';
import { invalidateAll } from './resolve';
@@ -30,7 +31,10 @@ import { invalidateAll } from './resolve';
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
//
// GET /_health ours. Reports network, chain reachability, store key.
-// GET /_officer/config network + esplora + unlock TTL + storeKeyConfigured
+// GET /_config the owner's Esplora endpoint (or the built-in default)
+// PUT /_config {url} — probed before it is stored
+// DELETE /_config fall back to the built-in default
+// GET /_officer/config network + unlock TTL + storeKeyConfigured
//
// GET /_officer/wallets list. Never includes secrets.
// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE,
@@ -103,18 +107,42 @@ const server = Bun.serve({
async fetch(req) {
const url = new URL(req.url);
+ // `/_config` is the chain endpoint, which is per-owner — so unlike /_health it needs the header.
+ if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
+ const officerUser = req.headers.get('X-Officer-User');
+ const userId = Number(officerUser);
+ if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
+ return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
+ }
+ try {
+ return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
+ } catch (err) {
+ console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
+ return Response.json({ error: 'internal error' }, { status: 500 });
+ }
+ }
+
if (url.pathname === '/_health') {
const cfg = getConfig();
+ // Health has to probe the endpoint the wallets ACTUALLY use, not the built-in default — reporting
+ // the default green while the owner's own indexer is down would be worse than no health at all.
+ // The header is optional here (health predates it), so fall back rather than 401.
+ const healthUser = Number(req.headers.get('X-Officer-User'));
+ const chain =
+ Number.isInteger(healthUser) && healthUser > 0
+ ? await getChainSource(healthUser)
+ : { baseUrl: cfg.esploraUrl, custom: false };
const started = Date.now();
try {
- const res = await fetch(`${cfg.esploraUrl}/blocks/tip/height`, {
+ const res = await fetch(`${chain.baseUrl}/blocks/tip/height`, {
signal: AbortSignal.timeout(5_000),
});
const height = res.ok ? Number(await res.text()) : null;
return Response.json({
ok: res.ok,
network: cfg.network,
- esplora: cfg.esploraUrl,
+ esplora: chain.baseUrl,
+ custom: chain.custom,
blockHeight: Number.isFinite(height) ? height : null,
// Surfaced because wallet creation is refused without it, and that failure would otherwise
// look like a bug rather than a missing config line.
@@ -126,7 +154,8 @@ const server = Bun.serve({
{
ok: false,
network: cfg.network,
- esplora: cfg.esploraUrl,
+ esplora: chain.baseUrl,
+ custom: chain.custom,
error: String(err),
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
@@ -152,8 +181,8 @@ const server = Bun.serve({
},
});
-const cfg = getConfig();
-console.log(`[wallet] listening on 127.0.0.1:${port} — network=${cfg.network} esplora=${cfg.esploraUrl}`);
+// No esplora URL in the banner: it is per-owner state read from the database now, not a boot constant.
+console.log(`[wallet] listening on 127.0.0.1:${port} — network=${getConfig().network}`);
if (!hasStoreKey()) {
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
}
diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts
index 1b5fa4e3..5bb1d26a 100644
--- a/src/servers/sidecar/wallet/resolve.ts
+++ b/src/servers/sidecar/wallet/resolve.ts
@@ -14,7 +14,7 @@ import { LndHubBackend } from './backends/lndhub';
import { NwcBackend } from './backends/nwc';
import { OnchainBackend } from './backends/onchain';
import { sessionFor } from './keys';
-import { getConfig } from './upstream';
+import { getChainSource } from './upstream';
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
@@ -38,8 +38,8 @@ export function invalidateAll(): void {
}
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
-function versionOf(wallet: WalletSummary, config: Record | null): string {
- return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
+function versionOf(wallet: WalletSummary, config: Record | null, esploraUrl: string): string {
+ return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config, esploraUrl]);
}
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
@@ -50,12 +50,15 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
const secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null;
- const version = versionOf(wallet, config);
+ // Read per resolve rather than per build: the URL is in the fingerprint, so pointing the owner at a
+ // different indexer rebuilds every backend and drops the scan taken through the old one.
+ const { baseUrl: esploraUrl } = await getChainSource(userId);
+ const version = versionOf(wallet, config, esploraUrl);
const hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
- const backend = build(wallet, config);
+ const backend = build(wallet, config, esploraUrl);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
@@ -81,7 +84,7 @@ function required(config: Record | null, key: string, kind: str
return v;
}
-function build(wallet: WalletSummary, config: Record | null): WalletBackend {
+function build(wallet: WalletSummary, config: Record | null, esploraUrl: string): WalletBackend {
const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) {
@@ -123,7 +126,6 @@ function build(wallet: WalletSummary, config: Record | null): W
if (Object.keys(accountXpub).length === 0) {
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
}
- const { esploraUrl } = getConfig();
return new OnchainBackend({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
network,
diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts
index 259f13de..22a8c104 100644
--- a/src/servers/sidecar/wallet/routes.ts
+++ b/src/servers/sidecar/wallet/routes.ts
@@ -116,12 +116,17 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise();
+
+export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/\/+$/, '');
+
+/**
+ * Where to read the chain, for this owner.
+ *
+ * Falls back to the public default rather than failing: a wallet with no stored endpoint is still a
+ * working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address
+ * it asks about. The default is a starting point, not a destination.
+ */
+export async function getChainSource(userId: number): Promise {
+ const hit = chainCache.get(userId);
+ if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
+
+ const creds = await getServiceCredentials(userId, 'esplora');
+ const source: ChainSource = creds?.url
+ ? { baseUrl: normalizeEsploraUrl(creds.url), custom: true }
+ : { baseUrl: getConfig().esploraUrl, custom: false };
+
+ chainCache.set(userId, { source, at: Date.now() });
+ return source;
+}
+
+export function invalidateChainSource(userId: number): void {
+ chainCache.delete(userId);
+}
+
/**
* Whether VAULT_STORE_KEY is present. The sidecar can serve a locked, watch-only view without it, but
* every write path that touches an encrypted column will throw, so /_health reports it explicitly rather
diff --git a/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx
new file mode 100644
index 00000000..fd3a0f90
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx
@@ -0,0 +1,129 @@
+import type { ServiceConnectionState } from '../../hooks/useServiceConnection';
+import { useEffect, useState } from 'react';
+import { CheckCircle2, Loader2, RotateCcw, TriangleAlert } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ serviceErrorMessage,
+ useServiceConnection,
+ useServiceConnectionActions,
+} from '../../hooks/useServiceConnection';
+
+// Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has,
+// which is why it sits here and not on a wallet.
+//
+// PRIVACY, NOT CUSTODY. An Esplora endpoint learns every address you ask it about, so pointing this at
+// your own indexer is a real upgrade — but it never sees a key and cannot move a coin. That framing is
+// on the card deliberately: the risk of getting it wrong is being watched, not being robbed.
+//
+// It must speak the Esplora REST API. bitcoind's own RPC is NOT that — running your own node means
+// running electrs, esplora or mempool.space alongside it and pointing this at that.
+
+type ChainState = ServiceConnectionState & {
+ esploraUrl: string;
+ custom: boolean;
+ defaultUrl: string;
+ network: string;
+};
+
+type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } };
+
+export const ChainSourceCard = () => {
+ const { data, isLoading } = useServiceConnection('wallet');
+ const { save, forget } = useServiceConnectionActions<{ url: string }, SaveResult>('wallet');
+
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState(null);
+ const [height, setHeight] = useState(null);
+
+ // Seeded from whatever is in use, so the field starts as the answer to "what is this pointed at" and the
+ // owner edits it rather than retyping it. Keyed on the URL: a save or reset should re-seed.
+ useEffect(() => {
+ if (data) setUrl(data.esploraUrl);
+ }, [data?.esploraUrl]);
+
+ const trimmed = url.trim();
+ const dirty = !!data && trimmed !== data.esploraUrl;
+ const busy = save.isPending || forget.isPending;
+
+ const submit = async () => {
+ setError(null);
+ setHeight(null);
+ try {
+ const result = await save.mutateAsync({ url: trimmed });
+ setHeight(result.probe?.blockHeight ?? null);
+ } catch (err) {
+ setError(serviceErrorMessage(err));
+ }
+ };
+
+ const reset = async () => {
+ setError(null);
+ setHeight(null);
+ try {
+ await forget.mutateAsync();
+ } catch (err) {
+ setError(serviceErrorMessage(err));
+ }
+ };
+
+ return (
+
+
Chain source
+
+ The Esplora API on-chain wallets read balances and history from. It sees every address this wallet asks about,
+ so your own indexer is a privacy upgrade — it never sees a key and cannot spend.
+
+ {data?.custom
+ ? 'Using your endpoint.'
+ : `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '}
+ This must be an Esplora REST API — electrs, esplora or a mempool.space
+ instance. bitcoin-core's own RPC port will not work.
+
>
+ ) : addressError ? (
+ // Deriving an address needs the chain source, so this fails whenever the Esplora endpoint is
+ // down or rate-limiting. Showing "no address available" for that sent the last debugging
+ // session looking at derivation code when the answer was a 429 in the response body.
+
+
+
+
{errorMessage(addressError, 'Could not derive an address')}
+
+ Addresses are derived locally, but the wallet checks the chain to find an unused one. Check{' '}
+ Settings → Chain source.
+
+
+
) : (
No address available.
)}
diff --git a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx
index 629c5a6d..e1500f9c 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx
+++ b/src/workspaces/officerdev/src/apps/Wallet/WalletSettingsView.tsx
@@ -5,7 +5,7 @@ import { Check, Eye, KeyRound, Pencil, Star, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KIND_LABELS, walletSectionPath } from './shared';
-import { truncateMiddle } from './format';
+import { ChainSourceCard } from './ChainSourceCard';
import { CopyField } from './CopyField';
import { EmptyWallet } from './EmptyWallet';
import { LockBadge } from './LockBadge';
@@ -130,14 +130,13 @@ export const WalletSettingsView = () => {
)}
+
+
{config && (
Deployment
{config.network}
-
- {truncateMiddle(config.esploraUrl, 28, 12)}
- {formatMinutes(config.unlockTtlSec)}
{config.storeKeyConfigured ? 'configured' : missing}
diff --git a/src/workspaces/officerdev/src/apps/Wallet/shared.ts b/src/workspaces/officerdev/src/apps/Wallet/shared.ts
index 98e0060a..41b27a50 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/shared.ts
+++ b/src/workspaces/officerdev/src/apps/Wallet/shared.ts
@@ -82,7 +82,6 @@ export type WalletSummary = {
export type WalletConfig = {
network: BitcoinNetwork;
- esploraUrl: string;
unlockTtlSec: number;
/** Wallet creation is refused without it, so the UI blocks the form rather than failing on submit. */
storeKeyConfigured: boolean;
diff --git a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
index ea401fd0..42ddb0ec 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
+++ b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
@@ -220,22 +220,35 @@ export function useFees(walletId: number | null, enabled = true) {
* A receive address. `peek` returns the current unused address without advancing the derivation index —
* which is what a screen that merely *displays* an address must do, or every render burns an address.
*/
+type AddressResponse = { address: string; type: string };
+
export function useReceiveAddress(walletId: number | null, enabled = true) {
const { get } = useClient();
+ const qc = useQueryClient();
+ const key = [...ROOT_KEY, 'address', walletId] as const;
const query = useQuery({
- queryKey: [...ROOT_KEY, 'address', walletId] as const,
- queryFn: () => get<{ address: string; type: string }>(`${base(walletId!)}/address?peek=true`),
+ queryKey: key,
+ queryFn: () => get(`${base(walletId!)}/address?peek=true`),
enabled: walletId != null && enabled,
staleTime: 60_000,
});
+ // `peek=false` CONSUMES: the sidecar advances its issued mark so this address is never handed out
+ // again. Refetching the peeked query returns the same address forever, which is why this is a separate
+ // call and not `refetch()` — that was the bug behind a "new address" button that changed nothing.
+ const next = useMutation({
+ mutationFn: () => get(`${base(walletId!)}/address?peek=false`),
+ onSuccess: (data) => qc.setQueryData(key, data),
+ onError: (err) => toast.error(errorMessage(err, 'Could not derive a new address')),
+ });
+
return {
address: query.data?.address ?? null,
addressType: query.data?.type ?? null,
isLoading: query.isLoading,
error: query.error,
- refetch: query.refetch,
+ next,
};
}
diff --git a/src/workspaces/officerdev/src/hooks/useServiceConnection.ts b/src/workspaces/officerdev/src/hooks/useServiceConnection.ts
index 7b2cf586..21b24019 100644
--- a/src/workspaces/officerdev/src/hooks/useServiceConnection.ts
+++ b/src/workspaces/officerdev/src/hooks/useServiceConnection.ts
@@ -46,11 +46,16 @@ export function serviceErrorMessage(err: unknown): string {
const configKey = (service: string) => [service, 'connection'] as const;
const healthKey = (service: string) => [service, 'health'] as const;
-export function useServiceConnection(service: string) {
+/**
+ * `TState` widens the GET body for services that answer with more than the two common fields — the wallet
+ * adds the URL actually in use and whether it is the owner's or a fallback. The base shape is required, so
+ * `configured`/`connection` are always there to branch on.
+ */
+export function useServiceConnection(service: string) {
const { get } = useClient();
return useQuery({
queryKey: configKey(service),
- queryFn: () => get(`/${service}/_config`),
+ queryFn: () => get(`/${service}/_config`),
staleTime: 60_000,
retry: false,
});
@@ -94,14 +99,17 @@ export function useServiceHealth(service: string) {
* Save and forget. Both invalidate the WHOLE service prefix, not just the connection: re-pointing at a
* different daemon invalidates every list, stat and setting already in the cache.
*/
-export function useServiceConnectionActions>(service: string) {
+export function useServiceConnectionActions<
+ TInput extends Record,
+ TSaved = { connection: ServiceConnection },
+>(service: string) {
const { put, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: [service] });
const save = useMutation({
- mutationFn: (input: TInput) => put<{ connection: ServiceConnection }>(`/${service}/_config`, input),
+ mutationFn: (input: TInput) => put(`/${service}/_config`, input),
onSuccess: invalidate,
});