feat: improve webhook integration (customized body and headers)
This commit is contained in:
@@ -41,6 +41,7 @@ COPY packages/payments/package.json packages/payments/
|
|||||||
COPY packages/constants/package.json packages/constants/
|
COPY packages/constants/package.json packages/constants/
|
||||||
COPY packages/validation/package.json packages/validation/
|
COPY packages/validation/package.json packages/validation/
|
||||||
COPY packages/integrations/package.json packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
|
COPY packages/js-runtime/package.json packages/js-runtime/
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# BUILD
|
||||||
@@ -108,6 +109,7 @@ COPY --from=build /app/packages/payments ./packages/payments
|
|||||||
COPY --from=build /app/packages/constants ./packages/constants
|
COPY --from=build /app/packages/constants ./packages/constants
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||||
|
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,16 @@
|
|||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"ai": "^4.2.10",
|
"ai": "^4.2.10",
|
||||||
"bind-event-listener": "^3.0.0",
|
"bind-event-listener": "^3.0.0",
|
||||||
|
"@codemirror/commands": "^6.7.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.0",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"d3": "^7.8.5",
|
"d3": "^7.8.5",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"debounce": "^2.2.0",
|
"debounce": "^2.2.0",
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
||||||
|
import { JsonEditor } from '@/components/json-editor';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { PlusIcon, TrashIcon } from 'lucide-react';
|
||||||
import { path, mergeDeepRight } from 'ramda';
|
import { path, mergeDeepRight } from 'ramda';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
||||||
|
|
||||||
|
const DEFAULT_TRANSFORMER = `(payload) => {
|
||||||
|
return payload;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Convert Record<string, string> to array format for form
|
||||||
|
function headersToArray(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
): { key: string; value: string }[] {
|
||||||
|
if (!headers || Object.keys(headers).length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(headers).map(([key, value]) => ({ key, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert array format back to Record<string, string> for API
|
||||||
|
function headersToRecord(
|
||||||
|
headers: { key: string; value: string }[],
|
||||||
|
): Record<string, string> {
|
||||||
|
return headers.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key.trim()) {
|
||||||
|
acc[key.trim()] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function WebhookIntegrationForm({
|
export function WebhookIntegrationForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -21,6 +56,13 @@ export function WebhookIntegrationForm({
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { organizationId } = useAppParams();
|
const { organizationId } = useAppParams();
|
||||||
|
|
||||||
|
// Convert headers from Record to array format for form UI
|
||||||
|
const defaultHeaders =
|
||||||
|
defaultValues?.config && 'headers' in defaultValues.config
|
||||||
|
? headersToArray(defaultValues.config.headers)
|
||||||
|
: [];
|
||||||
|
|
||||||
const form = useForm<IForm>({
|
const form = useForm<IForm>({
|
||||||
defaultValues: mergeDeepRight(
|
defaultValues: mergeDeepRight(
|
||||||
{
|
{
|
||||||
@@ -30,18 +72,68 @@ export function WebhookIntegrationForm({
|
|||||||
type: 'webhook' as const,
|
type: 'webhook' as const,
|
||||||
url: '',
|
url: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
|
mode: 'message' as const,
|
||||||
|
javascriptTemplate: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultValues ?? {},
|
defaultValues ?? {},
|
||||||
),
|
),
|
||||||
resolver: zodResolver(zCreateWebhookIntegration),
|
resolver: zodResolver(zCreateWebhookIntegration),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use a separate form for headers array to work with useFieldArray
|
||||||
|
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
|
||||||
|
defaultValues: {
|
||||||
|
headers: defaultHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const headersArray = useFieldArray({
|
||||||
|
control: headersForm.control,
|
||||||
|
name: 'headers',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch headers array and sync to main form
|
||||||
|
const watchedHeaders = useWatch({
|
||||||
|
control: headersForm.control,
|
||||||
|
name: 'headers',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync headers array changes back to main form
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchedHeaders) {
|
||||||
|
const validHeaders = watchedHeaders.filter(
|
||||||
|
(h): h is { key: string; value: string } =>
|
||||||
|
h !== undefined &&
|
||||||
|
typeof h.key === 'string' &&
|
||||||
|
typeof h.value === 'string',
|
||||||
|
);
|
||||||
|
form.setValue('config.headers', headersToRecord(validHeaders), {
|
||||||
|
shouldValidate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [watchedHeaders, form]);
|
||||||
|
|
||||||
|
const mode = form.watch('config.mode');
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.integration.createOrUpdate.mutationOptions({
|
trpc.integration.createOrUpdate.mutationOptions({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError() {
|
onError(error) {
|
||||||
|
// Handle validation errors from tRPC
|
||||||
|
if (error.data?.code === 'BAD_REQUEST') {
|
||||||
|
const errorMessage = error.message || 'Invalid JavaScript template';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
// Set form error if it's a JavaScript template error
|
||||||
|
if (errorMessage.includes('JavaScript template')) {
|
||||||
|
form.setError('config.javascriptTemplate', {
|
||||||
|
type: 'manual',
|
||||||
|
message: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
toast.error('Failed to create integration');
|
toast.error('Failed to create integration');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -70,7 +162,176 @@ export function WebhookIntegrationForm({
|
|||||||
{...form.register('config.url')}
|
{...form.register('config.url')}
|
||||||
error={path(['config', 'url', 'message'], form.formState.errors)}
|
error={path(['config', 'url', 'message'], form.formState.errors)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
|
<WithLabel
|
||||||
|
label="Headers"
|
||||||
|
info="Add custom HTTP headers to include with webhook requests"
|
||||||
|
>
|
||||||
|
<div className="col gap-2">
|
||||||
|
{headersArray.fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="row gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Header Name"
|
||||||
|
{...headersForm.register(`headers.${index}.key`)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Header Value"
|
||||||
|
{...headersForm.register(`headers.${index}.value`)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => headersArray.remove(index)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => headersArray.append({ key: '', value: '' })}
|
||||||
|
className="self-start"
|
||||||
|
icon={PlusIcon}
|
||||||
|
>
|
||||||
|
Add Header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</WithLabel>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="config.mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<WithLabel
|
||||||
|
label="Payload Format"
|
||||||
|
info="Choose how to format the webhook payload"
|
||||||
|
>
|
||||||
|
<Combobox
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Select format"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Message',
|
||||||
|
value: 'message' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'JavaScript',
|
||||||
|
value: 'javascript' as const,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={field.value ?? 'message'}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'javascript' && (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="config.javascriptTemplate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<WithLabel
|
||||||
|
label="JavaScript Transform"
|
||||||
|
info={
|
||||||
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
|
<p>
|
||||||
|
Write a JavaScript function that transforms the event
|
||||||
|
payload. The function receives <code>payload</code> as a
|
||||||
|
parameter and should return an object.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold mt-2">
|
||||||
|
Available in payload:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm">
|
||||||
|
<li>
|
||||||
|
<code>payload.name</code> - Event name
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>payload.profileId</code> - User profile ID
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>payload.properties</code> - Full properties object
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>payload.properties.your.property</code> - Nested
|
||||||
|
property value
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>payload.profile.firstName</code> - Profile property
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div className="flex gap-x-2 flex-wrap mt-1">
|
||||||
|
<code>country</code>
|
||||||
|
<code>city</code>
|
||||||
|
<code>device</code>
|
||||||
|
<code>os</code>
|
||||||
|
<code>browser</code>
|
||||||
|
<code>path</code>
|
||||||
|
<code>createdAt</code>
|
||||||
|
and more...
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm font-semibold mt-2">
|
||||||
|
Available helpers:
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm">
|
||||||
|
<li>
|
||||||
|
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
|
||||||
|
<code>Array</code>, <code>String</code>,{' '}
|
||||||
|
<code>Object</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
<strong>Example:</strong>
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
|
||||||
|
{`(payload) => ({
|
||||||
|
event: payload.name,
|
||||||
|
user: payload.profileId,
|
||||||
|
data: payload.properties,
|
||||||
|
timestamp: new Date(payload.createdAt).toISOString(),
|
||||||
|
location: \`\${payload.city}, \${payload.country}\`
|
||||||
|
})`}
|
||||||
|
</pre>
|
||||||
|
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
|
||||||
|
<strong>Security:</strong> Network calls, file system
|
||||||
|
access, and other dangerous operations are blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<JsonEditor
|
||||||
|
value={field.value ?? DEFAULT_TRANSFORMER}
|
||||||
|
onChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (form.formState.errors.config?.javascriptTemplate) {
|
||||||
|
form.clearErrors('config.javascriptTemplate');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={DEFAULT_TRANSFORMER}
|
||||||
|
minHeight="300px"
|
||||||
|
language="javascript"
|
||||||
|
/>
|
||||||
|
{form.formState.errors.config?.javascriptTemplate && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">
|
||||||
|
{form.formState.errors.config.javascriptTemplate.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</WithLabel>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
218
apps/start/src/components/json-editor.tsx
Normal file
218
apps/start/src/components/json-editor.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { json } from '@codemirror/lang-json';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import {
|
||||||
|
Compartment,
|
||||||
|
EditorState,
|
||||||
|
type Extension,
|
||||||
|
} from '@codemirror/state';
|
||||||
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTheme } from './theme-provider';
|
||||||
|
|
||||||
|
interface JsonEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
language?: 'json' | 'javascript';
|
||||||
|
onValidate?: (isValid: boolean, error?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '{}',
|
||||||
|
className = '',
|
||||||
|
minHeight = '200px',
|
||||||
|
language = 'json',
|
||||||
|
onValidate,
|
||||||
|
}: JsonEditorProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const themeCompartmentRef = useRef<Compartment | null>(null);
|
||||||
|
const languageCompartmentRef = useRef<Compartment | null>(null);
|
||||||
|
const { appTheme } = useTheme();
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const isUpdatingRef = useRef(false);
|
||||||
|
|
||||||
|
const validateContent = (content: string) => {
|
||||||
|
if (!content.trim()) {
|
||||||
|
setIsValid(true);
|
||||||
|
setError(undefined);
|
||||||
|
onValidate?.(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language === 'json') {
|
||||||
|
try {
|
||||||
|
JSON.parse(content);
|
||||||
|
setIsValid(true);
|
||||||
|
setError(undefined);
|
||||||
|
onValidate?.(true);
|
||||||
|
} catch (e) {
|
||||||
|
setIsValid(false);
|
||||||
|
const errorMsg =
|
||||||
|
e instanceof Error ? e.message : 'Invalid JSON syntax';
|
||||||
|
setError(errorMsg);
|
||||||
|
onValidate?.(false, errorMsg);
|
||||||
|
}
|
||||||
|
} else if (language === 'javascript') {
|
||||||
|
// No frontend validation for JavaScript - validation happens in tRPC
|
||||||
|
setIsValid(true);
|
||||||
|
setError(undefined);
|
||||||
|
onValidate?.(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create editor once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || viewRef.current) return;
|
||||||
|
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
themeCompartmentRef.current = themeCompartment;
|
||||||
|
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
languageCompartmentRef.current = languageCompartment;
|
||||||
|
|
||||||
|
const extensions: Extension[] = [
|
||||||
|
basicSetup,
|
||||||
|
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
|
||||||
|
EditorState.tabSize.of(2),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
isUpdatingRef.current = true;
|
||||||
|
const newValue = update.state.doc.toString();
|
||||||
|
onChange(newValue);
|
||||||
|
validateContent(newValue);
|
||||||
|
|
||||||
|
// Reset flag after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
isUpdatingRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontSize: '14px',
|
||||||
|
minHeight,
|
||||||
|
maxHeight: '400px',
|
||||||
|
},
|
||||||
|
'&.cm-editor': {
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: `1px solid ${
|
||||||
|
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
|
||||||
|
}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
minHeight,
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
padding: '12px 12px 12px 0',
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
minHeight,
|
||||||
|
},
|
||||||
|
'.cm-focused': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: 'hsl(var(--muted))',
|
||||||
|
borderRight: '1px solid hsl(var(--border))',
|
||||||
|
paddingLeft: '8px',
|
||||||
|
},
|
||||||
|
'.cm-lineNumbers .cm-gutterElement': {
|
||||||
|
color: 'hsl(var(--muted-foreground))',
|
||||||
|
paddingRight: '12px',
|
||||||
|
paddingLeft: '4px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state,
|
||||||
|
parent: editorRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
|
// Initial validation
|
||||||
|
validateContent(value);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
themeCompartmentRef.current = null;
|
||||||
|
};
|
||||||
|
}, []); // Only create once
|
||||||
|
|
||||||
|
// Update theme using compartment
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewRef.current || !themeCompartmentRef.current) return;
|
||||||
|
|
||||||
|
viewRef.current.dispatch({
|
||||||
|
effects: themeCompartmentRef.current.reconfigure(
|
||||||
|
appTheme === 'dark' ? [oneDark] : [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [appTheme]);
|
||||||
|
|
||||||
|
// Update language using compartment
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewRef.current || !languageCompartmentRef.current) return;
|
||||||
|
|
||||||
|
viewRef.current.dispatch({
|
||||||
|
effects: languageCompartmentRef.current.reconfigure(
|
||||||
|
language === 'javascript' ? [javascript()] : [json()],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
validateContent(value);
|
||||||
|
}, [language, value]);
|
||||||
|
|
||||||
|
// Update editor content when value changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewRef.current || isUpdatingRef.current) return;
|
||||||
|
|
||||||
|
const currentContent = viewRef.current.state.doc.toString();
|
||||||
|
if (currentContent !== value) {
|
||||||
|
viewRef.current.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: viewRef.current.state.doc.length,
|
||||||
|
insert: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate after external update
|
||||||
|
validateContent(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
|
||||||
|
/>
|
||||||
|
{!isValid && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">
|
||||||
|
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ COPY packages/payments/package.json ./packages/payments/
|
|||||||
COPY packages/constants/package.json ./packages/constants/
|
COPY packages/constants/package.json ./packages/constants/
|
||||||
COPY packages/validation/package.json ./packages/validation/
|
COPY packages/validation/package.json ./packages/validation/
|
||||||
COPY packages/integrations/package.json ./packages/integrations/
|
COPY packages/integrations/package.json ./packages/integrations/
|
||||||
|
COPY packages/js-runtime/package.json ./packages/js-runtime/
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# BUILD
|
||||||
@@ -90,6 +91,7 @@ COPY --from=build /app/packages/payments ./packages/payments
|
|||||||
COPY --from=build /app/packages/constants ./packages/constants
|
COPY --from=build /app/packages/constants ./packages/constants
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||||
|
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
|
"@openpanel/js-runtime": "workspace:*",
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/logger": "workspace:*",
|
"@openpanel/logger": "workspace:*",
|
||||||
"@openpanel/importer": "workspace:*",
|
"@openpanel/importer": "workspace:*",
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { db } from '@openpanel/db';
|
import { Prisma, db } from '@openpanel/db';
|
||||||
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
||||||
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
||||||
import { setSuperJson } from '@openpanel/json';
|
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
|
||||||
import type { NotificationQueuePayload } from '@openpanel/queue';
|
import type { NotificationQueuePayload } from '@openpanel/queue';
|
||||||
import { getRedisPub, publishEvent } from '@openpanel/redis';
|
import { publishEvent } from '@openpanel/redis';
|
||||||
|
|
||||||
|
function isValidJson<T>(
|
||||||
|
value: T | Prisma.NullableJsonNullValueInput | null | undefined,
|
||||||
|
): value is T {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
value !== Prisma.JsonNull &&
|
||||||
|
value !== Prisma.DbNull
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
||||||
switch (job.data.type) {
|
switch (job.data.type) {
|
||||||
@@ -14,12 +25,10 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
|||||||
|
|
||||||
if (notification.sendToApp) {
|
if (notification.sendToApp) {
|
||||||
publishEvent('notification', 'created', notification);
|
publishEvent('notification', 'created', notification);
|
||||||
// empty for now
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.sendToEmail) {
|
if (notification.sendToEmail) {
|
||||||
// empty for now
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,18 +42,44 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const payload = notification.payload;
|
||||||
|
|
||||||
|
if (!isValidJson(payload)) {
|
||||||
|
return new Error('Invalid payload');
|
||||||
|
}
|
||||||
|
|
||||||
switch (integration.config.type) {
|
switch (integration.config.type) {
|
||||||
case 'webhook': {
|
case 'webhook': {
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
if (integration.config.mode === 'javascript') {
|
||||||
|
// We only transform event payloads for now (not funnel)
|
||||||
|
if (
|
||||||
|
integration.config.javascriptTemplate &&
|
||||||
|
payload.type === 'event'
|
||||||
|
) {
|
||||||
|
const result = executeJavaScriptTemplate(
|
||||||
|
integration.config.javascriptTemplate,
|
||||||
|
payload.event,
|
||||||
|
);
|
||||||
|
body = result;
|
||||||
|
} else {
|
||||||
|
body = payload;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body = {
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return fetch(integration.config.url, {
|
return fetch(integration.config.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(integration.config.headers ?? {}),
|
...(integration.config.headers ?? {}),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
title: notification.title,
|
|
||||||
message: notification.message,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'discord': {
|
case 'discord': {
|
||||||
|
|||||||
1
packages/js-runtime/index.ts
Normal file
1
packages/js-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './src/index';
|
||||||
21
packages/js-runtime/package.json
Normal file
21
packages/js-runtime/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@openpanel/js-runtime",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@openpanel/tsconfig": "workspace:*",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
packages/js-runtime/src/ast-walker.ts
Normal file
165
packages/js-runtime/src/ast-walker.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { ALLOWED_GLOBALS, ALLOWED_INSTANCE_METHODS, ALLOWED_METHODS } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple recursive AST walker that doesn't require @babel/traverse
|
||||||
|
*/
|
||||||
|
export function walkNode(
|
||||||
|
node: unknown,
|
||||||
|
visitor: (
|
||||||
|
node: Record<string, unknown>,
|
||||||
|
parent?: Record<string, unknown>,
|
||||||
|
) => void,
|
||||||
|
parent?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
walkNode(child, visitor, parent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeObj = node as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Only visit AST nodes (they have a 'type' property)
|
||||||
|
if (typeof nodeObj.type === 'string') {
|
||||||
|
visitor(nodeObj, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively walk all properties
|
||||||
|
for (const key of Object.keys(nodeObj)) {
|
||||||
|
const value = nodeObj[key];
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
walkNode(value, visitor, nodeObj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track declared variables/parameters to know what identifiers are "local"
|
||||||
|
*/
|
||||||
|
export function collectDeclaredIdentifiers(ast: unknown): Set<string> {
|
||||||
|
const declared = new Set<string>();
|
||||||
|
|
||||||
|
walkNode(ast, (node) => {
|
||||||
|
// Variable declarations: const x = ..., let y = ..., var z = ...
|
||||||
|
if (node.type === 'VariableDeclarator') {
|
||||||
|
const id = node.id as Record<string, unknown>;
|
||||||
|
if (id.type === 'Identifier') {
|
||||||
|
declared.add(id.name as string);
|
||||||
|
}
|
||||||
|
// Handle destructuring patterns
|
||||||
|
if (id.type === 'ObjectPattern') {
|
||||||
|
collectPatternIdentifiers(id, declared);
|
||||||
|
}
|
||||||
|
if (id.type === 'ArrayPattern') {
|
||||||
|
collectPatternIdentifiers(id, declared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function parameters
|
||||||
|
if (
|
||||||
|
node.type === 'ArrowFunctionExpression' ||
|
||||||
|
node.type === 'FunctionExpression' ||
|
||||||
|
node.type === 'FunctionDeclaration'
|
||||||
|
) {
|
||||||
|
const params = node.params as Array<Record<string, unknown>>;
|
||||||
|
for (const param of params) {
|
||||||
|
collectPatternIdentifiers(param, declared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect identifiers from destructuring patterns
|
||||||
|
*/
|
||||||
|
function collectPatternIdentifiers(
|
||||||
|
pattern: Record<string, unknown>,
|
||||||
|
declared: Set<string>,
|
||||||
|
): void {
|
||||||
|
if (pattern.type === 'Identifier') {
|
||||||
|
declared.add(pattern.name as string);
|
||||||
|
} else if (pattern.type === 'ObjectPattern') {
|
||||||
|
const properties = pattern.properties as Array<Record<string, unknown>>;
|
||||||
|
for (const prop of properties) {
|
||||||
|
if (prop.type === 'ObjectProperty') {
|
||||||
|
collectPatternIdentifiers(
|
||||||
|
prop.value as Record<string, unknown>,
|
||||||
|
declared,
|
||||||
|
);
|
||||||
|
} else if (prop.type === 'RestElement') {
|
||||||
|
collectPatternIdentifiers(
|
||||||
|
prop.argument as Record<string, unknown>,
|
||||||
|
declared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pattern.type === 'ArrayPattern') {
|
||||||
|
const elements = pattern.elements as Array<Record<string, unknown> | null>;
|
||||||
|
for (const elem of elements) {
|
||||||
|
if (elem) {
|
||||||
|
collectPatternIdentifiers(elem, declared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pattern.type === 'RestElement') {
|
||||||
|
collectPatternIdentifiers(
|
||||||
|
pattern.argument as Record<string, unknown>,
|
||||||
|
declared,
|
||||||
|
);
|
||||||
|
} else if (pattern.type === 'AssignmentPattern') {
|
||||||
|
// Default parameter values: (x = 5) => ...
|
||||||
|
collectPatternIdentifiers(
|
||||||
|
pattern.left as Record<string, unknown>,
|
||||||
|
declared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an identifier is used as a property key (not a value reference)
|
||||||
|
*/
|
||||||
|
export function isPropertyKey(
|
||||||
|
node: Record<string, unknown>,
|
||||||
|
parent?: Record<string, unknown>,
|
||||||
|
): boolean {
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
// Property in object literal: { foo: value } - foo is a key
|
||||||
|
if (
|
||||||
|
parent.type === 'ObjectProperty' &&
|
||||||
|
parent.key === node &&
|
||||||
|
!parent.computed
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property access: obj.foo - foo is a property access, not a global reference
|
||||||
|
if (
|
||||||
|
parent.type === 'MemberExpression' &&
|
||||||
|
parent.property === node &&
|
||||||
|
!parent.computed
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional chaining: obj?.foo - foo is a property access
|
||||||
|
if (
|
||||||
|
parent.type === 'OptionalMemberExpression' &&
|
||||||
|
parent.property === node &&
|
||||||
|
!parent.computed
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow function parameter used in callback: t => t.toUpperCase()
|
||||||
|
// The 't' in arrow function is already collected as declared identifier
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
141
packages/js-runtime/src/constants.ts
Normal file
141
packages/js-runtime/src/constants.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Allowed global identifiers - super restricted allowlist
|
||||||
|
*/
|
||||||
|
export const ALLOWED_GLOBALS = new Set([
|
||||||
|
// Basic values for comparisons
|
||||||
|
'undefined',
|
||||||
|
'null',
|
||||||
|
|
||||||
|
// Type coercion functions
|
||||||
|
'parseInt',
|
||||||
|
'parseFloat',
|
||||||
|
'isNaN',
|
||||||
|
'isFinite',
|
||||||
|
|
||||||
|
// Safe built-in objects (for static methods only)
|
||||||
|
'Math',
|
||||||
|
'Date',
|
||||||
|
'JSON',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed methods on built-in objects (static methods)
|
||||||
|
*/
|
||||||
|
export const ALLOWED_METHODS: Record<string, Set<string>> = {
|
||||||
|
// Math methods
|
||||||
|
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'random']),
|
||||||
|
|
||||||
|
// JSON methods
|
||||||
|
JSON: new Set(['parse', 'stringify']),
|
||||||
|
|
||||||
|
// Date static methods
|
||||||
|
Date: new Set(['now']),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed instance methods (methods called on values, not on global objects)
|
||||||
|
* These are safe methods that can be called on any value
|
||||||
|
*/
|
||||||
|
export const ALLOWED_INSTANCE_METHODS = new Set([
|
||||||
|
// Array instance methods
|
||||||
|
'map',
|
||||||
|
'filter',
|
||||||
|
'reduce',
|
||||||
|
'reduceRight',
|
||||||
|
'forEach',
|
||||||
|
'find',
|
||||||
|
'findIndex',
|
||||||
|
'findLast',
|
||||||
|
'findLastIndex',
|
||||||
|
'some',
|
||||||
|
'every',
|
||||||
|
'includes',
|
||||||
|
'indexOf',
|
||||||
|
'lastIndexOf',
|
||||||
|
'slice',
|
||||||
|
'concat',
|
||||||
|
'join',
|
||||||
|
'flat',
|
||||||
|
'flatMap',
|
||||||
|
'sort',
|
||||||
|
'reverse',
|
||||||
|
'fill',
|
||||||
|
'at',
|
||||||
|
'with',
|
||||||
|
'toSorted',
|
||||||
|
'toReversed',
|
||||||
|
'toSpliced',
|
||||||
|
|
||||||
|
// String instance methods
|
||||||
|
'toLowerCase',
|
||||||
|
'toUpperCase',
|
||||||
|
'toLocaleLowerCase',
|
||||||
|
'toLocaleUpperCase',
|
||||||
|
'trim',
|
||||||
|
'trimStart',
|
||||||
|
'trimEnd',
|
||||||
|
'padStart',
|
||||||
|
'padEnd',
|
||||||
|
'repeat',
|
||||||
|
'replace',
|
||||||
|
'replaceAll',
|
||||||
|
'split',
|
||||||
|
'substring',
|
||||||
|
'substr',
|
||||||
|
'charAt',
|
||||||
|
'charCodeAt',
|
||||||
|
'codePointAt',
|
||||||
|
'startsWith',
|
||||||
|
'endsWith',
|
||||||
|
'match',
|
||||||
|
'matchAll',
|
||||||
|
'search',
|
||||||
|
'normalize',
|
||||||
|
'localeCompare',
|
||||||
|
|
||||||
|
// Number instance methods
|
||||||
|
'toFixed',
|
||||||
|
'toPrecision',
|
||||||
|
'toExponential',
|
||||||
|
'toLocaleString',
|
||||||
|
|
||||||
|
// Date instance methods
|
||||||
|
'getTime',
|
||||||
|
'getFullYear',
|
||||||
|
'getMonth',
|
||||||
|
'getDate',
|
||||||
|
'getDay',
|
||||||
|
'getHours',
|
||||||
|
'getMinutes',
|
||||||
|
'getSeconds',
|
||||||
|
'getMilliseconds',
|
||||||
|
'getUTCFullYear',
|
||||||
|
'getUTCMonth',
|
||||||
|
'getUTCDate',
|
||||||
|
'getUTCDay',
|
||||||
|
'getUTCHours',
|
||||||
|
'getUTCMinutes',
|
||||||
|
'getUTCSeconds',
|
||||||
|
'getUTCMilliseconds',
|
||||||
|
'getTimezoneOffset',
|
||||||
|
'toISOString',
|
||||||
|
'toJSON',
|
||||||
|
'toDateString',
|
||||||
|
'toTimeString',
|
||||||
|
'toLocaleDateString',
|
||||||
|
'toLocaleTimeString',
|
||||||
|
'valueOf',
|
||||||
|
|
||||||
|
// Object instance methods
|
||||||
|
'hasOwnProperty',
|
||||||
|
'toString',
|
||||||
|
'valueOf',
|
||||||
|
|
||||||
|
// RegExp instance methods
|
||||||
|
'test',
|
||||||
|
'exec',
|
||||||
|
|
||||||
|
// Common property access (length, etc.)
|
||||||
|
'length',
|
||||||
|
'size',
|
||||||
|
]);
|
||||||
31
packages/js-runtime/src/execute.ts
Normal file
31
packages/js-runtime/src/execute.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Executes a JavaScript function template
|
||||||
|
* @param code - JavaScript function code (arrow function or function expression)
|
||||||
|
* @param payload - Payload object to pass to the function
|
||||||
|
* @returns The result of executing the function
|
||||||
|
*/
|
||||||
|
export function execute(
|
||||||
|
code: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): unknown {
|
||||||
|
try {
|
||||||
|
// Create the function code that will be executed
|
||||||
|
// 'use strict' ensures 'this' is undefined (not global object)
|
||||||
|
const funcCode = `
|
||||||
|
'use strict';
|
||||||
|
return (${code})(payload);
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create function with safe globals in scope
|
||||||
|
const func = new Function('payload', funcCode);
|
||||||
|
|
||||||
|
// Execute the function
|
||||||
|
return func(payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error executing JavaScript template: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/js-runtime/src/index.ts
Normal file
2
packages/js-runtime/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { validate } from './validate';
|
||||||
|
export { execute } from './execute';
|
||||||
332
packages/js-runtime/src/validate.test.ts
Normal file
332
packages/js-runtime/src/validate.test.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { execute, validate } from './index';
|
||||||
|
|
||||||
|
describe('validate', () => {
|
||||||
|
describe('Valid templates', () => {
|
||||||
|
it('should accept arrow function', () => {
|
||||||
|
const result = validate('(payload) => ({ event: payload.name })');
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject function expression', () => {
|
||||||
|
// Function expressions are not allowed - only arrow functions
|
||||||
|
const result = validate(
|
||||||
|
'(function(payload) { return { event: payload.name }; })',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('arrow functions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept complex transformations', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
event: payload.name,
|
||||||
|
user: payload.profileId,
|
||||||
|
data: payload.properties,
|
||||||
|
timestamp: new Date(payload.createdAt).toISOString()
|
||||||
|
})`;
|
||||||
|
const result = validate(code);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept array operations', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
||||||
|
})`;
|
||||||
|
const result = validate(code);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Blocked operations', () => {
|
||||||
|
it('should block fetch calls', () => {
|
||||||
|
const result = validate('(payload) => fetch("https://evil.com")');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block XMLHttpRequest', () => {
|
||||||
|
const result = validate('(payload) => new XMLHttpRequest()');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('XMLHttpRequest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block require calls', () => {
|
||||||
|
const result = validate('(payload) => require("fs")');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('require');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block import statements', () => {
|
||||||
|
const result = validate('import fs from "fs"; (payload) => ({})');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
// Import statements cause multiple statements error first
|
||||||
|
expect(result.error).toContain('single function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block eval calls', () => {
|
||||||
|
const result = validate('(payload) => eval("evil")');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('eval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block setTimeout', () => {
|
||||||
|
const result = validate('(payload) => setTimeout(() => {}, 1000)');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('setTimeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block process access', () => {
|
||||||
|
const result = validate('(payload) => process.env.SECRET');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('process');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block while loops', () => {
|
||||||
|
const result = validate(
|
||||||
|
'(payload) => { while(true) {} return payload; }',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Loops are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block for loops', () => {
|
||||||
|
const result = validate(
|
||||||
|
'(payload) => { for(let i = 0; i < 10; i++) {} return payload; }',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Loops are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block try/catch', () => {
|
||||||
|
const result = validate(
|
||||||
|
'(payload) => { try { return payload; } catch(e) {} }',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('try/catch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block async/await', () => {
|
||||||
|
const result = validate(
|
||||||
|
'async (payload) => { await something(); return payload; }',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('async/await');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block classes', () => {
|
||||||
|
const result = validate('(payload) => { class Foo {} return payload; }');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block new Array()', () => {
|
||||||
|
const result = validate('(payload) => new Array(10)');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('new Array()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block new Object()', () => {
|
||||||
|
const result = validate('(payload) => new Object()');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('new Object()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow new Date()', () => {
|
||||||
|
const result = validate(
|
||||||
|
'(payload) => ({ timestamp: new Date().toISOString() })',
|
||||||
|
);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid syntax', () => {
|
||||||
|
it('should reject non-function code', () => {
|
||||||
|
const result = validate('const x = 1;');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid JavaScript', () => {
|
||||||
|
const result = validate('(payload) => { invalid syntax }');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.error).toContain('Parse error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
const basePayload = {
|
||||||
|
name: 'page_view',
|
||||||
|
profileId: 'user-123',
|
||||||
|
country: 'US',
|
||||||
|
city: 'New York',
|
||||||
|
device: 'desktop',
|
||||||
|
os: 'Windows',
|
||||||
|
browser: 'Chrome',
|
||||||
|
longitude: -73.935242,
|
||||||
|
latitude: 40.73061,
|
||||||
|
createdAt: '2024-01-15T10:30:00Z',
|
||||||
|
properties: {
|
||||||
|
plan: 'premium',
|
||||||
|
userId: 'user-456',
|
||||||
|
metadata: {
|
||||||
|
source: 'web',
|
||||||
|
campaign: 'summer-sale',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
},
|
||||||
|
tags: ['tag1', 'tag2'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Basic transformations', () => {
|
||||||
|
it('should execute simple arrow function', () => {
|
||||||
|
const code = '(payload) => ({ event: payload.name })';
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ event: 'page_view' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should access nested properties', () => {
|
||||||
|
const code = '(payload) => ({ plan: payload.properties.plan })';
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ plan: 'premium' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple properties', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
event: payload.name,
|
||||||
|
user: payload.profileId,
|
||||||
|
location: payload.city
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({
|
||||||
|
event: 'page_view',
|
||||||
|
user: 'user-123',
|
||||||
|
location: 'New York',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Date operations', () => {
|
||||||
|
it('should format dates', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
timestamp: new Date(payload.createdAt).toISOString()
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toHaveProperty('timestamp');
|
||||||
|
expect((result as { timestamp: string }).timestamp).toBe(
|
||||||
|
'2024-01-15T10:30:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Array operations', () => {
|
||||||
|
it('should transform arrays', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ tags: ['TAG1', 'TAG2'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter arrays', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
filtered: payload.tags?.filter(t => t.includes('tag1')) || []
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ filtered: ['tag1'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('String operations', () => {
|
||||||
|
it('should use template literals', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
location: \`\${payload.city}, \${payload.country}\`
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ location: 'New York, US' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use string methods', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
upperName: payload.name.toUpperCase()
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ upperName: 'PAGE_VIEW' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Math operations', () => {
|
||||||
|
it('should use Math functions', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
roundedLng: Math.round(payload.longitude)
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ roundedLng: -74 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Conditional logic', () => {
|
||||||
|
it('should handle ternary operators', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
plan: payload.properties?.plan || 'free'
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ plan: 'premium' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle if conditions', () => {
|
||||||
|
const code = `(payload) => {
|
||||||
|
const result = { event: payload.name };
|
||||||
|
if (payload.properties?.plan === 'premium') {
|
||||||
|
result.plan = 'premium';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({ event: 'page_view', plan: 'premium' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex transformations', () => {
|
||||||
|
it('should handle nested object construction', () => {
|
||||||
|
const code = `(payload) => ({
|
||||||
|
event: payload.name,
|
||||||
|
user: {
|
||||||
|
id: payload.profileId,
|
||||||
|
name: \`\${payload.profile?.firstName} \${payload.profile?.lastName}\`
|
||||||
|
},
|
||||||
|
data: payload.properties,
|
||||||
|
meta: {
|
||||||
|
location: \`\${payload.city}, \${payload.country}\`,
|
||||||
|
device: payload.device
|
||||||
|
}
|
||||||
|
})`;
|
||||||
|
const result = execute(code, basePayload);
|
||||||
|
expect(result).toEqual({
|
||||||
|
event: 'page_view',
|
||||||
|
user: {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'John Doe',
|
||||||
|
},
|
||||||
|
data: basePayload.properties,
|
||||||
|
meta: {
|
||||||
|
location: 'New York, US',
|
||||||
|
device: 'desktop',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should handle runtime errors gracefully', () => {
|
||||||
|
const code = '(payload) => payload.nonexistent.property';
|
||||||
|
expect(() => {
|
||||||
|
execute(code, basePayload);
|
||||||
|
}).toThrow('Error executing JavaScript template');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
280
packages/js-runtime/src/validate.ts
Normal file
280
packages/js-runtime/src/validate.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { parse } from '@babel/parser';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ALLOWED_GLOBALS,
|
||||||
|
ALLOWED_INSTANCE_METHODS,
|
||||||
|
ALLOWED_METHODS,
|
||||||
|
} from './constants';
|
||||||
|
import {
|
||||||
|
collectDeclaredIdentifiers,
|
||||||
|
isPropertyKey,
|
||||||
|
walkNode,
|
||||||
|
} from './ast-walker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a JavaScript function is safe to execute
|
||||||
|
* by checking the AST for allowed operations only (allowlist approach)
|
||||||
|
*/
|
||||||
|
export function validate(code: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return { valid: false, error: 'Code must be a non-empty string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the code to AST
|
||||||
|
const ast = parse(code, {
|
||||||
|
sourceType: 'module',
|
||||||
|
allowReturnOutsideFunction: true,
|
||||||
|
plugins: ['typescript'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate root structure: must be exactly one function expression
|
||||||
|
const program = ast.program;
|
||||||
|
const body = program.body;
|
||||||
|
|
||||||
|
if (body.length === 0) {
|
||||||
|
return { valid: false, error: 'Code cannot be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.length > 1) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
'Code must contain only a single function. Multiple statements are not allowed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootStatement = body[0]!;
|
||||||
|
|
||||||
|
// Must be an expression statement containing a function
|
||||||
|
if (rootStatement.type !== 'ExpressionStatement') {
|
||||||
|
if (rootStatement.type === 'VariableDeclaration') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
'Variable declarations (const, let, var) are not allowed. Use a direct function expression instead.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (rootStatement.type === 'FunctionDeclaration') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
'Function declarations are not allowed. Use an arrow function or function expression instead: (payload) => { ... } or function(payload) { ... }',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Code must be a function expression or arrow function',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootExpression = rootStatement.expression;
|
||||||
|
if (rootExpression.type !== 'ArrowFunctionExpression') {
|
||||||
|
if (rootExpression.type === 'FunctionExpression') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
'Function expressions are not allowed. Use arrow functions instead: (payload) => { ... }',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Code must be an arrow function, e.g.: (payload) => { ... }',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all declared identifiers (variables, parameters)
|
||||||
|
const declaredIdentifiers = collectDeclaredIdentifiers(ast);
|
||||||
|
|
||||||
|
let validationError: string | undefined;
|
||||||
|
|
||||||
|
// Walk the AST to check for allowed patterns only
|
||||||
|
walkNode(ast, (node, parent) => {
|
||||||
|
// Skip if we already found an error
|
||||||
|
if (validationError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block import/export declarations
|
||||||
|
if (
|
||||||
|
node.type === 'ImportDeclaration' ||
|
||||||
|
node.type === 'ExportDeclaration'
|
||||||
|
) {
|
||||||
|
validationError = 'import/export statements are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block function declarations inside the function body
|
||||||
|
// (FunctionDeclaration creates a named function, not allowed)
|
||||||
|
if (node.type === 'FunctionDeclaration') {
|
||||||
|
validationError =
|
||||||
|
'Named function declarations are not allowed inside the function body.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block loops - use array methods like .map(), .filter() instead
|
||||||
|
if (
|
||||||
|
node.type === 'WhileStatement' ||
|
||||||
|
node.type === 'DoWhileStatement' ||
|
||||||
|
node.type === 'ForStatement' ||
|
||||||
|
node.type === 'ForInStatement' ||
|
||||||
|
node.type === 'ForOfStatement'
|
||||||
|
) {
|
||||||
|
validationError =
|
||||||
|
'Loops are not allowed. Use array methods like .map(), .filter(), .reduce() instead.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block advanced/dangerous features
|
||||||
|
if (node.type === 'TryStatement') {
|
||||||
|
validationError = 'try/catch statements are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'ThrowStatement') {
|
||||||
|
validationError = 'throw statements are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'WithStatement') {
|
||||||
|
validationError = 'with statements are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
|
||||||
|
validationError = 'Class definitions are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'AwaitExpression') {
|
||||||
|
validationError = 'async/await is not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === 'YieldExpression') {
|
||||||
|
validationError = 'Generators are not allowed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block 'this' keyword - arrow functions don't have their own 'this'
|
||||||
|
// but we block it entirely to prevent any scope leakage
|
||||||
|
if (node.type === 'ThisExpression') {
|
||||||
|
validationError =
|
||||||
|
"'this' keyword is not allowed. Use the payload parameter instead.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check identifiers that reference globals
|
||||||
|
if (node.type === 'Identifier') {
|
||||||
|
const name = node.name as string;
|
||||||
|
|
||||||
|
// Block 'arguments' - not available in arrow functions anyway
|
||||||
|
// but explicitly block to prevent any confusion
|
||||||
|
if (name === 'arguments') {
|
||||||
|
validationError =
|
||||||
|
"'arguments' is not allowed. Use explicit parameters instead.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if it's a property key (not a value reference)
|
||||||
|
if (isPropertyKey(node, parent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if it's a declared local variable/parameter
|
||||||
|
if (declaredIdentifiers.has(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an allowed global
|
||||||
|
if (!ALLOWED_GLOBALS.has(name)) {
|
||||||
|
validationError = `Use of '${name}' is not allowed. Only safe built-in functions are permitted.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check method calls on global objects (like Math.random, JSON.parse)
|
||||||
|
// Handles both regular calls and optional chaining (?.)
|
||||||
|
if (
|
||||||
|
node.type === 'CallExpression' ||
|
||||||
|
node.type === 'OptionalCallExpression'
|
||||||
|
) {
|
||||||
|
const callee = node.callee as Record<string, unknown>;
|
||||||
|
const isMemberExpr =
|
||||||
|
callee.type === 'MemberExpression' ||
|
||||||
|
callee.type === 'OptionalMemberExpression';
|
||||||
|
|
||||||
|
if (isMemberExpr) {
|
||||||
|
const obj = callee.object as Record<string, unknown>;
|
||||||
|
const prop = callee.property as Record<string, unknown>;
|
||||||
|
const computed = callee.computed as boolean;
|
||||||
|
|
||||||
|
// Static method call on global object: Math.random(), JSON.parse()
|
||||||
|
if (
|
||||||
|
obj.type === 'Identifier' &&
|
||||||
|
prop.type === 'Identifier' &&
|
||||||
|
!computed
|
||||||
|
) {
|
||||||
|
const objName = obj.name as string;
|
||||||
|
const methodName = prop.name as string;
|
||||||
|
|
||||||
|
// Check if it's a call on an allowed global object
|
||||||
|
if (ALLOWED_GLOBALS.has(objName) && ALLOWED_METHODS[objName]) {
|
||||||
|
if (!ALLOWED_METHODS[objName].has(methodName)) {
|
||||||
|
validationError = `Method '${objName}.${methodName}' is not allowed. Only safe methods are permitted.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance method call: arr.map(), str.toLowerCase(), arr?.map()
|
||||||
|
// We allow these if the method name is in ALLOWED_INSTANCE_METHODS
|
||||||
|
if (prop.type === 'Identifier' && !computed) {
|
||||||
|
const methodName = prop.name as string;
|
||||||
|
|
||||||
|
// If calling on something other than an allowed global,
|
||||||
|
// check if the method is in the allowed instance methods
|
||||||
|
if (
|
||||||
|
obj.type !== 'Identifier' ||
|
||||||
|
!ALLOWED_GLOBALS.has(obj.name as string)
|
||||||
|
) {
|
||||||
|
if (!ALLOWED_INSTANCE_METHODS.has(methodName)) {
|
||||||
|
validationError = `Method '.${methodName}()' is not allowed. Only safe methods are permitted.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 'new' expressions - only allow new Date()
|
||||||
|
if (node.type === 'NewExpression') {
|
||||||
|
const callee = node.callee as Record<string, unknown>;
|
||||||
|
if (callee.type === 'Identifier') {
|
||||||
|
const name = callee.name as string;
|
||||||
|
if (name !== 'Date') {
|
||||||
|
validationError = `'new ${name}()' is not allowed. Only 'new Date()' is permitted.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return { valid: false, error: validationError };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? `Parse error: ${error.message}`
|
||||||
|
: 'Unknown parse error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/js-runtime/tsconfig.json
Normal file
12
packages/js-runtime/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@openpanel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["."],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
|
"@openpanel/js-runtime": "workspace:*",
|
||||||
"@openpanel/payments": "workspace:^",
|
"@openpanel/payments": "workspace:^",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
zCreateWebhookIntegration,
|
zCreateWebhookIntegration,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { getOrganizationAccess } from '../access';
|
import { getOrganizationAccess } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
|
||||||
|
|
||||||
export const integrationRouter = createTRPCRouter({
|
export const integrationRouter = createTRPCRouter({
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
@@ -93,6 +94,22 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
createOrUpdate: protectedProcedure
|
createOrUpdate: protectedProcedure
|
||||||
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
|
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
// Validate JavaScript template if mode is javascript
|
||||||
|
if (
|
||||||
|
input.config.type === 'webhook' &&
|
||||||
|
input.config.mode === 'javascript' &&
|
||||||
|
input.config.javascriptTemplate
|
||||||
|
) {
|
||||||
|
const validation = validateJavaScriptTemplate(
|
||||||
|
input.config.javascriptTemplate,
|
||||||
|
);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw TRPCBadRequestError(
|
||||||
|
`Invalid JavaScript template: ${validation.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (input.id) {
|
if (input.id) {
|
||||||
return db.integration.update({
|
return db.integration.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export const zWebhookConfig = z.object({
|
|||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
headers: z.record(z.string()),
|
headers: z.record(z.string()),
|
||||||
payload: z.record(z.string(), z.unknown()).optional(),
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
mode: z.enum(['message', 'javascript']).default('message'),
|
||||||
|
javascriptTemplate: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
|
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
|
||||||
|
|
||||||
|
|||||||
526
pnpm-lock.yaml
generated
526
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user