launchbase 1.2.3 → 1.3.1
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/migrations/0_init/migration.sql +81 -1
- package/template/prisma/schema.prisma +61 -0
- 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/common/api-key.guard.ts +53 -0
- 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.1';
|
|
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
|
+
}
|
|
@@ -59,6 +59,7 @@ CREATE TABLE "Project" (
|
|
|
59
59
|
"organizationId" TEXT NOT NULL,
|
|
60
60
|
"name" TEXT NOT NULL,
|
|
61
61
|
"slug" TEXT NOT NULL,
|
|
62
|
+
"description" TEXT,
|
|
62
63
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
63
64
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
64
65
|
|
|
@@ -75,6 +76,7 @@ CREATE TABLE "EdgeFunction" (
|
|
|
75
76
|
"projectId" TEXT NOT NULL,
|
|
76
77
|
"name" TEXT NOT NULL,
|
|
77
78
|
"slug" TEXT NOT NULL,
|
|
79
|
+
"description" TEXT,
|
|
78
80
|
"runtime" TEXT NOT NULL DEFAULT 'nodejs18',
|
|
79
81
|
"sourceCode" TEXT NOT NULL,
|
|
80
82
|
"environment" JSONB,
|
|
@@ -82,6 +84,7 @@ CREATE TABLE "EdgeFunction" (
|
|
|
82
84
|
"status" TEXT NOT NULL DEFAULT 'draft',
|
|
83
85
|
"deployedAt" TIMESTAMP(3),
|
|
84
86
|
"deploymentUrl" TEXT,
|
|
87
|
+
"deploymentId" TEXT,
|
|
85
88
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
86
89
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
87
90
|
|
|
@@ -108,6 +111,7 @@ CREATE TABLE "VectorCollection" (
|
|
|
108
111
|
"id" TEXT NOT NULL,
|
|
109
112
|
"projectId" TEXT NOT NULL,
|
|
110
113
|
"name" TEXT NOT NULL,
|
|
114
|
+
"description" TEXT,
|
|
111
115
|
"provider" TEXT NOT NULL DEFAULT 'local',
|
|
112
116
|
"dimension" INTEGER NOT NULL DEFAULT 1536,
|
|
113
117
|
"embeddingModel" TEXT NOT NULL DEFAULT 'text-embedding-3-small',
|
|
@@ -122,7 +126,9 @@ CREATE TABLE "Deployment" (
|
|
|
122
126
|
"id" TEXT NOT NULL,
|
|
123
127
|
"projectId" TEXT NOT NULL,
|
|
124
128
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
|
125
|
-
"version" TEXT,
|
|
129
|
+
"version" TEXT NOT NULL,
|
|
130
|
+
"description" TEXT,
|
|
131
|
+
"error" TEXT,
|
|
126
132
|
"deployedAt" TIMESTAMP(3),
|
|
127
133
|
"platform" TEXT,
|
|
128
134
|
"url" TEXT,
|
|
@@ -132,6 +138,58 @@ CREATE TABLE "Deployment" (
|
|
|
132
138
|
CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id")
|
|
133
139
|
);
|
|
134
140
|
|
|
141
|
+
-- ============================================
|
|
142
|
+
-- API Keys & Webhooks (v1.3.0)
|
|
143
|
+
-- ============================================
|
|
144
|
+
|
|
145
|
+
-- CreateTable
|
|
146
|
+
CREATE TABLE "ProjectApiKey" (
|
|
147
|
+
"id" TEXT NOT NULL,
|
|
148
|
+
"projectId" TEXT NOT NULL,
|
|
149
|
+
"name" TEXT NOT NULL,
|
|
150
|
+
"keyHash" TEXT NOT NULL,
|
|
151
|
+
"prefix" TEXT NOT NULL,
|
|
152
|
+
"permissions" JSONB NOT NULL,
|
|
153
|
+
"lastUsed" TIMESTAMP(3),
|
|
154
|
+
"expiresAt" TIMESTAMP(3),
|
|
155
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
156
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
157
|
+
|
|
158
|
+
CONSTRAINT "ProjectApiKey_pkey" PRIMARY KEY ("id")
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
-- CreateTable
|
|
162
|
+
CREATE TABLE "ProjectWebhook" (
|
|
163
|
+
"id" TEXT NOT NULL,
|
|
164
|
+
"projectId" TEXT NOT NULL,
|
|
165
|
+
"name" TEXT NOT NULL,
|
|
166
|
+
"url" TEXT NOT NULL,
|
|
167
|
+
"events" JSONB NOT NULL,
|
|
168
|
+
"secret" TEXT NOT NULL,
|
|
169
|
+
"status" TEXT NOT NULL DEFAULT 'active',
|
|
170
|
+
"lastTriggered" TIMESTAMP(3),
|
|
171
|
+
"successCount" INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
"failCount" INTEGER NOT NULL DEFAULT 0,
|
|
173
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
174
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
175
|
+
|
|
176
|
+
CONSTRAINT "ProjectWebhook_pkey" PRIMARY KEY ("id")
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
-- CreateTable
|
|
180
|
+
CREATE TABLE "ProviderCredential" (
|
|
181
|
+
"id" TEXT NOT NULL,
|
|
182
|
+
"userId" TEXT NOT NULL,
|
|
183
|
+
"provider" TEXT NOT NULL,
|
|
184
|
+
"accessToken" TEXT NOT NULL,
|
|
185
|
+
"refreshToken" TEXT,
|
|
186
|
+
"expiresAt" TIMESTAMP(3),
|
|
187
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
188
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
189
|
+
|
|
190
|
+
CONSTRAINT "ProviderCredential_pkey" PRIMARY KEY ("id")
|
|
191
|
+
);
|
|
192
|
+
|
|
135
193
|
-- ============================================
|
|
136
194
|
-- Auth & Session Models
|
|
137
195
|
-- ============================================
|
|
@@ -319,6 +377,19 @@ CREATE UNIQUE INDEX "VectorCollection_projectId_name_key" ON "VectorCollection"(
|
|
|
319
377
|
CREATE INDEX "Deployment_projectId_idx" ON "Deployment"("projectId");
|
|
320
378
|
CREATE INDEX "Deployment_status_idx" ON "Deployment"("status");
|
|
321
379
|
|
|
380
|
+
-- ProjectApiKey indexes
|
|
381
|
+
CREATE INDEX "ProjectApiKey_projectId_idx" ON "ProjectApiKey"("projectId");
|
|
382
|
+
CREATE UNIQUE INDEX "ProjectApiKey_keyHash_key" ON "ProjectApiKey"("keyHash");
|
|
383
|
+
|
|
384
|
+
-- ProjectWebhook indexes
|
|
385
|
+
CREATE INDEX "ProjectWebhook_projectId_idx" ON "ProjectWebhook"("projectId");
|
|
386
|
+
CREATE INDEX "ProjectWebhook_status_idx" ON "ProjectWebhook"("status");
|
|
387
|
+
|
|
388
|
+
-- ProviderCredential indexes
|
|
389
|
+
CREATE INDEX "ProviderCredential_userId_idx" ON "ProviderCredential"("userId");
|
|
390
|
+
CREATE INDEX "ProviderCredential_provider_idx" ON "ProviderCredential"("provider");
|
|
391
|
+
CREATE UNIQUE INDEX "ProviderCredential_userId_provider_key" ON "ProviderCredential"("userId", "provider");
|
|
392
|
+
|
|
322
393
|
-- VerificationToken indexes
|
|
323
394
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
|
324
395
|
|
|
@@ -388,6 +459,15 @@ ALTER TABLE "VectorCollection" ADD CONSTRAINT "VectorCollection_projectId_fkey"
|
|
|
388
459
|
-- Deployment -> Project
|
|
389
460
|
ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
390
461
|
|
|
462
|
+
-- ProjectApiKey -> Project
|
|
463
|
+
ALTER TABLE "ProjectApiKey" ADD CONSTRAINT "ProjectApiKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
464
|
+
|
|
465
|
+
-- ProjectWebhook -> Project
|
|
466
|
+
ALTER TABLE "ProjectWebhook" ADD CONSTRAINT "ProjectWebhook_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
467
|
+
|
|
468
|
+
-- ProviderCredential -> User
|
|
469
|
+
ALTER TABLE "ProviderCredential" ADD CONSTRAINT "ProviderCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
470
|
+
|
|
391
471
|
-- VerificationToken -> User
|
|
392
472
|
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
393
473
|
|
|
@@ -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])
|
|
@@ -188,6 +191,64 @@ model Deployment {
|
|
|
188
191
|
@@index([status])
|
|
189
192
|
}
|
|
190
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
|
+
|
|
191
252
|
// ============================================
|
|
192
253
|
// Auth & Session Models
|
|
193
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
|
{
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|