realtimex-crm 0.8.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/realtimex-crm.js +56 -32
- package/dist/assets/{DealList-B3alafQv.js → DealList-DbwJCRGl.js} +2 -2
- package/dist/assets/{DealList-B3alafQv.js.map → DealList-DbwJCRGl.js.map} +1 -1
- package/dist/assets/index-C__S90Gb.css +1 -0
- package/dist/assets/index-mE-upBfc.js +166 -0
- package/dist/assets/index-mE-upBfc.js.map +1 -0
- package/dist/index.html +1 -1
- package/dist/stats.html +1 -1
- package/package.json +3 -1
- package/src/components/atomic-crm/activities/ActivitiesPage.tsx +16 -0
- package/src/components/atomic-crm/activities/ActivityFeed.tsx +212 -0
- package/src/components/atomic-crm/activities/FileUpload.tsx +359 -0
- package/src/components/atomic-crm/contacts/ContactShow.tsx +28 -10
- package/src/components/atomic-crm/integrations/ApiKeysTab.tsx +184 -0
- package/src/components/atomic-crm/integrations/CreateApiKeyDialog.tsx +217 -0
- package/src/components/atomic-crm/integrations/CreateChannelDialog.tsx +139 -0
- package/src/components/atomic-crm/integrations/IngestionChannelsTab.tsx +188 -0
- package/src/components/atomic-crm/integrations/IntegrationsPage.tsx +46 -0
- package/src/components/atomic-crm/integrations/WebhooksTab.tsx +402 -0
- package/src/components/atomic-crm/layout/Header.tsx +14 -1
- package/src/components/atomic-crm/root/CRM.tsx +2 -0
- package/src/components/ui/alert-dialog.tsx +155 -0
- package/src/lib/api-key-utils.ts +22 -0
- package/supabase/fix_webhook_hardcoded.sql +34 -0
- package/supabase/functions/_shared/apiKeyAuth.ts +171 -0
- package/supabase/functions/_shared/ingestionGuard.ts +128 -0
- package/supabase/functions/_shared/utils.ts +1 -1
- package/supabase/functions/_shared/webhookSignature.ts +23 -0
- package/supabase/functions/api-v1-activities/index.ts +137 -0
- package/supabase/functions/api-v1-companies/index.ts +166 -0
- package/supabase/functions/api-v1-contacts/index.ts +171 -0
- package/supabase/functions/api-v1-deals/index.ts +166 -0
- package/supabase/functions/ingest-activity/.well-known/supabase/config.toml +4 -0
- package/supabase/functions/ingest-activity/index.ts +261 -0
- package/supabase/functions/webhook-dispatcher/index.ts +133 -0
- package/supabase/migrations/20251219120000_api_integrations.sql +133 -0
- package/supabase/migrations/20251219120100_webhook_triggers.sql +176 -0
- package/supabase/migrations/20251219120200_webhook_cron.sql +26 -0
- package/supabase/migrations/20251220120000_realtime_ingestion.sql +154 -0
- package/supabase/migrations/20251221000000_contact_matching.sql +94 -0
- package/supabase/migrations/20251221000001_fix_ingestion_providers_rls.sql +23 -0
- package/supabase/migrations/20251221000002_fix_contact_matching_jsonb.sql +67 -0
- package/supabase/migrations/20251221000003_fix_email_matching.sql +70 -0
- package/supabase/migrations/20251221000004_time_based_work_stealing.sql +99 -0
- package/supabase/migrations/20251221000005_realtime_functions.sql +73 -0
- package/supabase/migrations/20251221000006_enable_pg_net.sql +3 -0
- package/supabase/migrations/20251222075019_enable_extensions_and_configure_cron.sql +86 -0
- package/supabase/migrations/20251222094036_large_payload_storage.sql +213 -0
- package/supabase/migrations/20251222094247_large_payload_cron.sql +28 -0
- package/supabase/migrations/20251222220000_fix_large_payload_types.sql +72 -0
- package/supabase/migrations/20251223000000_enable_realtime_all_crm_tables.sql +50 -0
- package/supabase/migrations/20251223185638_remove_large_payload_storage.sql +54 -0
- package/dist/assets/index-CHk72bAf.css +0 -1
- package/dist/assets/index-mhAjQ1_w.js +0 -153
- package/dist/assets/index-mhAjQ1_w.js.map +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
import { buttonVariants } from "@/components/ui/button"
|
|
6
|
+
|
|
7
|
+
function AlertDialog({
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
10
|
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function AlertDialogTrigger({
|
|
14
|
+
...props
|
|
15
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
16
|
+
return (
|
|
17
|
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function AlertDialogPortal({
|
|
22
|
+
...props
|
|
23
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
|
24
|
+
return (
|
|
25
|
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function AlertDialogOverlay({
|
|
30
|
+
className,
|
|
31
|
+
...props
|
|
32
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
|
33
|
+
return (
|
|
34
|
+
<AlertDialogPrimitive.Overlay
|
|
35
|
+
data-slot="alert-dialog-overlay"
|
|
36
|
+
className={cn(
|
|
37
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
{...props}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function AlertDialogContent({
|
|
46
|
+
className,
|
|
47
|
+
...props
|
|
48
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
|
49
|
+
return (
|
|
50
|
+
<AlertDialogPortal>
|
|
51
|
+
<AlertDialogOverlay />
|
|
52
|
+
<AlertDialogPrimitive.Content
|
|
53
|
+
data-slot="alert-dialog-content"
|
|
54
|
+
className={cn(
|
|
55
|
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
|
56
|
+
className
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
</AlertDialogPortal>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function AlertDialogHeader({
|
|
65
|
+
className,
|
|
66
|
+
...props
|
|
67
|
+
}: React.ComponentProps<"div">) {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
data-slot="alert-dialog-header"
|
|
71
|
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function AlertDialogFooter({
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
}: React.ComponentProps<"div">) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
data-slot="alert-dialog-footer"
|
|
84
|
+
className={cn(
|
|
85
|
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
86
|
+
className
|
|
87
|
+
)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function AlertDialogTitle({
|
|
94
|
+
className,
|
|
95
|
+
...props
|
|
96
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
|
97
|
+
return (
|
|
98
|
+
<AlertDialogPrimitive.Title
|
|
99
|
+
data-slot="alert-dialog-title"
|
|
100
|
+
className={cn("text-lg font-semibold", className)}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function AlertDialogDescription({
|
|
107
|
+
className,
|
|
108
|
+
...props
|
|
109
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
|
110
|
+
return (
|
|
111
|
+
<AlertDialogPrimitive.Description
|
|
112
|
+
data-slot="alert-dialog-description"
|
|
113
|
+
className={cn("text-muted-foreground text-sm", className)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function AlertDialogAction({
|
|
120
|
+
className,
|
|
121
|
+
...props
|
|
122
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
|
123
|
+
return (
|
|
124
|
+
<AlertDialogPrimitive.Action
|
|
125
|
+
className={cn(buttonVariants(), className)}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function AlertDialogCancel({
|
|
132
|
+
className,
|
|
133
|
+
...props
|
|
134
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
|
135
|
+
return (
|
|
136
|
+
<AlertDialogPrimitive.Cancel
|
|
137
|
+
className={cn(buttonVariants({ variant: "outline" }), className)}
|
|
138
|
+
{...props}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
AlertDialog,
|
|
145
|
+
AlertDialogPortal,
|
|
146
|
+
AlertDialogOverlay,
|
|
147
|
+
AlertDialogTrigger,
|
|
148
|
+
AlertDialogContent,
|
|
149
|
+
AlertDialogHeader,
|
|
150
|
+
AlertDialogFooter,
|
|
151
|
+
AlertDialogTitle,
|
|
152
|
+
AlertDialogDescription,
|
|
153
|
+
AlertDialogAction,
|
|
154
|
+
AlertDialogCancel,
|
|
155
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a new API key in format: ak_live_<32 hex chars>
|
|
3
|
+
*/
|
|
4
|
+
export function generateApiKey(): string {
|
|
5
|
+
const array = new Uint8Array(16);
|
|
6
|
+
crypto.getRandomValues(array);
|
|
7
|
+
const hex = Array.from(array)
|
|
8
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
9
|
+
.join("");
|
|
10
|
+
return `ak_live_${hex}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hash API key using SHA-256
|
|
15
|
+
*/
|
|
16
|
+
export async function hashApiKey(apiKey: string): Promise<string> {
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
const data = encoder.encode(apiKey);
|
|
19
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
20
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
21
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- ==================================================================================
|
|
2
|
+
-- HARDCODED WEBHOOK FIX (Bypasses Permission Issues)
|
|
3
|
+
-- ==================================================================================
|
|
4
|
+
|
|
5
|
+
-- 1. Ensure extensions are active
|
|
6
|
+
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
|
|
7
|
+
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
|
|
8
|
+
|
|
9
|
+
-- 2. Remove the old dynamic job that relies on missing settings
|
|
10
|
+
SELECT cron.unschedule(jobid)
|
|
11
|
+
FROM cron.job
|
|
12
|
+
WHERE jobname = 'webhook-dispatcher';
|
|
13
|
+
|
|
14
|
+
-- 3. Schedule the new job with HARDCODED credentials
|
|
15
|
+
-- !!! IMPORTANT: Replace [YOUR_SERVICE_ROLE_KEY] below before running !!!
|
|
16
|
+
|
|
17
|
+
SELECT cron.schedule(
|
|
18
|
+
'webhook-dispatcher',
|
|
19
|
+
'* * * * *', -- Run every minute
|
|
20
|
+
$$
|
|
21
|
+
select
|
|
22
|
+
net.http_post(
|
|
23
|
+
url:='https://xydvyhnspkzcsocewhuy.supabase.co/functions/v1/webhook-dispatcher',
|
|
24
|
+
headers:=jsonb_build_object(
|
|
25
|
+
'Content-Type','application/json',
|
|
26
|
+
'Authorization', 'Bearer [YOUR_SERVICE_ROLE_KEY]'
|
|
27
|
+
),
|
|
28
|
+
body:='{}'::jsonb
|
|
29
|
+
) as request_id;
|
|
30
|
+
$$
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- 4. Check that the job is scheduled
|
|
34
|
+
SELECT jobid, jobname, command FROM cron.job WHERE jobname = 'webhook-dispatcher';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { supabaseAdmin } from "./supabaseAdmin.ts";
|
|
2
|
+
import { createErrorResponse } from "./utils.ts";
|
|
3
|
+
|
|
4
|
+
// Rate limiting: Simple in-memory store (resets on function restart)
|
|
5
|
+
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
|
6
|
+
|
|
7
|
+
const RATE_LIMIT_MAX = 100; // requests per window
|
|
8
|
+
const RATE_LIMIT_WINDOW = 60 * 1000; // 60 seconds
|
|
9
|
+
|
|
10
|
+
interface ApiKey {
|
|
11
|
+
id: number;
|
|
12
|
+
sales_id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
scopes: string[];
|
|
15
|
+
is_active: boolean;
|
|
16
|
+
expires_at: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract and validate API key from request
|
|
21
|
+
* Returns api key record or error response
|
|
22
|
+
*/
|
|
23
|
+
export async function validateApiKey(
|
|
24
|
+
req: Request
|
|
25
|
+
): Promise<{ apiKey: ApiKey } | Response> {
|
|
26
|
+
const authHeader = req.headers.get("Authorization");
|
|
27
|
+
|
|
28
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
29
|
+
return createErrorResponse(401, "Missing or invalid Authorization header");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const apiKeyValue = authHeader.replace("Bearer ", "");
|
|
33
|
+
|
|
34
|
+
if (!apiKeyValue.startsWith("ak_live_")) {
|
|
35
|
+
return createErrorResponse(401, "Invalid API key format");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Hash the API key for lookup
|
|
39
|
+
const encoder = new TextEncoder();
|
|
40
|
+
const data = encoder.encode(apiKeyValue);
|
|
41
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
42
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
43
|
+
const keyHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
|
|
44
|
+
""
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Lookup API key in database
|
|
48
|
+
const { data: apiKey, error } = await supabaseAdmin
|
|
49
|
+
.from("api_keys")
|
|
50
|
+
.select("id, sales_id, name, scopes, is_active, expires_at")
|
|
51
|
+
.eq("key_hash", keyHash)
|
|
52
|
+
.single();
|
|
53
|
+
|
|
54
|
+
if (error || !apiKey) {
|
|
55
|
+
return createErrorResponse(401, "Invalid API key");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check if active
|
|
59
|
+
if (!apiKey.is_active) {
|
|
60
|
+
return createErrorResponse(401, "API key is disabled");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Check expiration
|
|
64
|
+
if (apiKey.expires_at && new Date(apiKey.expires_at) < new Date()) {
|
|
65
|
+
return createErrorResponse(401, "API key has expired");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Update last_used_at (fire and forget)
|
|
69
|
+
supabaseAdmin
|
|
70
|
+
.from("api_keys")
|
|
71
|
+
.update({ last_used_at: new Date().toISOString() })
|
|
72
|
+
.eq("id", apiKey.id)
|
|
73
|
+
.then(() => {});
|
|
74
|
+
|
|
75
|
+
return { apiKey };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check rate limit for API key
|
|
80
|
+
*/
|
|
81
|
+
export function checkRateLimit(apiKeyId: number): Response | null {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const key = `ratelimit:${apiKeyId}`;
|
|
84
|
+
|
|
85
|
+
let bucket = rateLimitStore.get(key);
|
|
86
|
+
|
|
87
|
+
if (!bucket || now > bucket.resetAt) {
|
|
88
|
+
// Create new bucket
|
|
89
|
+
bucket = {
|
|
90
|
+
count: 1,
|
|
91
|
+
resetAt: now + RATE_LIMIT_WINDOW,
|
|
92
|
+
};
|
|
93
|
+
rateLimitStore.set(key, bucket);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (bucket.count >= RATE_LIMIT_MAX) {
|
|
98
|
+
const retryAfter = Math.ceil((bucket.resetAt - now) / 1000);
|
|
99
|
+
return new Response(
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
status: 429,
|
|
102
|
+
message: "Rate limit exceeded",
|
|
103
|
+
retry_after: retryAfter,
|
|
104
|
+
}),
|
|
105
|
+
{
|
|
106
|
+
status: 429,
|
|
107
|
+
headers: {
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"Retry-After": retryAfter.toString(),
|
|
110
|
+
"X-RateLimit-Limit": RATE_LIMIT_MAX.toString(),
|
|
111
|
+
"X-RateLimit-Remaining": "0",
|
|
112
|
+
"X-RateLimit-Reset": bucket.resetAt.toString(),
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
bucket.count++;
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if API key has required scope
|
|
125
|
+
*/
|
|
126
|
+
export function hasScope(apiKey: ApiKey, requiredScope: string): boolean {
|
|
127
|
+
// Check for wildcard scope
|
|
128
|
+
if (apiKey.scopes.includes("*")) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check for exact scope match
|
|
133
|
+
if (apiKey.scopes.includes(requiredScope)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for resource wildcard (e.g., "contacts:*" matches "contacts:read")
|
|
138
|
+
const [resource] = requiredScope.split(":");
|
|
139
|
+
if (apiKey.scopes.includes(`${resource}:*`)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Log API request
|
|
148
|
+
*/
|
|
149
|
+
export async function logApiRequest(
|
|
150
|
+
apiKeyId: number,
|
|
151
|
+
endpoint: string,
|
|
152
|
+
method: string,
|
|
153
|
+
statusCode: number,
|
|
154
|
+
responseTimeMs: number,
|
|
155
|
+
req: Request,
|
|
156
|
+
errorMessage?: string
|
|
157
|
+
) {
|
|
158
|
+
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
159
|
+
const userAgent = req.headers.get("user-agent");
|
|
160
|
+
|
|
161
|
+
await supabaseAdmin.from("api_logs").insert({
|
|
162
|
+
api_key_id: apiKeyId,
|
|
163
|
+
endpoint,
|
|
164
|
+
method,
|
|
165
|
+
status_code: statusCode,
|
|
166
|
+
response_time_ms: responseTimeMs,
|
|
167
|
+
ip_address: ip,
|
|
168
|
+
user_agent: userAgent,
|
|
169
|
+
error_message: errorMessage,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createErrorResponse } from "./utils.ts";
|
|
2
|
+
import { supabaseAdmin } from "./supabaseAdmin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates the request signature or API key.
|
|
6
|
+
* Returns the resolved provider config or an error response.
|
|
7
|
+
* Note: For Twilio, signature validation must be done separately after body parsing
|
|
8
|
+
* using validateTwilioWebhook()
|
|
9
|
+
*/
|
|
10
|
+
export async function validateIngestionRequest(req: Request) {
|
|
11
|
+
const url = new URL(req.url);
|
|
12
|
+
const providerCode = url.searchParams.get("provider");
|
|
13
|
+
|
|
14
|
+
// Check for ingestion key in header (preferred) or URL query param (backward compatibility)
|
|
15
|
+
const ingestionKey = req.headers.get("x-ingestion-key") || url.searchParams.get("key");
|
|
16
|
+
|
|
17
|
+
// 1. Internal/Manual API Key (Bearer)
|
|
18
|
+
const authHeader = req.headers.get("Authorization");
|
|
19
|
+
if (authHeader?.startsWith("Bearer ak_live_")) {
|
|
20
|
+
// Validate Internal API Key (already implemented in apiKeyAuth.ts)
|
|
21
|
+
// For V1, we trust internal keys as "generic" provider
|
|
22
|
+
return { provider: { id: null, provider_code: "generic", sales_id: null } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Identify Provider by 'ingestion_key' (Preferred)
|
|
26
|
+
if (ingestionKey) {
|
|
27
|
+
const { data: provider, error } = await supabaseAdmin
|
|
28
|
+
.from("ingestion_providers")
|
|
29
|
+
.select("*")
|
|
30
|
+
.eq("ingestion_key", ingestionKey)
|
|
31
|
+
.eq("is_active", true)
|
|
32
|
+
.single();
|
|
33
|
+
|
|
34
|
+
if (error || !provider) {
|
|
35
|
+
return { error: createErrorResponse(401, "Invalid Ingestion Key") };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { provider };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Fallback: Identify by Query Param (Legacy/Public Webhooks)
|
|
42
|
+
if (providerCode === "twilio") {
|
|
43
|
+
// In this case, we need to find WHICH Twilio config to use.
|
|
44
|
+
// Usually, we match by the 'To' phone number in the body, but that requires parsing the body first.
|
|
45
|
+
// For strict security, we REJECT requests without an ingestion_key in the URL.
|
|
46
|
+
return { error: createErrorResponse(401, "Missing 'key' parameter in webhook URL") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { error: createErrorResponse(400, "Unknown Provider or Missing Authentication") };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates a Twilio webhook request after body parsing
|
|
54
|
+
* Call this after validateIngestionRequest() and body parsing
|
|
55
|
+
*/
|
|
56
|
+
export async function validateTwilioWebhook(
|
|
57
|
+
req: Request,
|
|
58
|
+
authToken: string,
|
|
59
|
+
body: Record<string, any>
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
return await validateTwilioSignature(req, authToken, body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validates Twilio X-Twilio-Signature using HMAC-SHA1
|
|
66
|
+
* Implementation follows Twilio's security specification:
|
|
67
|
+
* https://www.twilio.com/docs/usage/security#validating-requests
|
|
68
|
+
*/
|
|
69
|
+
async function validateTwilioSignature(
|
|
70
|
+
req: Request,
|
|
71
|
+
authToken: string,
|
|
72
|
+
body: Record<string, any>
|
|
73
|
+
): Promise<boolean> {
|
|
74
|
+
const signature = req.headers.get("X-Twilio-Signature");
|
|
75
|
+
if (!signature) return false;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// 1. Get the full URL (including protocol, host, path, and query params)
|
|
79
|
+
const url = req.url;
|
|
80
|
+
|
|
81
|
+
// 2. Sort POST parameters alphabetically and concatenate
|
|
82
|
+
const sortedParams = Object.keys(body)
|
|
83
|
+
.sort()
|
|
84
|
+
.map((key) => `${key}${body[key]}`)
|
|
85
|
+
.join("");
|
|
86
|
+
|
|
87
|
+
// 3. Concatenate URL + sorted params
|
|
88
|
+
const data = url + sortedParams;
|
|
89
|
+
|
|
90
|
+
// 4. Compute HMAC-SHA1
|
|
91
|
+
const encoder = new TextEncoder();
|
|
92
|
+
const keyData = encoder.encode(authToken);
|
|
93
|
+
const messageData = encoder.encode(data);
|
|
94
|
+
|
|
95
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
96
|
+
"raw",
|
|
97
|
+
keyData,
|
|
98
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
99
|
+
false,
|
|
100
|
+
["sign"]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
|
|
104
|
+
|
|
105
|
+
// 5. Convert to Base64
|
|
106
|
+
const signatureArray = Array.from(new Uint8Array(signatureBuffer));
|
|
107
|
+
const signatureBase64 = btoa(String.fromCharCode(...signatureArray));
|
|
108
|
+
|
|
109
|
+
// 6. Constant-time comparison to prevent timing attacks
|
|
110
|
+
return constantTimeCompare(signature, signatureBase64);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error("Twilio signature validation error:", error);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Constant-time string comparison to prevent timing attacks
|
|
119
|
+
*/
|
|
120
|
+
function constantTimeCompare(a: string, b: string): boolean {
|
|
121
|
+
if (a.length !== b.length) return false;
|
|
122
|
+
|
|
123
|
+
let result = 0;
|
|
124
|
+
for (let i = 0; i < a.length; i++) {
|
|
125
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
126
|
+
}
|
|
127
|
+
return result === 0;
|
|
128
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const corsHeaders = {
|
|
2
2
|
"Access-Control-Allow-Origin": "*",
|
|
3
3
|
"Access-Control-Allow-Headers":
|
|
4
|
-
"authorization, x-client-info, apikey, content-type",
|
|
4
|
+
"authorization, x-client-info, apikey, content-type, x-ingestion-key",
|
|
5
5
|
"Access-Control-Allow-Methods": "POST, PATCH, PUT, DELETE",
|
|
6
6
|
};
|
|
7
7
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate HMAC-SHA256 signature for webhook payload
|
|
3
|
+
*/
|
|
4
|
+
export async function generateWebhookSignature(
|
|
5
|
+
secret: string,
|
|
6
|
+
payload: string
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const keyData = encoder.encode(secret);
|
|
10
|
+
const messageData = encoder.encode(payload);
|
|
11
|
+
|
|
12
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
13
|
+
"raw",
|
|
14
|
+
keyData,
|
|
15
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
16
|
+
false,
|
|
17
|
+
["sign"]
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
|
|
21
|
+
const hashArray = Array.from(new Uint8Array(signature));
|
|
22
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
|
2
|
+
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
|
|
3
|
+
import { corsHeaders, createErrorResponse } from "../_shared/utils.ts";
|
|
4
|
+
import {
|
|
5
|
+
validateApiKey,
|
|
6
|
+
checkRateLimit,
|
|
7
|
+
hasScope,
|
|
8
|
+
logApiRequest,
|
|
9
|
+
} from "../_shared/apiKeyAuth.ts";
|
|
10
|
+
|
|
11
|
+
Deno.serve(async (req: Request) => {
|
|
12
|
+
const startTime = Date.now();
|
|
13
|
+
|
|
14
|
+
if (req.method === "OPTIONS") {
|
|
15
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const authResult = await validateApiKey(req);
|
|
19
|
+
if ("status" in authResult) {
|
|
20
|
+
return authResult;
|
|
21
|
+
}
|
|
22
|
+
const { apiKey } = authResult;
|
|
23
|
+
|
|
24
|
+
const rateLimitError = checkRateLimit(apiKey.id);
|
|
25
|
+
if (rateLimitError) {
|
|
26
|
+
await logApiRequest(
|
|
27
|
+
apiKey.id,
|
|
28
|
+
"/v1/activities",
|
|
29
|
+
req.method,
|
|
30
|
+
429,
|
|
31
|
+
Date.now() - startTime,
|
|
32
|
+
req
|
|
33
|
+
);
|
|
34
|
+
return rateLimitError;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (req.method === "POST") {
|
|
39
|
+
const response = await createActivity(apiKey, req);
|
|
40
|
+
const responseTime = Date.now() - startTime;
|
|
41
|
+
await logApiRequest(
|
|
42
|
+
apiKey.id,
|
|
43
|
+
"/v1/activities",
|
|
44
|
+
req.method,
|
|
45
|
+
response.status,
|
|
46
|
+
responseTime,
|
|
47
|
+
req
|
|
48
|
+
);
|
|
49
|
+
return response;
|
|
50
|
+
} else {
|
|
51
|
+
return createErrorResponse(404, "Not found");
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const responseTime = Date.now() - startTime;
|
|
55
|
+
await logApiRequest(
|
|
56
|
+
apiKey.id,
|
|
57
|
+
"/v1/activities",
|
|
58
|
+
req.method,
|
|
59
|
+
500,
|
|
60
|
+
responseTime,
|
|
61
|
+
req,
|
|
62
|
+
error.message
|
|
63
|
+
);
|
|
64
|
+
return createErrorResponse(500, "Internal server error");
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
async function createActivity(apiKey: any, req: Request) {
|
|
69
|
+
if (!hasScope(apiKey, "activities:write")) {
|
|
70
|
+
return createErrorResponse(403, "Insufficient permissions");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const body = await req.json();
|
|
74
|
+
const { type, ...activityData } = body;
|
|
75
|
+
|
|
76
|
+
// Activities can be notes or tasks
|
|
77
|
+
if (type === "note" || type === "contact_note") {
|
|
78
|
+
const { data, error } = await supabaseAdmin
|
|
79
|
+
.from("contactNotes")
|
|
80
|
+
.insert({
|
|
81
|
+
...activityData,
|
|
82
|
+
sales_id: apiKey.sales_id,
|
|
83
|
+
})
|
|
84
|
+
.select()
|
|
85
|
+
.single();
|
|
86
|
+
|
|
87
|
+
if (error) {
|
|
88
|
+
return createErrorResponse(400, error.message);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new Response(JSON.stringify({ data, type: "note" }), {
|
|
92
|
+
status: 201,
|
|
93
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
94
|
+
});
|
|
95
|
+
} else if (type === "task") {
|
|
96
|
+
const { data, error } = await supabaseAdmin
|
|
97
|
+
.from("tasks")
|
|
98
|
+
.insert({
|
|
99
|
+
...activityData,
|
|
100
|
+
sales_id: apiKey.sales_id,
|
|
101
|
+
})
|
|
102
|
+
.select()
|
|
103
|
+
.single();
|
|
104
|
+
|
|
105
|
+
if (error) {
|
|
106
|
+
return createErrorResponse(400, error.message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Response(JSON.stringify({ data, type: "task" }), {
|
|
110
|
+
status: 201,
|
|
111
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
112
|
+
});
|
|
113
|
+
} else if (type === "deal_note") {
|
|
114
|
+
const { data, error } = await supabaseAdmin
|
|
115
|
+
.from("dealNotes")
|
|
116
|
+
.insert({
|
|
117
|
+
...activityData,
|
|
118
|
+
sales_id: apiKey.sales_id,
|
|
119
|
+
})
|
|
120
|
+
.select()
|
|
121
|
+
.single();
|
|
122
|
+
|
|
123
|
+
if (error) {
|
|
124
|
+
return createErrorResponse(400, error.message);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new Response(JSON.stringify({ data, type: "deal_note" }), {
|
|
128
|
+
status: 201,
|
|
129
|
+
headers: { "Content-Type": "application/json", ...corsHeaders },
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
return createErrorResponse(
|
|
133
|
+
400,
|
|
134
|
+
"Invalid activity type. Must be 'note', 'contact_note', 'task', or 'deal_note'"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|