Skip to content
Draft
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
254 changes: 251 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions packages/server/lib/controllers/functions/compile/postCompile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { z } from 'zod';

import { configService } from '@nangohq/shared';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

import { CompilerError, invokeCompiler } from '../../../services/remote-function/compiler-client.js';
import { sendStepError } from '../../../services/remote-function/helpers.js';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';

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

const bodySchema = z
.object({
integration_id: z.string().min(1),
function_name: z.string().min(1),
function_type: z.enum(['action', 'sync']),
code: z.string().min(1)
})
.strict();

export const postRemoteFunctionCompile = asyncWrapper<PostRemoteFunctionCompile>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

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

const body = valBody.data;
const { environment } = res.locals;

const providerConfig = await configService.getProviderConfig(body.integration_id, environment.id);
if (!providerConfig) {
res.status(404).send({ error: { code: 'integration_not_found', message: `Integration '${body.integration_id}' was not found` } });
return;
}

try {
const result = await invokeCompiler({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
code: body.code
});

res.status(200).send({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
bundle_size_bytes: result.bundleSizeBytes,
bundled_js: result.bundledJs,
compiled_at: new Date().toISOString()
});
} catch (err) {
sendStepError({
res,
step: 'compilation',
error: err,
status: err instanceof CompilerError ? 400 : 500
});
}
});
83 changes: 83 additions & 0 deletions packages/server/lib/controllers/functions/deploy/postDeploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { z } from 'zod';

import db from '@nangohq/database';
import { configService, getApiUrl, getSyncConfigRaw, secretService } from '@nangohq/shared';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

import { invokeDeploy } from '../../../services/remote-function/deploy-client.js';
import { sendStepError } from '../../../services/remote-function/helpers.js';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';

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

const bodySchema = z
.object({
integration_id: z.string().min(1),
function_name: z.string().min(1),
function_type: z.enum(['action', 'sync']),
code: z.string().min(1)
})
.strict();

export const postRemoteFunctionDeploy = asyncWrapper<PostRemoteFunctionDeploy>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

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

const body = valBody.data;
const { environment } = res.locals;

const providerConfig = await configService.getProviderConfig(body.integration_id, environment.id);
if (!providerConfig || !providerConfig.id) {
res.status(404).send({ error: { code: 'integration_not_found', message: `Integration '${body.integration_id}' was not found` } });
return;
}

// Guard: refuse to overwrite pre-built or public functions
// TODO: once agent-generated functions have a distinct identifier, relax this check to allow overwriting agent functions only
const existingSyncConfig = await getSyncConfigRaw({
environmentId: environment.id,
config_id: providerConfig.id,
name: body.function_name,
isAction: body.function_type === 'action'
});
if (existingSyncConfig && (existingSyncConfig.is_public || existingSyncConfig.pre_built)) {
res.status(400).send({
error: {
code: 'invalid_request',
message: `Cannot overwrite pre-built function '${body.function_name}'`
}
});
return;
}

const defaultSecret = await secretService.getDefaultSecretForEnv(db.readOnly, environment.id);
if (defaultSecret.isErr()) {
sendStepError({ res, step: 'deployment', status: 500, error: defaultSecret.error });
return;
}

const result = await invokeDeploy({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
code: body.code,
nango_secret_key: defaultSecret.value.secret,
nango_host: getApiUrl()
});

res.status(200).send({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
output: result.output
});
});
86 changes: 86 additions & 0 deletions packages/server/lib/controllers/functions/dryrun/postDryrun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod';

import db from '@nangohq/database';
import { connectionService, getApiUrl, secretService } from '@nangohq/shared';
import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils';

import { invokeDryrun } from '../../../services/remote-function/dryrun-client.js';
import { sendStepError } from '../../../services/remote-function/helpers.js';
import { asyncWrapper } from '../../../utils/asyncWrapper.js';

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

const bodySchema = z
.object({
integration_id: z.string().min(1),
function_name: z.string().min(1),
function_type: z.enum(['action', 'sync']),
code: z.string().min(1),
connection_id: z.string().min(1),
input: z.unknown().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
checkpoint: z.record(z.string(), z.unknown()).optional(),
last_sync_date: z.string().datetime().optional()
})
.strict();

export const postRemoteFunctionDryrun = asyncWrapper<PostRemoteFunctionDryrun>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

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

const body = valBody.data;
const { environment } = res.locals;

const connectionResult = await connectionService.getConnection(body.connection_id, body.integration_id, environment.id);
if (!connectionResult.success || !connectionResult.response) {
sendStepError({
res,
step: 'lookup',
status: 404,
error: { type: 'connection_not_found', message: `Connection '${body.connection_id}' was not found for integration '${body.integration_id}'` }
});
return;
}

const defaultSecret = await secretService.getDefaultSecretForEnv(db.readOnly, environment.id);
if (defaultSecret.isErr()) {
sendStepError({ res, step: 'lookup', status: 500, error: defaultSecret.error });
return;
}

const startedAt = new Date();

const result = await invokeDryrun({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
code: body.code,
connection_id: body.connection_id,
nango_secret_key: defaultSecret.value.secret,
nango_host: getApiUrl(),
...(body.input !== undefined ? { input: body.input } : {}),
...(body.metadata ? { metadata: body.metadata } : {}),
...(body.checkpoint ? { checkpoint: body.checkpoint } : {}),
...(body.last_sync_date ? { last_sync_date: body.last_sync_date } : {})
});

const durationMs = Date.now() - startedAt.getTime();

res.status(200).send({
integration_id: body.integration_id,
function_name: body.function_name,
function_type: body.function_type,
execution_timeout_at: new Date(startedAt.getTime() + 5 * 60 * 1000).toISOString(),
duration_ms: durationMs,
output: result.output
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { z } from 'zod';

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

import { getSessionByToken, subscribeToSession } from '../../../../../services/agent/agent-session.service.js';
import { asyncWrapper } from '../../../../../utils/asyncWrapper.js';

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

const paramsSchema = z
.object({
sessionToken: z.string().min(1)
})
.strict();

const heartbeatIntervalMs = 15_000;

export const getAgentSessionEvents = asyncWrapper<GetAgentSessionEvents>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

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

const { sessionToken } = valParams.data;
const { environment } = res.locals;

const session = await getSessionByToken(sessionToken, environment.id);
if (!session) {
res.status(404).send({ error: { code: 'not_found', message: 'Session not found or access denied' } });
return;
}

res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();

const write = (event: string, data: unknown): void => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};

const { backlog, unsubscribe } = subscribeToSession(session, (browserEvent) => {
write(browserEvent.event, browserEvent.data);
});

// Flush backlog to catch up any missed events
for (const e of backlog) {
write(e.event, e.data);
}

const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, heartbeatIntervalMs);

req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { z } from 'zod';

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

import { answerSession, getSessionByToken } from '../../../../../services/agent/agent-session.service.js';
import { asyncWrapper } from '../../../../../utils/asyncWrapper.js';

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

const paramsSchema = z
.object({
sessionToken: z.string().min(1)
})
.strict();

const bodySchema = z
.object({
question_id: z.string().min(1),
response: z.string().min(1)
})
.strict();

export const postAgentSessionAnswer = asyncWrapper<PostAgentSessionAnswer>(async (req, res) => {
const emptyQuery = requireEmptyQuery(req);
if (emptyQuery) {
res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } });
return;
}

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

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

const { sessionToken } = valParams.data;
const body = valBody.data;
const { environment } = res.locals;

const session = await getSessionByToken(sessionToken, environment.id);
if (!session) {
res.status(404).send({ error: { code: 'not_found', message: 'Session not found or access denied' } });
return;
}

try {
await answerSession(session, body);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
res.status(400).send({ error: { code: 'invalid_request', message } });
return;
}

res.status(200).send({
success: true,
accepted_at: new Date().toISOString()
});
});
Loading
Loading