diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index e5c0e912..1646b74d 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -155,6 +155,70 @@ describe('PUT /api/stacks/:stackName/git-source — stack existence guard', () = }); }); +describe('git-source routes — repository path validation', () => { + it('rejects compose_path traversal before service execution', async () => { + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: '../compose.yaml', + auth_type: 'none', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/compose_path/i); + }); + + it('rejects absolute env_path on create-from-git', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + stack_name: 'route-from-git-env-abs', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + sync_env: true, + env_path: '/etc/passwd', + auth_type: 'none', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/env_path/i); + }); + + it('rejects string auto_deploy_on_apply on update', async () => { + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'none', + auto_deploy_on_apply: 'true', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/auto_deploy_on_apply/i); + }); + + it('rejects string auto_deploy_on_apply on create-from-git', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + stack_name: 'route-from-git-auto-deploy-string', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'none', + auto_deploy_on_apply: 'true', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/auto_deploy_on_apply/i); + }); +}); + describe('git-source routes — invalid stack names', () => { it('returns 400 for traversal attempts on GET per-stack', async () => { const res = await request(app) diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 89273238..d4616b55 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -530,6 +530,25 @@ describe('GitSourceService.fetchFromGit (.git metadata guard)', () => { composePath: 'gitops.yaml', })).resolves.toBeDefined(); }); + + it('rejects compose paths that are symbolic links', async () => { + mockSuccessfulClone(); + const { promises: fsp } = await import('fs'); + const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValue({ + isSymbolicLink: () => true, + } as Awaited>); + + await expect(svc().fetchFromGit({ + repoUrl: 'https://github.com/example/repo.git', + branch: 'main', + composePath: 'compose.yaml', + })).rejects.toMatchObject({ + code: 'FILE_NOT_FOUND', + message: expect.stringMatching(/symbolic link/i), + }); + + lstatSpy.mockRestore(); + }); }); describe('GitSourceService.fetchFromGit (LFS + submodule detection)', () => { diff --git a/backend/src/__tests__/webhooks-git-source.test.ts b/backend/src/__tests__/webhooks-git-source.test.ts new file mode 100644 index 00000000..ad7ce841 --- /dev/null +++ b/backend/src/__tests__/webhooks-git-source.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let WebhookService: typeof import('../services/WebhookService').WebhookService; + +function adminToken(): string { + return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ WebhookService } = await import('../services/WebhookService')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper'); +}); + +describe('node-aware Git source webhooks', () => { + it('persists node_id when creating a webhook', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id; + db.upsertGitSource({ + stack_name: 'webhook-local-git', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + sync_env: false, + env_path: null, + auth_type: 'none', + encrypted_token: null, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + last_applied_content_hash: null, + pending_commit_sha: null, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + }); + + const res = await request(app) + .post('/api/webhooks') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + node_id: nodeId, + name: 'local git webhook', + stack_name: 'webhook-local-git', + action: 'git-pull', + }); + + expect(res.status).toBe(201); + const row = db.getWebhook(res.body.id); + expect(row?.node_id).toBe(nodeId); + }); + + it('checks remote git-source existence through the target node', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-git-webhook', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://remote.example', + api_token: 'remote-token', + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}', { status: 200 })); + + const res = await request(app) + .post('/api/webhooks') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + node_id: remoteNodeId, + name: 'remote git webhook', + stack_name: 'remote-stack', + action: 'git-pull', + }); + + expect(res.status).toBe(201); + expect(fetchSpy).toHaveBeenCalledWith( + 'http://remote.example/api/stacks/remote-stack/git-source', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('rejects malformed webhook node_id values', async () => { + const res = await request(app) + .post('/api/webhooks') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + node_id: 'remote', + name: 'bad node id webhook', + stack_name: 'remote-stack', + action: 'deploy', + }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/node_id/i); + }); + + it('rejects retargeting an existing git-pull webhook without a Git source', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id; + db.upsertGitSource({ + stack_name: 'retarget-source-stack', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + sync_env: false, + env_path: null, + auth_type: 'none', + encrypted_token: null, + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + last_applied_content_hash: null, + pending_commit_sha: null, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + }); + const webhookId = db.addWebhook({ + node_id: nodeId, + name: 'retarget git webhook', + stack_name: 'retarget-source-stack', + action: 'git-pull', + secret: WebhookService.getInstance().generateSecret(), + enabled: true, + }); + + const res = await request(app) + .put(`/api/webhooks/${webhookId}`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ stack_name: 'retarget-no-source-stack' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Git source/i); + }); + + it('records failure when remote node disconnects before execution', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-disconnected-webhook', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + const webhookId = db.addWebhook({ + node_id: remoteNodeId, + name: 'disconnected remote git', + stack_name: 'remote-stack', + action: 'git-pull', + secret: WebhookService.getInstance().generateSecret(), + enabled: true, + }); + + const result = await WebhookService.getInstance().execute(webhookId, 'git-pull', 'test'); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/unreachable|configured/i); + const history = db.getWebhookExecutions(webhookId); + expect(history[0].status).toBe('failure'); + expect(history[0].error).toMatch(/unreachable|configured/i); + }); + + it('records failure when remote node request times out', async () => { + vi.useFakeTimers(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-timeout-webhook', + type: 'remote', + compose_dir: '/tmp', + is_default: false, + api_url: 'http://remote-timeout.example', + api_token: 'remote-token', + }); + const webhookId = db.addWebhook({ + node_id: remoteNodeId, + name: 'timeout remote git', + stack_name: 'remote-stack', + action: 'git-pull', + secret: WebhookService.getInstance().generateSecret(), + enabled: true, + }); + vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => new Promise((_resolve, reject) => { + const signal = (init as RequestInit | undefined)?.signal; + signal?.addEventListener('abort', () => reject(new Error('aborted'))); + })); + + const pending = WebhookService.getInstance().execute(webhookId, 'git-pull', 'test'); + await vi.advanceTimersByTimeAsync(30_000); + const result = await pending; + + expect(result.success).toBe(false); + expect(result.error).toMatch(/timed out/i); + const history = db.getWebhookExecutions(webhookId); + expect(history[0].status).toBe('failure'); + expect(history[0].error).toMatch(/timed out/i); + vi.useRealTimers(); + }); +}); diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index da62dd72..7e37fcf4 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -6,7 +6,7 @@ import { DatabaseService } from '../services/DatabaseService'; import { checkPermission, requirePermission } from '../middleware/permissions'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { triggerPostDeployScan } from '../helpers/policyGate'; -import { isValidStackName } from '../utils/validation'; +import { isValidGitSourcePath, isValidStackName } from '../utils/validation'; import { sendGitSourceError } from '../utils/gitSourceHttp'; import { sanitizeForLog } from '../utils/safeLog'; @@ -95,6 +95,14 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res res.status(400).json({ error: 'auth_type must be "none" or "token"' }); return; } + if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') { + res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' }); + return; + } + if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') { + res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' }); + return; + } if (!/^https:\/\//i.test(repo_url)) { res.status(400).json({ error: 'Only HTTPS repository URLs are supported' }); return; @@ -115,10 +123,21 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res res.status(400).json({ error: 'env_path is too long' }); return; } + if (!isValidGitSourcePath(compose_path.trim())) { + res.status(400).json({ error: 'compose_path must be a relative repository file path' }); + return; + } + if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) { + res.status(400).json({ error: 'env_path must be a relative repository file path' }); + return; + } if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) { res.status(400).json({ error: 'token is too long' }); return; } + const autoApplyOnWebhook = auto_apply_on_webhook === true; + const autoDeployOnApply = auto_deploy_on_apply === true; + if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; // Confirm the stack actually exists on the active node. Without this guard // a caller could stash a git-source row for a name that does not exist @@ -145,8 +164,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res envPath: resolvedEnvPath, authType: auth_type, token: typeof token === 'string' ? token : undefined, - autoApplyOnWebhook: Boolean(auto_apply_on_webhook), - autoDeployOnApply: Boolean(auto_deploy_on_apply), + autoApplyOnWebhook, + autoDeployOnApply, }); console.log(`[GitSource] Configured git source for ${stackName}`); @@ -232,6 +251,23 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r } }); +stackGitSourceRouter.post('/:stackName/git-source/webhook-pull', async (req: Request, res: Response): Promise => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + try { + const source = GitSourceService.getInstance().get(stackName); + if (source?.auto_apply_on_webhook && source.auto_deploy_on_apply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + const result = await GitSourceService.getInstance().handleWebhookPull(stackName); + res.json(result); + } catch (error) { + sendGitSourceError(res, error); + } +}); + stackGitSourceRouter.post('/:stackName/git-source/dismiss-pending', async (req: Request, res: Response): Promise => { const stackName = req.params.stackName as string; if (!isValidStackName(stackName)) { diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 6fcf5bea..63d01d48 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -13,7 +13,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { requirePermission } from '../middleware/permissions'; import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates'; import { NotificationService, type NotificationCategory } from '../services/NotificationService'; -import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; +import { isValidGitSourcePath, isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; @@ -406,6 +406,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (typeof compose_path !== 'string' || !compose_path.trim()) { return res.status(400).json({ error: 'compose_path is required' }); } + if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') { + return res.status(400).json({ error: 'auto_apply_on_webhook must be a boolean' }); + } + if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') { + return res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' }); + } const resolvedAuthType = auth_type === 'token' ? 'token' : 'none'; if (!/^https:\/\//i.test(repo_url)) { return res.status(400).json({ error: 'Only HTTPS repository URLs are supported' }); @@ -425,6 +431,16 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (typeof token === 'string' && token.length > 8192) { return res.status(400).json({ error: 'token is too long' }); } + if (!isValidGitSourcePath(compose_path.trim())) { + return res.status(400).json({ error: 'compose_path must be a relative repository file path' }); + } + if (typeof env_path === 'string' && env_path.trim() && !isValidGitSourcePath(env_path.trim())) { + return res.status(400).json({ error: 'env_path must be a relative repository file path' }); + } + const autoApplyOnWebhook = auto_apply_on_webhook === true; + const autoDeployOnApply = auto_deploy_on_apply === true; + if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return; + if (deploy_now === true && !requirePermission(req, res, 'stack:deploy', 'stack', stack_name)) return; const stacks = await FileSystemService.getInstance(req.nodeId).getStacks(); if (stacks.includes(stack_name)) { @@ -440,7 +456,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (fromGitDiag) { console.log( - `[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}` + `[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${autoApplyOnWebhook} autoDeployOnApply=${autoDeployOnApply} deployNow=${deploy_now === true}` ); } @@ -453,8 +469,8 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { envPath: resolvedEnvPath, authType: resolvedAuthType, token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null, - autoApplyOnWebhook: Boolean(auto_apply_on_webhook), - autoDeployOnApply: Boolean(auto_deploy_on_apply), + autoApplyOnWebhook, + autoDeployOnApply, }); invalidateNodeCaches(req.nodeId); diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 5285e8ef..8a2f1151 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -1,7 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; import { WebhookService } from '../services/WebhookService'; -import { GitSourceService } from '../services/GitSourceService'; import { LicenseService } from '../services/LicenseService'; import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; @@ -27,7 +26,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { - const { name, stack_name, action, enabled } = req.body; + const { name, stack_name, action, enabled, node_id } = req.body; if (!name || !stack_name || !action) { res.status(400).json({ error: 'name, stack_name, and action are required' }); return; @@ -36,7 +35,16 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` }); return; } - if (action === 'git-pull' && !GitSourceService.getInstance().get(stack_name)) { + if (node_id !== undefined && !Number.isInteger(node_id)) { + res.status(400).json({ error: 'node_id must be an integer' }); + return; + } + const targetNodeId = node_id ?? req.nodeId ?? DatabaseService.getInstance().getDefaultNode()?.id; + if (!targetNodeId || !DatabaseService.getInstance().getNode(targetNodeId)) { + res.status(400).json({ error: 'node_id must reference an existing node' }); + return; + } + if (action === 'git-pull' && !(await WebhookService.getInstance().gitSourceExists(stack_name, targetNodeId))) { res.status(400).json({ error: 'Configure a Git source for this stack before creating a git-pull webhook' }); return; } @@ -44,6 +52,7 @@ webhooksRouter.post('/', authMiddleware, async (req: Request, res: Response): Pr const svc = WebhookService.getInstance(); const secret = svc.generateSecret(); const id = DatabaseService.getInstance().addWebhook({ + node_id: targetNodeId, name, stack_name, action, secret, enabled: enabled !== false, }); @@ -63,20 +72,31 @@ webhooksRouter.put('/:id', authMiddleware, async (req: Request, res: Response): const webhook = DatabaseService.getInstance().getWebhook(id); if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; } - const { name, stack_name, action, enabled } = req.body; + const { name, stack_name, action, enabled, node_id } = req.body; + if (node_id !== undefined && !Number.isInteger(node_id)) { + res.status(400).json({ error: 'node_id must be an integer' }); + return; + } + const targetNodeId = node_id ?? webhook.node_id; + if (node_id !== undefined && !DatabaseService.getInstance().getNode(targetNodeId)) { + res.status(400).json({ error: 'node_id must reference an existing node' }); + return; + } if (action && !VALID_WEBHOOK_ACTIONS.includes(action)) { res.status(400).json({ error: `action must be one of: ${VALID_WEBHOOK_ACTIONS.join(', ')}` }); return; } - if (action === 'git-pull') { - const targetStack = stack_name || webhook.stack_name; - if (!GitSourceService.getInstance().get(targetStack)) { + const effectiveAction = action ?? webhook.action; + const effectiveStackName = stack_name ?? webhook.stack_name; + if (effectiveAction === 'git-pull') { + const targetStack = effectiveStackName; + if (!(await WebhookService.getInstance().gitSourceExists(targetStack, targetNodeId))) { res.status(400).json({ error: 'Configure a Git source for this stack before enabling a git-pull webhook' }); return; } } - DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled }); + DatabaseService.getInstance().updateWebhook(id, { node_id, name, stack_name, action, enabled }); res.json({ success: true }); } catch (error) { console.error('[Webhooks] Update error:', error); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 3bd50375..a1ab4878 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -103,6 +103,7 @@ export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | ' export interface Webhook { id?: number; + node_id: number; name: string; stack_name: string; action: WebhookAction; @@ -757,6 +758,7 @@ export class DatabaseService { CREATE TABLE IF NOT EXISTS webhooks ( id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER, name TEXT NOT NULL, stack_name TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'deploy', @@ -1157,6 +1159,12 @@ export class DatabaseService { // Distributed API model columns maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''"); maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''"); + maybeAddCol('webhooks', 'node_id', 'INTEGER'); + this.db.prepare(` + UPDATE webhooks + SET node_id = COALESCE((SELECT id FROM nodes WHERE is_default = 1 LIMIT 1), 1) + WHERE node_id IS NULL + `).run(); // Pilot Agent outbound-mode columns maybeAddCol('nodes', 'mode', "TEXT NOT NULL DEFAULT 'proxy'"); @@ -2286,6 +2294,7 @@ export class DatabaseService { public getWebhooks(): Webhook[] { return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({ ...row, + node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1), enabled: row.enabled === 1, })); } @@ -2293,21 +2302,26 @@ export class DatabaseService { public getWebhook(id: number): Webhook | undefined { const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any; if (!row) return undefined; - return { ...row, enabled: row.enabled === 1 }; + return { + ...row, + node_id: Number(row.node_id ?? this.getDefaultNode()?.id ?? 1), + enabled: row.enabled === 1, + }; } public addWebhook(webhook: Omit): number { const now = Date.now(); const result = this.db.prepare( - 'INSERT INTO webhooks (name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now); + 'INSERT INTO webhooks (node_id, name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(webhook.node_id, webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now); return result.lastInsertRowid as number; } - public updateWebhook(id: number, updates: Partial>): void { + public updateWebhook(id: number, updates: Partial>): void { const fields: string[] = []; const values: (string | number)[] = []; + if (updates.node_id !== undefined) { fields.push('node_id = ?'); values.push(updates.node_id); } if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); } if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 79ea1df8..bba060c8 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -187,8 +187,11 @@ export class FileSystemService { } async getEnvContent(stackName: string): Promise { - const stackDir = this.resolveStackDir(stackName); - const envPath = path.join(stackDir, '.env'); + const base = path.resolve(this.baseDir); + const envPath = path.resolve(base, path.basename(stackName), '.env'); + if (!isPathWithinBase(envPath, base)) { + throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' }); + } try { return await fsPromises.readFile(envPath, 'utf-8'); } catch (error) { diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index a41bb51b..9bb71951 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -12,6 +12,8 @@ import { NodeRegistry } from './NodeRegistry'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; +import { isPathWithinBase } from '../utils/validation'; +import type { GitHttpRequest, GitHttpResponse, HttpClient } from 'isomorphic-git/http/node'; // isomorphic-git is the heaviest dependency in the backend (~5 MB) and only // fires when a stack is created from a Git source. Lazy-load it so cold @@ -34,6 +36,70 @@ async function loadIsomorphicGit(): Promise<{ git: IsomorphicGit; gitHttp: Isomo return { git: cachedGit, gitHttp: cachedGitHttp }; } +function cloneTimeoutError(): Error & { code: string } { + return Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' }); +} + +async function collectGitBody(body: AsyncIterableIterator, signal: AbortSignal): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of body) { + if (signal.aborted) throw cloneTimeoutError(); + chunks.push(chunk); + size += chunk.byteLength; + } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function responseBodyIterator(body: ReadableStream | null): AsyncIterableIterator { + async function* iterate(): AsyncIterableIterator { + if (!body) return; + const reader = body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } + } + return iterate(); +} + +function createAbortableGitHttp(signal: AbortSignal): HttpClient { + return { + async request(request: GitHttpRequest): Promise { + if (signal.aborted) { + throw cloneTimeoutError(); + } + + const response = await fetch(request.url, { + method: request.method ?? 'GET', + headers: request.headers, + body: request.body ? await collectGitBody(request.body, signal) : undefined, + signal, + }); + + return { + url: response.url, + method: request.method, + statusCode: response.status, + statusMessage: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseBodyIterator(response.body), + }; + }, + }; +} + /** * GitSourceService - fetch compose files from a Git repository and apply * them to local stacks. Tokens are encrypted via CryptoService. Shallow @@ -211,6 +277,47 @@ async function hasSubmodules(dir: string): Promise { } } +async function readRepoFile(rootDir: string, relPath: string, label: string): Promise { + const root = path.resolve(rootDir); + const safeRel = relPath.split('/').map(s => path.basename(s)).join('/'); + const abs = path.resolve(root, safeRel); + if (!isPathWithinBase(abs, root)) { + throw new GitSourceError('FILE_NOT_FOUND', `${label} resolves outside the repository.`); + } + + let stat; + try { + stat = await fsPromises.lstat(abs); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${relPath}`); + } + throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); + } + if (stat.isSymbolicLink()) { + throw new GitSourceError('FILE_NOT_FOUND', `${label} cannot be a symbolic link.`); + } + + let real; + try { + real = await fsPromises.realpath(abs); + } catch (e) { + throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); + } + if (!isPathWithinBase(real, root)) { + throw new GitSourceError('FILE_NOT_FOUND', `${label} resolves outside the repository.`); + } + + try { + return await fsPromises.readFile(real, 'utf-8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${relPath}`); + } + throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); + } +} + const SUBMODULE_WARNING = 'Repository contains Git submodules. Their contents are not cloned; any paths referenced from them will be missing at deploy time.'; @@ -414,23 +521,22 @@ export class GitSourceService { : undefined; try { - const { git, gitHttp } = await loadIsomorphicGit(); - // isomorphic-git does not natively accept an AbortSignal, so we - // wrap the clone in a Promise.race against a timeout rejection. - // The clone will keep running in the background until the socket - // resolves, but we will not block the caller indefinitely. + const { git } = await loadIsomorphicGit(); + // Bound clone duration and abort the HTTP transport so timed-out + // fetches do not keep sockets and packfile streams alive. let timer: NodeJS.Timeout | undefined; + const controller = new AbortController(); const timeout = new Promise((_, reject) => { - timer = setTimeout( - () => reject(Object.assign(new Error('Clone timed out'), { code: 'ETIMEDOUT' })), - timeoutMs, - ); + timer = setTimeout(() => { + controller.abort(); + reject(cloneTimeoutError()); + }, timeoutMs); }); try { await Promise.race([ git.clone({ fs: { promises: fsPromises }, - http: gitHttp, + http: createAbortableGitHttp(controller.signal), dir, url: repoUrl, ref: branch, @@ -453,19 +559,7 @@ export class GitSourceService { } const commitSha = log[0].oid; - const composeAbs = path.resolve(dir, composePath); - if (!composeAbs.startsWith(path.resolve(dir))) { - throw new GitSourceError('FILE_NOT_FOUND', 'Compose path resolves outside the repository.'); - } - let composeContent: string; - try { - composeContent = await fsPromises.readFile(composeAbs, 'utf-8'); - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') { - throw new GitSourceError('FILE_NOT_FOUND', `File not found in repository: ${composePath}`); - } - throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); - } + const composeContent = await readRepoFile(dir, composePath, 'Compose path'); if (isLfsPointer(composeContent)) { console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`); throw new GitSourceError( @@ -476,20 +570,16 @@ export class GitSourceService { let envContent: string | null = null; if (envPath) { - const envAbs = path.resolve(dir, envPath); - if (!envAbs.startsWith(path.resolve(dir))) { - throw new GitSourceError('FILE_NOT_FOUND', 'Env path resolves outside the repository.'); - } try { - envContent = await fsPromises.readFile(envAbs, 'utf-8'); + envContent = await readRepoFile(dir, envPath, 'Env path'); } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (e instanceof GitSourceError && e.code === 'FILE_NOT_FOUND' && e.message.startsWith('File not found')) { // A missing sibling .env is legitimate (repo may not carry one // in the requested directory). Return null so the caller can // decide whether to warn. envContent = null; } else { - throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message)); + throw e; } } if (envContent !== null && isLfsPointer(envContent)) { diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index 162b9d16..0b2251b0 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -1,11 +1,20 @@ import crypto from 'crypto'; -import { DatabaseService } from './DatabaseService'; import { ComposeService } from './ComposeService'; +import { DatabaseService } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; import { GitSourceService } from './GitSourceService'; +import { LicenseService } from './LicenseService'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers'; import { NodeRegistry } from './NodeRegistry'; +import { getErrorMessage } from '../utils/errors'; +import { isValidStackName } from '../utils/validation'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; +type ExecutionResult = { success: boolean; error?: string; duration_ms: number }; +type ExecutionStatus = 'success' | 'failure'; + +const REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS = 30_000; + export class WebhookService { private static instance: WebhookService; @@ -21,7 +30,6 @@ export class WebhookService { } public validateSignature(payload: string, secret: string, signature: string): boolean { - // Expect format: sha256= const parts = signature.split('='); if (parts.length !== 2 || parts[0] !== 'sha256') return false; @@ -30,123 +38,259 @@ export class WebhookService { .update(payload) .digest('hex'); - return crypto.timingSafeEqual( - Buffer.from(expected, 'hex'), - Buffer.from(parts[1], 'hex') - ); + try { + return crypto.timingSafeEqual( + Buffer.from(expected, 'hex'), + Buffer.from(parts[1], 'hex'), + ); + } catch { + return false; + } } - public async execute(webhookId: number, action: string, triggerSource: string | null, atomic?: boolean): Promise<{ success: boolean; error?: string; duration_ms: number }> { - const db = DatabaseService.getInstance(); - const webhook = db.getWebhook(webhookId); + public async gitSourceExists(stackName: string, nodeId: number): Promise { + const node = NodeRegistry.getInstance().getNode(nodeId); + if (!node) return false; + if (node.type !== 'remote') return GitSourceService.getInstance().get(stackName) !== undefined; + + const response = await this.remoteStackRequest(nodeId, stackName, 'git-source', 'GET'); + return response.ok; + } + + public async execute( + webhookId: number, + action: string, + triggerSource: string | null, + atomic?: boolean, + ): Promise { + const webhook = DatabaseService.getInstance().getWebhook(webhookId); if (!webhook) throw new Error('Webhook not found'); - const defaultNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const nodeId = webhook.node_id || NodeRegistry.getInstance().getDefaultNodeId(); + const node = NodeRegistry.getInstance().getNode(nodeId); + if (!node) { + const error = `Node for webhook "${webhook.name}" was not found`; + this.recordExecution(webhookId, action, 'failure', triggerSource, 0, error); + return { success: false, error, duration_ms: 0 }; + } - // Validate the stack still exists - const stacks = await FileSystemService.getInstance(defaultNodeId).getStacks(); - if (!stacks.includes(webhook.stack_name)) { - const error = `Stack "${webhook.stack_name}" not found`; - db.addWebhookExecution({ - webhook_id: webhookId, - action, - status: 'failure', - trigger_source: triggerSource, - duration_ms: 0, - error, - executed_at: Date.now(), - }); + if (node.type === 'remote') { + return this.executeRemote(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic); + } + + return this.executeLocal(webhookId, nodeId, webhook.stack_name, action, triggerSource, atomic); + } + + public maskSecret(secret: string): string { + if (secret.length <= 8) return '********'; + return '********' + secret.slice(-4); + } + + private async executeLocal( + webhookId: number, + nodeId: number, + stackName: string, + action: string, + triggerSource: string | null, + atomic?: boolean, + ): Promise { + const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + if (!stacks.includes(stackName)) { + const error = `Stack "${stackName}" not found`; + this.recordExecution(webhookId, action, 'failure', triggerSource, 0, error); return { success: false, error, duration_ms: 0 }; } const startTime = Date.now(); try { - const compose = ComposeService.getInstance(defaultNodeId); + const compose = ComposeService.getInstance(nodeId); switch (action) { case 'deploy': await assertPolicyGateAllows( - webhook.stack_name, - defaultNodeId, + stackName, + nodeId, buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }), ); - await compose.deployStack(webhook.stack_name, undefined, atomic); + await compose.deployStack(stackName, undefined, atomic); break; case 'restart': - await compose.runCommand(webhook.stack_name, 'restart'); + await compose.runCommand(stackName, 'restart'); break; case 'stop': - await compose.runCommand(webhook.stack_name, 'stop'); + await compose.runCommand(stackName, 'stop'); break; case 'start': - await compose.runCommand(webhook.stack_name, 'start'); + await compose.runCommand(stackName, 'start'); break; case 'pull': await assertPolicyGateAllows( - webhook.stack_name, - defaultNodeId, + stackName, + nodeId, buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }), ); - await compose.updateStack(webhook.stack_name, undefined, atomic); + await compose.updateStack(stackName, undefined, atomic); break; - case 'git-pull': { - const result = await GitSourceService.getInstance().handleWebhookPull(webhook.stack_name); - const duration_ms = Date.now() - startTime; - if (result.status === 'error') { - db.addWebhookExecution({ - webhook_id: webhookId, - action, - status: 'failure', - trigger_source: triggerSource, - duration_ms, - error: result.message, - executed_at: Date.now(), - }); - return { success: false, error: result.message, duration_ms }; - } - db.addWebhookExecution({ - webhook_id: webhookId, - action, - status: result.status === 'skipped' ? 'failure' : 'success', - trigger_source: triggerSource, - duration_ms, - error: result.status === 'skipped' ? result.message : null, - executed_at: Date.now(), - }); - return { success: result.status === 'success', error: result.status === 'skipped' ? result.message : undefined, duration_ms }; - } + case 'git-pull': + return this.executeLocalGitPull(webhookId, stackName, action, triggerSource, startTime); default: throw new Error(`Unknown action: ${action}`); } - const duration_ms = Date.now() - startTime; - db.addWebhookExecution({ - webhook_id: webhookId, - action, - status: 'success', - trigger_source: triggerSource, - duration_ms, - error: null, - executed_at: Date.now(), - }); - return { success: true, duration_ms }; + const durationMs = Date.now() - startTime; + this.recordExecution(webhookId, action, 'success', triggerSource, durationMs, null); + return { success: true, duration_ms: durationMs }; } catch (err) { - const duration_ms = Date.now() - startTime; - const error = (err as Error).message || 'Unknown error'; - db.addWebhookExecution({ - webhook_id: webhookId, - action, - status: 'failure', - trigger_source: triggerSource, - duration_ms, - error, - executed_at: Date.now(), - }); - return { success: false, error, duration_ms }; + const durationMs = Date.now() - startTime; + const error = getErrorMessage(err, 'Unknown error'); + this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error); + return { success: false, error, duration_ms: durationMs }; } } - public maskSecret(secret: string): string { - if (secret.length <= 8) return '••••••••'; - return '••••••••' + secret.slice(-4); + private async executeLocalGitPull( + webhookId: number, + stackName: string, + action: string, + triggerSource: string | null, + startTime: number, + ): Promise { + const result = await GitSourceService.getInstance().handleWebhookPull(stackName); + const durationMs = Date.now() - startTime; + if (result.status === 'error') { + this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, result.message); + return { success: false, error: result.message, duration_ms: durationMs }; + } + + const skipped = result.status === 'skipped'; + this.recordExecution( + webhookId, + action, + skipped ? 'failure' : 'success', + triggerSource, + durationMs, + skipped ? result.message : null, + ); + return { success: !skipped, error: skipped ? result.message : undefined, duration_ms: durationMs }; + } + + private async executeRemote( + webhookId: number, + nodeId: number, + stackName: string, + action: string, + triggerSource: string | null, + atomic?: boolean, + ): Promise { + const startTime = Date.now(); + try { + const endpoint = action === 'git-pull' + ? 'git-source/webhook-pull' + : action === 'pull' + ? 'update' + : action; + const body = atomic === undefined ? undefined : { atomic }; + const response = await this.remoteStackRequest(nodeId, stackName, endpoint, 'POST', body); + const durationMs = Date.now() - startTime; + const payload = await response.json().catch(() => ({})) as { error?: string; message?: string; status?: string }; + + if (!response.ok || payload.status === 'error' || payload.status === 'skipped') { + const error = payload.error || payload.message || `Remote ${action} failed with status ${response.status}`; + this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error); + return { success: false, error, duration_ms: durationMs }; + } + + this.recordExecution(webhookId, action, 'success', triggerSource, durationMs, null); + return { success: true, duration_ms: durationMs }; + } catch (err) { + const durationMs = Date.now() - startTime; + const error = getErrorMessage(err, 'Remote node operation failed'); + this.recordExecution(webhookId, action, 'failure', triggerSource, durationMs, error); + return { success: false, error, duration_ms: durationMs }; + } + } + + private async remoteStackRequest( + nodeId: number, + stackName: string, + endpoint: string, + method: 'GET' | 'POST', + body?: unknown, + ): Promise { + const target = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!target) throw new Error('Remote node is unreachable or not configured'); + + const headers: Record = { 'Content-Type': 'application/json' }; + if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; + + const licenseHeaders = LicenseService.getInstance().getProxyHeaders(); + headers[PROXY_TIER_HEADER] = licenseHeaders.tier; + headers[PROXY_VARIANT_HEADER] = licenseHeaders.variant || ''; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REMOTE_WEBHOOK_REQUEST_TIMEOUT_MS); + try { + // nodeId selects a server-controlled entry from the registry + // (CodeQL GOOD pattern: user input maps to known values, not concatenated into the URL). + const targetBase = new URL(target.apiUrl); + const protocol = targetBase.protocol; + const host = targetBase.host; + const hostname = targetBase.hostname; + + // Verify the hostname is in the configured-node allow-list. + const allowedHosts = DatabaseService.getInstance().getNodes() + .filter(n => n.api_url) + .map(n => new URL(n.api_url!).hostname); + if (!allowedHosts.includes(hostname)) { + throw new Error('Remote node hostname is not a configured node'); + } + + // Restrict protocol to http/https (prevents file://, ftp://, etc.). + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error('Remote node URL must use http:// or https://'); + } + + // Validate path components to prevent traversal. + if (!isValidStackName(stackName)) { + throw new Error('Invalid stack name'); + } + if (!/^[a-z][a-z0-9/-]*$/.test(endpoint) || endpoint.includes('..')) { + throw new Error('Invalid endpoint'); + } + + // Build URL from validated, server-controlled components. + const url = `${protocol}//${host}/api/stacks/${encodeURIComponent(stackName)}/${endpoint}`; + return await fetch(url, { + method, + headers, + body: method === 'GET' || body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (controller.signal.aborted) { + throw new Error('Remote node request timed out', { cause: err }); + } + throw err; + } finally { + clearTimeout(timer); + } + } + + private recordExecution( + webhookId: number, + action: string, + status: ExecutionStatus, + triggerSource: string | null, + durationMs: number, + error: string | null, + ): void { + DatabaseService.getInstance().addWebhookExecution({ + webhook_id: webhookId, + action, + status, + trigger_source: triggerSource, + duration_ms: durationMs, + error, + executed_at: Date.now(), + }); } } diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 51529730..1a450cd1 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -95,6 +95,18 @@ export function isValidRelativeStackPath(rel: string): boolean { return !segments.some(seg => seg === '..' || seg === '.'); } +/** + * Validates a file path inside a fetched Git repository. + * Git source paths are POSIX-style relative file paths. They must not escape + * the clone root or target Git metadata. + */ +export function isValidGitSourcePath(rel: string): boolean { + if (!isValidRelativeStackPath(rel)) return false; + if (rel === '') return false; + const segments = rel.split('/').map(seg => seg.toLowerCase()); + return !segments.some(seg => seg === '.git'); +} + /** * Asserts that a resolved file path stays within a given base directory. * Returns true if the path is safe, false if it escapes the base. diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index df86b740..7e305783 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -5,6 +5,7 @@ import { TogglePill } from '@/components/ui/toggle-pill'; import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { @@ -19,6 +20,7 @@ import { useMastheadStats } from './MastheadStatsContext'; interface WebhookItem { id: number; + node_id: number; name: string; stack_name: string; action: string; @@ -40,6 +42,7 @@ interface WebhookExecution { } export function WebhooksSection({ isPaid }: { isPaid: boolean }) { + const { activeNode, nodes } = useNodes(); const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); @@ -68,7 +71,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { } catch { /* ignore */ } }; - useEffect(() => { fetchWebhooks(); fetchStacks(); }, []); + useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]); const enabledCount = webhooks.filter(w => w.enabled).length; useMastheadStats( @@ -94,7 +97,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { const res = await apiFetch('/webhooks', { method: 'POST', localOnly: true, - body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }), + body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction, node_id: activeNode?.id }), }); if (res.ok) { const data = await res.json(); @@ -175,6 +178,13 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) { + {activeNode && ( + +
+ {activeNode.name} +
+
+ )}