diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..b21d16db
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@.github/copilot-instructions.md
diff --git a/api-docs/openapi.yaml b/api-docs/openapi.yaml
index dae40dd9..3ae76fd9 100644
--- a/api-docs/openapi.yaml
+++ b/api-docs/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
- version: 2.5.1
+ version: 2.6.0
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
new file mode 100644
index 00000000..8601095a
--- /dev/null
+++ b/docs/CLAUDE.md
@@ -0,0 +1,2 @@
+@../.github/instructions/docs.instructions.md
+@../.github/instructions/changelog.instructions.md
diff --git a/docs/changelog.md b/docs/changelog.md
index 8e3083ad..3cdf097d 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -2,6 +2,47 @@
All notable changes to DBackup are documented here.
+## v2.6.0 - Security Update, Vault Credential Profiles, OAuth Improvements, and Multiple Bug Fixes
+*Released: June 6, 2026*
+
+> ๐ **Security Update:** This release fixes a security vulnerability in DBackup's own code ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc)). Update as soon as possible.
+
+> โ ๏ธ **Breaking:** OAuth storage destinations (Dropbox, Google Drive, OneDrive) and token-based notification channels (Discord, Slack, Teams, Generic Webhook, Twilio) no longer store secrets inline - they require a Vault credential profile to function. After updating, create a matching `OAUTH`, `WEBHOOK`, or `TOKEN` profile in the Security Vault and assign it to each affected adapter via the edit form. Adapters without an assigned profile will fail connection tests and backup/notification jobs until migrated.
+
+### โจ Features
+
+- **credentials**: Credential profiles now support `WEBHOOK` (Discord, Slack, Teams, Generic Webhook), `OAUTH` (Dropbox, Google Drive, OneDrive), and `TOKEN` (Twilio) types, allowing notification and storage secrets to be stored in the vault and resolved server-side.
+- **OAuth**: OAuth authorization flows (Dropbox, Google Drive, OneDrive) now require an assigned credential profile. Tokens are stored in the vault and the credential picker refreshes automatically after authorization.
+- **OAuth**: Authorization dialogs now open in a popup window instead of redirecting the current page.
+
+### ๐ Security
+
+- **SMB**: Passwords are now redacted from error messages and logs when SMB connections fail.
+- **adapters**: All adapter endpoints (list, create, update, clone) now return a safe DTO that strips all sensitive fields and replaces them with a `secretStatus` map - decrypted secrets are no longer serialized to API responses. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc))
+- **adapters**: Notification webhook URLs and tokens (`webhookUrl`, `botToken`, `authToken`, `authHeader`, `appToken`, `accessToken`) and SSH keys (`sshPassword`, `sshPrivateKey`, `sshPassphrase`) added to `SENSITIVE_KEYS` and redacted in all DTO and strip operations. Thanks @YHalo-wyh ([GHSA-cj5h-46h6-72wc](https://github.com/skyfay/DBackup/security/advisories/GHSA-cj5h-46h6-72wc))
+- **OAuth**: Refresh tokens for Dropbox, Google Drive, and OneDrive are now stored exclusively in credential profiles instead of adapter configs.
+- **adapters**: Adapter update (PUT) now preserves existing secrets via `mergeSecrets` - re-saving an adapter form without changing secret fields no longer overwrites stored credentials with empty values.
+
+### ๐จ Improvements
+
+- **adapters**: Secret fields in the adapter form show a "saved - leave blank to keep" placeholder when a value is already stored.
+- **credentials**: Credential profile dialog extended to support creating `WEBHOOK` and `OAUTH` profile types.
+- **encryption**: The key resolution dialog now automatically switches to the raw key tab when no vault profiles are available.
+
+### ๐งช Tests
+
+- **adapters**: Added audit tests verifying no sensitive fields are returned by any adapter API endpoint.
+- **adapters**: Added DTO unit tests verifying that notification secrets (Telegram `botToken`, Discord `webhookUrl`) are redacted and `secretStatus` flags are set correctly.
+- **crypto**: Added unit tests for `stripSecrets`, `mergeSecrets`, and `getSecretStatus`.
+
+### ๐ณ Docker
+
+- **Image**: `skyfay/dbackup:v2.6.0`
+- **Also tagged as**: `latest`, `v2`
+- **CI Image**: `skyfay/dbackup:ci`
+- **Platforms**: linux/amd64, linux/arm64
+
+
## v2.5.1 - Security Update, Smart Recovery Improvements, and Multiple Bug Fixes
*Released: June 2, 2026*
diff --git a/docs/package.json b/docs/package.json
index 28251c8b..d2f3906c 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -1,6 +1,6 @@
{
"name": "dbackup-docs",
- "version": "2.5.1",
+ "version": "2.6.0",
"private": true,
"scripts": {
"dev": "vitepress dev",
diff --git a/package.json b/package.json
index e6554090..5d1b5c49 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dbackup",
- "version": "2.5.1",
+ "version": "2.6.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/public/openapi.yaml b/public/openapi.yaml
index 4f4048e9..ced1abba 100644
--- a/public/openapi.yaml
+++ b/public/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: DBackup API
- version: 2.5.1
+ version: 2.6.0
description: |
REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention.
diff --git a/src/CLAUDE.md b/src/CLAUDE.md
new file mode 100644
index 00000000..73242990
--- /dev/null
+++ b/src/CLAUDE.md
@@ -0,0 +1,2 @@
+@../.github/instructions/logic.instructions.md
+@../.github/instructions/ui.instructions.md
diff --git a/src/app/api/adapters/[id]/clone/route.ts b/src/app/api/adapters/[id]/clone/route.ts
index d647dd8d..9b34feab 100644
--- a/src/app/api/adapters/[id]/clone/route.ts
+++ b/src/app/api/adapters/[id]/clone/route.ts
@@ -8,6 +8,7 @@ import { PERMISSIONS, Permission } from "@/lib/auth/permissions";
import { logger } from "@/lib/logging/logger";
import { wrapError, getErrorMessage } from "@/lib/logging/errors";
import { registerAdapters } from "@/lib/adapters";
+import { toAdapterListItem } from "@/lib/adapters/dto";
registerAdapters();
@@ -82,7 +83,7 @@ export async function POST(
cloned.id
);
- return NextResponse.json(cloned, { status: 201 });
+ return NextResponse.json(toAdapterListItem(cloned), { status: 201 });
} catch (error: unknown) {
log.error("Clone adapter error", { adapterId: params.id }, wrapError(error));
return NextResponse.json({
diff --git a/src/app/api/adapters/[id]/route.ts b/src/app/api/adapters/[id]/route.ts
index d290e0e3..67fdc4d4 100644
--- a/src/app/api/adapters/[id]/route.ts
+++ b/src/app/api/adapters/[id]/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";
-import { encryptConfig } from "@/lib/crypto";
+import { encryptConfig, decryptConfig, mergeSecrets } from "@/lib/crypto";
+import { toAdapterListItem } from "@/lib/adapters/dto";
import { headers } from "next/headers";
import { auditService } from "@/services/audit-service";
import { AUDIT_ACTIONS, AUDIT_RESOURCES } from "@/lib/core/audit-types";
@@ -111,7 +112,7 @@ export async function PUT(
// RBAC: Check permission based on adapter type
const existingAdapter = await prisma.adapterConfig.findUnique({
where: { id: params.id },
- select: { type: true, adapterId: true, lastError: true }
+ select: { type: true, adapterId: true, lastError: true, config: true }
});
if (!existingAdapter) {
return NextResponse.json({ success: false, error: "Adapter not found" }, { status: 404 });
@@ -151,15 +152,29 @@ export async function PUT(
}
}
- const configObj = typeof config === 'string' ? JSON.parse(config) : config;
- const encryptedConfig = encryptConfig(configObj);
- const configString = JSON.stringify(encryptedConfig);
+ // Build the encrypted config string only when a config payload was supplied.
+ // Secret-preserving merge: the API returns redacted secrets, so an edit
+ // round-trip submits empty secret fields. Re-fill them from the existing
+ // (decrypted) config before re-encrypting so we never clobber a real
+ // secret with an encrypted empty string (data-loss bug).
+ let configString: string | undefined;
+ if (config !== undefined) {
+ const incomingConfig = typeof config === 'string' ? JSON.parse(config) : config;
+ let existingDecrypted: unknown = {};
+ try {
+ existingDecrypted = decryptConfig(JSON.parse(existingAdapter.config));
+ } catch (e) {
+ log.warn("Failed to decrypt existing config during update; secret merge skipped", { adapterId: params.id }, wrapError(e));
+ }
+ const mergedConfig = mergeSecrets(incomingConfig, existingDecrypted);
+ configString = JSON.stringify(encryptConfig(mergedConfig));
+ }
const updatedAdapter = await prisma.adapterConfig.update({
where: { id: params.id },
data: {
name,
- config: configString,
+ ...(configString !== undefined ? { config: configString } : {}),
...(primaryCredentialId !== undefined ? { primaryCredentialId: primaryCredentialId ?? null } : {}),
...(sshCredentialId !== undefined ? { sshCredentialId: sshCredentialId ?? null } : {}),
...(metadata !== undefined ? { metadata: JSON.stringify(metadata) } : {}),
@@ -180,7 +195,7 @@ export async function PUT(
);
}
- return NextResponse.json(updatedAdapter);
+ return NextResponse.json(toAdapterListItem(updatedAdapter));
} catch (_error) {
return NextResponse.json({ error: "Failed to update adapter" }, { status: 500 });
}
diff --git a/src/app/api/adapters/dropbox/auth/route.ts b/src/app/api/adapters/dropbox/auth/route.ts
index 32a00cfc..40a266c6 100644
--- a/src/app/api/adapters/dropbox/auth/route.ts
+++ b/src/app/api/adapters/dropbox/auth/route.ts
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { DropboxAuth } from "dropbox";
import { headers } from "next/headers";
-import prisma from "@/lib/prisma";
import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
-import { decryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
const log = logger.child({ route: "adapters/dropbox/auth" });
@@ -12,7 +12,8 @@ const log = logger.child({ route: "adapters/dropbox/auth" });
/**
* POST /api/adapters/dropbox/auth
* Generates the Dropbox OAuth authorization URL.
- * Body: { adapterId: string } - The saved adapter config ID to authorize.
+ * Body: { credentialId: string } - The OAUTH credential profile to authorize.
+ * The resulting refresh token is written back into that profile.
*/
export async function POST(req: NextRequest) {
const ctx = await getAuthContext(await headers());
@@ -21,25 +22,16 @@ export async function POST(req: NextRequest) {
}
try {
- checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE);
+ checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE);
- const { adapterId } = await req.json();
- if (!adapterId) {
- return NextResponse.json({ error: "Missing adapterId" }, { status: 400 });
+ const { credentialId } = await req.json();
+ if (!credentialId) {
+ return NextResponse.json({ error: "Missing credentialId" }, { status: 400 });
}
- // Load the adapter config to get clientId and clientSecret
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: adapterId },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "dropbox") {
- return NextResponse.json({ error: "Adapter not found or not a Dropbox adapter" }, { status: 404 });
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
+ const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
- if (!config.clientId || !config.clientSecret) {
+ if (!profile.clientId || !profile.clientSecret) {
return NextResponse.json({ error: "App Key and App Secret are required" }, { status: 400 });
}
@@ -48,14 +40,14 @@ export async function POST(req: NextRequest) {
const redirectUri = `${origin}/api/adapters/dropbox/callback`;
const dbxAuth = new DropboxAuth({
- clientId: config.clientId,
- clientSecret: config.clientSecret,
+ clientId: profile.clientId,
+ clientSecret: profile.clientSecret,
fetch: fetch,
});
const authUrl = await dbxAuth.getAuthenticationUrl(
redirectUri,
- adapterId, // state parameter for callback
+ credentialId, // state parameter for callback
"code",
"offline", // Request offline access to get refresh_token
undefined, // scopes - use app-configured scopes
@@ -63,7 +55,7 @@ export async function POST(req: NextRequest) {
false
);
- log.info("Generated Dropbox OAuth URL", { adapterId });
+ log.info("Generated Dropbox OAuth URL", { credentialId });
return NextResponse.json({ success: true, data: { authUrl: String(authUrl) } });
} catch (error) {
diff --git a/src/app/api/adapters/dropbox/callback/route.ts b/src/app/api/adapters/dropbox/callback/route.ts
index dd74bb5b..6dbeb84c 100644
--- a/src/app/api/adapters/dropbox/callback/route.ts
+++ b/src/app/api/adapters/dropbox/callback/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import prisma from "@/lib/prisma";
-import { decryptConfig, encryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
@@ -10,8 +10,8 @@ const log = logger.child({ route: "adapters/dropbox/callback" });
/**
* GET /api/adapters/dropbox/callback
* Handles the OAuth callback from Dropbox.
- * Exchanges auth code for tokens and stores the refresh token in the adapter config.
- * Redirects back to the destinations page with success/error status.
+ * `state` is the OAUTH credential profile id; the refresh token is written back
+ * into that profile. Redirects back to the destinations page with status.
*/
export async function GET(req: NextRequest) {
const origin = process.env.BETTER_AUTH_URL || req.nextUrl.origin;
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
}
const code = req.nextUrl.searchParams.get("code");
- const state = req.nextUrl.searchParams.get("state"); // adapter config ID
+ const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id
const error = req.nextUrl.searchParams.get("error");
const errorDescription = req.nextUrl.searchParams.get("error_description");
@@ -46,18 +46,8 @@ export async function GET(req: NextRequest) {
}
try {
- // Load the adapter config
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: state },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "dropbox") {
- return NextResponse.redirect(
- `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}`
- );
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
+ // `state` is the OAUTH credential profile id.
+ const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData;
const redirectUri = `${origin}/api/adapters/dropbox/callback`;
@@ -71,8 +61,8 @@ export async function GET(req: NextRequest) {
body: new URLSearchParams({
code,
grant_type: "authorization_code",
- client_id: config.clientId,
- client_secret: config.clientSecret,
+ client_id: profile.clientId,
+ client_secret: profile.clientSecret,
redirect_uri: redirectUri,
}),
});
@@ -88,28 +78,18 @@ export async function GET(req: NextRequest) {
const tokenData = await tokenRes.json();
if (!tokenData.refresh_token) {
- log.warn("No refresh token received from Dropbox", { adapterId: state });
+ log.warn("No refresh token received from Dropbox", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Ensure the app has 'offline' access configured.")}`
);
}
- // Update the adapter config with the refresh token
- const updatedConfig = {
- ...config,
- refreshToken: tokenData.refresh_token,
- };
-
- const encryptedConfig = encryptConfig(updatedConfig);
-
- await prisma.adapterConfig.update({
- where: { id: state },
- data: {
- config: JSON.stringify(encryptedConfig),
- },
+ // Store the refresh token in the credential profile.
+ await updateCredentialProfile(state, {
+ data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData,
});
- log.info("Dropbox OAuth completed successfully", { adapterId: state });
+ log.info("Dropbox OAuth completed successfully", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("Dropbox authorized successfully!")}`
diff --git a/src/app/api/adapters/google-drive/auth/route.ts b/src/app/api/adapters/google-drive/auth/route.ts
index 9a825e89..1453b916 100644
--- a/src/app/api/adapters/google-drive/auth/route.ts
+++ b/src/app/api/adapters/google-drive/auth/route.ts
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { google } from "googleapis";
import { headers } from "next/headers";
-import prisma from "@/lib/prisma";
import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
-import { decryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
const log = logger.child({ route: "adapters/google-drive/auth" });
@@ -17,7 +17,11 @@ const SCOPES = [
/**
* POST /api/adapters/google-drive/auth
* Generates the Google OAuth authorization URL.
- * Body: { adapterId: string } - The saved adapter config ID to authorize.
+ * Body: { credentialId: string } - The OAUTH credential profile to authorize.
+ *
+ * Authorization is tied to the credential profile (not a saved adapter): the
+ * resulting refresh token is written back into that profile, so any destination
+ * referencing it becomes authorized at once.
*/
export async function POST(req: NextRequest) {
const ctx = await getAuthContext(await headers());
@@ -26,25 +30,17 @@ export async function POST(req: NextRequest) {
}
try {
- checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE);
+ checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE);
- const { adapterId } = await req.json();
- if (!adapterId) {
- return NextResponse.json({ error: "Missing adapterId" }, { status: 400 });
+ const { credentialId } = await req.json();
+ if (!credentialId) {
+ return NextResponse.json({ error: "Missing credentialId" }, { status: 400 });
}
- // Load the adapter config to get clientId and clientSecret
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: adapterId },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "google-drive") {
- return NextResponse.json({ error: "Adapter not found or not a Google Drive adapter" }, { status: 404 });
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
+ // clientId + clientSecret come from the OAUTH credential profile.
+ const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
- if (!config.clientId || !config.clientSecret) {
+ if (!profile.clientId || !profile.clientSecret) {
return NextResponse.json({ error: "Client ID and Client Secret are required" }, { status: 400 });
}
@@ -53,8 +49,8 @@ export async function POST(req: NextRequest) {
const redirectUri = `${origin}/api/adapters/google-drive/callback`;
const oauth2Client = new google.auth.OAuth2(
- config.clientId,
- config.clientSecret,
+ profile.clientId,
+ profile.clientSecret,
redirectUri
);
@@ -62,10 +58,10 @@ export async function POST(req: NextRequest) {
access_type: "offline",
scope: SCOPES,
prompt: "consent", // Force consent to always get refresh_token
- state: adapterId, // Pass adapter config ID as state for callback
+ state: credentialId, // Pass credential profile ID as state for callback
});
- log.info("Generated Google OAuth URL", { adapterId });
+ log.info("Generated Google OAuth URL", { credentialId });
return NextResponse.json({ success: true, data: { authUrl } });
} catch (error) {
diff --git a/src/app/api/adapters/google-drive/callback/route.ts b/src/app/api/adapters/google-drive/callback/route.ts
index 72c08093..8e7dd317 100644
--- a/src/app/api/adapters/google-drive/callback/route.ts
+++ b/src/app/api/adapters/google-drive/callback/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { google } from "googleapis";
-import prisma from "@/lib/prisma";
-import { decryptConfig, encryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
@@ -11,7 +11,8 @@ const log = logger.child({ route: "adapters/google-drive/callback" });
/**
* GET /api/adapters/google-drive/callback
* Handles the OAuth callback from Google.
- * Exchanges auth code for tokens and stores the refresh token in the adapter config.
+ * `state` is the OAUTH credential profile id; the refresh token is written back
+ * into that profile, so every destination referencing it becomes authorized.
* Redirects back to the destinations page with success/error status.
*/
export async function GET(req: NextRequest) {
@@ -27,7 +28,7 @@ export async function GET(req: NextRequest) {
}
const code = req.nextUrl.searchParams.get("code");
- const state = req.nextUrl.searchParams.get("state"); // adapter config ID
+ const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id
const error = req.nextUrl.searchParams.get("error");
// Handle user denial
@@ -46,24 +47,14 @@ export async function GET(req: NextRequest) {
}
try {
- // Load the adapter config
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: state },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "google-drive") {
- return NextResponse.redirect(
- `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}`
- );
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
+ // `state` is the OAUTH credential profile id. Load its clientId + secret.
+ const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData;
const redirectUri = `${origin}/api/adapters/google-drive/callback`;
const oauth2Client = new google.auth.OAuth2(
- config.clientId,
- config.clientSecret,
+ profile.clientId,
+ profile.clientSecret,
redirectUri
);
@@ -71,28 +62,18 @@ export async function GET(req: NextRequest) {
const { tokens } = await oauth2Client.getToken(code);
if (!tokens.refresh_token) {
- log.warn("No refresh token received from Google", { adapterId: state });
+ log.warn("No refresh token received from Google", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Please revoke app access in your Google Account settings and try again.")}`
);
}
- // Update the adapter config with the refresh token
- const updatedConfig = {
- ...config,
- refreshToken: tokens.refresh_token,
- };
-
- const encryptedConfig = encryptConfig(updatedConfig);
-
- await prisma.adapterConfig.update({
- where: { id: state },
- data: {
- config: JSON.stringify(encryptedConfig),
- },
+ // Store the refresh token in the credential profile.
+ await updateCredentialProfile(state, {
+ data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokens.refresh_token } satisfies OAuthData,
});
- log.info("Google Drive OAuth completed successfully", { adapterId: state });
+ log.info("Google Drive OAuth completed successfully", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("Google Drive authorized successfully!")}`
diff --git a/src/app/api/adapters/onedrive/auth/route.ts b/src/app/api/adapters/onedrive/auth/route.ts
index c52ebdba..67834fd3 100644
--- a/src/app/api/adapters/onedrive/auth/route.ts
+++ b/src/app/api/adapters/onedrive/auth/route.ts
@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
-import prisma from "@/lib/prisma";
import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
-import { decryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
const log = logger.child({ route: "adapters/onedrive/auth" });
@@ -17,7 +17,8 @@ const SCOPES = [
/**
* POST /api/adapters/onedrive/auth
* Generates the Microsoft OAuth authorization URL.
- * Body: { adapterId: string } - The saved adapter config ID to authorize.
+ * Body: { credentialId: string } - The OAUTH credential profile to authorize.
+ * The resulting refresh token is written back into that profile.
*/
export async function POST(req: NextRequest) {
const ctx = await getAuthContext(await headers());
@@ -26,26 +27,16 @@ export async function POST(req: NextRequest) {
}
try {
- checkPermissionWithContext(ctx, PERMISSIONS.DESTINATIONS.WRITE);
+ checkPermissionWithContext(ctx, PERMISSIONS.CREDENTIALS.WRITE);
- const { adapterId } = await req.json();
- if (!adapterId) {
- return NextResponse.json({ error: "Missing adapterId" }, { status: 400 });
+ const { credentialId } = await req.json();
+ if (!credentialId) {
+ return NextResponse.json({ error: "Missing credentialId" }, { status: 400 });
}
- // Load the adapter config to get clientId and clientSecret
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: adapterId },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "onedrive") {
- return NextResponse.json({ error: "Adapter not found or not a OneDrive adapter" }, { status: 404 });
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
-
- if (!config.clientId || !config.clientSecret) {
- return NextResponse.json({ error: "Client ID and Client Secret are required" }, { status: 400 });
+ const profile = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
+ if (!profile.clientId) {
+ return NextResponse.json({ error: "Client ID is required" }, { status: 400 });
}
// Build callback URL from the request origin
@@ -53,15 +44,15 @@ export async function POST(req: NextRequest) {
const redirectUri = `${origin}/api/adapters/onedrive/callback`;
const authUrl = new URL("https://login.microsoftonline.com/common/oauth2/v2.0/authorize");
- authUrl.searchParams.set("client_id", config.clientId);
+ authUrl.searchParams.set("client_id", profile.clientId);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", SCOPES.join(" "));
authUrl.searchParams.set("response_mode", "query");
authUrl.searchParams.set("prompt", "consent"); // Force consent to always get refresh_token
- authUrl.searchParams.set("state", adapterId); // Pass adapter config ID as state for callback
+ authUrl.searchParams.set("state", credentialId); // Pass credential profile ID as state for callback
- log.info("Generated Microsoft OAuth URL", { adapterId });
+ log.info("Generated Microsoft OAuth URL", { credentialId });
return NextResponse.json({ success: true, data: { authUrl: authUrl.toString() } });
} catch (error) {
diff --git a/src/app/api/adapters/onedrive/callback/route.ts b/src/app/api/adapters/onedrive/callback/route.ts
index d478af07..cec05ac9 100644
--- a/src/app/api/adapters/onedrive/callback/route.ts
+++ b/src/app/api/adapters/onedrive/callback/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
-import prisma from "@/lib/prisma";
-import { decryptConfig, encryptConfig } from "@/lib/crypto";
+import { getDecryptedCredentialData, updateCredentialProfile } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
@@ -10,8 +10,8 @@ const log = logger.child({ route: "adapters/onedrive/callback" });
/**
* GET /api/adapters/onedrive/callback
* Handles the OAuth callback from Microsoft.
- * Exchanges auth code for tokens and stores the refresh token in the adapter config.
- * Redirects back to the destinations page with success/error status.
+ * `state` is the OAUTH credential profile id; the refresh token is written back
+ * into that profile. Redirects back to the destinations page with status.
*/
export async function GET(req: NextRequest) {
const origin = process.env.BETTER_AUTH_URL || req.nextUrl.origin;
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
}
const code = req.nextUrl.searchParams.get("code");
- const state = req.nextUrl.searchParams.get("state"); // adapter config ID
+ const state = req.nextUrl.searchParams.get("state"); // OAUTH credential profile id
const error = req.nextUrl.searchParams.get("error");
const errorDescription = req.nextUrl.searchParams.get("error_description");
@@ -46,18 +46,8 @@ export async function GET(req: NextRequest) {
}
try {
- // Load the adapter config
- const adapterConfig = await prisma.adapterConfig.findUnique({
- where: { id: state },
- });
-
- if (!adapterConfig || adapterConfig.adapterId !== "onedrive") {
- return NextResponse.redirect(
- `${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("Adapter not found.")}`
- );
- }
-
- const config = decryptConfig(JSON.parse(adapterConfig.config));
+ // `state` is the OAUTH credential profile id.
+ const profile = (await getDecryptedCredentialData(state, "OAUTH")) as OAuthData;
const redirectUri = `${origin}/api/adapters/onedrive/callback`;
@@ -70,8 +60,8 @@ export async function GET(req: NextRequest) {
body: new URLSearchParams({
code,
grant_type: "authorization_code",
- client_id: config.clientId,
- client_secret: config.clientSecret,
+ client_id: profile.clientId,
+ client_secret: profile.clientSecret,
redirect_uri: redirectUri,
scope: "Files.ReadWrite.All offline_access User.Read",
}),
@@ -88,28 +78,18 @@ export async function GET(req: NextRequest) {
const tokenData = await tokenRes.json();
if (!tokenData.refresh_token) {
- log.warn("No refresh token received from Microsoft", { adapterId: state });
+ log.warn("No refresh token received from Microsoft", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=error&message=${encodeURIComponent("No refresh token received. Ensure the app has 'offline_access' scope configured.")}`
);
}
- // Update the adapter config with the refresh token
- const updatedConfig = {
- ...config,
- refreshToken: tokenData.refresh_token,
- };
-
- const encryptedConfig = encryptConfig(updatedConfig);
-
- await prisma.adapterConfig.update({
- where: { id: state },
- data: {
- config: JSON.stringify(encryptedConfig),
- },
+ // Store the refresh token in the credential profile.
+ await updateCredentialProfile(state, {
+ data: { clientId: profile.clientId, clientSecret: profile.clientSecret, refreshToken: tokenData.refresh_token } satisfies OAuthData,
});
- log.info("OneDrive OAuth completed successfully", { adapterId: state });
+ log.info("OneDrive OAuth completed successfully", { credentialId: state });
return NextResponse.redirect(
`${origin}/dashboard/destinations?oauth=success&message=${encodeURIComponent("OneDrive authorized successfully!")}`
diff --git a/src/app/api/adapters/route.ts b/src/app/api/adapters/route.ts
index fcf1582c..1c1d92a4 100644
--- a/src/app/api/adapters/route.ts
+++ b/src/app/api/adapters/route.ts
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";
-import { encryptConfig, decryptConfig } from "@/lib/crypto";
+import { encryptConfig } from "@/lib/crypto";
+import { toAdapterListItem } from "@/lib/adapters/dto";
import { headers } from "next/headers";
import { getAuthContext, checkPermissionWithContext } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
@@ -45,32 +46,13 @@ export async function GET(req: NextRequest) {
orderBy: { createdAt: 'desc' }
});
- const decryptedAdapters = adapters.map(adapter => {
- try {
- // Parse the config JSON first
- const configObj = JSON.parse(adapter.config);
- // Decrypt sensitive fields
- const decryptedConfig = decryptConfig(configObj);
- // Return adapter with config object (or string depending on frontend expectation)
- // The frontend seems to expect objects if we look at similar code or just stringified
- // Actually the API previously returned the raw Prisma result where `config` is string.
- // However, the frontend likely JSON.parse() it.
- // Wait, Prisma returns `config` as string because schema says `String`.
-
- // If I modify the response here, I should make sure I am consistent.
- // If I return the string, I should stringify it back.
-
- return {
- ...adapter,
- config: JSON.stringify(decryptedConfig)
- };
- } catch (e: unknown) {
- log.error("Failed to process config for adapter", { adapterId: adapter.id }, wrapError(e));
- return adapter; // Return as-is if error (fallback)
- }
- });
+ // Map every row through the safe DTO. `toAdapterListItem` redacts all
+ // sensitive keys (deletes them, not blanks them) and reports `secretStatus`,
+ // so a decrypted secret can never reach the client regardless of caller
+ // permission level. See src/lib/adapters/dto.ts.
+ const items = adapters.map(toAdapterListItem);
- return NextResponse.json(decryptedAdapters);
+ return NextResponse.json(items);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to fetch adapters";
return NextResponse.json({ error: message }, { status: 500 });
@@ -153,7 +135,7 @@ export async function POST(req: NextRequest) {
);
}
- return NextResponse.json(newAdapter, { status: 201 });
+ return NextResponse.json(toAdapterListItem(newAdapter), { status: 201 });
} catch (error: unknown) {
log.error("Create adapter error", {}, wrapError(error));
return NextResponse.json({ error: getErrorMessage(error) || "Failed to create adapter" }, { status: 500 });
diff --git a/src/app/api/system/filesystem/dropbox/route.ts b/src/app/api/system/filesystem/dropbox/route.ts
index 81f50ba4..c4f776db 100644
--- a/src/app/api/system/filesystem/dropbox/route.ts
+++ b/src/app/api/system/filesystem/dropbox/route.ts
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { Dropbox } from "dropbox";
import { checkPermission } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { wrapError } from "@/lib/logging/errors";
@@ -12,19 +14,28 @@ const log = logger.child({ route: "system/filesystem/dropbox" });
* Browse Dropbox folders for the folder picker.
*
* Body: {
- * config: { clientId, clientSecret, refreshToken },
- * folderPath?: string // Folder to list ("" or undefined = root)
+ * credentialId: string, // OAUTH credential profile id
+ * folderPath?: string // Folder to list ("" or undefined = root)
* }
*
+ * Credentials are resolved server-side from the OAUTH credential profile - they
+ * never travel through the client.
+ *
* Returns the same shape as the local/remote filesystem API:
* { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } }
*/
export async function POST(req: NextRequest) {
try {
- await checkPermission(PERMISSIONS.SETTINGS.READ);
+ await checkPermission(PERMISSIONS.DESTINATIONS.READ);
const body = await req.json();
- const { config, folderPath } = body;
+ const { credentialId, folderPath } = body;
+
+ if (!credentialId) {
+ return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 });
+ }
+
+ const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) {
return NextResponse.json(
diff --git a/src/app/api/system/filesystem/google-drive/route.ts b/src/app/api/system/filesystem/google-drive/route.ts
index 3ba1c1d7..4a84f6b1 100644
--- a/src/app/api/system/filesystem/google-drive/route.ts
+++ b/src/app/api/system/filesystem/google-drive/route.ts
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { google } from "googleapis";
import { checkPermission } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { wrapError } from "@/lib/logging/errors";
@@ -12,10 +14,13 @@ const log = logger.child({ route: "system/filesystem/google-drive" });
* Browse Google Drive folders for the folder picker.
*
* Body: {
- * config: { clientId, clientSecret, refreshToken },
- * folderId?: string // Folder to list (undefined = root)
+ * credentialId: string, // OAUTH credential profile id
+ * folderId?: string // Folder to list (undefined = root)
* }
*
+ * Credentials are resolved server-side from the OAUTH credential profile - they
+ * never travel through the client.
+ *
* Returns the same shape as the local/remote filesystem API:
* { success, data: { currentPath, parentPath, entries: [{ name, type, path }] } }
*
@@ -23,10 +28,16 @@ const log = logger.child({ route: "system/filesystem/google-drive" });
*/
export async function POST(req: NextRequest) {
try {
- await checkPermission(PERMISSIONS.SETTINGS.READ);
+ await checkPermission(PERMISSIONS.DESTINATIONS.READ);
const body = await req.json();
- const { config, folderId } = body;
+ const { credentialId, folderId } = body;
+
+ if (!credentialId) {
+ return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 });
+ }
+
+ const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) {
return NextResponse.json(
diff --git a/src/app/api/system/filesystem/onedrive/route.ts b/src/app/api/system/filesystem/onedrive/route.ts
index 3f335bb6..a5843adb 100644
--- a/src/app/api/system/filesystem/onedrive/route.ts
+++ b/src/app/api/system/filesystem/onedrive/route.ts
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { Client } from "@microsoft/microsoft-graph-client";
import { checkPermission } from "@/lib/auth/access-control";
import { PERMISSIONS } from "@/lib/auth/permissions";
+import { getDecryptedCredentialData } from "@/services/auth/credential-service";
+import type { OAuthData } from "@/lib/core/credentials";
import { logger } from "@/lib/logging/logger";
import { wrapError } from "@/lib/logging/errors";
@@ -15,19 +17,28 @@ const TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
* Browse OneDrive folders for the folder picker.
*
* Body: {
- * config: { clientId, clientSecret, refreshToken },
- * folderPath?: string // Folder to list ("" or undefined = root)
+ * credentialId: string, // OAUTH credential profile id
+ * folderPath?: string // Folder to list ("" or undefined = root)
* }
*
+ * Credentials are resolved server-side from the OAUTH credential profile - they
+ * never travel through the client.
+ *
* Returns the same shape as the local/remote filesystem API:
* { success, data: { currentPath, currentName, parentPath, entries: [{ name, type, path }] } }
*/
export async function POST(req: NextRequest) {
try {
- await checkPermission(PERMISSIONS.SETTINGS.READ);
+ await checkPermission(PERMISSIONS.DESTINATIONS.READ);
const body = await req.json();
- const { config, folderPath } = body;
+ const { credentialId, folderPath } = body;
+
+ if (!credentialId) {
+ return NextResponse.json({ success: false, error: "Missing credentialId" }, { status: 400 });
+ }
+
+ const config = (await getDecryptedCredentialData(credentialId, "OAUTH")) as OAuthData;
if (!config?.clientId || !config?.clientSecret || !config?.refreshToken) {
return NextResponse.json(
diff --git a/src/components/adapter/adapter-form.tsx b/src/components/adapter/adapter-form.tsx
index 4259be6c..7c9b658d 100644
--- a/src/components/adapter/adapter-form.tsx
+++ b/src/components/adapter/adapter-form.tsx
@@ -23,6 +23,7 @@ import { AdapterConfig } from "./types";
import { useAdapterConnection } from "./use-adapter-connection";
import { DatabaseFormContent, GenericFormContent, NotificationFormContent, StorageFormContent } from "./form-sections";
import { SchemaField } from "./schema-field";
+import { SecretStatusProvider } from "./secret-status-context";
/**
* Walks a Zod schema shape and calls form.setValue for every field that has a
@@ -113,11 +114,17 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }:
credentialKeys.push("username", "authType", "password", "privateKey", "passphrase");
}
if (selectedAdapter.credentials?.primary === "TOKEN") {
- credentialKeys.push("token", "appToken", "accessToken", "botToken");
+ credentialKeys.push("token", "appToken", "accessToken", "botToken", "authToken");
}
if (selectedAdapter.credentials?.primary === "SMTP") {
credentialKeys.push("user", "password");
}
+ if (selectedAdapter.credentials?.primary === "WEBHOOK") {
+ credentialKeys.push("webhookUrl", "url", "authHeader");
+ }
+ if (selectedAdapter.credentials?.primary === "OAUTH") {
+ credentialKeys.push("clientId", "clientSecret", "refreshToken");
+ }
if (selectedAdapter.credentials?.ssh === "SSH_KEY") {
credentialKeys.push(
"sshUsername", "sshAuthType", "sshPassword", "sshPrivateKey", "sshPassphrase",
@@ -265,6 +272,7 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }:
return (
<>
!open && setConnectionError(null)}>
diff --git a/src/components/adapter/credential-picker.tsx b/src/components/adapter/credential-picker.tsx
index 4401b4e9..32ab3cd2 100644
--- a/src/components/adapter/credential-picker.tsx
+++ b/src/components/adapter/credential-picker.tsx
@@ -35,6 +35,10 @@ interface Props {
/** Render label/help text inline. */
label?: string;
description?: string;
+ /** Notified with the resolved profile object whenever the selection changes (incl. after load). */
+ onSelectedProfile?: (profile: CredentialProfileSummary | null) => void;
+ /** Increment to trigger a profiles re-fetch (e.g. after OAuth completes). */
+ refreshKey?: number;
}
const TYPE_BADGE: Record = {
@@ -43,6 +47,8 @@ const TYPE_BADGE: Record = {
ACCESS_KEY: "Access Key",
TOKEN: "Token",
SMTP: "SMTP",
+ WEBHOOK: "Webhook",
+ OAUTH: "OAuth",
};
export function CredentialPicker({
@@ -52,6 +58,8 @@ export function CredentialPicker({
onChange,
label,
description,
+ onSelectedProfile,
+ refreshKey,
}: Props) {
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
@@ -78,7 +86,7 @@ export function CredentialPicker({
useEffect(() => {
fetchProfiles();
- }, [fetchProfiles]);
+ }, [fetchProfiles, refreshKey]);
const onCreated = (profile: CredentialProfileSummary) => {
setProfiles((prev) => [profile, ...prev.filter((p) => p.id !== profile.id)]);
@@ -86,6 +94,15 @@ export function CredentialPicker({
};
const selected = profiles.find((p) => p.id === value);
+
+ // Surface the resolved profile to the parent (e.g. so an OAuth form can read
+ // its `secretStatus` to know whether it's authorized). Keyed on the resolved
+ // id so it also fires once the profile list finishes loading.
+ useEffect(() => {
+ onSelectedProfile?.(selected ?? null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selected?.id, selected?.updatedAt]);
+
const defaultLabel = slot === "ssh" ? "SSH Credential Profile" : "Credential Profile";
const finalLabel = label ?? defaultLabel;
diff --git a/src/components/adapter/dropbox-folder-browser.tsx b/src/components/adapter/dropbox-folder-browser.tsx
index 788d25fe..e805fef5 100644
--- a/src/components/adapter/dropbox-folder-browser.tsx
+++ b/src/components/adapter/dropbox-folder-browser.tsx
@@ -24,11 +24,8 @@ interface DropboxFolderBrowserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (folderPath: string) => void;
- config: {
- clientId: string;
- clientSecret: string;
- refreshToken: string;
- };
+ /** OAUTH credential profile id; credentials are resolved server-side from it. */
+ credentialId: string;
initialPath?: string;
}
@@ -41,7 +38,7 @@ export function DropboxFolderBrowser({
open,
onOpenChange,
onSelect,
- config,
+ credentialId,
initialPath,
}: DropboxFolderBrowserProps) {
const [currentPath, setCurrentPath] = useState(initialPath || "");
@@ -66,7 +63,7 @@ export function DropboxFolderBrowser({
const res = await fetch("/api/system/filesystem/dropbox", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ config, folderPath }),
+ body: JSON.stringify({ credentialId, folderPath }),
});
const json = await res.json();
diff --git a/src/components/adapter/dropbox-oauth-button.tsx b/src/components/adapter/dropbox-oauth-button.tsx
index f4874232..811aafd7 100644
--- a/src/components/adapter/dropbox-oauth-button.tsx
+++ b/src/components/adapter/dropbox-oauth-button.tsx
@@ -7,31 +7,33 @@ import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface DropboxOAuthButtonProps {
- /** The saved adapter config ID (from database) */
- adapterId?: string;
- /** Whether a refresh token already exists */
- hasRefreshToken?: boolean;
+ /** The OAUTH credential profile id to authorize */
+ credentialId?: string;
+ /** Whether the profile already has a refresh token */
+ authorized?: boolean;
+ /** Called when authorization completes successfully in the popup. */
+ onAuthorized?: () => void;
}
/**
* OAuth authorization button for Dropbox.
- * Must only be shown AFTER the adapter config is saved (needs the DB ID).
+ * Authorizes the selected OAUTH credential profile - no saved destination needed.
*/
-export function DropboxOAuthButton({ adapterId, hasRefreshToken }: DropboxOAuthButtonProps) {
+export function DropboxOAuthButton({ credentialId, authorized, onAuthorized }: DropboxOAuthButtonProps) {
const [isLoading, setIsLoading] = useState(false);
- if (!adapterId) {
+ if (!credentialId) {
return (
- Save the configuration first, then you can authorize with Dropbox.
+ Select or create an OAuth credential profile (with the app key + secret) first, then authorize with Dropbox.
);
}
- if (hasRefreshToken) {
+ if (authorized) {
return (
@@ -58,20 +60,54 @@ export function DropboxOAuthButton({ adapterId, hasRefreshToken }: DropboxOAuthB
const res = await fetch("/api/adapters/dropbox/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ adapterId }),
+ body: JSON.stringify({ credentialId }),
});
const data = await res.json();
if (data.success && data.data?.authUrl) {
- // Redirect to Dropbox consent screen
- window.location.href = data.data.authUrl;
+ const popup = window.open(
+ data.data.authUrl,
+ "dbackup_oauth",
+ "width=600,height=700,scrollbars=yes,resizable=yes"
+ );
+
+ if (!popup) {
+ // Popup blocked - fall back to full navigation.
+ window.location.href = data.data.authUrl;
+ return;
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== "oauth_complete") return;
+ window.removeEventListener("message", handleMessage);
+ clearInterval(pollClosed);
+ setIsLoading(false);
+ if (event.data.status === "success") {
+ toast.success(event.data.message);
+ onAuthorized?.();
+ } else {
+ toast.error(event.data.message);
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+
+ // Fallback: detect when the popup is closed without a message.
+ const pollClosed = setInterval(() => {
+ if (popup.closed) {
+ clearInterval(pollClosed);
+ window.removeEventListener("message", handleMessage);
+ setIsLoading(false);
+ }
+ }, 500);
} else {
toast.error(data.error || "Failed to start authorization");
+ setIsLoading(false);
}
} catch {
toast.error("Failed to start Dropbox authorization");
- } finally {
setIsLoading(false);
}
}
diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx
index 794c4a66..4e5bf759 100644
--- a/src/components/adapter/form-sections.tsx
+++ b/src/components/adapter/form-sections.tsx
@@ -1,5 +1,5 @@
import { useFormContext } from "react-hook-form";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
Tabs,
@@ -40,6 +40,7 @@ import { DropboxFolderBrowser } from "./dropbox-folder-browser";
import { OneDriveOAuthButton } from "./onedrive-oauth-button";
import { OneDriveFolderBrowser } from "./onedrive-folder-browser";
import { CredentialPicker } from "./credential-picker";
+import type { CredentialProfileSummary } from "@/components/settings/credential-profile-dialog";
import { AdapterConfig } from "./types";
interface CredentialPickerHostProps {
@@ -66,7 +67,9 @@ function PrimaryCredentialPickerSlot({
adapter,
primaryCredentialId,
onPrimaryChange,
-}: { adapter: AdapterDefinition } & CredentialPickerHostProps) {
+ onSelectedProfile,
+ refreshKey,
+}: { adapter: AdapterDefinition; onSelectedProfile?: (p: CredentialProfileSummary | null) => void; refreshKey?: number } & CredentialPickerHostProps) {
const required = adapter.credentials?.primary;
if (!required || !onPrimaryChange) return null;
return (
@@ -76,6 +79,8 @@ function PrimaryCredentialPickerSlot({
value={primaryCredentialId ?? null}
onChange={onPrimaryChange}
label="Credential Profile"
+ onSelectedProfile={onSelectedProfile}
+ refreshKey={refreshKey}
/>
);
}
@@ -372,7 +377,7 @@ export function DatabaseFormContent({
onPrimaryChange={onPrimaryChange}
/>
@@ -509,7 +514,7 @@ function SshAwareTabLayout({
onPrimaryChange={onPrimaryChange}
/>
@@ -552,7 +557,7 @@ function SshAwareTabLayout({
onPrimaryChange={onPrimaryChange}
/>
@@ -658,7 +663,7 @@ function SshConfigSection({ adapter, sshAuthType, sshCredentialId, description }
export function StorageFormContent({
adapter,
- initialData,
+ initialData: _initialData,
healthNotificationsDisabled,
onHealthNotificationsDisabledChange,
primaryCredentialId,
@@ -676,30 +681,20 @@ export function StorageFormContent({
const isGoogleDrive = adapter.id === 'google-drive';
const isDropbox = adapter.id === 'dropbox';
const isOneDrive = adapter.id === 'onedrive';
- const isOAuthAdapter = isGoogleDrive || isDropbox || isOneDrive;
-
- // For OAuth adapters: filter out refreshToken from connection keys (auto-managed via OAuth)
- const connectionKeys = isOAuthAdapter
- ? STORAGE_CONNECTION_KEYS.filter(k => k !== 'refreshToken')
- : STORAGE_CONNECTION_KEYS;
- // For OAuth adapters: filter out refreshToken from config keys too
- const configKeys = isOAuthAdapter
- ? STORAGE_CONFIG_KEYS.filter(k => k !== 'refreshToken')
- : STORAGE_CONFIG_KEYS;
+ const connectionKeys = STORAGE_CONNECTION_KEYS;
+ const configKeys = STORAGE_CONFIG_KEYS;
- // Check if the config has a refresh token (for existing/authorized adapters)
- const hasRefreshToken = initialData ? (() => {
- try {
- const config = JSON.parse(initialData.config);
- return !!config.refreshToken;
- } catch {
- return false;
- }
- })() : false;
+ // OAuth authorization now lives on the credential profile: the refresh token
+ // is stored there, not on the adapter. We read the selected profile's
+ // `secretStatus` to know whether it's authorized - so it reflects reality
+ // even before the destination itself is saved.
+ const [selectedProfile, setSelectedProfile] = useState(null);
+ const authorized = selectedProfile?.secretStatus?.refreshToken === true;
- // Watch full config for Google Drive folder browser
- const config = watch("config");
+ // Incremented after a successful popup OAuth to trigger a credential re-fetch.
+ const [credentialRefreshKey, setCredentialRefreshKey] = useState(0);
+ const handleOAuthAuthorized = useCallback(() => setCredentialRefreshKey((k) => k + 1), []);
return (
@@ -715,6 +710,8 @@ export function StorageFormContent({
adapter={adapter}
primaryCredentialId={primaryCredentialId}
onPrimaryChange={onPrimaryChange}
+ onSelectedProfile={setSelectedProfile}
+ refreshKey={credentialRefreshKey}
/>
{(adapter.id === 'sftp' || adapter.id === 'rsync') ? (
@@ -738,29 +735,23 @@ export function StorageFormContent({
)}
) : isGoogleDrive ? (
-
-
-
-
+
) : isDropbox ? (
-
-
-
-
+
) : isOneDrive ? (
-
-
-
-
+
) : (
)}
@@ -771,20 +762,20 @@ export function StorageFormContent({
{isGoogleDrive ? (
) : isDropbox ? (
) : isOneDrive ? (
) : hasRealConfigKeys ? (
<>
@@ -879,21 +870,21 @@ export function GenericFormContent({ adapter, detectedVersion }: { adapter: Adap
*/
function GoogleDriveFolderField({
adapter: _adapter,
- config,
- hasRefreshToken,
+ authorized,
+ credentialId,
}: {
adapter: AdapterDefinition;
- config: Record;
- hasRefreshToken: boolean;
+ authorized: boolean;
+ credentialId?: string;
}) {
const { setValue, watch } = useFormContext();
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
const folderId = watch("config.folderId") || "";
const [folderName, setFolderName] = useState(null);
- // Get refresh token from current form values (might be encrypted in DB but decrypted in form)
- const refreshToken = config?.refreshToken as string | undefined;
- const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret;
+ // Secrets (clientSecret/refreshToken) live in the vault and are resolved
+ // server-side by adapterId; the browser only needs the saved adapter id.
+ const canBrowse = authorized && !!credentialId;
return (
@@ -937,11 +928,7 @@ function GoogleDriveFolderField({
setValue("config.folderId", selectedId);
setFolderName(selectedName);
}}
- config={{
- clientId: config.clientId as string,
- clientSecret: config.clientSecret as string,
- refreshToken: refreshToken!,
- }}
+ credentialId={credentialId!}
initialFolderId={folderId || undefined}
/>
)}
@@ -955,19 +942,18 @@ function GoogleDriveFolderField({
*/
function DropboxFolderField({
adapter: _adapter,
- config,
- hasRefreshToken,
+ authorized,
+ credentialId,
}: {
adapter: AdapterDefinition;
- config: Record
;
- hasRefreshToken: boolean;
+ authorized: boolean;
+ credentialId?: string;
}) {
const { setValue, watch } = useFormContext();
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
const folderPath = watch("config.folderPath") || "";
- const refreshToken = config?.refreshToken as string | undefined;
- const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret;
+ const canBrowse = authorized && !!credentialId;
return (
@@ -1005,11 +991,7 @@ function DropboxFolderField({
onSelect={(selectedPath) => {
setValue("config.folderPath", selectedPath);
}}
- config={{
- clientId: config.clientId as string,
- clientSecret: config.clientSecret as string,
- refreshToken: refreshToken!,
- }}
+ credentialId={credentialId!}
initialPath={folderPath || undefined}
/>
)}
@@ -1023,19 +1005,18 @@ function DropboxFolderField({
*/
function OneDriveFolderField({
adapter: _adapter,
- config,
- hasRefreshToken,
+ authorized,
+ credentialId,
}: {
adapter: AdapterDefinition;
- config: Record
;
- hasRefreshToken: boolean;
+ authorized: boolean;
+ credentialId?: string;
}) {
const { setValue, watch } = useFormContext();
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
const folderPath = watch("config.folderPath") || "";
- const refreshToken = config?.refreshToken as string | undefined;
- const canBrowse = hasRefreshToken && !!refreshToken && !!config?.clientId && !!config?.clientSecret;
+ const canBrowse = authorized && !!credentialId;
return (
@@ -1073,11 +1054,7 @@ function OneDriveFolderField({
onSelect={(selectedPath) => {
setValue("config.folderPath", selectedPath);
}}
- config={{
- clientId: config.clientId as string,
- clientSecret: config.clientSecret as string,
- refreshToken: refreshToken!,
- }}
+ credentialId={credentialId!}
initialPath={folderPath || undefined}
/>
)}
@@ -1158,9 +1135,14 @@ function getCredentialManagedKeys(adapter: AdapterDefinition): Set
{
} else if (reqs.primary === "ACCESS_KEY") {
["accessKeyId", "secretAccessKey"].forEach((k) => hidden.add(k));
} else if (reqs.primary === "TOKEN") {
- ["token", "appToken", "accessToken", "botToken"].forEach((k) => hidden.add(k));
+ ["token", "appToken", "accessToken", "botToken", "authToken"].forEach((k) => hidden.add(k));
} else if (reqs.primary === "SMTP") {
["user", "password"].forEach((k) => hidden.add(k));
+ } else if (reqs.primary === "WEBHOOK") {
+ ["webhookUrl", "url", "authHeader"].forEach((k) => hidden.add(k));
+ } else if (reqs.primary === "OAUTH") {
+ // The whole OAuth-app identity lives in the vault profile.
+ ["clientId", "clientSecret", "refreshToken"].forEach((k) => hidden.add(k));
}
if (reqs.ssh === "SSH_KEY") {
diff --git a/src/components/adapter/google-drive-folder-browser.tsx b/src/components/adapter/google-drive-folder-browser.tsx
index 696d14e8..64f9896c 100644
--- a/src/components/adapter/google-drive-folder-browser.tsx
+++ b/src/components/adapter/google-drive-folder-browser.tsx
@@ -24,11 +24,8 @@ interface GoogleDriveFolderBrowserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (folderId: string, folderName: string) => void;
- config: {
- clientId: string;
- clientSecret: string;
- refreshToken: string;
- };
+ /** OAUTH credential profile id; credentials are resolved server-side from it. */
+ credentialId: string;
initialFolderId?: string;
}
@@ -41,7 +38,7 @@ export function GoogleDriveFolderBrowser({
open,
onOpenChange,
onSelect,
- config,
+ credentialId,
initialFolderId,
}: GoogleDriveFolderBrowserProps) {
const [currentFolderId, setCurrentFolderId] = useState(initialFolderId || "root");
@@ -71,7 +68,7 @@ export function GoogleDriveFolderBrowser({
const res = await fetch("/api/system/filesystem/google-drive", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ config, folderId }),
+ body: JSON.stringify({ credentialId, folderId }),
});
const json = await res.json();
diff --git a/src/components/adapter/google-drive-oauth-button.tsx b/src/components/adapter/google-drive-oauth-button.tsx
index 463deae2..1419d828 100644
--- a/src/components/adapter/google-drive-oauth-button.tsx
+++ b/src/components/adapter/google-drive-oauth-button.tsx
@@ -7,31 +7,33 @@ import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface GoogleDriveOAuthButtonProps {
- /** The saved adapter config ID (from database) */
- adapterId?: string;
- /** Whether a refresh token already exists */
- hasRefreshToken?: boolean;
+ /** The OAUTH credential profile id to authorize */
+ credentialId?: string;
+ /** Whether the profile already has a refresh token */
+ authorized?: boolean;
+ /** Called when authorization completes successfully in the popup. */
+ onAuthorized?: () => void;
}
/**
* OAuth authorization button for Google Drive.
- * Must only be shown AFTER the adapter config is saved (needs the DB ID).
+ * Authorizes the selected OAUTH credential profile - no saved destination needed.
*/
-export function GoogleDriveOAuthButton({ adapterId, hasRefreshToken }: GoogleDriveOAuthButtonProps) {
+export function GoogleDriveOAuthButton({ credentialId, authorized, onAuthorized }: GoogleDriveOAuthButtonProps) {
const [isLoading, setIsLoading] = useState(false);
- if (!adapterId) {
+ if (!credentialId) {
return (
- Save the configuration first, then you can authorize with Google.
+ Select or create an OAuth credential profile (with the client ID + secret) first, then authorize with Google.
);
}
- if (hasRefreshToken) {
+ if (authorized) {
return (
@@ -55,23 +57,58 @@ export function GoogleDriveOAuthButton({ adapterId, hasRefreshToken }: GoogleDri
async function handleAuthorize() {
setIsLoading(true);
try {
+
const res = await fetch("/api/adapters/google-drive/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ adapterId }),
+ body: JSON.stringify({ credentialId }),
});
const data = await res.json();
if (data.success && data.data?.authUrl) {
- // Redirect to Google consent screen
- window.location.href = data.data.authUrl;
+ const popup = window.open(
+ data.data.authUrl,
+ "dbackup_oauth",
+ "width=600,height=700,scrollbars=yes,resizable=yes"
+ );
+
+ if (!popup) {
+ // Popup blocked - fall back to full navigation.
+ window.location.href = data.data.authUrl;
+ return;
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== "oauth_complete") return;
+ window.removeEventListener("message", handleMessage);
+ clearInterval(pollClosed);
+ setIsLoading(false);
+ if (event.data.status === "success") {
+ toast.success(event.data.message);
+ onAuthorized?.();
+ } else {
+ toast.error(event.data.message);
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+
+ // Fallback: detect when the popup is closed without a message.
+ const pollClosed = setInterval(() => {
+ if (popup.closed) {
+ clearInterval(pollClosed);
+ window.removeEventListener("message", handleMessage);
+ setIsLoading(false);
+ }
+ }, 500);
} else {
toast.error(data.error || "Failed to start authorization");
+ setIsLoading(false);
}
} catch {
toast.error("Failed to start Google authorization");
- } finally {
setIsLoading(false);
}
}
diff --git a/src/components/adapter/oauth-toast-handler.tsx b/src/components/adapter/oauth-toast-handler.tsx
index 205be613..d49b439f 100644
--- a/src/components/adapter/oauth-toast-handler.tsx
+++ b/src/components/adapter/oauth-toast-handler.tsx
@@ -7,6 +7,9 @@ import { toast } from "sonner";
/**
* Handles OAuth redirect query parameters (?oauth=success|error&message=...)
* and displays a toast notification. Cleans up the URL after processing.
+ *
+ * When loaded inside a popup window (window.opener is set), it sends a
+ * postMessage to the opener and closes the popup instead of showing a toast.
*/
export function OAuthToastHandler() {
const searchParams = useSearchParams();
@@ -17,6 +20,16 @@ export function OAuthToastHandler() {
const message = searchParams.get("message");
if (oauthStatus && message) {
+ // When running in a popup, communicate back to the parent and close.
+ if (window.opener) {
+ window.opener.postMessage(
+ { type: "oauth_complete", status: oauthStatus, message },
+ window.location.origin
+ );
+ window.close();
+ return;
+ }
+
if (oauthStatus === "success") {
toast.success(message);
} else if (oauthStatus === "error") {
diff --git a/src/components/adapter/onedrive-folder-browser.tsx b/src/components/adapter/onedrive-folder-browser.tsx
index 8ab74295..a5afff11 100644
--- a/src/components/adapter/onedrive-folder-browser.tsx
+++ b/src/components/adapter/onedrive-folder-browser.tsx
@@ -24,11 +24,8 @@ interface OneDriveFolderBrowserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (folderPath: string) => void;
- config: {
- clientId: string;
- clientSecret: string;
- refreshToken: string;
- };
+ /** OAUTH credential profile id; credentials are resolved server-side from it. */
+ credentialId: string;
initialPath?: string;
}
@@ -41,7 +38,7 @@ export function OneDriveFolderBrowser({
open,
onOpenChange,
onSelect,
- config,
+ credentialId,
initialPath,
}: OneDriveFolderBrowserProps) {
const [currentPath, setCurrentPath] = useState(initialPath || "");
@@ -66,7 +63,7 @@ export function OneDriveFolderBrowser({
const res = await fetch("/api/system/filesystem/onedrive", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ config, folderPath }),
+ body: JSON.stringify({ credentialId, folderPath }),
});
const json = await res.json();
diff --git a/src/components/adapter/onedrive-oauth-button.tsx b/src/components/adapter/onedrive-oauth-button.tsx
index 6742bebb..da6b39ca 100644
--- a/src/components/adapter/onedrive-oauth-button.tsx
+++ b/src/components/adapter/onedrive-oauth-button.tsx
@@ -7,31 +7,33 @@ import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface OneDriveOAuthButtonProps {
- /** The saved adapter config ID (from database) */
- adapterId?: string;
- /** Whether a refresh token already exists */
- hasRefreshToken?: boolean;
+ /** The OAUTH credential profile id to authorize */
+ credentialId?: string;
+ /** Whether the profile already has a refresh token */
+ authorized?: boolean;
+ /** Called when authorization completes successfully in the popup. */
+ onAuthorized?: () => void;
}
/**
* OAuth authorization button for OneDrive.
- * Must only be shown AFTER the adapter config is saved (needs the DB ID).
+ * Authorizes the selected OAUTH credential profile - no saved destination needed.
*/
-export function OneDriveOAuthButton({ adapterId, hasRefreshToken }: OneDriveOAuthButtonProps) {
+export function OneDriveOAuthButton({ credentialId, authorized, onAuthorized }: OneDriveOAuthButtonProps) {
const [isLoading, setIsLoading] = useState(false);
- if (!adapterId) {
+ if (!credentialId) {
return (
- Save the configuration first, then you can authorize with Microsoft.
+ Select or create an OAuth credential profile (with the client ID + secret) first, then authorize with Microsoft.
);
}
- if (hasRefreshToken) {
+ if (authorized) {
return (
@@ -58,20 +60,54 @@ export function OneDriveOAuthButton({ adapterId, hasRefreshToken }: OneDriveOAut
const res = await fetch("/api/adapters/onedrive/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ adapterId }),
+ body: JSON.stringify({ credentialId }),
});
const data = await res.json();
if (data.success && data.data?.authUrl) {
- // Redirect to Microsoft consent screen
- window.location.href = data.data.authUrl;
+ const popup = window.open(
+ data.data.authUrl,
+ "dbackup_oauth",
+ "width=600,height=700,scrollbars=yes,resizable=yes"
+ );
+
+ if (!popup) {
+ // Popup blocked - fall back to full navigation.
+ window.location.href = data.data.authUrl;
+ return;
+ }
+
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type !== "oauth_complete") return;
+ window.removeEventListener("message", handleMessage);
+ clearInterval(pollClosed);
+ setIsLoading(false);
+ if (event.data.status === "success") {
+ toast.success(event.data.message);
+ onAuthorized?.();
+ } else {
+ toast.error(event.data.message);
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+
+ // Fallback: detect when the popup is closed without a message.
+ const pollClosed = setInterval(() => {
+ if (popup.closed) {
+ clearInterval(pollClosed);
+ window.removeEventListener("message", handleMessage);
+ setIsLoading(false);
+ }
+ }, 500);
} else {
toast.error(data.error || "Failed to start authorization");
+ setIsLoading(false);
}
} catch {
toast.error("Failed to start Microsoft authorization");
- } finally {
setIsLoading(false);
}
}
diff --git a/src/components/adapter/schema-field.tsx b/src/components/adapter/schema-field.tsx
index 156379d0..c2421964 100644
--- a/src/components/adapter/schema-field.tsx
+++ b/src/components/adapter/schema-field.tsx
@@ -30,6 +30,7 @@ import {
import { Button } from "@/components/ui/button";
import { FolderOpen } from "lucide-react";
import { PLACEHOLDERS } from "./form-constants";
+import { useSecretStatus } from "./secret-status-context";
import { DatabasePicker } from "./database-picker";
import { FileBrowserDialog } from "@/components/system/file-browser-dialog";
import { useState } from "react";
@@ -99,7 +100,15 @@ export function SchemaField({
const isTextArea = fieldKey.toLowerCase().includes("privatekey") || fieldKey.toLowerCase().includes("certificate") || fieldKey.toLowerCase().includes("options") || fieldKey === "customHeaders" || fieldKey === "payloadTemplate";
const description = (schemaShape as any).description;
- const placeholder = PLACEHOLDERS[`${adapterId}.${fieldKey}`] || PLACEHOLDERS[fieldKey];
+ const rawPlaceholder = PLACEHOLDERS[`${adapterId}.${fieldKey}`] || PLACEHOLDERS[fieldKey];
+
+ // When editing, the API redacts stored secrets (the value is never sent).
+ // `secretStatus[fieldKey]` tells us a value IS stored, so show a "leave blank
+ // to keep" hint instead of an empty-looking field. Submitting it blank keeps
+ // the existing secret (server-side mergeSecrets); typing replaces it.
+ const secretStatus = useSecretStatus();
+ const hasStoredSecret = secretStatus[fieldKey] === true;
+ const placeholder = hasStoredSecret ? "โขโขโขโขโขโขโขโข โ saved, leave blank to keep" : rawPlaceholder;
const isPathField = fieldKey === 'path' || fieldKey === 'sqliteBinaryPath' || fieldKey === 'basePath';
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
diff --git a/src/components/adapter/secret-status-context.tsx b/src/components/adapter/secret-status-context.tsx
new file mode 100644
index 00000000..792ebd7e
--- /dev/null
+++ b/src/components/adapter/secret-status-context.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+/**
+ * Provides the `secretStatus` map from the adapter list DTO (which sensitive
+ * fields already have a stored value) down to individual form fields, so a
+ * secret input can render a "saved โ leave blank to keep" placeholder instead
+ * of looking empty. The API never sends the secret value itself.
+ */
+const SecretStatusContext = createContext>({});
+
+export const SecretStatusProvider = SecretStatusContext.Provider;
+
+export function useSecretStatus(): Record {
+ return useContext(SecretStatusContext);
+}
diff --git a/src/components/adapter/types.ts b/src/components/adapter/types.ts
index b4e2cd17..9eb0d746 100644
--- a/src/components/adapter/types.ts
+++ b/src/components/adapter/types.ts
@@ -4,7 +4,9 @@ export interface AdapterConfig {
name: string;
adapterId: string;
type: string;
- config: string; // JSON string
+ config: string; // JSON string (sensitive keys redacted by the API DTO)
+ /** Map of sensitive key -> whether a non-empty value is stored (from the list DTO). */
+ secretStatus?: Record;
metadata?: string; // JSON string
createdAt: string;
primaryCredentialId?: string | null;
diff --git a/src/components/common/encryption-key-resolution-dialog.tsx b/src/components/common/encryption-key-resolution-dialog.tsx
index 5a691430..5e749690 100644
--- a/src/components/common/encryption-key-resolution-dialog.tsx
+++ b/src/components/common/encryption-key-resolution-dialog.tsx
@@ -46,14 +46,18 @@ export function EncryptionKeyResolutionDialog({
const [rawKeyError, setRawKeyError] = useState("");
const [activeTab, setActiveTab] = useState<"profile" | "rawKey">("profile");
- // Fetch profiles when dialog opens
+ // Fetch profiles when dialog opens; auto-switch to raw key tab if vault is empty
useEffect(() => {
if (!open) return;
getEncryptionProfiles().then((res) => {
if (res.success && res.data) {
- setProfiles(res.data.map((p: { id: string; name: string }) => ({ id: p.id, name: p.name })));
+ const mapped = res.data.map((p: { id: string; name: string }) => ({ id: p.id, name: p.name }));
+ setProfiles(mapped);
+ setActiveTab(mapped.length === 0 ? "rawKey" : "profile");
+ } else {
+ setActiveTab("rawKey");
}
- }).catch(() => {});
+ }).catch(() => { setActiveTab("rawKey"); });
}, [open]);
const handleConfirm = () => {
diff --git a/src/components/settings/credential-profile-dialog.tsx b/src/components/settings/credential-profile-dialog.tsx
index 95c5e472..c1a3a9c5 100644
--- a/src/components/settings/credential-profile-dialog.tsx
+++ b/src/components/settings/credential-profile-dialog.tsx
@@ -31,14 +31,18 @@ const TYPE_LABELS: Record = {
ACCESS_KEY: "Access Key (S3 / API)",
TOKEN: "Token",
SMTP: "SMTP",
+ WEBHOOK: "Webhook URL",
+ OAUTH: "OAuth (Client Secret)",
};
const TYPE_DESCRIPTIONS: Record = {
USERNAME_PASSWORD: "Database / FTP / SMB user + password.",
SSH_KEY: "SSH credentials (password, private key, or agent).",
ACCESS_KEY: "S3-style access key + secret key pair.",
- TOKEN: "Bearer token (Gotify, ntfy, Telegram bot, generic webhook).",
+ TOKEN: "Bearer token (Gotify, ntfy, Telegram bot, Twilio).",
SMTP: "SMTP user + password for email notifications.",
+ WEBHOOK: "Webhook URL (Discord, Slack, Teams, generic webhook) + optional auth header.",
+ OAUTH: "OAuth app (Google Drive, Dropbox, OneDrive): client ID + secret. The refresh token is added automatically after authorization.",
};
export interface CredentialProfileSummary {
@@ -48,6 +52,8 @@ export interface CredentialProfileSummary {
description: string | null;
createdAt: string | Date;
updatedAt: string | Date;
+ /** Which sensitive fields are set (e.g. OAUTH `refreshToken`) - no values. */
+ secretStatus?: Record;
}
interface Props {
@@ -68,6 +74,8 @@ const DEFAULTS: Record = {
ACCESS_KEY: { accessKeyId: "", secretAccessKey: "" },
TOKEN: { token: "" },
SMTP: { user: "", password: "" },
+ WEBHOOK: { url: "", authHeader: "" },
+ OAUTH: { clientId: "", clientSecret: "" },
};
export function CredentialProfileDialog({
@@ -362,6 +370,24 @@ function TypeFields({
);
}
+ if (type === "WEBHOOK") {
+ return (
+
+ update("url", v)} />
+ update("authHeader", v)} />
+
+ );
+ }
+
+ if (type === "OAUTH") {
+ return (
+
+ update("clientId", v)} />
+ update("clientSecret", v)} />
+
+ );
+ }
+
return null;
}
diff --git a/src/components/settings/credential-profiles-list.tsx b/src/components/settings/credential-profiles-list.tsx
index 79ab83a0..88247dc5 100644
--- a/src/components/settings/credential-profiles-list.tsx
+++ b/src/components/settings/credential-profiles-list.tsx
@@ -30,6 +30,8 @@ const TYPE_LABELS: Record = {
ACCESS_KEY: "Access Key",
TOKEN: "Token",
SMTP: "SMTP",
+ WEBHOOK: "Webhook",
+ OAUTH: "OAuth",
};
const TYPE_FILTER_OPTIONS = (Object.entries(TYPE_LABELS) as [CredentialType, string][]).map(
diff --git a/src/lib/adapters/CLAUDE.md b/src/lib/adapters/CLAUDE.md
new file mode 100644
index 00000000..b8472c89
--- /dev/null
+++ b/src/lib/adapters/CLAUDE.md
@@ -0,0 +1 @@
+@../../../.github/instructions/logic.instructions.md
diff --git a/src/lib/adapters/config-resolver.ts b/src/lib/adapters/config-resolver.ts
index 95063844..81fed130 100644
--- a/src/lib/adapters/config-resolver.ts
+++ b/src/lib/adapters/config-resolver.ts
@@ -11,6 +11,8 @@ import type {
AccessKeyData,
TokenData,
SmtpData,
+ WebhookData,
+ OAuthData,
} from "@/lib/core/credentials";
const log = logger.child({ module: "ConfigResolver" });
@@ -215,12 +217,13 @@ function applyPrimaryOverlay(
const p = profile as TokenData;
// Write to all known token field names. Each notification adapter
// schema uses a different key (Gotify: appToken, ntfy: accessToken,
- // Telegram: botToken) and zod strips unknowns it doesn't declare,
- // so spraying is safe and avoids per-adapter switch logic.
+ // Telegram: botToken, Twilio: authToken) and zod strips unknowns it
+ // doesn't declare, so spraying is safe and avoids per-adapter switch logic.
config.token = p.token;
config.appToken = p.token;
config.accessToken = p.token;
config.botToken = p.token;
+ config.authToken = p.token;
return;
}
case "SMTP": {
@@ -229,6 +232,22 @@ function applyPrimaryOverlay(
config.password = p.password;
return;
}
+ case "WEBHOOK": {
+ const p = profile as WebhookData;
+ // Discord/Slack/Teams use `webhookUrl`; the generic webhook also uses
+ // an optional `authHeader`. Spray both known field names.
+ config.webhookUrl = p.url;
+ config.url = p.url;
+ if (p.authHeader !== undefined) config.authHeader = p.authHeader;
+ return;
+ }
+ case "OAUTH": {
+ const p = profile as OAuthData;
+ config.clientId = p.clientId;
+ config.clientSecret = p.clientSecret;
+ if (p.refreshToken !== undefined) config.refreshToken = p.refreshToken;
+ return;
+ }
}
}
diff --git a/src/lib/adapters/database/mongodb/connection.ts b/src/lib/adapters/database/mongodb/connection.ts
index a1085e1f..903ee0db 100644
--- a/src/lib/adapters/database/mongodb/connection.ts
+++ b/src/lib/adapters/database/mongodb/connection.ts
@@ -15,6 +15,9 @@ const log = logger.child({ service: "mongodb-connection" });
* Build MongoDB connection URI from config
*/
function buildConnectionUri(config: MongoDBConfig): string {
+ // Backward-compat: honor a stored inline `uri` for sources created before the
+ // URI field was deprecated. The UI no longer exposes it; new sources arrive
+ // here with host/port + credentials resolved from the vault profile.
if (config.uri) {
return config.uri;
}
diff --git a/src/lib/adapters/definitions/database.ts b/src/lib/adapters/definitions/database.ts
index 36fa2e59..8022d815 100644
--- a/src/lib/adapters/definitions/database.ts
+++ b/src/lib/adapters/definitions/database.ts
@@ -34,7 +34,11 @@ export const PostgresSchema = z.object({
});
export const MongoDBSchema = z.object({
- uri: z.string().optional().describe("Connection URI (overrides other settings)"),
+ // DEPRECATED: inline connection URI. No longer offered in the UI because it
+ // embeds credentials directly in the adapter config. New sources build the
+ // URI from host/port + a USERNAME_PASSWORD credential profile. Still honored
+ // at runtime for existing sources (see buildConnectionUri) until reconfigured.
+ uri: z.string().optional().describe("DEPRECATED โ use host/port + a credential profile instead"),
host: z.string().default("localhost"),
port: z.coerce.number().default(27017),
user: z.string().optional(),
diff --git a/src/lib/adapters/dto.ts b/src/lib/adapters/dto.ts
new file mode 100644
index 00000000..bc9bd3d1
--- /dev/null
+++ b/src/lib/adapters/dto.ts
@@ -0,0 +1,105 @@
+import { decryptConfig, redactSecrets, getSecretStatus } from "@/lib/crypto";
+
+/**
+ * Adapter-list Data Transfer Object.
+ *
+ * This is the ONLY shape the adapter-listing API serialises back to clients.
+ * Its `config` field is rebuilt via `redactSecrets`, which DELETES every key in
+ * `SENSITIVE_KEYS` โ so the DTO structurally cannot carry a decrypted secret,
+ * regardless of which adapter type or future field is added. `secretStatus`
+ * tells the UI which secrets are set (so it can render "leave blank to keep")
+ * without exposing the value.
+ *
+ * Do not add a field here that could hold a secret value. Secrets belong in the
+ * Vault (`CredentialProfile`) and are surfaced only via the audited reveal flow.
+ */
+export interface AdapterListItemDTO {
+ id: string;
+ name: string;
+ type: string;
+ adapterId: string;
+ /** JSON string of the structural config with all sensitive keys removed. */
+ config: string;
+ /** Map of sensitive key -> whether a non-empty value is stored. */
+ secretStatus: Record;
+ metadata: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ primaryCredentialId: string | null;
+ sshCredentialId: string | null;
+ defaultRetentionPolicyId: string | null;
+ lastHealthCheck: Date | null;
+ lastStatus: string;
+ lastError: string | null;
+ consecutiveFailures: number;
+}
+
+/**
+ * The minimum set of `AdapterConfig` row fields the DTO mapper needs. Kept loose
+ * so Prisma rows can be passed directly without importing generated types.
+ */
+export interface AdapterRowInput {
+ id: string;
+ name: string;
+ type: string;
+ adapterId: string;
+ config: string;
+ metadata: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ primaryCredentialId: string | null;
+ sshCredentialId: string | null;
+ defaultRetentionPolicyId: string | null;
+ lastHealthCheck: Date | null;
+ lastStatus: string;
+ lastError: string | null;
+ consecutiveFailures: number;
+}
+
+/**
+ * Maps a stored `AdapterConfig` row to the safe list DTO. Decrypts the config
+ * internally only to compute `secretStatus` and to redact it โ no decrypted
+ * secret value ever leaves this function.
+ */
+export function toAdapterListItem(row: AdapterRowInput): AdapterListItemDTO {
+ let config = "{}";
+ let secretStatus: Record = {};
+
+ try {
+ const parsed = JSON.parse(row.config);
+ try {
+ const decrypted = decryptConfig(parsed);
+ secretStatus = getSecretStatus(decrypted);
+ config = JSON.stringify(redactSecrets(decrypted));
+ } catch {
+ // Decryption failed (corrupt data / rotated key). Still redact secret
+ // keys from the raw parsed config so structural fields survive without
+ // leaking the (encrypted) secret values.
+ secretStatus = getSecretStatus(parsed);
+ config = JSON.stringify(redactSecrets(parsed));
+ }
+ } catch {
+ // Unparseable config โ return an empty structural config rather than the raw string.
+ config = "{}";
+ secretStatus = {};
+ }
+
+ return {
+ id: row.id,
+ name: row.name,
+ type: row.type,
+ adapterId: row.adapterId,
+ config,
+ secretStatus,
+ metadata: row.metadata,
+ createdAt: row.createdAt,
+ updatedAt: row.updatedAt,
+ primaryCredentialId: row.primaryCredentialId,
+ sshCredentialId: row.sshCredentialId,
+ defaultRetentionPolicyId: row.defaultRetentionPolicyId,
+ lastHealthCheck: row.lastHealthCheck,
+ lastStatus: row.lastStatus,
+ lastError: row.lastError,
+ consecutiveFailures: row.consecutiveFailures,
+ };
+}
diff --git a/src/lib/adapters/notification/discord.ts b/src/lib/adapters/notification/discord.ts
index 17ddc362..76f38d00 100644
--- a/src/lib/adapters/notification/discord.ts
+++ b/src/lib/adapters/notification/discord.ts
@@ -11,6 +11,7 @@ export const DiscordAdapter: NotificationAdapter = {
type: "notification",
name: "Discord Webhook",
configSchema: DiscordSchema,
+ credentials: { primary: "WEBHOOK" },
async test(config: DiscordConfig): Promise<{ success: boolean; message: string }> {
try {
diff --git a/src/lib/adapters/notification/generic-webhook.ts b/src/lib/adapters/notification/generic-webhook.ts
index e45f7324..3f6325d8 100644
--- a/src/lib/adapters/notification/generic-webhook.ts
+++ b/src/lib/adapters/notification/generic-webhook.ts
@@ -31,6 +31,7 @@ export const GenericWebhookAdapter: NotificationAdapter = {
type: "notification",
name: "Generic Webhook",
configSchema: GenericWebhookSchema,
+ credentials: { primary: "WEBHOOK" },
async test(config: GenericWebhookConfig): Promise<{ success: boolean; message: string }> {
try {
diff --git a/src/lib/adapters/notification/slack.ts b/src/lib/adapters/notification/slack.ts
index 27e72e10..13d238cb 100644
--- a/src/lib/adapters/notification/slack.ts
+++ b/src/lib/adapters/notification/slack.ts
@@ -11,6 +11,7 @@ export const SlackAdapter: NotificationAdapter = {
type: "notification",
name: "Slack Webhook",
configSchema: SlackSchema,
+ credentials: { primary: "WEBHOOK" },
async test(config: SlackConfig): Promise<{ success: boolean; message: string }> {
try {
diff --git a/src/lib/adapters/notification/teams.ts b/src/lib/adapters/notification/teams.ts
index 10f9d1c7..70a3ec73 100644
--- a/src/lib/adapters/notification/teams.ts
+++ b/src/lib/adapters/notification/teams.ts
@@ -17,6 +17,7 @@ export const TeamsAdapter: NotificationAdapter = {
type: "notification",
name: "Microsoft Teams",
configSchema: TeamsSchema,
+ credentials: { primary: "WEBHOOK" },
async test(config: TeamsConfig): Promise<{ success: boolean; message: string }> {
try {
diff --git a/src/lib/adapters/notification/twilio-sms.ts b/src/lib/adapters/notification/twilio-sms.ts
index 1630ad69..c026dbdc 100644
--- a/src/lib/adapters/notification/twilio-sms.ts
+++ b/src/lib/adapters/notification/twilio-sms.ts
@@ -43,6 +43,9 @@ export const TwilioSmsAdapter: NotificationAdapter = {
type: "notification",
name: "SMS (Twilio)",
configSchema: TwilioSmsSchema,
+ // The auth token is the secret; it comes from a TOKEN profile (resolver sprays
+ // it to `authToken`). `accountSid` stays structural (now in SENSITIVE_KEYS).
+ credentials: { primary: "TOKEN" },
async test(config: TwilioSmsConfig): Promise<{ success: boolean; message: string }> {
try {
diff --git a/src/lib/adapters/storage/dropbox.ts b/src/lib/adapters/storage/dropbox.ts
index 59fe4720..3ee842de 100644
--- a/src/lib/adapters/storage/dropbox.ts
+++ b/src/lib/adapters/storage/dropbox.ts
@@ -144,6 +144,9 @@ export const DropboxAdapter: StorageAdapter = {
type: "storage",
name: "Dropbox",
configSchema: DropboxSchema,
+ // clientSecret + refreshToken live in an OAUTH credential profile; clientId
+ // stays structural. The refreshToken is written by the OAuth callback.
+ credentials: { primary: "OAUTH" },
async upload(
config: DropboxConfig,
diff --git a/src/lib/adapters/storage/google-drive.ts b/src/lib/adapters/storage/google-drive.ts
index b0baac5d..3513d4ae 100644
--- a/src/lib/adapters/storage/google-drive.ts
+++ b/src/lib/adapters/storage/google-drive.ts
@@ -154,6 +154,9 @@ export const GoogleDriveAdapter: StorageAdapter = {
type: "storage",
name: "Google Drive",
configSchema: GoogleDriveSchema,
+ // clientSecret + refreshToken live in an OAUTH credential profile; clientId
+ // stays structural. The refreshToken is written by the OAuth callback.
+ credentials: { primary: "OAUTH" },
async upload(
config: GoogleDriveConfig,
diff --git a/src/lib/adapters/storage/onedrive.ts b/src/lib/adapters/storage/onedrive.ts
index 3dbffcca..1e681dc5 100644
--- a/src/lib/adapters/storage/onedrive.ts
+++ b/src/lib/adapters/storage/onedrive.ts
@@ -268,6 +268,9 @@ export const OneDriveAdapter: StorageAdapter = {
type: "storage",
name: "Microsoft OneDrive",
configSchema: OneDriveSchema,
+ // clientSecret + refreshToken live in an OAUTH credential profile; clientId
+ // stays structural. The refreshToken is written by the OAuth callback.
+ credentials: { primary: "OAUTH" },
async openSession(config: OneDriveConfig, onLog?): Promise {
const accessToken = await getAccessToken(config);
diff --git a/src/lib/adapters/storage/smb.ts b/src/lib/adapters/storage/smb.ts
index baf580eb..24c39ea4 100644
--- a/src/lib/adapters/storage/smb.ts
+++ b/src/lib/adapters/storage/smb.ts
@@ -10,6 +10,15 @@ import { wrapError } from "@/lib/logging/errors";
const log = logger.child({ adapter: "smb" });
+function sanitizeSmbError(error: unknown, password?: string): Error {
+ const original = error instanceof Error ? error : new Error(String(error));
+ if (!password) return original;
+ const sanitized = original.message.split(password).join("****");
+ const out = new Error(sanitized);
+ out.stack = original.stack?.split(password).join("****");
+ return out;
+}
+
interface SMBConfig {
address: string;
username: string;
@@ -93,8 +102,9 @@ async function performSmbUpload(
if (onLog) onLog("SMB upload completed successfully", "info", "storage");
return true;
} catch (error: unknown) {
- log.error("SMB upload failed", { address: config.address, remotePath }, wrapError(error));
- if (onLog && error instanceof Error) onLog(`SMB upload failed: ${error.message}`, "error", "storage", error.stack);
+ const safe = sanitizeSmbError(error, config.password);
+ log.error("SMB upload failed", { address: config.address, remotePath }, wrapError(safe));
+ if (onLog) onLog(`SMB upload failed: ${safe.message}`, "error", "storage", safe.stack);
return false;
}
}
@@ -133,8 +143,9 @@ export const SMBAdapter: StorageAdapter = {
await client.getFile(source, localPath);
return true;
} catch (error: unknown) {
- log.error("SMB download failed", { address: config.address, remotePath }, wrapError(error));
- if (onLog && error instanceof Error) onLog(`SMB download failed: ${error.message}`, "error", "storage", error.stack);
+ const safe = sanitizeSmbError(error, config.password);
+ log.error("SMB download failed", { address: config.address, remotePath }, wrapError(safe));
+ if (onLog) onLog(`SMB download failed: ${safe.message}`, "error", "storage", safe.stack);
return false;
}
},
@@ -180,7 +191,7 @@ export const SMBAdapter: StorageAdapter = {
} catch (error: unknown) {
// Root directory listing failure means the share is unreachable or inaccessible.
// Propagate to trigger the DB fallback in the stats cache.
- if (currentDir === startDir) throw error;
+ if (currentDir === startDir) throw sanitizeSmbError(error, config.password);
// Sub-directory listing failure (e.g. permission denied on one folder): skip silently.
return;
}
@@ -216,8 +227,9 @@ export const SMBAdapter: StorageAdapter = {
await walk(startDir);
return files;
} catch (error: unknown) {
- log.error("SMB list failed", { address: config.address, dir }, wrapError(error));
- throw error;
+ const safe = sanitizeSmbError(error, config.password);
+ log.error("SMB list failed", { address: config.address, dir }, wrapError(safe));
+ throw safe;
}
},
@@ -229,7 +241,8 @@ export const SMBAdapter: StorageAdapter = {
await client.deleteFile(target);
return true;
} catch (error: unknown) {
- log.error("SMB delete failed", { address: config.address, remotePath }, wrapError(error));
+ const safe = sanitizeSmbError(error, config.password);
+ log.error("SMB delete failed", { address: config.address, remotePath }, wrapError(safe));
return false;
}
},
@@ -262,8 +275,8 @@ export const SMBAdapter: StorageAdapter = {
return { success: true, message: "Connection successful (Write/Delete verified)" };
} catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- return { success: false, message: `SMB Connection failed: ${message}` };
+ const safe = sanitizeSmbError(error, config.password);
+ return { success: false, message: `SMB Connection failed: ${safe.message}` };
} finally {
// Always attempt remote cleanup. This guards against the edge case where
// sendFile partially succeeded (file exists on the SMB share) but then
diff --git a/src/lib/core/credential-requirements.ts b/src/lib/core/credential-requirements.ts
index 6540648f..2d43b962 100644
--- a/src/lib/core/credential-requirements.ts
+++ b/src/lib/core/credential-requirements.ts
@@ -8,8 +8,8 @@ import type { CredentialType } from "@/lib/core/credentials";
* - The adapter form to decide whether to render the primary/SSH picker
*
* NOTE: When adding a new adapter, mirror its `credentials` declaration here.
- * Adapters without a credential profile (local-filesystem, OAuth storage,
- * non-token webhook notifications) are intentionally absent.
+ * Adapters without a credential profile (e.g. local-filesystem) are
+ * intentionally absent.
*/
export const ADAPTER_CREDENTIAL_REQUIREMENTS: Record<
string,
@@ -39,11 +39,23 @@ export const ADAPTER_CREDENTIAL_REQUIREMENTS: Record<
"s3-r2": { primary: "ACCESS_KEY" },
"s3-hetzner": { primary: "ACCESS_KEY" },
+ // OAuth cloud storage (clientSecret + refreshToken in an OAUTH profile)
+ "google-drive": { primary: "OAUTH" },
+ dropbox: { primary: "OAUTH" },
+ onedrive: { primary: "OAUTH" },
+
// Notifications
email: { primary: "SMTP" },
gotify: { primary: "TOKEN" },
ntfy: { primary: "TOKEN" },
telegram: { primary: "TOKEN" },
+ "twilio-sms": { primary: "TOKEN" }, // token = Twilio Auth Token; accountSid stays structural
+
+ // Webhook notifications (URL + optional auth header in the vault)
+ discord: { primary: "WEBHOOK" },
+ slack: { primary: "WEBHOOK" },
+ teams: { primary: "WEBHOOK" },
+ "generic-webhook": { primary: "WEBHOOK" },
};
export function getCredentialRequirements(adapterId: string) {
diff --git a/src/lib/core/credentials.ts b/src/lib/core/credentials.ts
index 3c03fb17..d2b647fe 100644
--- a/src/lib/core/credentials.ts
+++ b/src/lib/core/credentials.ts
@@ -13,6 +13,8 @@ export const CREDENTIAL_TYPES = [
"ACCESS_KEY",
"TOKEN",
"SMTP",
+ "WEBHOOK",
+ "OAUTH",
] as const;
export type CredentialType = (typeof CREDENTIAL_TYPES)[number];
@@ -65,6 +67,22 @@ export const SmtpSchema = z.object({
password: z.string().min(1, "Password is required"),
});
+export const WebhookSchema = z.object({
+ url: z.string().url("Valid Webhook URL is required"),
+ authHeader: z.string().optional(),
+});
+
+export const OAuthSchema = z.object({
+ // clientId + clientSecret form one OAuth-app registration; keeping them together
+ // (with the refreshToken) avoids the mismatch where a refreshToken issued for one
+ // clientId is used with another. clientId is not itself a secret.
+ clientId: z.string().min(1, "Client ID is required"),
+ clientSecret: z.string().min(1, "Client Secret is required"),
+ // Set automatically by the OAuth callback after the consent flow; optional so
+ // the profile can be created up-front with just the client id + secret.
+ refreshToken: z.string().optional(),
+});
+
// ---------------------------------------------------------------------------
// Type-to-schema map (single source of truth for validation)
// ---------------------------------------------------------------------------
@@ -75,6 +93,8 @@ export const CREDENTIAL_SCHEMAS = {
ACCESS_KEY: AccessKeySchema,
TOKEN: TokenSchema,
SMTP: SmtpSchema,
+ WEBHOOK: WebhookSchema,
+ OAUTH: OAuthSchema,
} as const satisfies Record;
export type UsernamePasswordData = z.infer;
@@ -82,13 +102,17 @@ export type SshKeyData = z.infer;
export type AccessKeyData = z.infer;
export type TokenData = z.infer;
export type SmtpData = z.infer;
+export type WebhookData = z.infer;
+export type OAuthData = z.infer;
export type CredentialData =
| UsernamePasswordData
| SshKeyData
| AccessKeyData
| TokenData
- | SmtpData;
+ | SmtpData
+ | WebhookData
+ | OAuthData;
/**
* A credential profile as exposed to clients (no `data` payload).
@@ -102,6 +126,13 @@ export interface CredentialProfileShape {
description: string | null;
createdAt: Date;
updatedAt: Date;
+ /**
+ * Which sensitive fields of the (encrypted) payload hold a non-empty value,
+ * e.g. `{ clientSecret: true, refreshToken: false }`. Lets the UI tell whether
+ * an OAUTH profile has been authorized (refreshToken present) WITHOUT exposing
+ * any secret value. Never contains the values themselves.
+ */
+ secretStatus?: Record;
}
/**
diff --git a/src/lib/crypto/index.ts b/src/lib/crypto/index.ts
index a29dc8eb..7231ce95 100644
--- a/src/lib/crypto/index.ts
+++ b/src/lib/crypto/index.ts
@@ -88,7 +88,7 @@ export function decrypt(text: string): string {
}
}
-const SENSITIVE_KEYS = [
+export const SENSITIVE_KEYS = [
'password',
'token',
'secret',
@@ -101,8 +101,17 @@ const SENSITIVE_KEYS = [
'uri', // MongoDB Connection String
'passphrase', // SSH Key Passphrase
'privateKey', // SSH Private Key
+ 'sshPassword', // SSH tunnel password (legacy inline SSH field)
+ 'sshPrivateKey', // SSH tunnel private key (legacy inline SSH field)
+ 'sshPassphrase', // SSH tunnel key passphrase (legacy inline SSH field)
'clientSecret', // OAuth Client Secret (Google Drive, etc.)
'refreshToken', // OAuth Refresh Token
+ 'authHeader', // Generic Webhook Authorization header
+ 'accountSid', // Twilio Account SID
+ 'authToken', // Twilio Auth Token
+ 'appToken', // Gotify application token
+ 'botToken', // Telegram bot token
+ 'accessToken', // ntfy access token
];
@@ -177,3 +186,99 @@ export function decryptConfig(config: any): any {
return result;
}
+
+/**
+ * Merges sensitive fields of an incoming (plaintext) config with an existing
+ * (plaintext) config, preserving the existing secret whenever the incoming
+ * value for a sensitive key is empty or absent.
+ *
+ * This is the server-side half of the "hasSecret" pattern: the API only ever
+ * returns redacted secrets (see `stripSecrets` / the adapter DTO), so an edit
+ * round-trip submits empty secret fields. Without this merge, re-encrypting the
+ * submitted config would clobber the real secret with an encrypted empty string.
+ *
+ * Non-sensitive keys are taken verbatim from `incoming`. Nested objects are
+ * merged recursively for sensitive keys; non-object structural values pass
+ * through from `incoming`.
+ */
+export function mergeSecrets(incoming: any, existing: any): any {
+ if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
+ return incoming;
+ }
+ const existingObj =
+ existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
+
+ const result: Record = { ...incoming };
+
+ const isEmpty = (v: unknown) => v === undefined || v === null || v === '';
+
+ // 1. Sensitive keys present in `incoming`: if empty, restore from existing.
+ // Nested objects merge recursively.
+ for (const key of Object.keys(result)) {
+ const value = result[key];
+ const existingValue = existingObj[key];
+
+ if (value && typeof value === 'object') {
+ result[key] = mergeSecrets(value, existingValue);
+ } else if (SENSITIVE_KEYS.includes(key) && isEmpty(value) && existingValue !== undefined) {
+ result[key] = existingValue;
+ }
+ }
+
+ // 2. Sensitive keys present in `existing` but absent from `incoming`: restore
+ // them. The API redacts (removes) secret keys, so an edit round-trip omits
+ // untouched secrets entirely โ without this they would be lost on save.
+ for (const key of Object.keys(existingObj)) {
+ if (!(key in result) && SENSITIVE_KEYS.includes(key) && !isEmpty(existingObj[key])) {
+ result[key] = existingObj[key];
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Recursively removes sensitive fields from a (decrypted) config, returning a
+ * config that structurally cannot carry a secret. Unlike `stripSecrets` (which
+ * blanks values to `""`), this deletes the keys entirely so a response DTO never
+ * even hints at a value. Use together with `getSecretStatus` to tell the client
+ * which secrets are set without exposing them.
+ */
+export function redactSecrets(config: any): any {
+ if (!config || typeof config !== 'object') {
+ return config;
+ }
+
+ if (Array.isArray(config)) {
+ return config.map(redactSecrets);
+ }
+
+ const result: Record = {};
+ for (const key of Object.keys(config)) {
+ const value = config[key];
+ if (SENSITIVE_KEYS.includes(key) && typeof value !== 'object') {
+ continue; // drop scalar secret entirely
+ }
+ result[key] = value && typeof value === 'object' ? redactSecrets(value) : value;
+ }
+ return result;
+}
+
+/**
+ * Reports which sensitive top-level keys of a (decrypted) config hold a
+ * non-empty value, e.g. `{ clientSecret: true, refreshToken: false }`. Lets the
+ * UI render "secret is set, leave blank to keep" without seeing the value.
+ */
+export function getSecretStatus(config: any): Record {
+ const status: Record = {};
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
+ return status;
+ }
+ for (const key of Object.keys(config)) {
+ if (SENSITIVE_KEYS.includes(key)) {
+ const value = config[key];
+ status[key] = typeof value === 'string' ? value.length > 0 : value != null;
+ }
+ }
+ return status;
+}
diff --git a/src/lib/server/startup-checks.ts b/src/lib/server/startup-checks.ts
index c24716e1..aec684b0 100644
--- a/src/lib/server/startup-checks.ts
+++ b/src/lib/server/startup-checks.ts
@@ -40,7 +40,7 @@ export async function validateAdapterCredentials(): Promise {
for (const cfg of configs) {
const adapter = registry.get(cfg.adapterId);
- const requiresPrimary = adapter?.credentials?.primary !== undefined;
+ const requiresPrimary = adapter?.credentials?.primary !== undefined && !adapter?.credentials?.primaryOptional;
const isMissing = requiresPrimary && !cfg.primaryCredentialId;
if (isMissing) {
diff --git a/src/services/auth/credential-service.ts b/src/services/auth/credential-service.ts
index 2573a973..e2b68ea6 100644
--- a/src/services/auth/credential-service.ts
+++ b/src/services/auth/credential-service.ts
@@ -1,5 +1,5 @@
import prisma from "@/lib/prisma";
-import { encrypt, decrypt } from "@/lib/crypto";
+import { encrypt, decrypt, getSecretStatus } from "@/lib/crypto";
import { logger } from "@/lib/logging/logger";
import { ConflictError, NotFoundError, ValidationError, wrapError } from "@/lib/logging/errors";
import {
@@ -34,6 +34,19 @@ function sanitize(profile: {
};
}
+/**
+ * Computes which sensitive payload fields are set, WITHOUT exposing values.
+ * Used so the UI can tell whether an OAUTH profile is authorized (has a
+ * refreshToken). Returns undefined if the payload can't be decrypted/parsed.
+ */
+function secretStatusOf(encryptedData: string): Record | undefined {
+ try {
+ return getSecretStatus(JSON.parse(decrypt(encryptedData)));
+ } catch {
+ return undefined;
+ }
+}
+
/**
* Creates a new credential profile.
* Validates the payload against the type-specific schema before encrypting.
@@ -90,7 +103,7 @@ export async function listCredentialProfiles(
where: type ? { type } : undefined,
orderBy: { createdAt: "desc" },
});
- return profiles.map(sanitize);
+ return profiles.map((p) => ({ ...sanitize(p), secretStatus: secretStatusOf(p.data) }));
}
/**
@@ -111,6 +124,7 @@ export async function listCredentialProfilesWithCounts(
});
return profiles.map((p) => ({
...sanitize(p),
+ secretStatus: secretStatusOf(p.data),
usageCount: p._count.primaryAdapters + p._count.sshAdapters,
}));
}
@@ -123,7 +137,7 @@ export async function getCredentialProfile(id: string): Promise {
+ const API_DIR = path.join(process.cwd(), 'src/app/api');
+
+ function collectRouteFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) out.push(...collectRouteFiles(full));
+ else if (entry.isFile() && entry.name === 'route.ts') out.push(full);
+ }
+ return out;
+ }
+
+ const routeFiles = collectRouteFiles(API_DIR);
+
+ it('finds API route files to scan', () => {
+ expect(routeFiles.length).toBeGreaterThan(0);
+ });
+
+ routeFiles.forEach((file) => {
+ const rel = path.relative(process.cwd(), file);
+ const content = fs.readFileSync(file, 'utf-8');
+
+ const usesDecrypt = /decryptConfig\s*\(|resolveAdapterConfig\s*\(/.test(content);
+ if (!usesDecrypt) return;
+
+ it(`${rel} does not serialise a decrypted config to the client`, () => {
+ // Identifiers assigned from a decrypt/resolve call.
+ const decryptedIdents = new Set();
+ const assignRe =
+ /(?:const|let|var)\s+([A-Za-z0-9_]+)\s*=\s*(?:await\s+)?(?:[A-Za-z0-9_.]*\s*=\s*)?(?:.*?)(?:decryptConfig|resolveAdapterConfig)\s*\(/g;
+ let m: RegExpExecArray | null;
+ while ((m = assignRe.exec(content)) !== null) {
+ decryptedIdents.add(m[1]);
+ }
+
+ for (const ident of decryptedIdents) {
+ const escaped = ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ // NextResponse.json(ident) or NextResponse.json({ ...ident }) or NextResponse.json({ config: ident })
+ const leakRe = new RegExp(
+ `NextResponse\\.json\\(\\s*(?:\\{[^}]*(?:\\.\\.\\.|:\\s*)${escaped}\\b|${escaped}\\b)`
+ );
+ expect(
+ leakRe.test(content),
+ `${rel} appears to pass decrypted config "${ident}" into NextResponse.json. ` +
+ `Return derived non-secret data or use toAdapterListItem/redactSecrets instead.`
+ ).toBe(false);
+ }
+ });
+ });
+
+ it('the adapter-listing route uses the safe DTO (toAdapterListItem)', () => {
+ const listRoute = path.join(API_DIR, 'adapters/route.ts');
+ const content = fs.readFileSync(listRoute, 'utf-8');
+ expect(content).toMatch(/toAdapterListItem/);
+ // Must not hand-roll a raw decrypted response.
+ expect(content).not.toMatch(/JSON\.stringify\(\s*decryptConfig/);
+ });
+});
diff --git a/tests/unit/adapters/config-resolver.test.ts b/tests/unit/adapters/config-resolver.test.ts
index 73c6f4ea..24922349 100644
--- a/tests/unit/adapters/config-resolver.test.ts
+++ b/tests/unit/adapters/config-resolver.test.ts
@@ -202,6 +202,61 @@ describe("resolveAdapterConfig", () => {
expect(result.password).toBe("smtp-pw");
});
+ it("overlays WEBHOOK onto `webhookUrl`/`url`/`authHeader` for webhook adapters", async () => {
+ (getDecryptedCredentialData as any).mockResolvedValue({
+ url: "https://hooks.example.com/abc",
+ authHeader: "Bearer xyz",
+ });
+
+ const result = (await resolveAdapterConfig(
+ buildRow({
+ adapterId: "discord",
+ primaryCredentialId: "cred-1",
+ config: { username: "Backup Bot" },
+ })
+ )) as Record;
+
+ expect(result.webhookUrl).toBe("https://hooks.example.com/abc");
+ expect(result.url).toBe("https://hooks.example.com/abc");
+ expect(result.authHeader).toBe("Bearer xyz");
+ });
+
+ it("overlays TOKEN onto `authToken` for Twilio", async () => {
+ (getDecryptedCredentialData as any).mockResolvedValue({ token: "tw-secret" });
+
+ const result = (await resolveAdapterConfig(
+ buildRow({
+ adapterId: "twilio-sms",
+ primaryCredentialId: "cred-1",
+ config: { accountSid: "AC123", from: "+1", to: "+2" },
+ })
+ )) as Record;
+
+ expect(result.authToken).toBe("tw-secret");
+ expect(result.accountSid).toBe("AC123"); // structural field preserved
+ });
+
+ it("overlays OAUTH clientId/clientSecret/refreshToken for cloud storage", async () => {
+ (getDecryptedCredentialData as any).mockResolvedValue({
+ clientId: "oauth-client-id",
+ clientSecret: "oauth-client-secret",
+ refreshToken: "oauth-refresh",
+ });
+
+ const result = (await resolveAdapterConfig(
+ buildRow({
+ adapterId: "google-drive",
+ primaryCredentialId: "cred-1",
+ config: { folderId: "root" },
+ })
+ )) as Record;
+
+ expect(result.clientId).toBe("oauth-client-id");
+ expect(result.clientSecret).toBe("oauth-client-secret");
+ expect(result.refreshToken).toBe("oauth-refresh");
+ expect(result.folderId).toBe("root");
+ });
+
it("throws ConfigurationError when config JSON is malformed", async () => {
await expect(
resolveAdapterConfig({
@@ -257,8 +312,10 @@ describe("Adapter credential declarations", () => {
expect(registry.get("email")?.credentials).toEqual({ primary: "SMTP", primaryOptional: true });
expect(registry.get("sqlite")?.credentials).toEqual({ ssh: "SSH_KEY" });
expect(registry.get("local-filesystem")?.credentials).toBeUndefined();
- expect(registry.get("discord")?.credentials).toBeUndefined();
- expect(registry.get("google-drive")?.credentials).toBeUndefined();
+ expect(registry.get("discord")?.credentials).toEqual({ primary: "WEBHOOK" });
+ expect(registry.get("generic-webhook")?.credentials).toEqual({ primary: "WEBHOOK" });
+ expect(registry.get("twilio-sms")?.credentials).toEqual({ primary: "TOKEN" });
+ expect(registry.get("google-drive")?.credentials).toEqual({ primary: "OAUTH" });
});
});
diff --git a/tests/unit/lib/adapter-dto.test.ts b/tests/unit/lib/adapter-dto.test.ts
new file mode 100644
index 00000000..a2a33b76
--- /dev/null
+++ b/tests/unit/lib/adapter-dto.test.ts
@@ -0,0 +1,124 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+const VALID_KEY = "a".repeat(64);
+
+/**
+ * Mirrors the reported PoC (read-only user retrieving decrypted adapter secrets):
+ * a stored adapter whose secrets are encrypted must, once mapped through the
+ * list DTO, expose NO decrypted secret value or secret key to the client.
+ */
+describe("toAdapterListItem (adapter list DTO)", () => {
+ beforeEach(() => vi.stubEnv("ENCRYPTION_KEY", VALID_KEY));
+ afterEach(() => vi.unstubAllEnvs());
+
+ async function getModules() {
+ vi.resetModules();
+ const crypto = await import("@/lib/crypto");
+ const dto = await import("@/lib/adapters/dto");
+ return { ...crypto, ...dto };
+ }
+
+ function baseRow(config: string) {
+ return {
+ id: "a1",
+ name: "Prod",
+ type: "storage",
+ adapterId: "google-drive",
+ config,
+ metadata: null,
+ createdAt: new Date(0),
+ updatedAt: new Date(0),
+ primaryCredentialId: null,
+ sshCredentialId: null,
+ defaultRetentionPolicyId: null,
+ lastHealthCheck: null,
+ lastStatus: "ONLINE",
+ lastError: null,
+ consecutiveFailures: 0,
+ };
+ }
+
+ it("never leaks decrypted secret values for an OAuth storage adapter", async () => {
+ const { encryptConfig, toAdapterListItem } = await getModules();
+ const stored = JSON.stringify(
+ encryptConfig({
+ clientId: "poc-client-id",
+ clientSecret: "POC_CLIENT_SECRET_SHOULD_NOT_LEAK",
+ refreshToken: "POC_REFRESH_TOKEN_SHOULD_NOT_LEAK",
+ folderId: "root",
+ })
+ );
+
+ const dto = toAdapterListItem(baseRow(stored));
+ const serialized = JSON.stringify(dto);
+
+ expect(serialized).not.toContain("POC_CLIENT_SECRET_SHOULD_NOT_LEAK");
+ expect(serialized).not.toContain("POC_REFRESH_TOKEN_SHOULD_NOT_LEAK");
+
+ const cfg = JSON.parse(dto.config);
+ expect(cfg.clientId).toBe("poc-client-id"); // non-secret preserved
+ expect(cfg.folderId).toBe("root");
+ expect("clientSecret" in cfg).toBe(false); // secret key removed entirely
+ expect("refreshToken" in cfg).toBe(false);
+
+ // secretStatus reports presence without the value
+ expect(dto.secretStatus.clientSecret).toBe(true);
+ expect(dto.secretStatus.refreshToken).toBe(true);
+ });
+
+ it("reports secretStatus=false for an absent/empty secret", async () => {
+ const { encryptConfig, toAdapterListItem } = await getModules();
+ const stored = JSON.stringify(encryptConfig({ clientId: "id", clientSecret: "set" }));
+ const dto = toAdapterListItem(baseRow(stored));
+ expect(dto.secretStatus.clientSecret).toBe(true);
+ expect(dto.secretStatus.refreshToken).toBeUndefined();
+ });
+
+ it("redacts a database password without dropping structural fields", async () => {
+ const { encryptConfig, toAdapterListItem } = await getModules();
+ const stored = JSON.stringify(
+ encryptConfig({ host: "db.prod", port: 5432, username: "u", password: "TOPSECRET" })
+ );
+ const dto = toAdapterListItem({ ...baseRow(stored), type: "database", adapterId: "postgres" });
+ expect(JSON.stringify(dto)).not.toContain("TOPSECRET");
+ const cfg = JSON.parse(dto.config);
+ expect(cfg).toEqual({ host: "db.prod", port: 5432, username: "u" });
+ });
+
+ it("never leaks a token-type notification secret (Telegram botToken)", async () => {
+ const { encryptConfig, toAdapterListItem } = await getModules();
+ const stored = JSON.stringify(
+ encryptConfig({ botToken: "SECRET_BOT_TOKEN_SHOULD_NOT_LEAK", chatId: "99999" })
+ );
+ const dto = toAdapterListItem({ ...baseRow(stored), type: "notification", adapterId: "telegram" });
+ const serialized = JSON.stringify(dto);
+
+ expect(serialized).not.toContain("SECRET_BOT_TOKEN_SHOULD_NOT_LEAK");
+ const cfg = JSON.parse(dto.config);
+ expect("botToken" in cfg).toBe(false); // secret key removed entirely
+ expect(cfg.chatId).toBe("99999"); // non-secret preserved
+ expect(dto.secretStatus.botToken).toBe(true);
+ });
+
+ it("never leaks a webhook-type notification secret (Discord webhookUrl)", async () => {
+ const { encryptConfig, toAdapterListItem } = await getModules();
+ const stored = JSON.stringify(
+ encryptConfig({ webhookUrl: "https://discord.com/api/webhooks/SECRET_HOOK", username: "Backup Bot" })
+ );
+ const dto = toAdapterListItem({ ...baseRow(stored), type: "notification", adapterId: "discord" });
+ const serialized = JSON.stringify(dto);
+
+ expect(serialized).not.toContain("https://discord.com/api/webhooks/SECRET_HOOK");
+ const cfg = JSON.parse(dto.config);
+ expect("webhookUrl" in cfg).toBe(false); // secret key removed entirely
+ expect(cfg.username).toBe("Backup Bot"); // non-secret preserved
+ expect(dto.secretStatus.webhookUrl).toBe(true);
+ });
+
+ it("returns an empty config for unparseable stored config (no raw leak)", async () => {
+ const { toAdapterListItem } = await getModules();
+ const dto = toAdapterListItem(baseRow("not-json"));
+ expect(dto.config).toBe("{}");
+ expect(dto.secretStatus).toEqual({});
+ });
+});
diff --git a/tests/unit/lib/adapters-route-secret-disclosure.test.ts b/tests/unit/lib/adapters-route-secret-disclosure.test.ts
new file mode 100644
index 00000000..11aa0d50
--- /dev/null
+++ b/tests/unit/lib/adapters-route-secret-disclosure.test.ts
@@ -0,0 +1,182 @@
+/**
+ * Regression tests for GHSA adapter secret disclosure.
+ *
+ * Verifies that GET /api/adapters never returns decrypted values for fields
+ * in SENSITIVE_KEYS to callers who only have read/view permission.
+ */
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { NextRequest } from "next/server";
+
+// โโ Encryption key for AES-256-GCM (64 hex chars = 32 bytes) โโโโโโโโโโโโโโโโโ
+const VALID_KEY = "a".repeat(64);
+
+// Set the key before any module is imported so crypto functions work throughout
+vi.stubEnv("ENCRYPTION_KEY", VALID_KEY);
+
+// โโ Mocks โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+vi.mock("@/lib/logging/logger", () => ({
+ logger: {
+ child: () => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ }),
+ },
+}));
+
+vi.mock("@/lib/auth/access-control", () => ({
+ getAuthContext: vi.fn().mockResolvedValue({ userId: "user-readonly" }),
+ checkPermissionWithContext: vi.fn(),
+}));
+
+vi.mock("next/headers", () => ({
+ headers: vi.fn().mockResolvedValue({}),
+}));
+
+vi.mock("@/lib/adapters", () => ({
+ registerAdapters: vi.fn(),
+}));
+
+vi.mock("@/services/audit-service", () => ({
+ auditService: { log: vi.fn() },
+}));
+
+vi.mock("@/lib/core/audit-types", () => ({
+ AUDIT_ACTIONS: {},
+ AUDIT_RESOURCES: {},
+}));
+
+vi.mock("@/lib/auth/permissions", () => ({
+ PERMISSIONS: {
+ DESTINATIONS: { READ: "destinations:read", WRITE: "destinations:write" },
+ SOURCES: { VIEW: "sources:view", WRITE: "sources:write" },
+ NOTIFICATIONS: { READ: "notifications:read", WRITE: "notifications:write" },
+ },
+}));
+
+vi.mock("@/lib/adapters/credential-validation", () => ({
+ validateCredentialAssignments: vi.fn(),
+}));
+
+const mockFindMany = vi.fn();
+vi.mock("@/lib/prisma", () => ({
+ default: {
+ adapterConfig: {
+ findMany: (...args: any[]) => mockFindMany(...args),
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn(),
+ },
+ },
+}));
+
+// Import real crypto (not mocked) and route after all vi.mock calls
+import { encryptConfig } from "@/lib/crypto";
+const { GET } = await import("@/app/api/adapters/route");
+
+// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("GET /api/adapters โ secret disclosure regression", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("does not return decrypted secretAccessKey / accessKeyId for storage adapters", async () => {
+ const encryptedConfig = JSON.stringify(encryptConfig({
+ endpoint: "https://s3.example.com",
+ bucket: "my-bucket",
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ }));
+
+ mockFindMany.mockResolvedValue([
+ { id: "1", type: "storage", name: "S3 Prod", adapterId: "s3-generic", config: encryptedConfig, createdAt: new Date() },
+ ]);
+
+ const req = new NextRequest("http://localhost/api/adapters?type=storage");
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+
+ const body = await res.json();
+ const config = JSON.parse(body[0].config);
+
+ expect(config.bucket).toBe("my-bucket");
+ expect(config.endpoint).toBe("https://s3.example.com");
+ expect(config.accessKeyId).toBeUndefined();
+ expect(config.secretAccessKey).toBeUndefined();
+ });
+
+ it("does not return decrypted clientSecret / refreshToken for OAuth storage adapters", async () => {
+ const encryptedConfig = JSON.stringify(encryptConfig({
+ clientId: "my-client-id",
+ clientSecret: "POC_CLIENT_SECRET_SHOULD_NOT_LEAK",
+ refreshToken: "POC_REFRESH_TOKEN_SHOULD_NOT_LEAK",
+ folderId: "root",
+ }));
+
+ mockFindMany.mockResolvedValue([
+ { id: "2", type: "storage", name: "Google Drive", adapterId: "google-drive", config: encryptedConfig, createdAt: new Date() },
+ ]);
+
+ const req = new NextRequest("http://localhost/api/adapters?type=storage");
+ const res = await GET(req);
+ const body = await res.json();
+ const config = JSON.parse(body[0].config);
+
+ expect(config.clientId).toBe("my-client-id");
+ expect(config.folderId).toBe("root");
+ expect(config.clientSecret).toBeUndefined();
+ expect(config.refreshToken).toBeUndefined();
+ });
+
+ it("does not return decrypted password / privateKey for database adapters", async () => {
+ const encryptedConfig = JSON.stringify(encryptConfig({
+ host: "db.internal",
+ user: "dbuser",
+ password: "super-secret-db-pass",
+ privateKey: "-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----",
+ }));
+
+ mockFindMany.mockResolvedValue([
+ { id: "3", type: "database", name: "Prod Postgres", adapterId: "postgres", config: encryptedConfig, createdAt: new Date() },
+ ]);
+
+ const req = new NextRequest("http://localhost/api/adapters?type=database");
+ const res = await GET(req);
+ const body = await res.json();
+ const config = JSON.parse(body[0].config);
+
+ expect(config.host).toBe("db.internal");
+ expect(config.user).toBe("dbuser");
+ expect(config.password).toBeUndefined();
+ expect(config.privateKey).toBeUndefined();
+ });
+
+ it("does not return decrypted webhookUrl / token for notification adapters", async () => {
+ const encryptedConfig = JSON.stringify(encryptConfig({
+ webhookUrl: "https://hooks.slack.com/services/SECRET_TOKEN",
+ token: "xoxb-some-slack-bot-token",
+ channel: "#alerts",
+ }));
+
+ mockFindMany.mockResolvedValue([
+ { id: "4", type: "notification", name: "Slack Alerts", adapterId: "slack", config: encryptedConfig, createdAt: new Date() },
+ ]);
+
+ const req = new NextRequest("http://localhost/api/adapters?type=notification");
+ const res = await GET(req);
+ const body = await res.json();
+ const config = JSON.parse(body[0].config);
+
+ expect(config.channel).toBe("#alerts");
+ expect(config.webhookUrl).toBeUndefined();
+ expect(config.token).toBeUndefined();
+ });
+
+ it("returns 400 when type parameter is missing", async () => {
+ const req = new NextRequest("http://localhost/api/adapters");
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ });
+});
diff --git a/tests/unit/lib/crypto.test.ts b/tests/unit/lib/crypto.test.ts
index 4ff53e4f..3e1fa1a8 100644
--- a/tests/unit/lib/crypto.test.ts
+++ b/tests/unit/lib/crypto.test.ts
@@ -190,6 +190,115 @@ describe("stripSecrets", () => {
stripSecrets(original);
expect(original.password).toBe("secret");
});
+
+ it("strips the newly added webhook/twilio/token sensitive keys", async () => {
+ const { stripSecrets } = await getCrypto();
+ const result = stripSecrets({
+ authHeader: "Bearer abc",
+ accountSid: "AC123",
+ authToken: "tok",
+ appToken: "app",
+ botToken: "bot",
+ accessToken: "acc",
+ serverUrl: "https://example.com",
+ });
+ expect(result.authHeader).toBe("");
+ expect(result.accountSid).toBe("");
+ expect(result.authToken).toBe("");
+ expect(result.appToken).toBe("");
+ expect(result.botToken).toBe("");
+ expect(result.accessToken).toBe("");
+ expect(result.serverUrl).toBe("https://example.com");
+ });
+});
+
+describe("mergeSecrets", () => {
+ async function getCrypto() {
+ vi.resetModules();
+ return import("@/lib/crypto");
+ }
+
+ it("keeps the existing secret when the incoming value is empty", async () => {
+ const { mergeSecrets } = await getCrypto();
+ const result = mergeSecrets(
+ { host: "new-host", password: "" },
+ { host: "old-host", password: "real-secret" }
+ );
+ expect(result.host).toBe("new-host");
+ expect(result.password).toBe("real-secret");
+ });
+
+ it("restores a secret that is absent from incoming (DTO redacts the key)", async () => {
+ const { mergeSecrets } = await getCrypto();
+ const result = mergeSecrets({ host: "h" }, { host: "h", refreshToken: "rt" });
+ // The list DTO removes secret keys, so an untouched secret is omitted on
+ // re-submit and must be restored from the existing config.
+ expect(result.refreshToken).toBe("rt");
+ });
+
+ it("does not restore an absent non-sensitive key", async () => {
+ const { mergeSecrets } = await getCrypto();
+ const result = mergeSecrets({ host: "h" }, { host: "h", region: "eu" });
+ expect(result.region).toBeUndefined();
+ });
+
+ it("overwrites the existing secret when a non-empty value is supplied", async () => {
+ const { mergeSecrets } = await getCrypto();
+ const result = mergeSecrets(
+ { clientSecret: "new-secret" },
+ { clientSecret: "old-secret" }
+ );
+ expect(result.clientSecret).toBe("new-secret");
+ });
+
+ it("merges nested objects recursively", async () => {
+ const { mergeSecrets } = await getCrypto();
+ const result = mergeSecrets(
+ { db: { host: "h", password: "" } },
+ { db: { host: "old", password: "kept" } }
+ );
+ expect(result.db.password).toBe("kept");
+ expect(result.db.host).toBe("h");
+ });
+
+ it("returns incoming verbatim when existing is not an object", async () => {
+ const { mergeSecrets } = await getCrypto();
+ expect(mergeSecrets({ password: "" }, null)).toEqual({ password: "" });
+ });
+});
+
+describe("redactSecrets / getSecretStatus", () => {
+ async function getCrypto() {
+ vi.resetModules();
+ return import("@/lib/crypto");
+ }
+
+ it("removes scalar secret keys entirely (not blanked to \"\")", async () => {
+ const { redactSecrets } = await getCrypto();
+ const result = redactSecrets({ host: "h", password: "secret", clientSecret: "cs" });
+ expect(result).toEqual({ host: "h" });
+ expect("password" in result).toBe(false);
+ expect("clientSecret" in result).toBe(false);
+ });
+
+ it("recurses into nested objects and arrays", async () => {
+ const { redactSecrets } = await getCrypto();
+ const result = redactSecrets({ db: { port: 5432, password: "x" }, list: [{ token: "t", id: 1 }] });
+ expect(result.db).toEqual({ port: 5432 });
+ expect(result.list[0]).toEqual({ id: 1 });
+ });
+
+ it("reports which secrets are set via getSecretStatus", async () => {
+ const { getSecretStatus } = await getCrypto();
+ const status = getSecretStatus({
+ host: "h",
+ clientSecret: "cs",
+ refreshToken: "",
+ password: "pw",
+ });
+ expect(status).toEqual({ clientSecret: true, refreshToken: false, password: true });
+ expect("host" in status).toBe(false);
+ });
});
describe("encryptConfig / decryptConfig", () => {