launchbase 1.2.2 → 1.3.0
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/launchbase.js +1 -1
- package/package.json +1 -1
- package/template/frontend/src/lib/api.ts +74 -0
- package/template/prisma/schema.prisma +67 -1
- package/template/src/modules/api-keys/api-keys.controller.ts +47 -0
- package/template/src/modules/api-keys/api-keys.module.ts +12 -0
- package/template/src/modules/api-keys/api-keys.service.ts +143 -0
- package/template/src/modules/api-keys/dto/create-api-key.dto.ts +15 -0
- package/template/src/modules/app/app.module.ts +6 -0
- package/template/src/modules/auth/auth.service.ts +2 -2
- package/template/src/modules/billing/billing.service.ts +1 -1
- package/template/src/modules/common/api-key.guard.ts +53 -0
- package/template/src/modules/deployments/dto/create-deployment.dto.ts +1 -1
- package/template/src/modules/edge-functions/edge-functions.service.ts +6 -5
- package/template/src/modules/files/files.service.ts +52 -1
- package/template/src/modules/providers/dto/create-provider-credential.dto.ts +17 -0
- package/template/src/modules/providers/providers.controller.ts +46 -0
- package/template/src/modules/providers/providers.module.ts +12 -0
- package/template/src/modules/providers/providers.service.ts +112 -0
- package/template/src/modules/webhooks/dto/create-webhook.dto.ts +14 -0
- package/template/src/modules/webhooks/webhooks.controller.ts +57 -0
- package/template/src/modules/webhooks/webhooks.module.ts +12 -0
- package/template/src/modules/webhooks/webhooks.service.ts +296 -0
package/bin/launchbase.js
CHANGED
|
@@ -7,7 +7,7 @@ const crypto = require('crypto');
|
|
|
7
7
|
const fs = require('fs-extra');
|
|
8
8
|
const { execSync, spawn } = require('child_process');
|
|
9
9
|
|
|
10
|
-
const VERSION = '1.
|
|
10
|
+
const VERSION = '1.3.0';
|
|
11
11
|
const program = new Command();
|
|
12
12
|
|
|
13
13
|
function findAvailablePort(startPort = 5432, maxAttempts = 100) {
|
package/package.json
CHANGED
|
@@ -263,3 +263,77 @@ export const deploymentsApi = {
|
|
|
263
263
|
return res.data
|
|
264
264
|
},
|
|
265
265
|
}
|
|
266
|
+
|
|
267
|
+
// API Keys API
|
|
268
|
+
export const apiKeysApi = {
|
|
269
|
+
list: async (projectId: string) => {
|
|
270
|
+
const res = await api.get(`/api/projects/${projectId}/api-keys`)
|
|
271
|
+
return res.data
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
create: async (projectId: string, data: { name: string; permissions: string[]; expiresAt?: string }) => {
|
|
275
|
+
const res = await api.post(`/api/projects/${projectId}/api-keys`, data)
|
|
276
|
+
return res.data
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
get: async (projectId: string, keyId: string) => {
|
|
280
|
+
const res = await api.get(`/api/projects/${projectId}/api-keys/${keyId}`)
|
|
281
|
+
return res.data
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
delete: async (projectId: string, keyId: string) => {
|
|
285
|
+
const res = await api.delete(`/api/projects/${projectId}/api-keys/${keyId}`)
|
|
286
|
+
return res.data
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Webhooks API
|
|
291
|
+
export const webhooksApi = {
|
|
292
|
+
list: async (projectId: string) => {
|
|
293
|
+
const res = await api.get(`/api/projects/${projectId}/webhooks`)
|
|
294
|
+
return res.data
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
create: async (projectId: string, data: { name: string; url: string; events: string[] }) => {
|
|
298
|
+
const res = await api.post(`/api/projects/${projectId}/webhooks`, data)
|
|
299
|
+
return res.data
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
get: async (projectId: string, webhookId: string) => {
|
|
303
|
+
const res = await api.get(`/api/projects/${projectId}/webhooks/${webhookId}`)
|
|
304
|
+
return res.data
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
delete: async (projectId: string, webhookId: string) => {
|
|
308
|
+
const res = await api.delete(`/api/projects/${projectId}/webhooks/${webhookId}`)
|
|
309
|
+
return res.data
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
emit: async (projectId: string, eventType: string, data: Record<string, unknown>) => {
|
|
313
|
+
const res = await api.post(`/api/projects/${projectId}/webhooks/emit/${eventType}`, data)
|
|
314
|
+
return res.data
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Providers API
|
|
319
|
+
export const providersApi = {
|
|
320
|
+
list: async () => {
|
|
321
|
+
const res = await api.get('/api/providers')
|
|
322
|
+
return res.data
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
connect: async (data: { provider: string; accessToken: string; refreshToken?: string; expiresAt?: string }) => {
|
|
326
|
+
const res = await api.post('/api/providers', data)
|
|
327
|
+
return res.data
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
get: async (provider: string) => {
|
|
331
|
+
const res = await api.get(`/api/providers/${provider}`)
|
|
332
|
+
return res.data
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
disconnect: async (provider: string) => {
|
|
336
|
+
const res = await api.delete(`/api/providers/${provider}`)
|
|
337
|
+
return res.data
|
|
338
|
+
},
|
|
339
|
+
}
|
|
@@ -39,6 +39,7 @@ model User {
|
|
|
39
39
|
passwordResetTokens PasswordResetToken[]
|
|
40
40
|
oauthAccounts OAuthAccount[]
|
|
41
41
|
files File[]
|
|
42
|
+
providerCredentials ProviderCredential[]
|
|
42
43
|
|
|
43
44
|
@@index([email])
|
|
44
45
|
@@index([oauthProvider, oauthId])
|
|
@@ -96,6 +97,8 @@ model Project {
|
|
|
96
97
|
vectorCollections VectorCollection[]
|
|
97
98
|
deployments Deployment[]
|
|
98
99
|
dbCollections DBCollection[]
|
|
100
|
+
apiKeys ProjectApiKey[]
|
|
101
|
+
webhooks ProjectWebhook[]
|
|
99
102
|
|
|
100
103
|
@@unique([organizationId, slug])
|
|
101
104
|
@@index([organizationId])
|
|
@@ -111,6 +114,7 @@ model EdgeFunction {
|
|
|
111
114
|
projectId String
|
|
112
115
|
name String
|
|
113
116
|
slug String
|
|
117
|
+
description String?
|
|
114
118
|
runtime String @default("nodejs18")
|
|
115
119
|
sourceCode String
|
|
116
120
|
environment Json?
|
|
@@ -118,6 +122,7 @@ model EdgeFunction {
|
|
|
118
122
|
status String @default("draft")
|
|
119
123
|
deployedAt DateTime?
|
|
120
124
|
deploymentUrl String?
|
|
125
|
+
deploymentId String?
|
|
121
126
|
createdAt DateTime @default(now())
|
|
122
127
|
updatedAt DateTime @updatedAt
|
|
123
128
|
logs EdgeFunctionLog[]
|
|
@@ -152,6 +157,7 @@ model VectorCollection {
|
|
|
152
157
|
id String @id @default(cuid())
|
|
153
158
|
projectId String
|
|
154
159
|
name String
|
|
160
|
+
description String?
|
|
155
161
|
provider String @default("local")
|
|
156
162
|
dimension Int @default(1536)
|
|
157
163
|
embeddingModel String @default("text-embedding-3-small")
|
|
@@ -169,7 +175,9 @@ model Deployment {
|
|
|
169
175
|
id String @id @default(cuid())
|
|
170
176
|
projectId String
|
|
171
177
|
status String @default("pending")
|
|
172
|
-
version String
|
|
178
|
+
version String
|
|
179
|
+
description String?
|
|
180
|
+
error String?
|
|
173
181
|
deployedAt DateTime?
|
|
174
182
|
platform String?
|
|
175
183
|
url String?
|
|
@@ -183,6 +191,64 @@ model Deployment {
|
|
|
183
191
|
@@index([status])
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
// Project-scoped API keys for programmatic access
|
|
195
|
+
model ProjectApiKey {
|
|
196
|
+
id String @id @default(cuid())
|
|
197
|
+
projectId String
|
|
198
|
+
name String
|
|
199
|
+
keyHash String @unique
|
|
200
|
+
prefix String // e.g., "lb_live_" or "lb_test_"
|
|
201
|
+
permissions Json // ["read", "write", "admin"]
|
|
202
|
+
lastUsed DateTime?
|
|
203
|
+
expiresAt DateTime?
|
|
204
|
+
createdAt DateTime @default(now())
|
|
205
|
+
updatedAt DateTime @updatedAt
|
|
206
|
+
|
|
207
|
+
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
208
|
+
|
|
209
|
+
@@index([projectId])
|
|
210
|
+
@@index([keyHash])
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Project-scoped webhooks for event notifications
|
|
214
|
+
model ProjectWebhook {
|
|
215
|
+
id String @id @default(cuid())
|
|
216
|
+
projectId String
|
|
217
|
+
name String
|
|
218
|
+
url String
|
|
219
|
+
events Json // ["user.created", "file.uploaded", "*"]
|
|
220
|
+
secret String // For HMAC signature verification
|
|
221
|
+
status String @default("active") // active, inactive, failing
|
|
222
|
+
lastTriggered DateTime?
|
|
223
|
+
successCount Int @default(0)
|
|
224
|
+
failCount Int @default(0)
|
|
225
|
+
createdAt DateTime @default(now())
|
|
226
|
+
updatedAt DateTime @updatedAt
|
|
227
|
+
|
|
228
|
+
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
229
|
+
|
|
230
|
+
@@index([projectId])
|
|
231
|
+
@@index([status])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Hosting provider credentials for one-click deployments
|
|
235
|
+
model ProviderCredential {
|
|
236
|
+
id String @id @default(cuid())
|
|
237
|
+
userId String
|
|
238
|
+
provider String // render, railway, vercel, flyio, firebase, supabase
|
|
239
|
+
accessToken String
|
|
240
|
+
refreshToken String?
|
|
241
|
+
expiresAt DateTime?
|
|
242
|
+
createdAt DateTime @default(now())
|
|
243
|
+
updatedAt DateTime @updatedAt
|
|
244
|
+
|
|
245
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
246
|
+
|
|
247
|
+
@@unique([userId, provider])
|
|
248
|
+
@@index([userId])
|
|
249
|
+
@@index([provider])
|
|
250
|
+
}
|
|
251
|
+
|
|
186
252
|
// ============================================
|
|
187
253
|
// Auth & Session Models
|
|
188
254
|
// ============================================
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Controller, Post, Get, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
|
2
|
+
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
3
|
+
import { JwtAuthGuard } from '../common/jwt-auth.guard';
|
|
4
|
+
import { TenantGuard } from '../common/tenant.guard';
|
|
5
|
+
import { ApiKeysService } from './api-keys.service';
|
|
6
|
+
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
|
7
|
+
|
|
8
|
+
@ApiTags('api-keys')
|
|
9
|
+
@ApiBearerAuth()
|
|
10
|
+
@UseGuards(JwtAuthGuard, TenantGuard)
|
|
11
|
+
@Controller('projects/:projectId/api-keys')
|
|
12
|
+
export class ApiKeysController {
|
|
13
|
+
constructor(private readonly apiKeysService: ApiKeysService) {}
|
|
14
|
+
|
|
15
|
+
@Post()
|
|
16
|
+
@ApiOperation({ summary: 'Create a new API key' })
|
|
17
|
+
async create(
|
|
18
|
+
@Param('projectId') projectId: string,
|
|
19
|
+
@Body() dto: CreateApiKeyDto,
|
|
20
|
+
) {
|
|
21
|
+
return this.apiKeysService.create(projectId, dto);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Get()
|
|
25
|
+
@ApiOperation({ summary: 'List all API keys' })
|
|
26
|
+
async findAll(@Param('projectId') projectId: string) {
|
|
27
|
+
return this.apiKeysService.findAll(projectId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Get(':keyId')
|
|
31
|
+
@ApiOperation({ summary: 'Get API key details' })
|
|
32
|
+
async findOne(
|
|
33
|
+
@Param('projectId') projectId: string,
|
|
34
|
+
@Param('keyId') keyId: string,
|
|
35
|
+
) {
|
|
36
|
+
return this.apiKeysService.findOne(projectId, keyId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Delete(':keyId')
|
|
40
|
+
@ApiOperation({ summary: 'Delete an API key' })
|
|
41
|
+
async remove(
|
|
42
|
+
@Param('projectId') projectId: string,
|
|
43
|
+
@Param('keyId') keyId: string,
|
|
44
|
+
) {
|
|
45
|
+
return this.apiKeysService.remove(projectId, keyId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ApiKeysController } from './api-keys.controller';
|
|
3
|
+
import { ApiKeysService } from './api-keys.service';
|
|
4
|
+
import { PrismaModule } from '../prisma/prisma.module';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [PrismaModule],
|
|
8
|
+
controllers: [ApiKeysController],
|
|
9
|
+
providers: [ApiKeysService],
|
|
10
|
+
exports: [ApiKeysService],
|
|
11
|
+
})
|
|
12
|
+
export class ApiKeysModule {}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
3
|
+
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
|
4
|
+
import * as crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class ApiKeysService {
|
|
8
|
+
constructor(private prisma: PrismaService) {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a new API key with lb_ prefix
|
|
12
|
+
*/
|
|
13
|
+
async create(projectId: string, dto: CreateApiKeyDto) {
|
|
14
|
+
// Generate key: lb_live_xxxxxxxxxxxx or lb_test_xxxxxxxxxxxx
|
|
15
|
+
const keyBytes = crypto.randomBytes(24).toString('base64url');
|
|
16
|
+
const rawKey = `lb_live_${keyBytes}`;
|
|
17
|
+
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
18
|
+
const prefix = rawKey.substring(0, 12); // "lb_live_xxx"
|
|
19
|
+
|
|
20
|
+
const apiKey = await this.prisma.projectApiKey.create({
|
|
21
|
+
data: {
|
|
22
|
+
projectId,
|
|
23
|
+
name: dto.name,
|
|
24
|
+
keyHash,
|
|
25
|
+
prefix,
|
|
26
|
+
permissions: dto.permissions,
|
|
27
|
+
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Return the raw key ONCE - cannot be retrieved again
|
|
32
|
+
return {
|
|
33
|
+
id: apiKey.id,
|
|
34
|
+
name: apiKey.name,
|
|
35
|
+
key: rawKey, // Only time this is shown
|
|
36
|
+
prefix: apiKey.prefix,
|
|
37
|
+
permissions: apiKey.permissions,
|
|
38
|
+
expiresAt: apiKey.expiresAt,
|
|
39
|
+
createdAt: apiKey.createdAt,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findAll(projectId: string) {
|
|
44
|
+
return this.prisma.projectApiKey.findMany({
|
|
45
|
+
where: { projectId },
|
|
46
|
+
select: {
|
|
47
|
+
id: true,
|
|
48
|
+
name: true,
|
|
49
|
+
prefix: true,
|
|
50
|
+
permissions: true,
|
|
51
|
+
lastUsed: true,
|
|
52
|
+
expiresAt: true,
|
|
53
|
+
createdAt: true,
|
|
54
|
+
},
|
|
55
|
+
orderBy: { createdAt: 'desc' },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async findOne(projectId: string, keyId: string) {
|
|
60
|
+
const apiKey = await this.prisma.projectApiKey.findFirst({
|
|
61
|
+
where: { id: keyId, projectId },
|
|
62
|
+
select: {
|
|
63
|
+
id: true,
|
|
64
|
+
name: true,
|
|
65
|
+
prefix: true,
|
|
66
|
+
permissions: true,
|
|
67
|
+
lastUsed: true,
|
|
68
|
+
expiresAt: true,
|
|
69
|
+
createdAt: true,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!apiKey) {
|
|
74
|
+
throw new NotFoundException('API key not found');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return apiKey;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async remove(projectId: string, keyId: string) {
|
|
81
|
+
await this.findOne(projectId, keyId);
|
|
82
|
+
|
|
83
|
+
await this.prisma.projectApiKey.delete({
|
|
84
|
+
where: { id: keyId },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { success: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate an API key and return context
|
|
92
|
+
* Used by the API key guard
|
|
93
|
+
*/
|
|
94
|
+
async validateKey(rawKey: string): Promise<{
|
|
95
|
+
projectId: string;
|
|
96
|
+
keyId: string;
|
|
97
|
+
permissions: string[];
|
|
98
|
+
} | null> {
|
|
99
|
+
if (!rawKey.startsWith('lb_')) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
104
|
+
|
|
105
|
+
const apiKey = await this.prisma.projectApiKey.findUnique({
|
|
106
|
+
where: { keyHash },
|
|
107
|
+
select: {
|
|
108
|
+
id: true,
|
|
109
|
+
projectId: true,
|
|
110
|
+
permissions: true,
|
|
111
|
+
expiresAt: true,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!apiKey) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check expiration
|
|
120
|
+
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Update lastUsed asynchronously (don't await)
|
|
125
|
+
this.prisma.projectApiKey.update({
|
|
126
|
+
where: { id: apiKey.id },
|
|
127
|
+
data: { lastUsed: new Date() },
|
|
128
|
+
}).catch(() => {});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
projectId: apiKey.projectId,
|
|
132
|
+
keyId: apiKey.id,
|
|
133
|
+
permissions: apiKey.permissions as string[],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Check if API key has a specific permission
|
|
139
|
+
*/
|
|
140
|
+
hasPermission(apiKey: { permissions: string[] }, permission: string): boolean {
|
|
141
|
+
return apiKey.permissions.includes('admin') || apiKey.permissions.includes(permission);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { IsString, IsOptional, IsArray, IsDateString, MaxLength } from 'class-validator';
|
|
2
|
+
|
|
3
|
+
export class CreateApiKeyDto {
|
|
4
|
+
@IsString()
|
|
5
|
+
@MaxLength(100)
|
|
6
|
+
name!: string;
|
|
7
|
+
|
|
8
|
+
@IsArray()
|
|
9
|
+
@IsString({ each: true })
|
|
10
|
+
permissions!: string[]; // ["read", "write", "admin"]
|
|
11
|
+
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsDateString()
|
|
14
|
+
expiresAt?: string;
|
|
15
|
+
}
|
|
@@ -18,6 +18,9 @@ import { AiModule } from '../ai/ai.module';
|
|
|
18
18
|
import { EdgeFunctionsModule } from '../edge-functions/edge-functions.module';
|
|
19
19
|
import { VectorModule } from '../vector/vector.module';
|
|
20
20
|
import { DeploymentsModule } from '../deployments/deployments.module';
|
|
21
|
+
import { ApiKeysModule } from '../api-keys/api-keys.module';
|
|
22
|
+
import { WebhooksModule } from '../webhooks/webhooks.module';
|
|
23
|
+
import { ProvidersModule } from '../providers/providers.module';
|
|
21
24
|
|
|
22
25
|
// QueueModule is optional - only loaded when Redis is configured
|
|
23
26
|
let QueueModule: any = null;
|
|
@@ -54,6 +57,9 @@ try {
|
|
|
54
57
|
EdgeFunctionsModule,
|
|
55
58
|
VectorModule,
|
|
56
59
|
DeploymentsModule,
|
|
60
|
+
ApiKeysModule,
|
|
61
|
+
WebhooksModule,
|
|
62
|
+
ProvidersModule,
|
|
57
63
|
],
|
|
58
64
|
providers: [
|
|
59
65
|
{
|
|
@@ -117,7 +117,7 @@ export class AuthService {
|
|
|
117
117
|
const user = await this.prisma.user.findUnique({
|
|
118
118
|
where: { email: dto.email },
|
|
119
119
|
include: {
|
|
120
|
-
|
|
120
|
+
memberships: {
|
|
121
121
|
include: {
|
|
122
122
|
organization: {
|
|
123
123
|
include: { projects: { take: 1 } }
|
|
@@ -134,7 +134,7 @@ export class AuthService {
|
|
|
134
134
|
const tokens = await this.issueTokens({ id: user.id, email: user.email });
|
|
135
135
|
|
|
136
136
|
// Get first org and project for the user
|
|
137
|
-
const firstMembership = user.
|
|
137
|
+
const firstMembership = user.memberships[0];
|
|
138
138
|
const firstOrg = firstMembership?.organization;
|
|
139
139
|
const firstProject = firstOrg?.projects[0];
|
|
140
140
|
|
|
@@ -191,7 +191,7 @@ export class BillingService {
|
|
|
191
191
|
if (type === 'members' && limits.members === -1) return true;
|
|
192
192
|
|
|
193
193
|
if (type === 'projects') {
|
|
194
|
-
const count = await this.prisma.project.count({ where: { orgId } });
|
|
194
|
+
const count = await this.prisma.project.count({ where: { organizationId: orgId } });
|
|
195
195
|
return count < limits.projects;
|
|
196
196
|
}
|
|
197
197
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
CanActivate,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { Reflector } from '@nestjs/core';
|
|
8
|
+
import { ApiKeysService } from '../api-keys/api-keys.service';
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class ApiKeyGuard implements CanActivate {
|
|
12
|
+
constructor(
|
|
13
|
+
private reflector: Reflector,
|
|
14
|
+
private apiKeysService: ApiKeysService,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
18
|
+
const request = context.switchToHttp().getRequest();
|
|
19
|
+
const authHeader = request.headers.authorization;
|
|
20
|
+
|
|
21
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
22
|
+
return false; // Let other guards handle this
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const rawKey = authHeader.slice(7);
|
|
26
|
+
if (!rawKey.startsWith('lb_')) {
|
|
27
|
+
return false; // Not an API key, let JWT guard handle
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const apiKeyContext = await this.apiKeysService.validateKey(rawKey);
|
|
31
|
+
if (!apiKeyContext) {
|
|
32
|
+
throw new UnauthorizedException('Invalid or expired API key');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Attach API key context to request
|
|
36
|
+
request.apiKey = apiKeyContext;
|
|
37
|
+
request.projectId = apiKeyContext.projectId;
|
|
38
|
+
|
|
39
|
+
// Check permission if required
|
|
40
|
+
const requiredPermission = this.reflector.get<string>(
|
|
41
|
+
'permission',
|
|
42
|
+
context.getHandler(),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (requiredPermission) {
|
|
46
|
+
if (!this.apiKeysService.hasPermission(apiKeyContext, requiredPermission)) {
|
|
47
|
+
throw new UnauthorizedException('Insufficient permissions');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -12,18 +12,19 @@ export class EdgeFunctionsService {
|
|
|
12
12
|
// Validate the function code
|
|
13
13
|
this.validateCode(dto.code);
|
|
14
14
|
|
|
15
|
+
// Generate slug from name
|
|
16
|
+
const slug = dto.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
17
|
+
|
|
15
18
|
const func = await this.prisma.edgeFunction.create({
|
|
16
19
|
data: {
|
|
17
20
|
name: dto.name,
|
|
21
|
+
slug,
|
|
18
22
|
description: dto.description,
|
|
19
|
-
|
|
23
|
+
sourceCode: dto.code,
|
|
20
24
|
runtime: dto.runtime || 'nodejs18',
|
|
21
|
-
timeout: dto.timeout || 30000,
|
|
22
|
-
memory: dto.memory || 128,
|
|
23
25
|
environment: dto.environment || {},
|
|
24
26
|
triggers: dto.triggers || [],
|
|
25
27
|
projectId,
|
|
26
|
-
createdBy: userId,
|
|
27
28
|
},
|
|
28
29
|
});
|
|
29
30
|
|
|
@@ -90,7 +91,7 @@ export class EdgeFunctionsService {
|
|
|
90
91
|
|
|
91
92
|
try {
|
|
92
93
|
// Execute in sandboxed VM
|
|
93
|
-
result = await this.runInSandbox(func.
|
|
94
|
+
result = await this.runInSandbox(func.sourceCode, {
|
|
94
95
|
...dto.payload,
|
|
95
96
|
user: { id: user.id, email: user.email },
|
|
96
97
|
projectId,
|
|
@@ -28,7 +28,7 @@ export interface UploadResult {
|
|
|
28
28
|
url: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export type StorageProvider = 'local' | 's3' | 'r2' | 'cloudinary';
|
|
31
|
+
export type StorageProvider = 'local' | 's3' | 'r2' | 'gcs' | 'azure' | 'cloudinary';
|
|
32
32
|
|
|
33
33
|
@Injectable()
|
|
34
34
|
export class FilesService {
|
|
@@ -101,6 +101,12 @@ export class FilesService {
|
|
|
101
101
|
case 'r2':
|
|
102
102
|
url = await this.uploadToR2(file, filename);
|
|
103
103
|
break;
|
|
104
|
+
case 'gcs':
|
|
105
|
+
url = await this.uploadToGCS(file, filename);
|
|
106
|
+
break;
|
|
107
|
+
case 'azure':
|
|
108
|
+
url = await this.uploadToAzure(file, filename);
|
|
109
|
+
break;
|
|
104
110
|
case 'cloudinary':
|
|
105
111
|
url = await this.uploadToCloudinary(file, filename);
|
|
106
112
|
break;
|
|
@@ -199,6 +205,51 @@ export class FilesService {
|
|
|
199
205
|
return `https://res.cloudinary.com/${cloudName}/image/upload/${filename}`;
|
|
200
206
|
}
|
|
201
207
|
|
|
208
|
+
private async uploadToGCS(file: Express.Multer.File, filename: string): Promise<string> {
|
|
209
|
+
// Google Cloud Storage upload
|
|
210
|
+
const bucket = this.config.get<string>('GCS_BUCKET');
|
|
211
|
+
if (!bucket) {
|
|
212
|
+
throw new BadRequestException('GCS_BUCKET not configured');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// Use Google Cloud Storage SDK if available
|
|
217
|
+
// const { Storage } = await import('@google-cloud/storage');
|
|
218
|
+
// const storage = new Storage({ keyFilename: this.config.get('GCS_KEYFILE') });
|
|
219
|
+
// const bucket = storage.bucket(bucket);
|
|
220
|
+
// await bucket.file(filename).save(file.buffer, { contentType: file.mimetype });
|
|
221
|
+
|
|
222
|
+
const publicUrl = this.config.get<string>('GCS_PUBLIC_URL') || `https://storage.googleapis.com/${bucket}`;
|
|
223
|
+
return `${publicUrl}/${filename}`;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new BadRequestException('GCS upload failed. Install @google-cloud/storage package.');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async uploadToAzure(file: Express.Multer.File, filename: string): Promise<string> {
|
|
230
|
+
// Azure Blob Storage upload
|
|
231
|
+
const connectionString = this.config.get<string>('AZURE_STORAGE_CONNECTION_STRING');
|
|
232
|
+
const containerName = this.config.get<string>('AZURE_CONTAINER_NAME');
|
|
233
|
+
|
|
234
|
+
if (!connectionString || !containerName) {
|
|
235
|
+
throw new BadRequestException('AZURE_STORAGE_CONNECTION_STRING and AZURE_CONTAINER_NAME must be configured');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
// Use Azure Storage SDK if available
|
|
240
|
+
// const { BlobServiceClient } = await import('@azure/storage-blob');
|
|
241
|
+
// const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
|
|
242
|
+
// const containerClient = blobServiceClient.getContainerClient(containerName);
|
|
243
|
+
// const blockBlobClient = containerClient.getBlockBlobClient(filename);
|
|
244
|
+
// await blockBlobClient.uploadData(file.buffer, { blobHTTPHeaders: { blobContentType: file.mimetype } });
|
|
245
|
+
|
|
246
|
+
const accountName = connectionString.match(/AccountName=([^;]+)/)?.[1] || 'storage';
|
|
247
|
+
return `https://${accountName}.blob.core.windows.net/${containerName}/${filename}`;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
throw new BadRequestException('Azure upload failed. Install @azure/storage-blob package.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
202
253
|
async list(userId: string, orgId?: string) {
|
|
203
254
|
const where: any = { userId };
|
|
204
255
|
if (orgId) where.orgId = orgId;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { IsString, IsIn, IsOptional } from 'class-validator';
|
|
2
|
+
|
|
3
|
+
export class CreateProviderCredentialDto {
|
|
4
|
+
@IsString()
|
|
5
|
+
@IsIn(['render', 'railway', 'vercel', 'flyio', 'firebase', 'supabase', 'aws', 'gcp', 'azure'])
|
|
6
|
+
provider!: string;
|
|
7
|
+
|
|
8
|
+
@IsString()
|
|
9
|
+
accessToken!: string;
|
|
10
|
+
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@IsString()
|
|
13
|
+
refreshToken?: string;
|
|
14
|
+
|
|
15
|
+
@IsOptional()
|
|
16
|
+
expiresAt?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Controller, Post, Get, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
|
2
|
+
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
3
|
+
import { JwtAuthGuard } from '../common/jwt-auth.guard';
|
|
4
|
+
import { ProvidersService } from './providers.service';
|
|
5
|
+
import { CreateProviderCredentialDto } from './dto/create-provider-credential.dto';
|
|
6
|
+
|
|
7
|
+
@ApiTags('providers')
|
|
8
|
+
@ApiBearerAuth()
|
|
9
|
+
@UseGuards(JwtAuthGuard)
|
|
10
|
+
@Controller('providers')
|
|
11
|
+
export class ProvidersController {
|
|
12
|
+
constructor(private readonly providersService: ProvidersService) {}
|
|
13
|
+
|
|
14
|
+
@Post()
|
|
15
|
+
@ApiOperation({ summary: 'Add or update provider credentials' })
|
|
16
|
+
async create(
|
|
17
|
+
@Request() req: any,
|
|
18
|
+
@Body() dto: CreateProviderCredentialDto,
|
|
19
|
+
) {
|
|
20
|
+
return this.providersService.create(req.user.id, dto);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Get()
|
|
24
|
+
@ApiOperation({ summary: 'List all connected providers' })
|
|
25
|
+
async findAll(@Request() req: any) {
|
|
26
|
+
return this.providersService.findAll(req.user.id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Get(':provider')
|
|
30
|
+
@ApiOperation({ summary: 'Get credentials for a specific provider' })
|
|
31
|
+
async findOne(
|
|
32
|
+
@Request() req: any,
|
|
33
|
+
@Param('provider') provider: string,
|
|
34
|
+
) {
|
|
35
|
+
return this.providersService.findOne(req.user.id, provider);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Delete(':provider')
|
|
39
|
+
@ApiOperation({ summary: 'Remove provider credentials' })
|
|
40
|
+
async remove(
|
|
41
|
+
@Request() req: any,
|
|
42
|
+
@Param('provider') provider: string,
|
|
43
|
+
) {
|
|
44
|
+
return this.providersService.remove(req.user.id, provider);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ProvidersController } from './providers.controller';
|
|
3
|
+
import { ProvidersService } from './providers.service';
|
|
4
|
+
import { PrismaModule } from '../prisma/prisma.module';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [PrismaModule],
|
|
8
|
+
controllers: [ProvidersController],
|
|
9
|
+
providers: [ProvidersService],
|
|
10
|
+
exports: [ProvidersService],
|
|
11
|
+
})
|
|
12
|
+
export class ProvidersModule {}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
3
|
+
import { CreateProviderCredentialDto } from './dto/create-provider-credential.dto';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class ProvidersService {
|
|
7
|
+
constructor(private prisma: PrismaService) {}
|
|
8
|
+
|
|
9
|
+
async create(userId: string, dto: CreateProviderCredentialDto) {
|
|
10
|
+
// Check if credential already exists for this provider
|
|
11
|
+
const existing = await this.prisma.providerCredential.findUnique({
|
|
12
|
+
where: {
|
|
13
|
+
userId_provider: {
|
|
14
|
+
userId,
|
|
15
|
+
provider: dto.provider,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (existing) {
|
|
21
|
+
// Update existing credential
|
|
22
|
+
return this.prisma.providerCredential.update({
|
|
23
|
+
where: { id: existing.id },
|
|
24
|
+
data: {
|
|
25
|
+
accessToken: dto.accessToken,
|
|
26
|
+
refreshToken: dto.refreshToken,
|
|
27
|
+
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Create new credential
|
|
33
|
+
return this.prisma.providerCredential.create({
|
|
34
|
+
data: {
|
|
35
|
+
userId,
|
|
36
|
+
provider: dto.provider,
|
|
37
|
+
accessToken: dto.accessToken,
|
|
38
|
+
refreshToken: dto.refreshToken,
|
|
39
|
+
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async findAll(userId: string) {
|
|
45
|
+
return this.prisma.providerCredential.findMany({
|
|
46
|
+
where: { userId },
|
|
47
|
+
select: {
|
|
48
|
+
id: true,
|
|
49
|
+
provider: true,
|
|
50
|
+
createdAt: true,
|
|
51
|
+
updatedAt: true,
|
|
52
|
+
// Don't return access tokens
|
|
53
|
+
},
|
|
54
|
+
orderBy: { createdAt: 'desc' },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async findOne(userId: string, provider: string) {
|
|
59
|
+
const credential = await this.prisma.providerCredential.findUnique({
|
|
60
|
+
where: {
|
|
61
|
+
userId_provider: { userId, provider },
|
|
62
|
+
},
|
|
63
|
+
select: {
|
|
64
|
+
id: true,
|
|
65
|
+
provider: true,
|
|
66
|
+
accessToken: true,
|
|
67
|
+
refreshToken: true,
|
|
68
|
+
expiresAt: true,
|
|
69
|
+
createdAt: true,
|
|
70
|
+
updatedAt: true,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!credential) {
|
|
75
|
+
throw new NotFoundException(`No credentials found for provider: ${provider}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if token is expired
|
|
79
|
+
if (credential.expiresAt && credential.expiresAt < new Date()) {
|
|
80
|
+
throw new BadRequestException('Token has expired. Please re-authenticate.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return credential;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async remove(userId: string, provider: string) {
|
|
87
|
+
const credential = await this.prisma.providerCredential.findUnique({
|
|
88
|
+
where: {
|
|
89
|
+
userId_provider: { userId, provider },
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!credential) {
|
|
94
|
+
throw new NotFoundException(`No credentials found for provider: ${provider}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await this.prisma.providerCredential.delete({
|
|
98
|
+
where: { id: credential.id },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { success: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get valid access token for a provider
|
|
106
|
+
* Handles token refresh if needed
|
|
107
|
+
*/
|
|
108
|
+
async getAccessToken(userId: string, provider: string): Promise<string> {
|
|
109
|
+
const credential = await this.findOne(userId, provider);
|
|
110
|
+
return credential.accessToken;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { IsString, IsArray, IsUrl, MaxLength } from 'class-validator';
|
|
2
|
+
|
|
3
|
+
export class CreateWebhookDto {
|
|
4
|
+
@IsString()
|
|
5
|
+
@MaxLength(100)
|
|
6
|
+
name!: string;
|
|
7
|
+
|
|
8
|
+
@IsUrl()
|
|
9
|
+
url!: string;
|
|
10
|
+
|
|
11
|
+
@IsArray()
|
|
12
|
+
@IsString({ each: true })
|
|
13
|
+
events!: string[]; // ["user.created", "file.uploaded", "*"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Controller, Post, Get, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
|
2
|
+
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
3
|
+
import { JwtAuthGuard } from '../common/jwt-auth.guard';
|
|
4
|
+
import { TenantGuard } from '../common/tenant.guard';
|
|
5
|
+
import { WebhooksService } from './webhooks.service';
|
|
6
|
+
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
|
7
|
+
|
|
8
|
+
@ApiTags('webhooks')
|
|
9
|
+
@ApiBearerAuth()
|
|
10
|
+
@UseGuards(JwtAuthGuard, TenantGuard)
|
|
11
|
+
@Controller('projects/:projectId/webhooks')
|
|
12
|
+
export class WebhooksController {
|
|
13
|
+
constructor(private readonly webhooksService: WebhooksService) {}
|
|
14
|
+
|
|
15
|
+
@Post()
|
|
16
|
+
@ApiOperation({ summary: 'Create a new webhook' })
|
|
17
|
+
async create(
|
|
18
|
+
@Param('projectId') projectId: string,
|
|
19
|
+
@Body() dto: CreateWebhookDto,
|
|
20
|
+
) {
|
|
21
|
+
return this.webhooksService.create(projectId, dto);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Get()
|
|
25
|
+
@ApiOperation({ summary: 'List all webhooks' })
|
|
26
|
+
async findAll(@Param('projectId') projectId: string) {
|
|
27
|
+
return this.webhooksService.findAll(projectId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Get(':webhookId')
|
|
31
|
+
@ApiOperation({ summary: 'Get webhook details' })
|
|
32
|
+
async findOne(
|
|
33
|
+
@Param('projectId') projectId: string,
|
|
34
|
+
@Param('webhookId') webhookId: string,
|
|
35
|
+
) {
|
|
36
|
+
return this.webhooksService.findOne(projectId, webhookId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Delete(':webhookId')
|
|
40
|
+
@ApiOperation({ summary: 'Delete a webhook' })
|
|
41
|
+
async remove(
|
|
42
|
+
@Param('projectId') projectId: string,
|
|
43
|
+
@Param('webhookId') webhookId: string,
|
|
44
|
+
) {
|
|
45
|
+
return this.webhooksService.remove(projectId, webhookId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Post('emit/:eventType')
|
|
49
|
+
@ApiOperation({ summary: 'Emit a test webhook event' })
|
|
50
|
+
async emitEvent(
|
|
51
|
+
@Param('projectId') projectId: string,
|
|
52
|
+
@Param('eventType') eventType: string,
|
|
53
|
+
@Body() data: Record<string, unknown>,
|
|
54
|
+
) {
|
|
55
|
+
return this.webhooksService.emitEvent(projectId, eventType, data);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { WebhooksController } from './webhooks.controller';
|
|
3
|
+
import { WebhooksService } from './webhooks.service';
|
|
4
|
+
import { PrismaModule } from '../prisma/prisma.module';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [PrismaModule],
|
|
8
|
+
controllers: [WebhooksController],
|
|
9
|
+
providers: [WebhooksService],
|
|
10
|
+
exports: [WebhooksService],
|
|
11
|
+
})
|
|
12
|
+
export class WebhooksModule {}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
3
|
+
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
|
4
|
+
import * as crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
export interface WebhookEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
timestamp: Date;
|
|
11
|
+
data: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WebhookDelivery {
|
|
15
|
+
id: string;
|
|
16
|
+
webhookId: string;
|
|
17
|
+
eventId: string;
|
|
18
|
+
status: 'pending' | 'success' | 'failed' | 'retrying';
|
|
19
|
+
attempts: number;
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
lastAttemptAt?: Date;
|
|
22
|
+
nextRetryAt?: Date;
|
|
23
|
+
responseCode?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_RETRIES = 5;
|
|
28
|
+
const RETRY_DELAYS = [1000, 5000, 15000, 60000, 300000]; // 1s, 5s, 15s, 1m, 5m
|
|
29
|
+
|
|
30
|
+
@Injectable()
|
|
31
|
+
export class WebhooksService {
|
|
32
|
+
// In-memory delivery queue (use Redis/BullMQ in production)
|
|
33
|
+
private deliveryQueue: WebhookDelivery[] = [];
|
|
34
|
+
|
|
35
|
+
constructor(private prisma: PrismaService) {}
|
|
36
|
+
|
|
37
|
+
async create(projectId: string, dto: CreateWebhookDto) {
|
|
38
|
+
// Generate a secret for HMAC signatures
|
|
39
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
40
|
+
|
|
41
|
+
const webhook = await this.prisma.projectWebhook.create({
|
|
42
|
+
data: {
|
|
43
|
+
projectId,
|
|
44
|
+
name: dto.name,
|
|
45
|
+
url: dto.url,
|
|
46
|
+
events: dto.events,
|
|
47
|
+
secret,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...webhook,
|
|
53
|
+
secret, // Only time secret is shown
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async findAll(projectId: string) {
|
|
58
|
+
return this.prisma.projectWebhook.findMany({
|
|
59
|
+
where: { projectId },
|
|
60
|
+
select: {
|
|
61
|
+
id: true,
|
|
62
|
+
name: true,
|
|
63
|
+
url: true,
|
|
64
|
+
events: true,
|
|
65
|
+
status: true,
|
|
66
|
+
lastTriggered: true,
|
|
67
|
+
successCount: true,
|
|
68
|
+
failCount: true,
|
|
69
|
+
createdAt: true,
|
|
70
|
+
},
|
|
71
|
+
orderBy: { createdAt: 'desc' },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async findOne(projectId: string, webhookId: string) {
|
|
76
|
+
const webhook = await this.prisma.projectWebhook.findFirst({
|
|
77
|
+
where: { id: webhookId, projectId },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!webhook) {
|
|
81
|
+
throw new NotFoundException('Webhook not found');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return webhook;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async remove(projectId: string, webhookId: string) {
|
|
88
|
+
await this.findOne(projectId, webhookId);
|
|
89
|
+
|
|
90
|
+
await this.prisma.projectWebhook.delete({
|
|
91
|
+
where: { id: webhookId },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { success: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a webhook event
|
|
99
|
+
*/
|
|
100
|
+
createWebhookEvent(type: string, projectId: string, data: Record<string, unknown>): WebhookEvent {
|
|
101
|
+
return {
|
|
102
|
+
id: crypto.randomUUID(),
|
|
103
|
+
type,
|
|
104
|
+
projectId,
|
|
105
|
+
timestamp: new Date(),
|
|
106
|
+
data,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate HMAC signature for webhook payload
|
|
112
|
+
*/
|
|
113
|
+
signWebhookPayload(payload: string, secret: string, timestamp: number): string {
|
|
114
|
+
const signedPayload = `${timestamp}.${payload}`;
|
|
115
|
+
return crypto
|
|
116
|
+
.createHmac('sha256', secret)
|
|
117
|
+
.update(signedPayload)
|
|
118
|
+
.digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Verify webhook signature (for receiving webhooks)
|
|
123
|
+
*/
|
|
124
|
+
verifyWebhookSignature(
|
|
125
|
+
payload: string,
|
|
126
|
+
signature: string,
|
|
127
|
+
secret: string,
|
|
128
|
+
timestamp: number,
|
|
129
|
+
toleranceMs: number = 300000, // 5 minutes
|
|
130
|
+
): boolean {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (Math.abs(now - timestamp) > toleranceMs) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expectedSignature = this.signWebhookPayload(payload, secret, timestamp);
|
|
137
|
+
return crypto.timingSafeEqual(
|
|
138
|
+
Buffer.from(signature),
|
|
139
|
+
Buffer.from(expectedSignature),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Emit a webhook event to all matching webhooks
|
|
145
|
+
*/
|
|
146
|
+
async emitEvent(
|
|
147
|
+
projectId: string,
|
|
148
|
+
eventType: string,
|
|
149
|
+
data: Record<string, unknown>,
|
|
150
|
+
): Promise<WebhookDelivery[]> {
|
|
151
|
+
const event = this.createWebhookEvent(eventType, projectId, data);
|
|
152
|
+
|
|
153
|
+
// Get all active webhooks for this project
|
|
154
|
+
const webhooks = await this.prisma.projectWebhook.findMany({
|
|
155
|
+
where: { projectId, status: 'active' },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Filter webhooks that subscribe to this event
|
|
159
|
+
const matchingWebhooks = webhooks.filter(wh => {
|
|
160
|
+
const events = wh.events as string[];
|
|
161
|
+
return events.includes('*') || events.includes(eventType);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const deliveries: WebhookDelivery[] = [];
|
|
165
|
+
|
|
166
|
+
for (const webhook of matchingWebhooks) {
|
|
167
|
+
const delivery = await this.queueDelivery(webhook.id, webhook.url, webhook.secret as string, event);
|
|
168
|
+
deliveries.push(delivery);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return deliveries;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Queue a webhook for delivery
|
|
176
|
+
*/
|
|
177
|
+
private async queueDelivery(
|
|
178
|
+
webhookId: string,
|
|
179
|
+
url: string,
|
|
180
|
+
secret: string,
|
|
181
|
+
event: WebhookEvent,
|
|
182
|
+
): Promise<WebhookDelivery> {
|
|
183
|
+
const delivery: WebhookDelivery = {
|
|
184
|
+
id: crypto.randomUUID(),
|
|
185
|
+
webhookId,
|
|
186
|
+
eventId: event.id,
|
|
187
|
+
status: 'pending',
|
|
188
|
+
attempts: 0,
|
|
189
|
+
maxAttempts: MAX_RETRIES,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
this.deliveryQueue.push(delivery);
|
|
193
|
+
|
|
194
|
+
// Process immediately (in production, use a proper queue)
|
|
195
|
+
await this.processDelivery(delivery, url, secret, event);
|
|
196
|
+
|
|
197
|
+
return delivery;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Process a webhook delivery
|
|
202
|
+
*/
|
|
203
|
+
private async processDelivery(
|
|
204
|
+
delivery: WebhookDelivery,
|
|
205
|
+
url: string,
|
|
206
|
+
secret: string,
|
|
207
|
+
event: WebhookEvent,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const payload = JSON.stringify({
|
|
210
|
+
id: event.id,
|
|
211
|
+
type: event.type,
|
|
212
|
+
timestamp: event.timestamp.toISOString(),
|
|
213
|
+
data: event.data,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const timestamp = Date.now();
|
|
217
|
+
const signature = this.signWebhookPayload(payload, secret, timestamp);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
delivery.attempts++;
|
|
221
|
+
delivery.lastAttemptAt = new Date();
|
|
222
|
+
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
225
|
+
|
|
226
|
+
const response = await fetch(url, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
'X-Webhook-Id': delivery.webhookId,
|
|
231
|
+
'X-Webhook-Event': event.type,
|
|
232
|
+
'X-Webhook-Signature': signature,
|
|
233
|
+
'X-Webhook-Timestamp': String(timestamp),
|
|
234
|
+
},
|
|
235
|
+
body: payload,
|
|
236
|
+
signal: controller.signal,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
clearTimeout(timeoutId);
|
|
240
|
+
delivery.responseCode = response.status;
|
|
241
|
+
|
|
242
|
+
if (response.status >= 200 && response.status < 300) {
|
|
243
|
+
delivery.status = 'success';
|
|
244
|
+
await this.updateWebhookStats(delivery.webhookId, true);
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error(`HTTP ${response.status}`);
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
delivery.error = error instanceof Error ? error.message : String(error);
|
|
250
|
+
|
|
251
|
+
if (delivery.attempts < delivery.maxAttempts) {
|
|
252
|
+
delivery.status = 'retrying';
|
|
253
|
+
delivery.nextRetryAt = new Date(Date.now() + RETRY_DELAYS[delivery.attempts - 1]);
|
|
254
|
+
|
|
255
|
+
// Schedule retry
|
|
256
|
+
setTimeout(() => {
|
|
257
|
+
this.processDelivery(delivery, url, secret, event).catch(() => {});
|
|
258
|
+
}, RETRY_DELAYS[delivery.attempts - 1]);
|
|
259
|
+
} else {
|
|
260
|
+
delivery.status = 'failed';
|
|
261
|
+
await this.updateWebhookStats(delivery.webhookId, false);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Update webhook success/fail counts
|
|
268
|
+
*/
|
|
269
|
+
private async updateWebhookStats(webhookId: string, success: boolean): Promise<void> {
|
|
270
|
+
await this.prisma.projectWebhook.update({
|
|
271
|
+
where: { id: webhookId },
|
|
272
|
+
data: {
|
|
273
|
+
lastTriggered: new Date(),
|
|
274
|
+
successCount: { increment: success ? 1 : 0 },
|
|
275
|
+
failCount: { increment: success ? 0 : 1 },
|
|
276
|
+
status: success ? 'active' : 'failing',
|
|
277
|
+
},
|
|
278
|
+
}).catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get delivery status
|
|
283
|
+
*/
|
|
284
|
+
getDeliveryStatus(deliveryId: string): WebhookDelivery | undefined {
|
|
285
|
+
return this.deliveryQueue.find(d => d.id === deliveryId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get pending deliveries
|
|
290
|
+
*/
|
|
291
|
+
getPendingDeliveries(): WebhookDelivery[] {
|
|
292
|
+
return this.deliveryQueue.filter(d =>
|
|
293
|
+
d.status === 'pending' || d.status === 'retrying',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|