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 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.3';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchbase",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
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
+ }
@@ -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
+ }