feat: tell plugins whether the admin has set up their accounts and keys

This commit is contained in:
Linus Rath
2026-10-14 08:24:28 +02:00
parent 1e62fbbee1
commit c3cf0ddcff
7 changed files with 85 additions and 14 deletions
@@ -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<typeof import('@/lib/plugin-secrets/proxy')>();
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';
+5 -1
View File
@@ -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 });
}
/**
+23 -1
View File
@@ -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,
+12 -4
View File
@@ -121,14 +121,22 @@ export interface ConnectedAccount {
}
export async function listConnectedAccounts(pluginId?: string): Promise<ConnectedAccount[]> {
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: [] };
}
}
+12 -5
View File
@@ -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)`. */
+6 -2
View File
@@ -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<Array<{ credentialId: string; origin: string; username?: string; connected: boolean }>>,
/**
* 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<Array<{ credentialId: string; scope: 'user' | 'admin'; origin?: string; username?: string; connected: boolean }>>,
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
},
// Reading mail (permission: email:read) and keyword mutations
+22
View File
@@ -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<string, CredentialSpec> } | 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<ResolvedCredential | null> {
const resolved = await resolveCredential(user, pluginId, credentialId);
return resolved?.scope === 'admin' ? resolved : null;
}
// ─── Rate limiting ────────────────────────────────────────────
const windows = new Map<string, number[]>();