diff --git a/backend/drizzle/0020_create-templates.sql b/backend/drizzle/0020_create-templates.sql new file mode 100644 index 00000000..b2be64f5 --- /dev/null +++ b/backend/drizzle/0020_create-templates.sql @@ -0,0 +1,59 @@ +-- Create templates table +CREATE TABLE "templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "category" varchar(100), + "tags" jsonb DEFAULT '[]'::jsonb NOT NULL, + "author" varchar(255), + "repository" varchar(255) NOT NULL, + "path" varchar(500) NOT NULL, + "branch" varchar(100) DEFAULT 'main' NOT NULL, + "version" varchar(50), + "commit_sha" varchar(100), + "manifest" jsonb NOT NULL, + "graph" jsonb, + "required_secrets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "popularity" integer DEFAULT 0 NOT NULL, + "is_official" boolean DEFAULT false NOT NULL, + "is_verified" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +-- Create templates_submissions table +CREATE TABLE "templates_submissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "template_name" varchar(255) NOT NULL, + "description" text, + "category" varchar(100), + "repository" varchar(255) NOT NULL, + "branch" varchar(100), + "path" varchar(500) NOT NULL, + "commit_sha" varchar(100), + "pr_number" integer, + "pr_url" varchar(500), + "status" varchar(50) DEFAULT 'pending' NOT NULL, + "submitted_by" varchar(191) NOT NULL, + "organization_id" varchar(191), + "manifest" jsonb, + "graph" jsonb, + "feedback" text, + "reviewed_by" varchar(191), + "reviewed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +-- Create indexes for templates +CREATE INDEX "templates_repository_path_idx" ON "templates" ("repository", "path"); +CREATE INDEX "templates_category_idx" ON "templates" ("category"); +CREATE INDEX "templates_is_active_idx" ON "templates" ("is_active"); +CREATE INDEX "templates_popularity_idx" ON "templates" ("popularity" DESC); +--> statement-breakpoint +-- Create indexes for templates_submissions +CREATE INDEX "templates_submissions_pr_number_idx" ON "templates_submissions" ("pr_number"); +CREATE INDEX "templates_submissions_submitted_by_idx" ON "templates_submissions" ("submitted_by"); +CREATE INDEX "templates_submissions_status_idx" ON "templates_submissions" ("status"); +CREATE INDEX "templates_submissions_organization_id_idx" ON "templates_submissions" ("organization_id"); diff --git a/backend/package.json b/backend/package.json index 676958ce..d8e62c25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -56,6 +56,7 @@ "mqtt": "^5.15.0", "multer": "^2.0.2", "nestjs-zod": "^5.1.1", + "octokit": "^4.0.2", "pg": "^8.17.2", "posthog-node": "^5.24.2", "reflect-metadata": "^0.2.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index c11cda7e..6039e7a3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -31,6 +31,7 @@ import { WebhooksModule } from './webhooks/webhooks.module'; import { HumanInputsModule } from './human-inputs/human-inputs.module'; import { McpServersModule } from './mcp-servers/mcp-servers.module'; import { McpGroupsModule } from './mcp-groups/mcp-groups.module'; +import { TemplatesModule } from './templates/templates.module'; const coreModules = [ AgentsModule, @@ -49,6 +50,7 @@ const coreModules = [ McpServersModule, McpGroupsModule, McpModule, + TemplatesModule, ]; const testingModules = process.env.NODE_ENV === 'production' ? [] : [TestingSupportModule]; diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts index 6d985721..d8b1335e 100644 --- a/backend/src/database/schema/index.ts +++ b/backend/src/database/schema/index.ts @@ -20,3 +20,4 @@ export * from './mcp-servers'; export * from './node-io'; export * from './organization-settings'; +export * from './templates'; diff --git a/backend/src/database/schema/templates.ts b/backend/src/database/schema/templates.ts new file mode 100644 index 00000000..e3027cef --- /dev/null +++ b/backend/src/database/schema/templates.ts @@ -0,0 +1,99 @@ +import { + pgTable, + uuid, + varchar, + text, + timestamp, + jsonb, + boolean, + integer, +} from 'drizzle-orm/pg-core'; +import { z } from 'zod'; + +/** + * Templates table - stores workflow template metadata + * Templates are synced from GitHub repository + */ +export const templatesTable = pgTable('templates', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 255 }).notNull(), + description: text('description'), + category: varchar('category', { length: 100 }), + tags: jsonb('tags').$type().default([]), + author: varchar('author', { length: 255 }), + // GitHub repository info + repository: varchar('repository', { length: 255 }).notNull(), // e.g., "org/templates" + path: varchar('path', { length: 500 }).notNull(), // Path to template in repo + branch: varchar('branch', { length: 100 }).default('main'), + version: varchar('version', { length: 50 }), // Optional version tag + commitSha: varchar('commit_sha', { length: 100 }), + // Template content + manifest: jsonb('manifest').$type().notNull(), + graph: jsonb('graph').$type>(), // Sanitized workflow graph + requiredSecrets: jsonb('required_secrets').$type().default([]), + // Stats and flags + popularity: integer('popularity').notNull().default(0), + isOfficial: boolean('is_official').notNull().default(false), + isVerified: boolean('is_verified').notNull().default(false), + isActive: boolean('is_active').notNull().default(true), + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +/** + * Template submissions table - tracks PR-based template submissions + */ +export const templatesSubmissionsTable = pgTable('templates_submissions', { + id: uuid('id').primaryKey().defaultRandom(), + templateName: varchar('template_name', { length: 255 }).notNull(), + description: text('description'), + category: varchar('category', { length: 100 }), + repository: varchar('repository', { length: 255 }).notNull(), + branch: varchar('branch', { length: 100 }), + path: varchar('path', { length: 500 }).notNull(), + commitSha: varchar('commit_sha', { length: 100 }), + pullRequestNumber: integer('pr_number'), + pullRequestUrl: varchar('pr_url', { length: 500 }), + status: varchar('status', { length: 50 }).notNull().default('pending'), // pending, approved, rejected, merged + submittedBy: varchar('submitted_by', { length: 191 }).notNull(), + organizationId: varchar('organization_id', { length: 191 }), + manifest: jsonb('manifest').$type(), + graph: jsonb('graph').$type>(), + feedback: text('feedback'), + reviewedBy: varchar('reviewed_by', { length: 191 }), + reviewedAt: timestamp('reviewed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + +// Zod schemas for validation +export const RequiredSecretSchema = z.object({ + name: z.string(), + type: z.string(), + description: z.string().optional(), + placeholder: z.string().optional(), +}); + +export const TemplateManifestSchema = z.object({ + name: z.string(), + description: z.string().optional(), + version: z.string().optional(), + author: z.string().optional(), + category: z.string().optional(), + tags: z.array(z.string()).optional(), + requiredSecrets: z.array(RequiredSecretSchema).optional(), + entryPoint: z.string().optional(), + screenshots: z.array(z.string()).optional(), + documentation: z.string().optional(), +}); + +// Type exports +export type TemplateManifest = z.infer; +export type RequiredSecret = z.infer; + +export type Template = typeof templatesTable.$inferSelect; +export type NewTemplate = typeof templatesTable.$inferInsert; + +export type TemplateSubmission = typeof templatesSubmissionsTable.$inferSelect; +export type NewTemplateSubmission = typeof templatesSubmissionsTable.$inferInsert; diff --git a/backend/src/templates/github-template.service.ts b/backend/src/templates/github-template.service.ts new file mode 100644 index 00000000..07e56c95 --- /dev/null +++ b/backend/src/templates/github-template.service.ts @@ -0,0 +1,305 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Octokit } from 'octokit'; + +/** + * GitHub Service for Template operations + * Handles PR creation, template fetching from GitHub repository + */ +@Injectable() +export class GitHubTemplateService { + private readonly logger = new Logger(GitHubTemplateService.name); + private readonly octokit: Octokit | null = null; + private readonly templateRepo: string; + private readonly templateBranch: string; + + constructor(private configService: ConfigService) { + const token = this.configService.get('GITHUB_TEMPLATE_TOKEN'); + this.templateRepo = this.configService.get('GITHUB_TEMPLATE_REPO', ''); + this.templateBranch = this.configService.get('GITHUB_TEMPLATE_BRANCH', 'main'); + + if (token) { + this.octokit = new Octokit({ auth: token }); + } + + if (!this.templateRepo) { + this.logger.warn('GITHUB_TEMPLATE_REPO not configured'); + } + } + + /** + * Check if GitHub integration is configured + */ + isConfigured(): boolean { + return !!this.octokit && !!this.templateRepo; + } + + /** + * Create a pull request with template content + */ + async createTemplatePR(params: { + templateName: string; + description: string; + category: string; + tags: string[]; + author: string; + manifest: Record; + graph: Record; + requiredSecrets: { name: string; type: string; description?: string; placeholder?: string }[]; + }): Promise<{ prNumber: number; prUrl: string; branch: string }> { + if (!this.isConfigured()) { + throw new Error('GitHub integration not configured'); + } + + const { templateName, description, category, tags, author, manifest, graph, requiredSecrets } = + params; + + // Create branch name + const timestamp = Date.now(); + const sanitizedName = templateName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const branchName = `template/${sanitizedName}-${timestamp}`; + const baseBranch = this.templateBranch; + + try { + // Get base commit SHA + const { data: baseRef } = await this.octokit!.rest.git.getRef({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + ref: `heads/${baseBranch}`, + }); + + const baseSha = baseRef.object.sha; + + // Create new branch + await this.octokit!.rest.git.createRef({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + ref: `refs/heads/${branchName}`, + sha: baseSha, + }); + + // Create template file + const templatePath = `templates/${sanitizedName}.json`; + const templateContent = { + manifest, + graph, + requiredSecrets, + }; + + // Commit the template file + await this.octokit!.rest.repos.createOrUpdateFileContents({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + path: templatePath, + branch: branchName, + content: Buffer.from(JSON.stringify(templateContent, null, 2)).toString('base64'), + message: `feat: Add ${templateName} template`, + }); + + // Create pull request + const { data: pr } = await this.octokit!.rest.pulls.create({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + title: `Add template: ${templateName}`, + head: branchName, + base: baseBranch, + body: this.generatePRDescription({ templateName, description, category, tags, author }), + labels: ['template', category], + }); + + this.logger.log(`Created PR #${pr.number} for template: ${templateName}`); + + return { + prNumber: pr.number, + prUrl: pr.html_url, + branch: branchName, + }; + } catch (error) { + this.logger.error( + `Failed to create template PR: ${(error as Error).message}`, + (error as Error).stack, + ); + throw error; + } + } + + /** + * Get all templates from the repository + */ + async getTemplatesFromRepo(): Promise< + { + name: string; + path: string; + sha: string; + content: Record; + }[] + > { + if (!this.isConfigured()) { + return []; + } + + try { + // Get tree for templates directory + const { data: tree } = await this.octokit!.rest.git.getTree({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + tree_sha: this.templateBranch, + recursive: 'true', + }); + + const templateFiles = tree.tree + .filter((item) => item.type === 'blob' && item.path?.startsWith('templates/')) + .filter((item) => item.path?.endsWith('.json')); + + const templates = []; + + for (const file of templateFiles) { + if (!file.path) continue; + + try { + const { data: blob } = await this.octokit!.rest.git.getBlob({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + file_sha: file.sha!, + }); + + const content = Buffer.from(blob.content, 'base64').toString('utf-8'); + templates.push({ + name: file.path.replace('templates/', '').replace('.json', ''), + path: file.path, + sha: file.sha!, + content: JSON.parse(content), + }); + } catch (error) { + this.logger.warn(`Failed to fetch template ${file.path}: ${(error as Error).message}`); + } + } + + return templates; + } catch (error) { + this.logger.error( + `Failed to fetch templates from repo: ${(error as Error).message}`, + (error as Error).stack, + ); + return []; + } + } + + /** + * Get a specific template by name + */ + async getTemplateByName(name: string): Promise | null> { + if (!this.isConfigured()) { + return null; + } + + try { + const sanitizedName = name.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const path = `templates/${sanitizedName}.json`; + + const { data: file } = await this.octokit!.rest.repos.getContent({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + path, + ref: this.templateBranch, + }); + + if ('content' in file && file.content) { + const content = Buffer.from(file.content, 'base64').toString('utf-8'); + return JSON.parse(content); + } + + return null; + } catch (error) { + if ((error as any).status === 404) { + return null; + } + this.logger.error( + `Failed to fetch template ${name}: ${(error as Error).message}`, + (error as Error).stack, + ); + return null; + } + } + + /** + * Generate PR description + */ + private generatePRDescription(params: { + templateName: string; + description: string; + category: string; + tags: string[]; + author: string; + }): string { + const { templateName, description, category, tags, author } = params; + + return `## Template Submission: ${templateName} + +**Description:** ${description || 'No description provided'} + +**Category:** ${category} +**Tags:** ${tags.join(', ') || 'None'} +**Author:** ${author} + +--- + +### Checklist +- [ ] Template follows the naming conventions +- [ ] All secrets have been removed and documented +- [ ] Workflow graph is valid and sanitized +- [ ] Required secrets are documented with placeholders +- [ ] Template has been tested + +### Review Notes + +`; + } + + /** + * Parse repository owner/name from GITHUB_TEMPLATE_REPO env var + * Format: "owner/repo" or "https://github.com/owner/repo" + */ + private getRepoOwner(): string { + const repo = this.templateRepo.replace('https://github.com/', '').replace('.git', ''); + return repo.split('/')[0]; + } + + private getRepoName(): string { + const repo = this.templateRepo.replace('https://github.com/', '').replace('.git', ''); + return repo.split('/')[1]; + } + + /** + * Check if a PR exists for a given branch + */ + async getPRByBranch(branchName: string): Promise<{ number: number; url: string } | null> { + if (!this.isConfigured()) { + return null; + } + + try { + const { data: pulls } = await this.octokit!.rest.pulls.list({ + owner: this.getRepoOwner(), + repo: this.getRepoName(), + head: `${this.getRepoOwner()}:${branchName}`, + state: 'open', + }); + + if (pulls.length > 0) { + return { + number: pulls[0].number, + url: pulls[0].html_url, + }; + } + + return null; + } catch (error) { + this.logger.error( + `Failed to check PR status: ${(error as Error).message}`, + (error as Error).stack, + ); + return null; + } + } +} diff --git a/backend/src/templates/templates.controller.ts b/backend/src/templates/templates.controller.ts new file mode 100644 index 00000000..4719aaf0 --- /dev/null +++ b/backend/src/templates/templates.controller.ts @@ -0,0 +1,153 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { TemplateService } from './templates.service'; +import { CurrentAuth } from '../auth/auth-context.decorator'; +import { RequireWorkflowRole } from '../workflows/workflow-role.guard'; + +/** + * Templates Controller + * Handles template library API endpoints + */ +@Controller('templates') +export class TemplatesController { + constructor(private readonly templateService: TemplateService) {} + + /** + * GET /templates - List all templates with optional filters + */ + @Get() + async listTemplates( + @Query('category') category?: string, + @Query('search') search?: string, + @Query('tags') tags?: string, + ) { + const filters: { + category?: string; + search?: string; + tags?: string[]; + } = {}; + + if (category) filters.category = category; + if (search) filters.search = search; + if (tags) filters.tags = tags.split(','); + + return await this.templateService.listTemplates(filters); + } + + /** + * GET /templates/categories - List available categories + */ + @Get('categories') + async getCategories() { + return await this.templateService.getCategories(); + } + + /** + * GET /templates/tags - List available tags + */ + @Get('tags') + async getTags() { + return await this.templateService.getTags(); + } + + /** + * GET /templates/my - Get user's submitted templates + */ + @Get('my') + async getMyTemplates(@CurrentAuth() auth: { userId?: string; organizationId?: string }) { + return await this.templateService.getMyTemplates(auth.userId || auth.organizationId); + } + + /** + * GET /templates/:id - Get template details by ID + */ + @Get(':id') + async getTemplate(@Param('id') id: string) { + const template = await this.templateService.getTemplateById(id); + if (!template) { + throw new HttpException('Template not found', HttpStatus.NOT_FOUND); + } + return template; + } + + /** + * POST /templates/publish - Publish a workflow as a template (creates PR) + */ + @Post('publish') + @UseGuards(RequireWorkflowRole('ADMIN')) + @HttpCode(HttpStatus.ACCEPTED) + async publishTemplate( + @CurrentAuth() auth: { userId?: string; organizationId?: string }, + @Body() + dto: { + workflowId: string; + name: string; + description: string; + category: string; + tags: string[]; + author: string; + }, + ) { + return await this.templateService.publishTemplate({ + ...dto, + submittedBy: auth.userId || auth.organizationId || 'unknown', + organizationId: auth.organizationId, + }); + } + + /** + * POST /templates/:id/use - Use a template to create a new workflow + */ + @Post(':id/use') + @UseGuards(RequireWorkflowRole('ADMIN')) + async useTemplate( + @Param('id') id: string, + @CurrentAuth() auth: { userId?: string; organizationId?: string }, + @Body() + dto: { + workflowName: string; + secretMappings?: Record; + }, + ) { + return await this.templateService.useTemplate(id, { + ...dto, + userId: auth.userId || auth.organizationId, + organizationId: auth.organizationId, + }); + } + + /** + * POST /templates/sync - Sync templates from GitHub (admin only) + */ + @Post('sync') + @UseGuards(RequireWorkflowRole('ADMIN')) + async syncTemplates(@CurrentAuth() _auth: { organizationId?: string }) { + return await this.templateService.syncTemplates(); + } + + /** + * GET /templates/submissions - Get template submissions for current user + */ + @Get('submissions') + async getSubmissions(@CurrentAuth() auth: { userId?: string; organizationId?: string }) { + return await this.templateService.getSubmissions(auth.userId || auth.organizationId || ''); + } +} + +class HttpException extends Error { + constructor( + message: string, + public status: number, + ) { + super(message); + } +} diff --git a/backend/src/templates/templates.module.ts b/backend/src/templates/templates.module.ts new file mode 100644 index 00000000..5fc6475b --- /dev/null +++ b/backend/src/templates/templates.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from '../database/database.module'; +import { TemplatesController } from './templates.controller'; +import { TemplateService } from './templates.service'; +import { GitHubTemplateService } from './github-template.service'; +import { WorkflowSanitizationService } from './workflow-sanitization.service'; +import { TemplatesRepository } from './templates.repository'; +import { WorkflowsModule } from '../workflows/workflows.module'; + +@Module({ + imports: [DatabaseModule, WorkflowsModule, ConfigModule], + controllers: [TemplatesController], + providers: [ + TemplateService, + GitHubTemplateService, + WorkflowSanitizationService, + TemplatesRepository, + ], + exports: [TemplateService], +}) +export class TemplatesModule {} diff --git a/backend/src/templates/templates.repository.ts b/backend/src/templates/templates.repository.ts new file mode 100644 index 00000000..790a6c8c --- /dev/null +++ b/backend/src/templates/templates.repository.ts @@ -0,0 +1,241 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + templatesTable, + templatesSubmissionsTable, + type TemplateManifest, +} from '../database/schema/templates'; +import { eq, and, desc, sql } from 'drizzle-orm'; +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DRIZZLE_TOKEN } from '../database/database.module'; + +/** + * Templates Repository + * Handles database operations for templates + */ +@Injectable() +export class TemplatesRepository { + constructor(@Inject(DRIZZLE_TOKEN) private readonly db: NodePgDatabase) {} + + /** + * Find all active templates + */ + async findAll(filters?: { category?: string; search?: string; tags?: string[] }) { + const query = this.db.select().from(templatesTable).where(eq(templatesTable.isActive, true)); + + // Apply filters if provided + if (filters?.category) { + return (query as any).where(eq(templatesTable.category, filters.category)).execute(); + } + + if (filters?.search) { + // Search in name and description + return (query as any) + .where( + sql`${templatesTable.name} ILIKE ${`%${filters.search}%`} OR ${templatesTable.description} ILIKE ${`%${filters.search}%`}`, + ) + .execute(); + } + + return (query as any).orderBy(desc(templatesTable.popularity)).execute(); + } + + /** + * Find template by ID + */ + async findById(id: string) { + const results = await this.db + .select() + .from(templatesTable) + .where(eq(templatesTable.id, id)) + .limit(1) + .execute(); + + return results[0] || null; + } + + /** + * Find template by repository and path + */ + async findByRepoAndPath(repository: string, path: string) { + const results = await this.db + .select() + .from(templatesTable) + .where(and(eq(templatesTable.repository, repository), eq(templatesTable.path, path))) + .limit(1) + .execute(); + + return results[0] || null; + } + + /** + * Create or update a template + */ + async upsert(template: { + name: string; + description?: string; + category?: string; + tags?: string[]; + author?: string; + repository: string; + path: string; + branch?: string; + version?: string; + commitSha?: string; + manifest: TemplateManifest; + graph?: Record; + requiredSecrets?: { name: string; type: string; description?: string }[]; + isOfficial?: boolean; + isVerified?: boolean; + }) { + // Check if template already exists + const existing = await this.findByRepoAndPath(template.repository, template.path); + + if (existing) { + // Update existing template + const results = await this.db + .update(templatesTable) + .set({ + ...template, + updatedAt: new Date(), + }) + .where(eq(templatesTable.id, existing.id)) + .returning() + .execute(); + + return results[0]; + } else { + // Create new template + const results = await this.db.insert(templatesTable).values(template).returning().execute(); + + return results[0]; + } + } + + /** + * Increment popularity counter + */ + async incrementPopularity(id: string) { + await this.db + .update(templatesTable) + .set({ + popularity: sql`${templatesTable.popularity} + 1`, + }) + .where(eq(templatesTable.id, id)) + .execute(); + } + + /** + * Get all categories with counts + */ + async getCategories() { + const results = await this.db + .select({ + category: templatesTable.category, + count: sql`count(*)`.as('count'), + }) + .from(templatesTable) + .where(eq(templatesTable.isActive, true)) + .groupBy(templatesTable.category) + .execute(); + + return results; + } + + /** + * Get all tags + */ + async getTags() { + const templates = await this.db + .select({ + tags: templatesTable.tags, + }) + .from(templatesTable) + .where(eq(templatesTable.isActive, true)) + .execute(); + + const tagSet = new Set(); + for (const template of templates) { + if (Array.isArray(template.tags)) { + for (const tag of template.tags) { + tagSet.add(tag); + } + } + } + + return Array.from(tagSet).sort(); + } + + /** + * Create a template submission record + */ + async createSubmission(submission: { + templateName: string; + description?: string; + category?: string; + repository: string; + branch?: string; + path: string; + commitSha?: string; + pullRequestNumber?: number; + pullRequestUrl?: string; + submittedBy: string; + organizationId?: string; + manifest?: TemplateManifest; + graph?: Record; + }) { + const results = await this.db.insert(templatesSubmissionsTable).values(submission).returning(); + + return results[0]; + } + + /** + * Find submission by PR number + */ + async findSubmissionByPR(prNumber: number) { + const results = await this.db + .select() + .from(templatesSubmissionsTable) + .where(eq(templatesSubmissionsTable.pullRequestNumber, prNumber)) + .limit(1) + .execute(); + + return results[0] || null; + } + + /** + * Update submission status + */ + async updateSubmissionStatus( + id: string, + status: 'pending' | 'approved' | 'rejected' | 'merged', + reviewedBy?: string, + feedback?: string, + ) { + const results = await this.db + .update(templatesSubmissionsTable) + .set({ + status, + reviewedBy, + feedback, + reviewedAt: reviewedBy ? new Date() : undefined, + updatedAt: new Date(), + }) + .where(eq(templatesSubmissionsTable.id, id)) + .returning() + .execute(); + + return results[0]; + } + + /** + * Get submissions by user + */ + async findSubmissionsByUser(submittedBy: string) { + return await this.db + .select() + .from(templatesSubmissionsTable) + .where(eq(templatesSubmissionsTable.submittedBy, submittedBy)) + .orderBy(desc(templatesSubmissionsTable.createdAt)) + .execute(); + } +} diff --git a/backend/src/templates/templates.service.ts b/backend/src/templates/templates.service.ts new file mode 100644 index 00000000..bbd2eef3 --- /dev/null +++ b/backend/src/templates/templates.service.ts @@ -0,0 +1,267 @@ +import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { GitHubTemplateService } from './github-template.service'; +import { WorkflowSanitizationService } from './workflow-sanitization.service'; +import { TemplatesRepository } from './templates.repository'; +import { WorkflowRepository } from '../workflows/repository/workflow.repository'; +import { TemplateManifest } from '../database/schema/templates'; + +/** + * Templates Service + * Business logic for template operations + */ +@Injectable() +export class TemplateService { + private readonly logger = new Logger(TemplateService.name); + + constructor( + private readonly githubService: GitHubTemplateService, + private readonly sanitizationService: WorkflowSanitizationService, + private readonly templatesRepository: TemplatesRepository, + private readonly workflowsRepository: WorkflowRepository, + ) {} + + /** + * List all templates with optional filters + */ + async listTemplates(filters?: { category?: string; search?: string; tags?: string[] }) { + return await this.templatesRepository.findAll(filters); + } + + /** + * Get template by ID + */ + async getTemplateById(id: string) { + return await this.templatesRepository.findById(id); + } + + /** + * Get user's submitted templates + */ + async getMyTemplates(userId: string | undefined) { + if (!userId) return []; + return await this.templatesRepository.findSubmissionsByUser(userId); + } + + /** + * Get template categories + */ + async getCategories() { + return await this.templatesRepository.getCategories(); + } + + /** + * Get template tags + */ + async getTags() { + return await this.templatesRepository.getTags(); + } + + /** + * Publish a workflow as a template (creates GitHub PR) + */ + async publishTemplate(params: { + workflowId: string; + name: string; + description: string; + category: string; + tags: string[]; + author: string; + submittedBy: string; + organizationId?: string; + }) { + const { workflowId, name, description, category, tags, author, submittedBy } = params; + + // Get workflow from database + const workflow = await this.workflowsRepository.findById(workflowId); + if (!workflow) { + throw new HttpException('Workflow not found', HttpStatus.NOT_FOUND); + } + + // Sanitize the workflow graph + const { sanitizedGraph, requiredSecrets, removedSecrets } = + this.sanitizationService.sanitizeWorkflow(workflow.graph as Record); + + // Validate sanitized graph + const validation = this.sanitizationService.validateSanitizedGraph(sanitizedGraph); + if (!validation.valid) { + throw new HttpException( + `Invalid workflow graph: ${validation.errors.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + // Generate manifest + const manifest = this.sanitizationService.generateManifest({ + name, + description, + category, + tags, + author, + graph: sanitizedGraph, + requiredSecrets, + }); + + // Get GitHub repo config + const templateRepo = process.env.GITHUB_TEMPLATE_REPO || ''; + if (!templateRepo) { + throw new HttpException( + 'Template repository not configured', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // Create GitHub PR + const prResult = await this.githubService.createTemplatePR({ + templateName: name, + description, + category, + tags, + author, + manifest, + graph: sanitizedGraph, + requiredSecrets, + }); + + // Create submission record + await this.templatesRepository.createSubmission({ + templateName: name, + description, + category, + repository: templateRepo, + branch: prResult.branch, + path: `templates/${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.json`, + pullRequestNumber: prResult.prNumber, + pullRequestUrl: prResult.prUrl, + submittedBy, + organizationId: undefined, + manifest: manifest as TemplateManifest, + graph: sanitizedGraph, + }); + + this.logger.log(`Template published by ${submittedBy}: PR #${prResult.prNumber}`); + + return { + templateId: `pending-${prResult.prNumber}`, + pullRequestUrl: prResult.prUrl, + pullRequestNumber: prResult.prNumber, + sanitizedSecrets: removedSecrets, + requiredSecrets, + }; + } + + /** + * Use a template to create a new workflow + */ + async useTemplate( + templateId: string, + params: { + workflowName: string; + secretMappings?: Record; + userId?: string; + organizationId?: string; + }, + ) { + const { workflowName, userId } = params; + + // Get template from database or GitHub + const template = await this.templatesRepository.findById(templateId); + let graph: Record; + + if (template && template.graph) { + graph = template.graph as Record; + } else { + // Try to fetch from GitHub + const templateContent = await this.githubService.getTemplateByName(templateId); + if (!templateContent) { + throw new HttpException('Template not found', HttpStatus.NOT_FOUND); + } + graph = templateContent.graph as Record; + } + + // Create new workflow from template graph + const newWorkflow = await this.workflowsRepository.create( + { + name: workflowName, + description: `Created from template: ${templateId}`, + nodes: Array.isArray(graph.nodes) ? graph.nodes : [], + edges: Array.isArray(graph.edges) ? graph.edges : [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + { organizationId: params.organizationId }, + ); + + // Increment template popularity + if (template) { + await this.templatesRepository.incrementPopularity(template.id); + } + + this.logger.log( + `Template ${templateId} used by ${userId} to create workflow ${newWorkflow.id}`, + ); + + return { + workflowId: newWorkflow.id, + templateName: template?.name || templateId, + }; + } + + /** + * Sync templates from GitHub repository + */ + async syncTemplates() { + if (!this.githubService.isConfigured()) { + throw new HttpException( + 'GitHub integration not configured', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const templates = await this.githubService.getTemplatesFromRepo(); + const synced = []; + + for (const template of templates) { + const { manifest, graph, requiredSecrets } = template.content; + + // Upsert template to database + const upserted = await this.templatesRepository.upsert({ + name: (manifest as any).name as string, + description: (manifest as any).description as string, + category: (manifest as any).category as string, + tags: (manifest as any).tags as string[], + author: (manifest as any).author as string, + repository: process.env.GITHUB_TEMPLATE_REPO!, + path: template.path, + branch: 'main', + version: (manifest as any).version as string, + manifest: manifest as TemplateManifest, + graph: graph as Record, + requiredSecrets: requiredSecrets as { + name: string; + type: string; + description?: string; + }[], + isOfficial: false, + isVerified: false, + }); + + synced.push({ + id: upserted.id, + name: upserted.name, + }); + } + + this.logger.log(`Synced ${synced.length} templates from GitHub`); + + return { + synced, + total: synced.length, + }; + } + + /** + * Get template submissions + */ + async getSubmissions(userId: string) { + return await this.templatesRepository.findSubmissionsByUser(userId); + } +} diff --git a/backend/src/templates/workflow-sanitization.service.ts b/backend/src/templates/workflow-sanitization.service.ts new file mode 100644 index 00000000..57a7f41e --- /dev/null +++ b/backend/src/templates/workflow-sanitization.service.ts @@ -0,0 +1,254 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RequiredSecret } from '../database/schema/templates'; + +/** + * Workflow Sanitization Service + * Removes secrets from workflows before publishing as templates + */ +@Injectable() +export class WorkflowSanitizationService { + private readonly logger = new Logger(WorkflowSanitizationService.name); + + /** + * Sanitize a workflow graph by removing all secret references + * Returns the sanitized graph along with detected secrets + */ + sanitizeWorkflow(graph: Record): { + sanitizedGraph: Record; + requiredSecrets: RequiredSecret[]; + removedSecrets: string[]; + } { + const requiredSecrets: RequiredSecret[] = []; + const removedSecrets: string[] = []; + + // Deep clone to avoid mutating original + const sanitizedGraph = JSON.parse(JSON.stringify(graph)); + + // Traverse the graph to find and remove secret references + this.traverseAndSanitize(sanitizedGraph, requiredSecrets, removedSecrets); + + this.logger.log(`Sanitized workflow: removed ${removedSecrets.length} secrets`); + + return { + sanitizedGraph, + requiredSecrets, + removedSecrets, + }; + } + + /** + * Deep traverse the graph and sanitize secret references + */ + private traverseAndSanitize( + obj: unknown, + requiredSecrets: RequiredSecret[], + removedSecrets: string[], + parentPath = '', + ): void { + if (!obj || typeof obj !== 'object') { + return; + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + this.traverseAndSanitize(obj[i], requiredSecrets, removedSecrets, `${parentPath}[${i}]`); + } + return; + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = parentPath ? `${parentPath}.${key}` : key; + + // Check for secret reference pattern + if (this.isSecretReference(value, key)) { + const secretInfo = this.extractSecretInfo(value, key); + if (secretInfo) { + requiredSecrets.push(secretInfo); + removedSecrets.push(secretInfo.name); + + // Replace with placeholder + (obj as Record)[key] = this.createPlaceholder(secretInfo); + } + } else if (typeof value === 'object' && value !== null) { + this.traverseAndSanitize(value, requiredSecrets, removedSecrets, currentPath); + } + } + } + + /** + * Check if a value is a secret reference + */ + private isSecretReference(value: unknown, key: string): boolean { + // Check for connection type references + if (key === 'connectionType' && typeof value === 'object' && value !== null) { + const connection = value as Record; + return connection.kind === 'secret' || connection.kind === 'primitive_secret'; + } + + // Check for secret references in specific fields + if (key === 'secretId' || key === 'secret_name' || key === 'apiKey') { + return true; + } + + // Check for secret pattern in strings + if (typeof value === 'string') { + return value.startsWith('{{secret:') || value.startsWith('{{ secrets.'); + } + + return false; + } + + /** + * Extract secret information from a secret reference + */ + private extractSecretInfo(value: unknown, key: string): RequiredSecret | null { + if (typeof value === 'object' && value !== null) { + const connection = value as Record; + if (connection.kind === 'secret' || connection.kind === 'primitive_secret') { + return { + name: (connection.name as string) || `secret_${key}`, + type: (connection.type as string) || 'string', + description: connection.description as string | undefined, + placeholder: this.generatePlaceholder((connection.name as string) || key), + }; + } + } + + if (typeof value === 'string') { + const match = value.match(/{{secret:(.+?)}}/) || value.match(/{{secrets\.(.+?)}}/); + if (match) { + return { + name: match[1].trim(), + type: 'string', + placeholder: this.generatePlaceholder(match[1].trim()), + }; + } + } + + return { + name: `secret_${key}`, + type: 'string', + placeholder: this.generatePlaceholder(key), + }; + } + + /** + * Create a placeholder for a secret + */ + private createPlaceholder(secretInfo: RequiredSecret): string { + return secretInfo.placeholder || `{{REPLACE_WITH_${secretInfo.name.toUpperCase()}}`; + } + + /** + * Generate a placeholder string + */ + private generatePlaceholder(secretName: string): string { + return `REPLACE_WITH_${secretName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`; + } + + /** + * Validate that a sanitized workflow graph is still valid + */ + validateSanitizedGraph(graph: Record): { + valid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + // Check if graph has required structure + if (!graph.nodes || !Array.isArray(graph.nodes)) { + errors.push('Graph must have a nodes array'); + } + + if (!graph.edges || !Array.isArray(graph.edges)) { + errors.push('Graph must have an edges array'); + } + + // Check if nodes have required properties + if (Array.isArray(graph.nodes) && graph.nodes.length > 0) { + for (const node of graph.nodes) { + if (typeof node !== 'object' || node === null) { + errors.push('All nodes must be objects'); + continue; + } + + if (!('id' in node)) { + errors.push(`Node missing required field: id`); + } + + if (!('componentId' in node)) { + errors.push(`Node ${node.id || 'unknown'} missing required field: componentId`); + } + } + } + + // Check for remaining secret references that shouldn't be there + const graphStr = JSON.stringify(graph); + const secretPatterns = ['{{secret:', '{{secrets.', 'connectionType.secret']; + for (const pattern of secretPatterns) { + if (graphStr.includes(pattern)) { + errors.push(`Graph still contains secret references: ${pattern}`); + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * Generate a template manifest from workflow and metadata + */ + generateManifest(params: { + name: string; + description: string; + category: string; + tags: string[]; + author: string; + graph: Record; + requiredSecrets: RequiredSecret[]; + }): Record { + const { name, description, category, tags, author, graph, requiredSecrets } = params; + + // Detect entry point (first trigger node) + const entryPoint = this.findEntryPoint(graph); + + return { + name, + description, + version: '1.0.0', + author, + category: category || 'other', + tags: tags || [], + requiredSecrets: requiredSecrets.map((s) => ({ + name: s.name, + type: s.type, + description: s.description || `Secret required for ${s.name}`, + })), + entryPoint, + nodeCount: Array.isArray(graph.nodes) ? graph.nodes.length : 0, + edgeCount: Array.isArray(graph.edges) ? graph.edges.length : 0, + createdAt: new Date().toISOString(), + }; + } + + /** + * Find the entry point node (first trigger node) + */ + private findEntryPoint(graph: Record): string | undefined { + if (!graph.nodes || !Array.isArray(graph.nodes)) { + return undefined; + } + + const triggerNode = graph.nodes.find((node: unknown) => { + if (typeof node === 'object' && node !== null) { + const n = node as Record; + return n.componentType === 'trigger'; + } + return false; + }); + + return triggerNode?.id as string | undefined; + } +} diff --git a/bun.lock b/bun.lock index d994c1f6..4a517da0 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,7 @@ "mqtt": "^5.15.0", "multer": "^2.0.2", "nestjs-zod": "^5.1.1", + "octokit": "^4.0.2", "pg": "^8.17.2", "posthog-node": "^5.24.2", "reflect-metadata": "^0.2.2", @@ -677,6 +678,56 @@ "@nuxtjs/opencollective": ["@nuxtjs/opencollective@0.3.2", "", { "dependencies": { "chalk": "^4.1.0", "consola": "^2.15.0", "node-fetch": "^2.6.1" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA=="], + "@octokit/app": ["@octokit/app@15.1.6", "", { "dependencies": { "@octokit/auth-app": "^7.2.1", "@octokit/auth-unauthenticated": "^6.1.3", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.6.1" } }, "sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw=="], + + "@octokit/auth-app": ["@octokit/auth-app@7.2.2", "", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.4", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw=="], + + "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@8.1.4", "", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ=="], + + "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@7.1.5", "", { "dependencies": { "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw=="], + + "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@5.1.6", "", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw=="], + + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@6.1.3", "", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA=="], + + "@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/oauth-app": ["@octokit/oauth-app@7.1.6", "", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.3", "@octokit/auth-oauth-user": "^5.1.3", "@octokit/auth-unauthenticated": "^6.1.2", "@octokit/core": "^6.1.4", "@octokit/oauth-authorization-url": "^7.1.1", "@octokit/oauth-methods": "^5.1.4", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA=="], + + "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@7.1.1", "", {}, "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA=="], + + "@octokit/oauth-methods": ["@octokit/oauth-methods@5.1.5", "", { "dependencies": { "@octokit/oauth-authorization-url": "^7.0.0", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@11.0.0", "", {}, "sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA=="], + + "@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@5.2.4", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@12.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-MPd6WK1VtZ52lFrgZ0R2FlaoiWllzgqFHaSZxvp72NmoDeZ0m8GeJdg4oB6ctqMTYyrnDYp592Xma21mrgiyDA=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@14.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-iQt6ovem4b7zZYZQtdv+PwgbL5VPq37th1m2x2TdkgimIDJpsi2A6Q/OI/23i/hR6z5mL0EgisNR4dcbmckSZQ=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@7.2.1", "", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@10.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^6.1.3" } }, "sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg=="], + + "@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="], + + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + + "@octokit/webhooks": ["@octokit/webhooks@13.9.1", "", { "dependencies": { "@octokit/openapi-webhooks-types": "11.0.0", "@octokit/request-error": "^6.1.7", "@octokit/webhooks-methods": "^5.1.1" } }, "sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@5.1.1", "", {}, "sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg=="], + "@okta/okta-sdk-nodejs": ["@okta/okta-sdk-nodejs@7.3.0", "", { "dependencies": { "@types/node-forge": "^1.3.1", "deep-copy": "^1.4.2", "eckles": "^1.4.1", "form-data": "^4.0.4", "https-proxy-agent": "^5.0.0", "js-yaml": "^4.1.0", "lodash": "^4.17.20", "njwt": "^2.0.1", "node-fetch": "^2.6.7", "node-jose": "^2.2.0", "parse-link-header": "^2.0.0", "rasha": "^1.2.5", "safe-flat": "^2.0.2", "url-parse": "^1.5.10", "uuid": "^11.1.0" } }, "sha512-6J3VV+8fBOqIXDqb3t2sBeXj1WOEZL6wP2AcGRzvMRMb2WL7JKR6ZDrt/1Kk7j4seXCKMpZrHsPYYdfRXwkSKQ=="], "@opensearch-project/opensearch": ["@opensearch-project/opensearch@3.5.1", "", { "dependencies": { "aws4": "^1.11.0", "debug": "^4.3.1", "hpagent": "^1.2.0", "json11": "^2.0.0", "ms": "^2.1.3", "secure-json-parse": "^2.4.0" } }, "sha512-6bf+HcuERzAtHZxrm6phjref54ABse39BpkDie/YO3AUFMCBrb3SK5okKSdT5n3+nDRuEEQLhQCl0RQV3s1qpA=="], @@ -1081,6 +1132,8 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.160", "", {}, "sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1421,6 +1474,8 @@ "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], @@ -1437,6 +1492,8 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -1781,6 +1838,8 @@ "extrareqp2": ["extrareqp2@1.0.0", "", { "dependencies": { "follow-redirects": "^1.14.0" } }, "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA=="], + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -2411,6 +2470,8 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "octokit": ["octokit@4.1.4", "", { "dependencies": { "@octokit/app": "^15.1.6", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/plugin-rest-endpoint-methods": "^14.0.0", "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^10.0.0", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.8.3" } }, "sha512-cRvxRte6FU3vAHRC9+PMSY3D+mRAs2Rd9emMoqp70UGRvJRM3sbAoim2IXRZNNsf8wVfn4sGxVBHRAP+JBVX/g=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -2897,6 +2958,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], @@ -2975,6 +3038,10 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], diff --git a/docs/TEMPLATE_LIBRARY.md b/docs/TEMPLATE_LIBRARY.md new file mode 100644 index 00000000..9dcb86e3 --- /dev/null +++ b/docs/TEMPLATE_LIBRARY.md @@ -0,0 +1,271 @@ +# Template Library Feature + +## Overview + +The Template Library feature allows users to share and discover workflow templates. Users can publish their workflows as templates, which are submitted via GitHub PR to a templates repository. Other users can browse and use these templates to quickly create new workflows. + +## Architecture + +### Backend Components + +1. **Templates Module** (`backend/src/templates/`) + - `templates.module.ts` - NestJS module configuration + - `templates.controller.ts` - API endpoints + - `templates.service.ts` - Business logic + - `templates.repository.ts` - Database operations + - `github-template.service.ts` - GitHub API integration + - `workflow-sanitization.service.ts` - Secret sanitization + +2. **Database Schema** (`backend/src/database/schema/templates.ts`) + - `templates` table - Stores template metadata (cached from GitHub) + - `templates_submissions` table - Tracks PR-based submissions + +### Frontend Components + +1. **Pages** + - `TemplateLibraryPage.tsx` - Main template library page with filtering + +2. **Features** + - `UseTemplateModal.tsx` - Modal for using a template + - `PublishTemplateModal.tsx` - Modal for publishing a workflow as template + +3. **Store** + - `templateStore.ts` - Zustand store for template state management + +4. **API** + - Extended `api.ts` with templates API client + +## API Endpoints + +### Public Endpoints + +- `GET /templates` - List all templates with optional filters + - Query params: `category`, `search`, `tags` +- `GET /templates/:id` - Get template details +- `GET /templates/categories` - Get available categories +- `GET /templates/tags` - Get available tags + +### Admin Endpoints + +- `POST /templates/publish` - Publish workflow as template (creates PR) +- `POST /templates/:id/use` - Use template to create new workflow +- `POST /templates/sync` - Sync templates from GitHub repository +- `GET /templates/my` - Get user's submitted templates +- `GET /templates/submissions` - Get template submissions + +## Environment Variables + +### Required + +```bash +# GitHub Configuration +GITHUB_TEMPLATE_REPO=org/templates-repo +GITHUB_TOKEN=ghp_xxx # GitHub PAT with repo permissions + +# GitHub OAuth (optional, for user authentication) +GITHUB_CLIENT_ID=xxx +GITHUB_CLIENT_SECRET=xxx +``` + +### GitHub Token Permissions + +The GitHub personal access token needs the following permissions: +- `repo` (full control of private repositories) +- `pull_requests` (to create PRs) + +## Workflow Sanitization + +When publishing a workflow as a template, the system: + +1. **Removes secret references** - All secret values are removed from the workflow graph +2. **Creates secret placeholders** - Each removed secret is documented as a required secret +3. **Validates the graph** - Ensures the sanitized graph is still valid +4. **Generates a manifest** - Creates metadata about the template + +### Required Secrets Schema + +```typescript +{ + name: string; // Secret name + type: string; // Secret type (e.g., "api_key", "token") + description?: string; // What this secret is for + placeholder?: string; // Example format +} +``` + +## Template Manifest + +Each template has a manifest with the following structure: + +```typescript +{ + name: string; // Template name + description?: string; // Template description + version?: string; // Version + author?: string; // Author name/org + category?: string; // Category + tags?: string[]; // Tags + requiredSecrets?: RequiredSecret[]; // Required secrets + entryPoint?: string; // Entry point reference +} +``` + +## GitHub PR Workflow + +### Publishing a Template + +1. User clicks "Publish as Template" in Workflow Builder +2. User fills in template metadata (name, description, category, tags, author) +3. Backend sanitizes the workflow graph (removes secrets) +4. Backend creates a new branch in the templates repository +5. Backend commits the template JSON files +6. Backend creates a pull request +7. User receives PR URL for tracking + +### Template File Structure + +``` +templates/ + ├── security-scanner.json + ├── incident-response.json + └── compliance-check.json +``` + +Each template file contains: + +```json +{ + "manifest": { ... }, + "graph": { ... }, + "requiredSecrets": [ ... ] +} +``` + +## Setup Instructions + +### 1. Create Templates Repository + +1. Create a new GitHub repository for templates +2. Configure repository settings (private/public based on your needs) +3. Add the repository URL to environment variables + +### 2. Configure GitHub App + +1. Create a GitHub Personal Access Token or GitHub App +2. Grant necessary permissions +3. Add credentials to environment variables + +### 3. Run Database Migration + +```bash +# The migration file is at: +backend/drizzle/0020_create-templates.sql +``` + +### 4. Add Templates Module + +The TemplatesModule is already imported in `backend/src/app.module.ts`. + +## Usage + +### For Users + +1. Browse templates in the Template Library +2. Filter by category, search, or tags +3. Click "Use Template" on a template +4. Configure required secrets +5. Create workflow from template + +### For Publishers + +1. Create a workflow in the Workflow Builder +2. Click "Publish as Template" in the top bar +3. Fill in template metadata +4. Submit - a PR will be created +5. Wait for PR review and merge +6. Template appears in library after sync + +## Template Types + +### Community Templates +- Submitted by users +- Reviewed before appearing in library +- Tagged with relevant categories + +### Official Templates +- Created and maintained by ShipSec team +- Verified and tested +- Marked with "Official" badge + +### Enterprise Templates +- Organization-specific templates +- Private to organization +- Custom workflows for internal use + +## Troubleshooting + +### Templates not appearing after PR merge + +1. Run the sync endpoint: `POST /templates/sync` +2. Check the GitHub repository configuration +3. Verify the backend has access to the repository + +### Secrets not being sanitized + +1. Check the workflow graph structure +2. Verify secret references follow the expected format +3. Check backend logs for sanitization errors + +### GitHub PR creation failing + +1. Verify `GITHUB_TOKEN` has correct permissions +2. Check `GITHUB_TEMPLATE_REPO` is correct +3. Ensure the repository exists and is accessible +4. Check GitHub rate limits + +## Development + +### Adding New Template Categories + +Edit `TEMPLATE_CATEGORIES` in `PublishTemplateModal.tsx`: + +```typescript +const TEMPLATE_CATEGORIES = [ + 'Security', + 'Monitoring', + 'Compliance', + 'Incident Response', + 'Data Processing', + 'Integration', + 'Automation', + 'Reporting', + 'Testing', + 'Other', + // Add your category here +]; +``` + +### Customizing Template Display + +Template cards are rendered in `TemplateCard` component within `TemplateLibraryPage.tsx`. + +### Modifying Sanitization Rules + +Edit `workflow-sanitization.service.ts` to customize how secrets are detected and removed. + +## Security Considerations + +1. **Secret Sanitization** - All secret values are removed before publishing +2. **PR Review** - Templates require review before being merged +3. **Access Control** - Only admins can publish templates +4. **Repository Permissions** - GitHub token should have minimal required permissions + +## Future Enhancements + +- [ ] Template versioning and updates +- [ ] Template ratings and reviews +- [ ] Template analytics (usage, popularity) +- [ ] Template preview screenshots +- [ ] Template documentation editor +- [ ] Bulk template operations +- [ ] Template marketplace integration diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87fea899..0c7cd058 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { WorkflowList } from '@/pages/WorkflowList'; +import { TemplateLibraryPage } from '@/pages/TemplateLibraryPage'; import { WorkflowBuilder } from '@/features/workflow-builder/WorkflowBuilder'; import { SecretsManager } from '@/pages/SecretsManager'; import { ApiKeysManager } from '@/pages/ApiKeysManager'; @@ -52,6 +53,7 @@ function App() { } /> + } /> Promise | void; onImport?: (file: File) => Promise | void; onExport?: () => void; + onPublishTemplate?: () => void; canManageWorkflows?: boolean; onUndo?: () => void; onRedo?: () => void; @@ -54,10 +56,12 @@ export function TopBar({ selectedRunId, selectedRunStatus, selectedRunOrgId, + isNew, onRun, onSave, onImport, onExport, + onPublishTemplate, canManageWorkflows = true, onUndo, onRedo, @@ -366,7 +370,7 @@ export function TopBar({ - {(onImport || onExport) && ( + {(onImport || onExport || onPublishTemplate) && (
{onImport && ( <> @@ -405,10 +409,26 @@ export function TopBar({ Export )} + {onPublishTemplate && !isNew && ( + + )}
)} - {(onImport || onExport) && ( + {(onImport || onExport || onPublishTemplate) && (
@@ -432,6 +452,12 @@ export function TopBar({ Export )} + {onPublishTemplate && !isNew && ( + + + Publish as Template + + )}
diff --git a/frontend/src/features/analytics/events.ts b/frontend/src/features/analytics/events.ts index 925e16a2..dee6dbe5 100644 --- a/frontend/src/features/analytics/events.ts +++ b/frontend/src/features/analytics/events.ts @@ -14,6 +14,8 @@ export const Events = { NodeAdded: 'ui_node_added', SecretCreated: 'ui_secret_created', SecretDeleted: 'ui_secret_deleted', + TemplateUseClicked: 'ui_template_use_clicked', + TemplatePublishClicked: 'ui_template_publish_clicked', } as const; type EventName = (typeof Events)[keyof typeof Events]; @@ -57,6 +59,15 @@ const payloadSchemas: Record> = { [Events.SecretDeleted]: z.object({ name_length: z.number().int().nonnegative().optional(), }), + [Events.TemplateUseClicked]: z.object({ + template_id: z.string().optional(), + template_name: z.string().optional(), + category: z.string().optional(), + }), + [Events.TemplatePublishClicked]: z.object({ + workflow_id: z.string().optional(), + template_name: z.string().optional(), + }), }; export function track(event: T, payload: unknown = {}): void { diff --git a/frontend/src/features/templates/PublishTemplateModal.tsx b/frontend/src/features/templates/PublishTemplateModal.tsx new file mode 100644 index 00000000..144d0e84 --- /dev/null +++ b/frontend/src/features/templates/PublishTemplateModal.tsx @@ -0,0 +1,344 @@ +import { useState, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Loader2, AlertCircle, CheckCircle2, GitPullRequest, X } from 'lucide-react'; +import { useTemplateStore } from '@/store/templateStore'; +import { cn } from '@/lib/utils'; + +interface PublishTemplateModalProps { + workflowId: string; + workflowName: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (result: { + templateId: string; + pullRequestUrl: string; + pullRequestNumber: number; + }) => void; +} + +const TEMPLATE_CATEGORIES = [ + 'Security', + 'Monitoring', + 'Compliance', + 'Incident Response', + 'Data Processing', + 'Integration', + 'Automation', + 'Reporting', + 'Testing', + 'Other', +]; + +const COMMON_TAGS = [ + 'security', + 'monitoring', + 'automation', + 'integration', + 'api', + 'notification', + 'compliance', + 'scanning', + 'analysis', + 'reporting', + 'incident', + 'response', + 'forensics', + 'enrichment', + 'detection', +]; + +export function PublishTemplateModal({ + workflowId, + workflowName, + open, + onOpenChange, + onSuccess, +}: PublishTemplateModalProps) { + const { publishTemplate, isLoading } = useTemplateStore(); + + const [name, setName] = useState(workflowName); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState(''); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(''); + const [author, setAuthor] = useState(''); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ + templateId: string; + pullRequestUrl: string; + pullRequestNumber: number; + } | null>(null); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError('Please enter a template name'); + return; + } + + if (!category) { + setError('Please select a category'); + return; + } + + if (!author.trim()) { + setError('Please enter your name or organization'); + return; + } + + try { + const publishResult = await publishTemplate({ + workflowId, + name: name.trim(), + description: description.trim() || undefined, + category: category || '', // Ensure category is a string + tags, + author: author.trim(), + }); + + setResult(publishResult); + onSuccess?.(publishResult); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to publish template'); + } + }, + [workflowId, name, description, category, tags, author, publishTemplate, onSuccess], + ); + + const handleAddTag = () => { + const tag = tagInput.trim().toLowerCase(); + if (tag && !tags.includes(tag)) { + setTags([...tags, tag]); + } + setTagInput(''); + }; + + const handleRemoveTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + const handleAddCommonTag = (tag: string) => { + if (!tags.includes(tag)) { + setTags([...tags, tag]); + } + }; + + const handleClose = () => { + if (!isLoading) { + onOpenChange(false); + // Reset form after a delay to avoid visual glitch + setTimeout(() => { + setName(workflowName); + setDescription(''); + setCategory(''); + setTags([]); + setAuthor(''); + setError(null); + setResult(null); + }, 200); + } + }; + + return ( + + + + + + Publish as Template + + + Submit your workflow as a template. A pull request will be created in the templates + repository. + + + + {result ? ( + // Success State +
+
+
+ +
+
+

Template Submitted!

+

+ Your template has been submitted as PR #{result.pullRequestNumber} +

+
+
+
+ Template ID: + + {result.templateId} + +
+ + View Pull Request → + +
+

+ Your workflow will be reviewed before being added to the template library. + You'll be notified once it's approved. +

+
+
+ ) : ( + // Form +
+ {/* Template Name */} +
+ + setName(e.target.value)} + placeholder="My Security Template" + /> +
+ + {/* Description */} +
+ +