feat: migrate Stalwart management API to JMAP x: methods (0.16)

Drops the 0.15 REST management API and routes all account/auth/crypto/
principal operations through Stalwart 0.16's schema-driven JMAP
endpoint via a single passthrough (/api/account/stalwart/jmap).

- New client helper `stalwartJmap` + typed `requireResult`
- account-security-store rewritten against x:AccountPassword, x:AppPassword,
  x:AccountSettings, x:Account (with currentSecret for TOTP ops)
- Client-side TOTP setup via `otpauth`; server-generated app password
  secrets shown once on create
- Admin check switched to /api/account permissions
  (sysAccountQuery/sysTenantQuery/sysSystemSettingsGet)
- Removed sieve vacation-overwrite workaround (fixed upstream #1251)
- Deleted old REST routes, StalwartClient, stale tests; added new
  tests for passthrough + store
This commit is contained in:
Linus Rath
2026-04-21 17:29:23 +02:00
parent 9ad2facad3
commit 794001fdbd
25 changed files with 1189 additions and 1592 deletions
+22 -4
View File
@@ -7,20 +7,38 @@ import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* Check if the current user is a Stalwart admin by probing an admin-only endpoint.
* Permissions that indicate Stalwart admin privileges.
* If the authenticated user has at least one of these, they can manage
* system-level resources and are considered an admin.
*/
const ADMIN_PERMISSIONS = [
'sysAccountQuery',
'sysTenantQuery',
'sysSystemSettingsGet',
];
/**
* Check if the current user is a Stalwart admin by inspecting the
* permissions list returned by Stalwart's /api/account endpoint.
*/
async function checkStalwartAdmin(request: NextRequest): Promise<boolean> {
try {
const creds = await getStalwartCredentials(request);
if (!creds) return false;
// Probe admin-only endpoint: listing principals requires admin privileges
const response = await fetch(`${creds.apiUrl}/api/principal?limit=1`, {
const response = await fetch(`${creds.serverUrl}/api/account`, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isAdmin = response.ok;
if (!response.ok) {
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin: false });
return false;
}
const data = await response.json() as { permissions?: string[] };
const permissions = Array.isArray(data.permissions) ? data.permissions : [];
const isAdmin = ADMIN_PERMISSIONS.some(p => permissions.includes(p));
logger.info('Stalwart admin check (auth)', { username: creds.username, status: response.status, isAdmin });
return isAdmin;
} catch (error) {
-41
View File
@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/**
* GET /api/admin/stalwart-check
* Check if the currently logged-in user is a Stalwart admin.
* Probes the admin-only principal-list endpoint - if the user can access it, they're an admin.
*/
export async function GET(request: NextRequest) {
try {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
// Probe an admin-only endpoint: listing principals requires admin privileges.
// Use limit=1 to minimize payload.
const url = `${creds.apiUrl}/api/principal?limit=1`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': creds.authHeader },
});
const isStalwartAdmin = response.ok;
logger.info('Stalwart admin check', { username: creds.username, status: response.status, isStalwartAdmin });
return NextResponse.json({ isStalwartAdmin }, {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('Stalwart admin check error', {
error: error instanceof Error ? error.message : 'Unknown',
});
return NextResponse.json({ isStalwartAdmin: false }, {
headers: { 'Cache-Control': 'no-store' },
});
}
}