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 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.2.2';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchbase",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Generate production-ready NestJS backends with authentication, multi-tenancy, billing, and deployment in minutes",
5
5
  "author": "LaunchBase",
6
6
  "keywords": [
@@ -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
- organizationMemberships: {
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.organizationMemberships[0];
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
+ }
@@ -3,7 +3,7 @@ import { IsString, IsOptional, MaxLength } from 'class-validator';
3
3
  export class CreateDeploymentDto {
4
4
  @IsString()
5
5
  @MaxLength(50)
6
- version: string;
6
+ version!: string;
7
7
 
8
8
  @IsString()
9
9
  @IsOptional()
@@ -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
- code: dto.code,
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.code, {
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
+ }