From c3cf0ddcff616a3f6c6f3da84992934d231be7a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:24:28 +0200 Subject: [PATCH] feat: tell plugins whether the admin has set up their accounts and keys --- .../plugin-secrets/__tests__/route.test.ts | 6 ++++- app/api/plugin-secrets/route.ts | 6 ++++- lib/__tests__/plugin-secret-proxy.test.ts | 24 ++++++++++++++++++- lib/plugin-sandbox/connect-dialog.ts | 16 +++++++++---- lib/plugin-sandbox/proxy-client.ts | 17 +++++++++---- lib/plugin-sandbox/runtime.tsx | 8 +++++-- lib/plugin-secrets/proxy.ts | 22 +++++++++++++++++ 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts index 1295624e7..c02a4ee51 100644 --- a/app/api/plugin-secrets/__tests__/route.test.ts +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -29,7 +29,11 @@ vi.mock('@/lib/plugin-secrets/vault', () => ({ const proxied: unknown[] = []; vi.mock('@/lib/plugin-secrets/proxy', async (orig) => { const real = await orig(); - return { ...real, proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }) }; + return { + ...real, + proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }), + adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]), + }; }); import { POST as saveCredential } from '../route'; diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index c40bdfba9..6aee51b74 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -3,6 +3,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials'; +import { adminCredentialStatus } from '@/lib/plugin-secrets/proxy'; import { VaultFullError, deleteUserCredentials, @@ -33,7 +34,10 @@ export async function GET(request: NextRequest) { if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) { return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 }); } - return NextResponse.json({ credentials: await listUserCredentials(user, pluginId) }, { headers: NO_STORE }); + const credentials = await listUserCredentials(user, pluginId); + // For one plugin, also say which of its admin credentials are set up. + const admin = pluginId ? await adminCredentialStatus(pluginId) : []; + return NextResponse.json({ credentials, admin }, { headers: NO_STORE }); } /** diff --git a/lib/__tests__/plugin-secret-proxy.test.ts b/lib/__tests__/plugin-secret-proxy.test.ts index 640149c03..053a9eaf8 100644 --- a/lib/__tests__/plugin-secret-proxy.test.ts +++ b/lib/__tests__/plugin-secret-proxy.test.ts @@ -22,7 +22,7 @@ import { checkCredentials, authHeaderFor } from '../plugin-secrets/credentials'; import { deleteUserCredentials, getUserCredential, listUserCredentials, putUserCredential, } from '../plugin-secrets/vault'; -import { proxyRequest, ProxyError, PROXY_RATE_LIMIT, resetProxyLimits } from '../plugin-secrets/proxy'; +import { adminCredentialStatus, proxyRequest, ProxyError, PROXY_RATE_LIMIT, resetProxyLimits } from '../plugin-secrets/proxy'; const USER = { username: 'alice@example.org', serverUrl: 'https://mail.example.org' }; const OTHER = { username: 'bob@example.org', serverUrl: 'https://mail.example.org' }; @@ -257,6 +257,28 @@ describe('proxy', () => { expect(seen[0].headers.authorization).toBeUndefined(); }); + it('says which admin credentials are set up, without their secrets', async () => { + const plugin = { + id: 'ai', enabled: true, + credentials: { + llm: { label: 'LLM', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', originKey: 'url' }, + other: { label: 'Other', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'otherKey', origin: 'https://o.example' }, + own: { label: 'Own', scope: 'user', inject: { type: 'bearer' } }, + }, + }; + vi.mocked(getPlugin).mockResolvedValue(plugin as never); + vi.mocked(getPluginConfig).mockResolvedValue({ key: 'sk-admin', url: `${origin}/v1` }); + try { + const status = await adminCredentialStatus('ai'); + + expect(status).toEqual([{ credentialId: 'llm', configured: true }, { credentialId: 'other', configured: false }]); + expect(JSON.stringify(status)).not.toContain('sk-admin'); + } finally { + vi.mocked(getPlugin).mockResolvedValue(null); + vi.mocked(getPluginConfig).mockResolvedValue({}); + } + }); + it('uses an admin credential for an installed plugin', async () => { vi.mocked(getPlugin).mockResolvedValueOnce({ id: 'ai', enabled: true, diff --git a/lib/plugin-sandbox/connect-dialog.ts b/lib/plugin-sandbox/connect-dialog.ts index 18c86d184..c9d40830e 100644 --- a/lib/plugin-sandbox/connect-dialog.ts +++ b/lib/plugin-sandbox/connect-dialog.ts @@ -121,14 +121,22 @@ export interface ConnectedAccount { } export async function listConnectedAccounts(pluginId?: string): Promise { + return (await credentialOverview(pluginId)).credentials; +} + +/** The user's connected accounts, and (for one plugin) which admin credentials are set up. */ +export async function credentialOverview(pluginId?: string): Promise<{ credentials: ConnectedAccount[]; admin: { credentialId: string; configured: boolean }[] }> { const query = pluginId ? `?plugin=${encodeURIComponent(pluginId)}` : ''; try { const res = await apiFetch(`/api/plugin-secrets${query}`); - if (!res.ok) return []; - const body = await res.json() as { credentials?: ConnectedAccount[] }; - return Array.isArray(body.credentials) ? body.credentials : []; + if (!res.ok) return { credentials: [], admin: [] }; + const body = await res.json() as { credentials?: ConnectedAccount[]; admin?: { credentialId: string; configured: boolean }[] }; + return { + credentials: Array.isArray(body.credentials) ? body.credentials : [], + admin: Array.isArray(body.admin) ? body.admin : [], + }; } catch { - return []; + return { credentials: [], admin: [] }; } } diff --git a/lib/plugin-sandbox/proxy-client.ts b/lib/plugin-sandbox/proxy-client.ts index 68632457c..2afc4740a 100644 --- a/lib/plugin-sandbox/proxy-client.ts +++ b/lib/plugin-sandbox/proxy-client.ts @@ -5,7 +5,7 @@ import type { InstalledPlugin } from '../plugin-types'; import { apiFetch, withBasePath } from '../browser-navigation'; -import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog'; +import { requestConnect, forgetCredentials, credentialOverview } from './connect-dialog'; /** Where streamed chunks and upload progress go: the iframe that asked. */ export interface StreamSink { @@ -281,10 +281,17 @@ export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Pr }); } -/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */ -export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> { - const list = await listConnectedAccounts(plugin.id); - return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected })); +/** + * `api.proxy.accounts()`: the user's connected accounts (with server and + * user name) and, with `scope: 'admin'`, whether each admin credential is + * set up. No secrets, and no server address for admin credentials. + */ +export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; scope: 'user' | 'admin'; origin?: string; username?: string; connected: boolean }[]> { + const { credentials, admin } = await credentialOverview(plugin.id); + return [ + ...credentials.map(({ credentialId, origin, username, connected }) => ({ credentialId, scope: 'user' as const, origin, ...(username ? { username } : {}), connected })), + ...admin.map(({ credentialId, configured }) => ({ credentialId, scope: 'admin' as const, connected: configured })), + ]; } /** `api.proxy.disconnect(credentialId)`. */ diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 9383a2a97..c614733f5 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -520,8 +520,12 @@ function buildPluginApi(manifest: PluginManifest) { /** Show the host's dialog to connect a user credential. */ connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) => callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>, - /** Which credentials are connected (no secrets). */ - accounts: () => callApi('proxy.accounts', []) as Promise>, + /** + * Which credentials are ready: accounts the user connected (scope + * 'user', with server and user name) and the admin's (scope 'admin', + * connected = set up). No secrets. + */ + accounts: () => callApi('proxy.accounts', []) as Promise>, disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise, }, // Reading mail (permission: email:read) and keyword mutations diff --git a/lib/plugin-secrets/proxy.ts b/lib/plugin-secrets/proxy.ts index 6d4893dd9..73b02ed1b 100644 --- a/lib/plugin-secrets/proxy.ts +++ b/lib/plugin-secrets/proxy.ts @@ -92,6 +92,28 @@ export async function resolveCredential(user: VaultUser, pluginId: string, crede }; } +/** + * Which of a plugin's admin credentials are ready to use (secret and server + * set). No secret or address leaves the server; plugins learn only yes/no. + */ +export async function adminCredentialStatus(pluginId: string): Promise<{ credentialId: string; configured: boolean }[]> { + const plugin = await getPlugin(pluginId); + const specs = (plugin as { credentials?: Record } | null)?.credentials; + if (!plugin || !plugin.enabled || !specs) return []; + const adminIds = Object.entries(specs).filter(([, spec]) => spec.scope === 'admin').map(([id]) => id); + if (adminIds.length === 0) return []; + const user = { username: '', serverUrl: '' }; + return Promise.all(adminIds.map(async (credentialId) => ({ + credentialId, + configured: !!(await resolveAdminOnly(user, pluginId, credentialId)), + }))); +} + +async function resolveAdminOnly(user: VaultUser, pluginId: string, credentialId: string): Promise { + const resolved = await resolveCredential(user, pluginId, credentialId); + return resolved?.scope === 'admin' ? resolved : null; +} + // ─── Rate limiting ──────────────────────────────────────────── const windows = new Map();