Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions backend-api/__tests__/channels.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
const mockDb = { query: jest.fn() };
const mockEncrypt = jest.fn((value) => `enc(${value})`);
const mockDecrypt = jest.fn((value) => value.startsWith("enc(") ? value.slice(4, -1) : value);
const mockEnsureEncryptionConfigured = jest.fn();

jest.mock("../db", () => mockDb);
jest.mock("../crypto", () => ({
encrypt: mockEncrypt,
decrypt: mockDecrypt,
ensureEncryptionConfigured: mockEnsureEncryptionConfigured,
}));
jest.mock("../../agent-runtime/lib/contracts", () => ({
agentRuntimeUrl: jest.fn(() => "http://runtime.test"),
}));
Expand All @@ -10,6 +18,9 @@ const channels = require("../channels");
describe("channel secret redaction", () => {
beforeEach(() => {
mockDb.query.mockReset();
mockEncrypt.mockClear();
mockDecrypt.mockClear();
mockEnsureEncryptionConfigured.mockClear();
});

it("redacts password and secret-like fields when creating a channel", async () => {
Expand Down Expand Up @@ -62,20 +73,35 @@ describe("channel secret redaction", () => {
});

it("redacts webhook URLs and password fields when updating a channel", async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{
id: "ch-3",
agent_id: "agent-1",
type: "slack",
name: "Ops Slack",
config: {
webhook_url: "https://hooks.slack.test/secret",
bot_token: "xoxb-secret",
channel: "#ops",
},
enabled: true,
}],
});
mockDb.query
.mockResolvedValueOnce({
rows: [{
id: "ch-3",
agent_id: "agent-1",
type: "slack",
name: "Ops Slack",
config: {
webhook_url: "enc(https://hooks.slack.test/secret)",
bot_token: "enc(xoxb-secret)",
channel: "#ops",
},
enabled: true,
}],
})
.mockResolvedValueOnce({
rows: [{
id: "ch-3",
agent_id: "agent-1",
type: "slack",
name: "Ops Slack",
config: {
webhook_url: "enc(https://hooks.slack.test/secret)",
bot_token: "enc(xoxb-secret)",
channel: "#ops",
},
enabled: true,
}],
});

const result = await channels.updateChannel("ch-3", "agent-1", {
config: {
Expand Down
135 changes: 135 additions & 0 deletions backend-api/__tests__/channelsEncryption.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
const mockDb = { query: jest.fn() };
const mockEncrypt = jest.fn((value) => `enc(${value})`);
const mockDecrypt = jest.fn((value) => value.startsWith("enc(") ? value.slice(4, -1) : value);
const mockEnsureEncryptionConfigured = jest.fn();
const mockSend = jest.fn().mockResolvedValue({ delivered: true });

jest.mock("../db", () => mockDb);
jest.mock("../crypto", () => ({
encrypt: mockEncrypt,
decrypt: mockDecrypt,
ensureEncryptionConfigured: mockEnsureEncryptionConfigured,
}));
jest.mock("../../agent-runtime/lib/contracts", () => ({
agentRuntimeUrl: jest.fn(() => "http://runtime.test"),
}));
jest.mock("../channels/adapters", () => ({
getAdapter: jest.fn((type) => ({
type,
configFields: [
{ key: "bot_token", type: "password" },
{ key: "access_token", type: "password" },
{ key: "channel_access_token", type: "password" },
{ key: "channel_secret", type: "password" },
{ key: "verify_token", type: "text" },
{ key: "webhook_url", type: "url" },
],
send: mockSend,
verify: jest.fn().mockResolvedValue({ valid: true }),
formatInbound: jest.fn((payload) => ({ content: payload.text || "ok", sender: "tester", metadata: {} })),
})),
listAdapterTypes: jest.fn(() => []),
}));

const channels = require("../channels");

describe("channel config encryption", () => {
beforeEach(() => {
mockDb.query.mockReset();
mockEncrypt.mockClear();
mockDecrypt.mockClear();
mockEnsureEncryptionConfigured.mockClear();
mockSend.mockClear();
});

it("encrypts sensitive config keys before storing a channel", async () => {
mockDb.query.mockResolvedValueOnce({
rows: [{
id: "ch-1",
agent_id: "agent-1",
type: "telegram",
name: "Ops Telegram",
config: { bot_token: "enc(secret-token)", chat_id: "42" },
enabled: true,
}],
});

const result = await channels.createChannel("agent-1", "telegram", "Ops Telegram", {
bot_token: "secret-token",
chat_id: "42",
});

expect(mockEnsureEncryptionConfigured).toHaveBeenCalledWith("Channel credential storage");
expect(mockEncrypt).toHaveBeenCalledWith("secret-token");
expect(JSON.parse(mockDb.query.mock.calls[0][1][3])).toEqual({
bot_token: "enc(secret-token)",
chat_id: "42",
});
expect(result.config).toEqual({ bot_token: "[REDACTED]", chat_id: "42" });
});

it("decrypts stored secrets before adapter send", async () => {
mockDb.query
.mockResolvedValueOnce({
rows: [{
id: "ch-2",
agent_id: "agent-1",
type: "telegram",
enabled: true,
config: { bot_token: "enc(secret-token)", chat_id: "42" },
}],
})
.mockResolvedValueOnce({ rows: [] });

await channels.sendMessage("ch-2", "hello", { to: "42" });

expect(mockDecrypt).toHaveBeenCalledWith("enc(secret-token)");
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
config: { bot_token: "secret-token", chat_id: "42" },
}),
"hello",
{ to: "42" }
);
});

it("encrypts secret-like non-password keys such as verify_token on update", async () => {
mockDb.query
.mockResolvedValueOnce({
rows: [{
id: "ch-3",
agent_id: "agent-1",
type: "whatsapp",
enabled: true,
config: { phone_number_id: "pn_123" },
}],
})
.mockResolvedValueOnce({
rows: [{
id: "ch-3",
agent_id: "agent-1",
type: "whatsapp",
enabled: true,
config: { phone_number_id: "pn_123", access_token: "enc(wa-secret)", verify_token: "enc(verify-me)" },
}],
});

const result = await channels.updateChannel("ch-3", "agent-1", {
config: { phone_number_id: "pn_123", access_token: "wa-secret", verify_token: "verify-me" },
});

expect(mockEnsureEncryptionConfigured).toHaveBeenCalledWith("Channel credential storage");
expect(mockEncrypt).toHaveBeenCalledWith("wa-secret");
expect(mockEncrypt).toHaveBeenCalledWith("verify-me");
expect(JSON.parse(mockDb.query.mock.calls[1][1][0])).toEqual({
phone_number_id: "pn_123",
access_token: "enc(wa-secret)",
verify_token: "enc(verify-me)",
});
expect(result.config).toEqual({
phone_number_id: "pn_123",
access_token: "[REDACTED]",
verify_token: "[REDACTED]",
});
});
});
87 changes: 76 additions & 11 deletions backend-api/channels/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

const db = require("../db");
const { encrypt, decrypt, ensureEncryptionConfigured } = require("../crypto");
const { agentRuntimeUrl } = require("../../agent-runtime/lib/contracts");
const { getAdapter, listAdapterTypes } = require("./adapters");

Expand All @@ -13,18 +14,56 @@ function parseConfig(config) {
return typeof config === "string" ? JSON.parse(config) : (config || {});
}

function redactChannelConfig(type, config = {}) {
function getSensitiveChannelKeys(type) {
const adapter = getAdapter(type);
const parsed = parseConfig(config);
const redacted = { ...parsed };
const passwordKeys = new Set(
return new Set(
(adapter.configFields || [])
.filter((field) => field?.type === "password")
.filter((field) => field?.type === "password" || SECRET_CONFIG_KEY_RE.test(field?.key || ""))
.map((field) => field.key)
);
}

function protectChannelConfig(type, config = {}) {
const parsed = parseConfig(config);
const sensitiveKeys = getSensitiveChannelKeys(type);
const secured = { ...parsed };
let hasSensitiveMaterial = false;

for (const key of Object.keys(secured)) {
const value = secured[key];
if (!value) continue;
if (sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) {
hasSensitiveMaterial = true;
secured[key] = encrypt(String(value));
}
}

return { secured, hasSensitiveMaterial };
}

function revealChannelConfig(type, config = {}) {
const parsed = parseConfig(config);
const sensitiveKeys = getSensitiveChannelKeys(type);
const revealed = { ...parsed };

for (const key of Object.keys(revealed)) {
const value = revealed[key];
if (!value) continue;
if (sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) {
revealed[key] = decrypt(String(value));
}
}

return revealed;
}

function redactChannelConfig(type, config = {}) {
const parsed = parseConfig(config);
const redacted = { ...parsed };
const sensitiveKeys = getSensitiveChannelKeys(type);

for (const key of Object.keys(redacted)) {
if ((passwordKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) && redacted[key]) {
if ((sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) && redacted[key]) {
redacted[key] = REDACTED_SECRET;
}
}
Expand All @@ -40,6 +79,14 @@ function sanitizeChannel(channel) {
};
}

function hydrateChannel(channel) {
if (!channel) return channel;
return {
...channel,
config: revealChannelConfig(channel.type, channel.config),
};
}

// ── Channel CRUD ─────────────────────────────────────────

async function listChannels(agentId) {
Expand All @@ -53,14 +100,25 @@ async function listChannels(agentId) {
async function createChannel(agentId, type, name, config = {}) {
// Verify the adapter type exists
getAdapter(type);
const { secured, hasSensitiveMaterial } = protectChannelConfig(type, config);
if (hasSensitiveMaterial) {
ensureEncryptionConfigured("Channel credential storage");
}
const result = await db.query(
"INSERT INTO channels(agent_id, type, name, config) VALUES($1, $2, $3, $4) RETURNING *",
[agentId, type, name, JSON.stringify(config)]
[agentId, type, name, JSON.stringify(secured)]
);
return sanitizeChannel(result.rows[0]);
}

async function updateChannel(channelId, agentId, updates) {
const existingResult = await db.query(
"SELECT * FROM channels WHERE id = $1 AND agent_id = $2",
[channelId, agentId]
);
const existing = existingResult.rows[0];
if (!existing) throw new Error("Channel not found");

const sets = [];
const params = [];
let idx = 1;
Expand All @@ -70,8 +128,12 @@ async function updateChannel(channelId, agentId, updates) {
params.push(updates.name);
}
if (updates.config !== undefined) {
const { secured, hasSensitiveMaterial } = protectChannelConfig(existing.type, updates.config);
sets.push(`config = $${idx++}`);
params.push(JSON.stringify(updates.config));
params.push(JSON.stringify(secured));
if (hasSensitiveMaterial) {
ensureEncryptionConfigured("Channel credential storage");
}
}
if (updates.enabled !== undefined) {
sets.push(`enabled = $${idx++}`);
Expand Down Expand Up @@ -101,7 +163,7 @@ async function deleteChannel(channelId, agentId) {

async function sendMessage(channelId, content, metadata = {}) {
const chResult = await db.query("SELECT * FROM channels WHERE id = $1", [channelId]);
const channel = chResult.rows[0];
const channel = hydrateChannel(chResult.rows[0]);
if (!channel) throw new Error("Channel not found");
if (!channel.enabled) throw new Error("Channel is disabled");

Expand Down Expand Up @@ -137,7 +199,7 @@ async function testChannel(channelId, agentId) {
"SELECT * FROM channels WHERE id = $1 AND agent_id = $2",
[channelId, agentId]
);
const channel = chResult.rows[0];
const channel = hydrateChannel(chResult.rows[0]);
if (!channel) throw new Error("Channel not found");

const adapter = getAdapter(channel.type);
Expand All @@ -159,7 +221,7 @@ async function testChannel(channelId, agentId) {

async function handleInboundWebhook(channelId, payload, headers) {
const chResult = await db.query("SELECT * FROM channels WHERE id = $1", [channelId]);
const channel = chResult.rows[0];
const channel = hydrateChannel(chResult.rows[0]);
if (!channel) throw new Error("Channel not found");
if (!channel.enabled) throw new Error("Channel is disabled");

Expand Down Expand Up @@ -217,4 +279,7 @@ module.exports = {
getChannelTypes,
redactChannelConfig,
sanitizeChannel,
protectChannelConfig,
revealChannelConfig,
hydrateChannel,
};
Loading