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
6 changes: 6 additions & 0 deletions docs/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,12 @@ paths:
properties:
tags:
$ref: '#/components/schemas/Tags'
webhook_url:
type: string
description: >
Override the environment's webhook URLs for this connection.
When set, this connection's webhooks are sent only to this URL.
Pass an empty string to clear the override.
end_user:
allOf:
- $ref: '#/components/schemas/EndUser'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,4 +255,69 @@ describe(`PATCH ${endpoint}`, () => {
expect(updatedConn?.tags).toStrictEqual({});
});
});

describe('webhook_url', () => {
it('should set webhook_url override', async () => {
const { env, apiKey } = await seeders.seedAccountEnvAndUser();
await seeders.createConfigSeed(env, 'github', 'github');
const conn = await seeders.createConnectionSeed({ env, provider: 'github' });

const res = await api.fetch(endpoint, {
method: 'PATCH',
token: apiKey.secret,
params: { connectionId: conn.connection_id },
query: { provider_config_key: 'github' },
body: { webhook_url: 'https://example.com/webhooks-from-nango' }
});

isSuccess(res.json);
expect(res.json).toStrictEqual({ success: true });

const updatedConn = await db.knex.select('*').from<DBConnection>('_nango_connections').where({ id: conn.id }).first();
expect(updatedConn?.connection_config).toMatchObject({ webhook_url: 'https://example.com/webhooks-from-nango' });
});

it('should clear webhook_url override with empty string', async () => {
const { env, apiKey } = await seeders.seedAccountEnvAndUser();
await seeders.createConfigSeed(env, 'github', 'github');
const conn = await seeders.createConnectionSeed({
env,
provider: 'github',
connectionConfig: { webhook_url: 'https://example.com/webhooks-from-nango' }
});

const res = await api.fetch(endpoint, {
method: 'PATCH',
token: apiKey.secret,
params: { connectionId: conn.connection_id },
query: { provider_config_key: 'github' },
body: { webhook_url: '' }
});

isSuccess(res.json);

const updatedConn = await db.knex.select('*').from<DBConnection>('_nango_connections').where({ id: conn.id }).first();
expect(updatedConn?.connection_config).not.toHaveProperty('webhook_url');
});

it('should reject invalid webhook_url', async () => {
const { env, apiKey } = await seeders.seedAccountEnvAndUser();
await seeders.createConfigSeed(env, 'github', 'github');
const conn = await seeders.createConnectionSeed({ env, provider: 'github' });

const res = await api.fetch(endpoint, {
method: 'PATCH',
token: apiKey.secret,
params: { connectionId: conn.connection_id },
query: { provider_config_key: 'github' },
body: { webhook_url: 'not-a-url' }
});

isError(res.json);
expect(res.json).toMatchObject({
error: { code: 'invalid_body' }
});
expect(res.res.status).toBe(400);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import * as z from 'zod';

import db from '@nangohq/database';
import { buildTagsFromEndUser, configService, connectionService, EndUserMapper, linkConnection, updateConnectionTags, upsertEndUser } from '@nangohq/shared';
import { zodErrorToHTTP } from '@nangohq/utils';

import { connectionIdSchema, connectionTagsSchema, endUserSchema, providerConfigKeySchema } from '../../../helpers/validation.js';
import { connectionIdSchema, providerConfigKeySchema } from '../../../helpers/validation.js';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';
import { handlePatchConnection, patchConnectionBodySchema } from '../../shared/connections/patchConnection.js';

import type { PatchPublicConnection } from '@nangohq/types';

const schemaBody = z.strictObject({
end_user: endUserSchema.optional(),
tags: connectionTagsSchema.optional()
});

const queryStringValidation = z.strictObject({
provider_config_key: providerConfigKeySchema
});
Expand All @@ -29,7 +23,7 @@ export const patchPublicConnection = asyncWrapper<PatchPublicConnection>(async (
return;
}

const valBody = schemaBody.safeParse(req.body);
const valBody = patchConnectionBodySchema.safeParse(req.body);
if (!valBody.success) {
res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } });
return;
Expand All @@ -42,53 +36,15 @@ export const patchPublicConnection = asyncWrapper<PatchPublicConnection>(async (
}

const { environment, account } = res.locals;

const queryParams: PatchPublicConnection['Querystring'] = queryParamValues.data;
const params: PatchPublicConnection['Params'] = paramValue.data;
const body: PatchPublicConnection['Body'] = valBody.data;

const integration = await configService.getProviderConfig(queryParams.provider_config_key, environment.id);
if (!integration) {
res.status(400).send({ error: { code: 'unknown_provider_config', message: 'Provider does not exists' } });
return;
}

const connectionRes = await connectionService.getConnection(params.connectionId, queryParams.provider_config_key, environment.id);
if (connectionRes.error || !connectionRes.response) {
res.status(404).send({ error: { code: 'not_found', message: 'Failed to find connection' } });
return;
}

const connection = connectionRes.response;

// Generate tags from end_user (similar to postSessions.ts and postReconnect.ts)
const endUserTags = body.end_user ? buildTagsFromEndUser(body.end_user, null) : {};
const mergedTags = { ...endUserTags, ...body.tags };

if (body.end_user) {
await db.knex.transaction(async (trx) => {
const endUserRes = await upsertEndUser(trx, { account, environment, connection, endUser: EndUserMapper.apiToEndUser(body.end_user!) });
if (endUserRes.isErr()) {
res.status(500).send({ error: { code: 'server_error', message: 'Failed to update end user' } });
return;
}

if (!connection.end_user_id) {
await linkConnection(trx, { endUserId: endUserRes.value.id, connection });
}
});
}

if (body.end_user || body.tags !== undefined) {
const tagsRes = await updateConnectionTags(db.knex, {
connection,
tags: mergedTags
});
if (tagsRes.isErr()) {
res.status(400).send({ error: { code: 'invalid_body', message: tagsRes.error.message } });
return;
}
}

res.status(200).send({ success: true });
await handlePatchConnection({
res,
account,
environment,
connectionId: params.connectionId,
providerConfigKey: queryParams.provider_config_key,
body: valBody.data
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as z from 'zod';

import db from '@nangohq/database';
import { buildTagsFromEndUser, configService, connectionService, EndUserMapper, linkConnection, updateConnectionTags, upsertEndUser } from '@nangohq/shared';
import { Err, Ok } from '@nangohq/utils';

import { connectionTagsSchema, endUserSchema, webhookUrlSchema } from '../../../helpers/validation.js';

import type { RequestLocals } from '../../../utils/express.js';
import type { DBEnvironment, DBTeam, EndUserInput, PatchPublicConnection, Tags } from '@nangohq/types';
import type { Response } from 'express';

export const patchConnectionBodySchema = z.strictObject({
end_user: endUserSchema.optional(),
tags: connectionTagsSchema.optional(),
webhook_url: webhookUrlSchema
});

export async function handlePatchConnection({
res,
account,
environment,
connectionId,
providerConfigKey,
body
}: {
res: Response<PatchPublicConnection['Reply'], Required<RequestLocals>>;
account: DBTeam;
environment: DBEnvironment;
connectionId: string;
providerConfigKey: string;
body: {
end_user?: EndUserInput | undefined;
tags?: Tags | undefined;
webhook_url?: string | undefined;
};
}): Promise<void> {
const integration = await configService.getProviderConfig(providerConfigKey, environment.id);
if (!integration) {
res.status(400).send({ error: { code: 'unknown_provider_config', message: 'Provider does not exists' } });
return;
}

const connectionRes = await connectionService.getConnection(connectionId, providerConfigKey, environment.id);
if (connectionRes.error || !connectionRes.response) {
res.status(404).send({ error: { code: 'not_found', message: 'Failed to find connection' } });
return;
}

const connection = connectionRes.response;

// Generate tags from end_user (similar to postSessions.ts and postReconnect.ts)
const endUserTags = body.end_user ? buildTagsFromEndUser(body.end_user, null) : {};
const mergedTags = { ...endUserTags, ...body.tags };

if (body.end_user) {
const endUserTxRes = await db.knex.transaction(async (trx) => {
const endUserRes = await upsertEndUser(trx, { account, environment, connection, endUser: EndUserMapper.apiToEndUser(body.end_user!) });
if (endUserRes.isErr()) {
return Err('Failed to update end user');
}

if (!connection.end_user_id) {
await linkConnection(trx, { endUserId: endUserRes.value.id, connection });
}

return Ok(undefined);
});
if (endUserTxRes.isErr()) {
res.status(500).send({ error: { code: 'server_error', message: endUserTxRes.error.message } });
return;
}
Comment thread
kaposke marked this conversation as resolved.
}

if (body.end_user || body.tags !== undefined) {
const tagsRes = await updateConnectionTags(db.knex, {
connection,
tags: mergedTags
});
if (tagsRes.isErr()) {
res.status(400).send({ error: { code: 'invalid_body', message: tagsRes.error.message } });
return;
}
}

if (typeof body.webhook_url === 'string') {
if (body.webhook_url === '') {
await connectionService.unsetConnectionConfigAttributes(connection, ['webhook_url']);
} else {
await connectionService.updateConnectionConfig(connection, { webhook_url: body.webhook_url });
}
}

res.status(200).send({ success: true });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

import db from '@nangohq/database';
import { seeders } from '@nangohq/shared';

import { isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../../utils/tests.js';

import type { DBConnection } from '@nangohq/types';

const route = '/api/v1/connections/:connectionId';
let api: Awaited<ReturnType<typeof runServer>>;

describe(`PATCH ${route}`, () => {
beforeAll(async () => {
api = await runServer();
});
afterAll(() => {
api.server.close();
});

it('should be protected', async () => {
const res = await api.fetch(route, {
method: 'PATCH',
params: { connectionId: 'test' },
query: { env: 'dev', provider_config_key: 'github' },
body: {}
});

shouldBeProtected(res);
});

it('should require query env', async () => {
const { apiKey } = await seeders.seedAccountEnvAndUser();
const res = await api.fetch(route, {
method: 'PATCH',
token: apiKey.secret,
params: { connectionId: 'test' },
query: { provider_config_key: 'github' } as any,
body: {}
});

shouldRequireQueryEnv(res);
});

it('should update webhook_url', async () => {
const { env, apiKey } = await seeders.seedAccountEnvAndUser();
await seeders.createConfigSeed(env, 'github', 'github');
const conn = await seeders.createConnectionSeed({ env, provider: 'github' });

const res = await api.fetch(route, {
method: 'PATCH',
token: apiKey.secret,
params: { connectionId: conn.connection_id },
query: { env: env.name, provider_config_key: 'github' },
body: { webhook_url: 'https://example.com/webhooks-from-nango' }
});

isSuccess(res.json);
expect(res.json).toStrictEqual({ success: true });

const updatedConn = await db.knex.select('*').from<DBConnection>('_nango_connections').where({ id: conn.id }).first();
expect(updatedConn?.connection_config).toMatchObject({ webhook_url: 'https://example.com/webhooks-from-nango' });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import * as z from 'zod';

import { zodErrorToHTTP } from '@nangohq/utils';

import { connectionIdSchema, envSchema, providerConfigKeySchema } from '../../../../helpers/validation.js';
import { asyncWrapper } from '../../../../utils/asyncWrapper.js';
import { handlePatchConnection, patchConnectionBodySchema } from '../../../shared/connections/patchConnection.js';

import type { PatchConnection } from '@nangohq/types';

const queryStringValidation = z.strictObject({
provider_config_key: providerConfigKeySchema,
env: envSchema
});

const paramValidation = z.strictObject({
connectionId: connectionIdSchema
});

export const patchConnection = asyncWrapper<PatchConnection>(async (req, res) => {
const queryParamValues = queryStringValidation.safeParse(req.query);
if (!queryParamValues.success) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(queryParamValues.error) } });
return;
}

const valBody = patchConnectionBodySchema.safeParse(req.body);
if (!valBody.success) {
res.status(400).send({ error: { code: 'invalid_body', errors: zodErrorToHTTP(valBody.error) } });
return;
}

const paramValue = paramValidation.safeParse(req.params);
if (!paramValue.success) {
res.status(400).send({ error: { code: 'invalid_uri_params', errors: zodErrorToHTTP(paramValue.error) } });
return;
}

const { environment, account } = res.locals;
const queryParams: PatchConnection['Querystring'] = queryParamValues.data;
const params: PatchConnection['Params'] = paramValue.data;

await handlePatchConnection({
res,
account,
environment,
connectionId: params.connectionId,
providerConfigKey: queryParams.provider_config_key,
body: valBody.data
});
});
2 changes: 1 addition & 1 deletion packages/server/lib/helpers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const webhookUrlSchema = z
);

// Connection params come from untrusted clients. `webhook_url` is intentionally NOT accepted here: it is a
// privileged routing directive set only by the backend via the connect session (see postSessions). Any
// privileged routing directive set only by trusted actors (connect session, public API, dashboard). Any
// client-supplied `webhook_url` is stripped in getConnectionConfig.
export const connectionConfigParamsSchema = z.looseObject({}).optional();

Expand Down
Loading
Loading