morpheus-cli 0.4.3 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,6 +242,31 @@ Morpheus features a dedicated middleware system called **Sati** (Mindfulness) th
242
242
  - **Data Privacy**: Stored in a local, independent SQLite database (`santi-memory.db`), ensuring sensitive data is handled securely and reducing context window usage.
243
243
  - **Memory Management**: View and manage your long-term memories through the Web UI or via API endpoints.
244
244
 
245
+ ### 🪝 Webhooks & Notifications
246
+
247
+ Morpheus includes a **Webhook System** that lets any external service (GitHub Actions, CI/CD pipelines, monitoring tools, etc.) trigger Oracle and receive the result asynchronously.
248
+
249
+ **How it works:**
250
+ 1. Create a webhook via the Web UI or API — give it a name (slug) and a prompt.
251
+ 2. You receive a unique `api_key` for that webhook.
252
+ 3. Trigger it from anywhere by posting JSON to `POST /api/webhooks/trigger/<name>` with the `x-api-key` header.
253
+ 4. Morpheus runs Oracle with your prompt + the received payload in the background.
254
+ 5. The result is saved as a **Notification** and optionally pushed to Telegram.
255
+
256
+ **Example — trigger from GitHub Actions:**
257
+ ```yaml
258
+ - name: Notify Morpheus of deployment
259
+ run: |
260
+ curl -s -X POST https://your-morpheus-host/api/webhooks/trigger/deploy-done \
261
+ -H "x-api-key: ${{ secrets.MORPHEUS_WEBHOOK_KEY }}" \
262
+ -H "Content-Type: application/json" \
263
+ -d '{"workflow":"${{ github.workflow }}","status":"success","ref":"${{ github.ref }}"}'
264
+ ```
265
+
266
+ **Security:** Each webhook has its own `api_key` (UUID). The key is sent in the `x-api-key` header — never in the URL — to prevent leakage in server logs. Management endpoints remain protected by `THE_ARCHITECT_PASS`.
267
+
268
+ **Notification channels:** `ui` (Web UI inbox with unread badge) and/or `telegram` (proactive push message).
269
+
245
270
  ### 📊 Usage Analytics
246
271
  Track your token usage across different providers and models directly from the Web UI. View detailed breakdowns of input/output tokens and message counts to monitor costs and activity.
247
272
 
@@ -920,6 +945,106 @@ Restart the Morpheus agent.
920
945
  }
921
946
  ```
922
947
 
948
+ ### Webhook Endpoints
949
+
950
+ All management endpoints require `x-architect-pass` authentication. The trigger endpoint is **public** — authenticated only by the per-webhook `x-api-key` header.
951
+
952
+ #### POST `/api/webhooks/trigger/:webhook_name`
953
+ Trigger a webhook and queue an Oracle agent execution in the background.
954
+
955
+ * **Authentication:** `x-api-key: <webhook_api_key>` header (no `x-architect-pass` required).
956
+ * **Parameters:** `webhook_name` — the slug of the webhook to trigger.
957
+ * **Body:** Any JSON payload (forwarded to the agent as context).
958
+ * **Response (202 Accepted):**
959
+ ```json
960
+ {
961
+ "accepted": true,
962
+ "notification_id": "uuid-..."
963
+ }
964
+ ```
965
+ * **Errors:** `401` for missing/invalid api_key; `404` for webhook not found or disabled.
966
+
967
+ #### GET `/api/webhooks`
968
+ List all configured webhooks.
969
+
970
+ * **Authentication:** `x-architect-pass` header.
971
+ * **Response:**
972
+ ```json
973
+ [
974
+ {
975
+ "id": "uuid",
976
+ "name": "deploy-done",
977
+ "api_key": "uuid",
978
+ "prompt": "Analyze the deployment result...",
979
+ "enabled": true,
980
+ "notification_channels": ["ui", "telegram"],
981
+ "created_at": 1700000000000,
982
+ "last_triggered_at": 1700001000000,
983
+ "trigger_count": 42
984
+ }
985
+ ]
986
+ ```
987
+
988
+ #### POST `/api/webhooks`
989
+ Create a new webhook.
990
+
991
+ * **Authentication:** `x-architect-pass` header.
992
+ * **Body:**
993
+ ```json
994
+ {
995
+ "name": "deploy-done",
996
+ "prompt": "A deployment just finished. Analyze the payload and summarize what happened.",
997
+ "notification_channels": ["ui", "telegram"],
998
+ "enabled": true
999
+ }
1000
+ ```
1001
+ * **Response (201):** The created webhook object including the generated `api_key`.
1002
+
1003
+ #### PUT `/api/webhooks/:id`
1004
+ Update an existing webhook (prompt, channels, enabled status).
1005
+
1006
+ * **Authentication:** `x-architect-pass` header.
1007
+ * **Note:** The `name` (slug) and `api_key` fields are immutable via this endpoint.
1008
+
1009
+ #### DELETE `/api/webhooks/:id`
1010
+ Delete a webhook and all its associated notifications.
1011
+
1012
+ * **Authentication:** `x-architect-pass` header.
1013
+
1014
+ #### GET `/api/webhooks/notifications`
1015
+ List webhook execution notifications.
1016
+
1017
+ * **Authentication:** `x-architect-pass` header.
1018
+ * **Query Parameters:** `unreadOnly=true` to filter unread notifications.
1019
+ * **Response:**
1020
+ ```json
1021
+ [
1022
+ {
1023
+ "id": "uuid",
1024
+ "webhook_id": "uuid",
1025
+ "webhook_name": "deploy-done",
1026
+ "status": "completed",
1027
+ "payload": "{\"ref\":\"main\"}",
1028
+ "result": "Deployment of main to production succeeded...",
1029
+ "read": false,
1030
+ "created_at": 1700001000000,
1031
+ "completed_at": 1700001005000
1032
+ }
1033
+ ]
1034
+ ```
1035
+
1036
+ #### POST `/api/webhooks/notifications/read`
1037
+ Mark notifications as read.
1038
+
1039
+ * **Authentication:** `x-architect-pass` header.
1040
+ * **Body:** `{ "ids": ["uuid1", "uuid2"] }`
1041
+
1042
+ #### GET `/api/webhooks/notifications/unread-count`
1043
+ Get the count of unread notifications (used by the sidebar badge).
1044
+
1045
+ * **Authentication:** `x-architect-pass` header.
1046
+ * **Response:** `{ "count": 3 }`
1047
+
923
1048
  ## Testing
924
1049
 
925
1050
  We use **Vitest** for testing.
@@ -957,8 +1082,10 @@ npm run test:watch
957
1082
 
958
1083
  - [x] **Web Dashboard**: Local UI for management and logs.
959
1084
  - [x] **MCP Support**: Full integration with Model Context Protocol.
1085
+ - [x] **Webhook System**: External triggers with Oracle execution and multi-channel notifications.
960
1086
  - [ ] **Discord Adapter**: Support for Discord interactions.
961
1087
  - [ ] **Plugin System**: Extend functionality via external modules.
1088
+ - [ ] **Webhook Retry Logic**: Exponential backoff for failed Oracle executions.
962
1089
 
963
1090
  ## 🕵️ Privacy Protection
964
1091
 
@@ -356,6 +356,29 @@ export class TelegramAdapter {
356
356
  await fs.writeFile(filePath, buffer);
357
357
  return filePath;
358
358
  }
359
+ /**
360
+ * Sends a proactive message to all allowed Telegram users.
361
+ * Used by the webhook notification system to push results.
362
+ */
363
+ async sendMessage(text) {
364
+ if (!this.isConnected || !this.bot) {
365
+ this.display.log('Cannot send message: Telegram bot not connected.', { source: 'Telegram', level: 'warning' });
366
+ return;
367
+ }
368
+ const allowedUsers = this.config.get().channels.telegram.allowedUsers;
369
+ if (allowedUsers.length === 0) {
370
+ this.display.log('No allowed Telegram users configured — skipping notification.', { source: 'Telegram', level: 'warning' });
371
+ return;
372
+ }
373
+ for (const userId of allowedUsers) {
374
+ try {
375
+ await this.bot.telegram.sendMessage(userId, text, { parse_mode: 'Markdown' });
376
+ }
377
+ catch (err) {
378
+ this.display.log(`Failed to send message to Telegram user ${userId}: ${err.message}`, { source: 'Telegram', level: 'error' });
379
+ }
380
+ }
381
+ }
359
382
  async disconnect() {
360
383
  if (!this.isConnected || !this.bot) {
361
384
  return;
@@ -522,44 +545,83 @@ How can I assist you today?`;
522
545
  }
523
546
  }
524
547
  async handleDoctorCommand(ctx, user) {
525
- // Implementação simplificada do diagnóstico
526
548
  const config = this.config.get();
527
549
  let response = '*Morpheus Doctor*\n\n';
528
550
  // Verificar versão do Node.js
529
551
  const nodeVersion = process.version;
530
552
  const majorVersion = parseInt(nodeVersion.replace('v', '').split('.')[0], 10);
531
553
  if (majorVersion >= 18) {
532
- response += '✅ Node.js Version: ' + nodeVersion + ' (Satisfied)\n';
554
+ response += '✅ Node.js: ' + nodeVersion + '\n';
533
555
  }
534
556
  else {
535
- response += '❌ Node.js Version: ' + nodeVersion + ' (Required: >=18)\n';
557
+ response += '❌ Node.js: ' + nodeVersion + ' (Required: >=18)\n';
536
558
  }
537
- // Verificar configuração
538
559
  if (config) {
539
560
  response += '✅ Configuration: Valid\n';
540
- // Verificar se chave de API disponível para o provedor ativo
561
+ // Helper para verificar API key de um provider
562
+ const hasApiKey = (provider, apiKey) => {
563
+ if (apiKey)
564
+ return true;
565
+ if (provider === 'openai')
566
+ return !!process.env.OPENAI_API_KEY;
567
+ if (provider === 'anthropic')
568
+ return !!process.env.ANTHROPIC_API_KEY;
569
+ if (provider === 'gemini' || provider === 'google')
570
+ return !!process.env.GOOGLE_API_KEY;
571
+ if (provider === 'openrouter')
572
+ return !!process.env.OPENROUTER_API_KEY;
573
+ return false; // ollama and others don't need keys
574
+ };
575
+ // Oracle (LLM)
541
576
  const llmProvider = config.llm?.provider;
542
577
  if (llmProvider && llmProvider !== 'ollama') {
543
- const hasLlmApiKey = config.llm?.api_key ||
544
- (llmProvider === 'openai' && process.env.OPENAI_API_KEY) ||
545
- (llmProvider === 'anthropic' && process.env.ANTHROPIC_API_KEY) ||
546
- (llmProvider === 'gemini' && process.env.GOOGLE_API_KEY) ||
547
- (llmProvider === 'openrouter' && process.env.OPENROUTER_API_KEY);
548
- if (hasLlmApiKey) {
549
- response += `✅ LLM API key available for ${llmProvider}\n`;
578
+ if (hasApiKey(llmProvider, config.llm?.api_key)) {
579
+ response += `✅ Oracle API key (${llmProvider})\n`;
580
+ }
581
+ else {
582
+ response += `❌ Oracle API key missing (${llmProvider})\n`;
583
+ }
584
+ }
585
+ // Sati
586
+ const sati = config.sati;
587
+ const satiProvider = sati?.provider || llmProvider;
588
+ if (satiProvider && satiProvider !== 'ollama') {
589
+ if (hasApiKey(satiProvider, sati?.api_key ?? config.llm?.api_key)) {
590
+ response += `✅ Sati API key (${satiProvider})\n`;
591
+ }
592
+ else {
593
+ response += `❌ Sati API key missing (${satiProvider})\n`;
594
+ }
595
+ }
596
+ // Apoc
597
+ const apoc = config.apoc;
598
+ const apocProvider = apoc?.provider || llmProvider;
599
+ if (apocProvider && apocProvider !== 'ollama') {
600
+ if (hasApiKey(apocProvider, apoc?.api_key ?? config.llm?.api_key)) {
601
+ response += `✅ Apoc API key (${apocProvider})\n`;
550
602
  }
551
603
  else {
552
- response += `❌ LLM API key missing for ${llmProvider}. Either set in config or define environment variable.\n`;
604
+ response += `❌ Apoc API key missing (${apocProvider})\n`;
553
605
  }
554
606
  }
555
- // Verificar token do Telegram se ativado
607
+ // Telegram token
556
608
  if (config.channels?.telegram?.enabled) {
557
609
  const hasTelegramToken = config.channels.telegram?.token || process.env.TELEGRAM_BOT_TOKEN;
558
610
  if (hasTelegramToken) {
559
- response += '✅ Telegram bot token available\n';
611
+ response += '✅ Telegram token\n';
612
+ }
613
+ else {
614
+ response += '❌ Telegram token missing\n';
615
+ }
616
+ }
617
+ // Audio API key
618
+ if (config.audio?.enabled) {
619
+ const audioKey = config.audio?.apiKey || process.env.GOOGLE_API_KEY;
620
+ if (audioKey) {
621
+ response += '✅ Audio API key\n';
560
622
  }
561
623
  else {
562
- response += '❌ Telegram bot token missing. Either set in config or define TELEGRAM_BOT_TOKEN environment variable.\n';
624
+ response += '❌ Audio API key missing\n';
563
625
  }
564
626
  }
565
627
  }
@@ -570,29 +632,46 @@ How can I assist you today?`;
570
632
  }
571
633
  async handleStatsCommand(ctx, user) {
572
634
  try {
573
- // Criar instância temporária do histórico para obter estatísticas
574
635
  const history = new SQLiteChatMessageHistory({
575
636
  sessionId: "default",
576
- databasePath: undefined, // Usará o caminho padrão
577
- limit: 100, // Limite arbitrário para esta operação
637
+ databasePath: undefined,
638
+ limit: 100,
578
639
  });
579
640
  const stats = await history.getGlobalUsageStats();
580
641
  const groupedStats = await history.getUsageStatsByProviderAndModel();
642
+ // Totals from global stats
643
+ const totalTokens = stats.totalInputTokens + stats.totalOutputTokens;
644
+ // Aggregate audio seconds and cost from grouped stats
645
+ const totalAudioSeconds = groupedStats.reduce((sum, s) => sum + (s.totalAudioSeconds || 0), 0);
646
+ const totalCost = stats.totalEstimatedCostUsd;
581
647
  let response = '*Token Usage Statistics*\n\n';
582
- response += `Total Input Tokens: ${stats.totalInputTokens}\n`;
583
- response += `Total Output Tokens: ${stats.totalOutputTokens}\n`;
584
- response += `Total Tokens: ${stats.totalInputTokens + stats.totalOutputTokens}\n\n`;
648
+ response += `Input Tokens: ${stats.totalInputTokens.toLocaleString()}\n`;
649
+ response += `Output Tokens: ${stats.totalOutputTokens.toLocaleString()}\n`;
650
+ response += `Total Tokens: ${totalTokens.toLocaleString()}\n`;
651
+ if (totalAudioSeconds > 0) {
652
+ response += `Audio Processed: ${totalAudioSeconds.toFixed(1)}s\n`;
653
+ }
654
+ if (totalCost != null) {
655
+ response += `Estimated Cost: $${totalCost.toFixed(4)}\n`;
656
+ }
657
+ response += '\n';
585
658
  if (groupedStats.length > 0) {
586
- response += '*Breakdown by Provider and Model:*\n';
659
+ response += '*By Provider/Model:*\n';
587
660
  for (const stat of groupedStats) {
588
- response += `- ${stat.provider}/${stat.model}:\n ${stat.totalTokens} tokens\n(${stat.messageCount} messages)\n\n`;
661
+ response += `\n*${stat.provider}/${stat.model}*\n`;
662
+ response += ` Tokens: ${stat.totalTokens.toLocaleString()} (${stat.messageCount} msgs)\n`;
663
+ if (stat.totalAudioSeconds > 0) {
664
+ response += ` Audio: ${stat.totalAudioSeconds.toFixed(1)}s\n`;
665
+ }
666
+ if (stat.estimatedCostUsd != null) {
667
+ response += ` Cost: $${stat.estimatedCostUsd.toFixed(4)}\n`;
668
+ }
589
669
  }
590
670
  }
591
671
  else {
592
672
  response += 'No detailed usage statistics available.';
593
673
  }
594
674
  await ctx.reply(response, { parse_mode: 'Markdown' });
595
- // Fechar conexão com o banco de dados
596
675
  history.close();
597
676
  }
598
677
  catch (error) {
@@ -632,11 +711,39 @@ How can I assist you today?`;
632
711
  response += `*Agent:*\n`;
633
712
  response += `- Name: ${config.agent.name}\n`;
634
713
  response += `- Personality: ${config.agent.personality}\n\n`;
635
- response += `*LLM:*\n`;
714
+ response += `*Oracle (LLM):*\n`;
636
715
  response += `- Provider: ${config.llm.provider}\n`;
637
716
  response += `- Model: ${config.llm.model}\n`;
638
717
  response += `- Temperature: ${config.llm.temperature}\n`;
639
718
  response += `- Context Window: ${config.llm.context_window || 100}\n\n`;
719
+ // Sati config (falls back to llm if not set)
720
+ const sati = config.sati;
721
+ response += `*Sati (Memory):*\n`;
722
+ if (sati?.provider) {
723
+ response += `- Provider: ${sati.provider}\n`;
724
+ response += `- Model: ${sati.model || config.llm.model}\n`;
725
+ response += `- Temperature: ${sati.temperature ?? config.llm.temperature}\n`;
726
+ response += `- Memory Limit: ${sati.memory_limit ?? 1000}\n`;
727
+ }
728
+ else {
729
+ response += `- Inherits Oracle config\n`;
730
+ }
731
+ response += '\n';
732
+ // Apoc config (falls back to llm if not set)
733
+ const apoc = config.apoc;
734
+ response += `*Apoc (DevTools):*\n`;
735
+ if (apoc?.provider) {
736
+ response += `- Provider: ${apoc.provider}\n`;
737
+ response += `- Model: ${apoc.model || config.llm.model}\n`;
738
+ response += `- Temperature: ${apoc.temperature ?? 0.2}\n`;
739
+ if (apoc.working_dir)
740
+ response += `- Working Dir: ${apoc.working_dir}\n`;
741
+ response += `- Timeout: ${apoc.timeout_ms ?? 30000}ms\n`;
742
+ }
743
+ else {
744
+ response += `- Inherits Oracle config\n`;
745
+ }
746
+ response += '\n';
640
747
  response += `*Channels:*\n`;
641
748
  response += `- Telegram Enabled: ${config.channels.telegram.enabled}\n`;
642
749
  response += `- Discord Enabled: ${config.channels.discord.enabled}\n\n`;
@@ -8,6 +8,7 @@ import { writePid, readPid, isProcessRunning, clearPid, checkStalePid, killProce
8
8
  import { ConfigManager } from '../../config/manager.js';
9
9
  import { renderBanner } from '../utils/render.js';
10
10
  import { TelegramAdapter } from '../../channels/telegram.js';
11
+ import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
11
12
  import { PATHS } from '../../config/paths.js';
12
13
  import { Oracle } from '../../runtime/oracle.js';
13
14
  import { ProviderError } from '../../runtime/errors.js';
@@ -136,6 +137,8 @@ export const startCommand = new Command('start')
136
137
  const telegram = new TelegramAdapter(oracle);
137
138
  try {
138
139
  await telegram.connect(config.channels.telegram.token, config.channels.telegram.allowedUsers || []);
140
+ // Wire Telegram adapter to webhook dispatcher for proactive notifications
141
+ WebhookDispatcher.setTelegramAdapter(telegram);
139
142
  adapters.push(telegram);
140
143
  }
141
144
  catch (e) {
@@ -26,6 +26,9 @@ export const ApocConfigSchema = LLMConfigSchema.extend({
26
26
  working_dir: z.string().optional(),
27
27
  timeout_ms: z.number().int().positive().optional(),
28
28
  });
29
+ export const WebhookConfigSchema = z.object({
30
+ telegram_notify_all: z.boolean().optional(),
31
+ }).optional();
29
32
  // Zod Schema matching MorpheusConfig interface
30
33
  export const ConfigSchema = z.object({
31
34
  agent: z.object({
@@ -35,6 +38,7 @@ export const ConfigSchema = z.object({
35
38
  llm: LLMConfigSchema.default(DEFAULT_CONFIG.llm),
36
39
  sati: SatiConfigSchema.optional(),
37
40
  apoc: ApocConfigSchema.optional(),
41
+ webhooks: WebhookConfigSchema,
38
42
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
39
43
  memory: z.object({
40
44
  limit: z.number().int().positive().optional(),
@@ -10,7 +10,7 @@ export const authMiddleware = (req, res, next) => {
10
10
  // If password is not configured (using default), log a warning
11
11
  if (!process.env.THE_ARCHITECT_PASS) {
12
12
  const display = DisplayManager.getInstance();
13
- display.log('Using default password for dashboard access. For security, set THE_ARCHITECT_PASS environment variable.', { source: 'http', level: 'warning' });
13
+ // display.log('Using default password for dashboard access. For security, set THE_ARCHITECT_PASS environment variable.', { source: 'http', level: 'warning' });
14
14
  }
15
15
  const providedPass = req.headers[AUTH_HEADER];
16
16
  if (providedPass === architectPass) {
@@ -6,7 +6,9 @@ import { fileURLToPath } from 'url';
6
6
  import { ConfigManager } from '../config/manager.js';
7
7
  import { DisplayManager } from '../runtime/display.js';
8
8
  import { createApiRouter } from './api.js';
9
+ import { createWebhooksRouter } from './webhooks-router.js';
9
10
  import { authMiddleware } from './middleware/auth.js';
11
+ import { WebhookDispatcher } from '../runtime/webhooks/dispatcher.js';
10
12
  const __filename = fileURLToPath(import.meta.url);
11
13
  const __dirname = path.dirname(__filename);
12
14
  export class HttpServer {
@@ -16,6 +18,8 @@ export class HttpServer {
16
18
  constructor(oracle) {
17
19
  this.app = express();
18
20
  this.oracle = oracle;
21
+ // Wire Oracle into the webhook dispatcher so triggers use the full agent
22
+ WebhookDispatcher.setOracle(oracle);
19
23
  this.setupMiddleware();
20
24
  this.setupRoutes();
21
25
  }
@@ -45,6 +49,10 @@ export class HttpServer {
45
49
  uptime: process.uptime()
46
50
  });
47
51
  });
52
+ // Webhooks router — mounted BEFORE the auth-guarded /api block.
53
+ // The trigger endpoint is public (validated via x-api-key header internally).
54
+ // All other webhook management endpoints apply authMiddleware internally.
55
+ this.app.use('/api/webhooks', createWebhooksRouter());
48
56
  this.app.use('/api', authMiddleware, createApiRouter(this.oracle));
49
57
  // Serve static frontend from compiled output
50
58
  const uiPath = path.resolve(__dirname, '../ui');
@@ -0,0 +1,181 @@
1
+ import { Router } from 'express';
2
+ import { z } from 'zod';
3
+ import { WebhookRepository } from '../runtime/webhooks/repository.js';
4
+ import { WebhookDispatcher } from '../runtime/webhooks/dispatcher.js';
5
+ import { authMiddleware } from './middleware/auth.js';
6
+ import { DisplayManager } from '../runtime/display.js';
7
+ const CreateWebhookSchema = z.object({
8
+ name: z
9
+ .string()
10
+ .min(1)
11
+ .max(100)
12
+ .regex(/^[a-z0-9-_]+$/, 'Name must be a slug: lowercase letters, numbers, hyphens, underscores only'),
13
+ prompt: z.string().min(1).max(10_000),
14
+ notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).default(['ui']),
15
+ });
16
+ const UpdateWebhookSchema = z.object({
17
+ name: z
18
+ .string()
19
+ .min(1)
20
+ .max(100)
21
+ .regex(/^[a-z0-9-_]+$/, 'Name must be a slug')
22
+ .optional(),
23
+ prompt: z.string().min(1).max(10_000).optional(),
24
+ enabled: z.boolean().optional(),
25
+ notification_channels: z.array(z.enum(['ui', 'telegram'])).min(1).optional(),
26
+ });
27
+ const MarkReadSchema = z.object({
28
+ ids: z.array(z.string().uuid()).min(1),
29
+ });
30
+ export function createWebhooksRouter() {
31
+ const router = Router();
32
+ const repo = WebhookRepository.getInstance();
33
+ const display = DisplayManager.getInstance();
34
+ const dispatcher = new WebhookDispatcher();
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ // PUBLIC TRIGGER ENDPOINT — no authMiddleware, api_key validated via header
37
+ // Must be declared BEFORE router.use(authMiddleware) below
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ router.post('/trigger/:webhook_name', async (req, res) => {
40
+ const { webhook_name } = req.params;
41
+ const apiKey = req.headers['x-api-key'];
42
+ if (!apiKey || typeof apiKey !== 'string') {
43
+ return res.status(401).json({ error: 'Missing x-api-key header' });
44
+ }
45
+ const webhook = repo.getAndValidateWebhook(webhook_name, apiKey);
46
+ if (!webhook) {
47
+ // Intentionally ambiguous — don't reveal whether the name exists or is disabled
48
+ return res.status(401).json({ error: 'Invalid webhook name or api key' });
49
+ }
50
+ const payload = req.body ?? {};
51
+ // Create pending notification immediately
52
+ const notification = repo.createNotification({
53
+ webhook_id: webhook.id,
54
+ webhook_name: webhook.name,
55
+ payload: JSON.stringify(payload),
56
+ });
57
+ // Increment trigger counter
58
+ repo.recordTrigger(webhook.id);
59
+ display.log(`Webhook "${webhook.name}" triggered (notification: ${notification.id})`, { source: 'Webhooks' });
60
+ // Fire-and-forget — respond immediately with 202
61
+ setImmediate(() => {
62
+ dispatcher.dispatch(webhook, payload, notification.id).catch((err) => {
63
+ display.log(`Unhandled webhook dispatch error for "${webhook.name}": ${err.message}`, { source: 'Webhooks', level: 'error' });
64
+ });
65
+ });
66
+ return res.status(202).json({
67
+ accepted: true,
68
+ notification_id: notification.id,
69
+ });
70
+ });
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // NOTIFICATION ENDPOINTS — declared before /:id to prevent route conflict
73
+ // (still public here, authMiddleware applied below protects management routes)
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ router.get('/notifications', authMiddleware, (req, res) => {
76
+ try {
77
+ const { webhookId, unreadOnly } = req.query;
78
+ const notifications = repo.listNotifications({
79
+ webhookId: typeof webhookId === 'string' ? webhookId : undefined,
80
+ unreadOnly: unreadOnly === 'true',
81
+ });
82
+ res.json(notifications);
83
+ }
84
+ catch (err) {
85
+ res.status(500).json({ error: err.message });
86
+ }
87
+ });
88
+ router.post('/notifications/read', authMiddleware, (req, res) => {
89
+ const parsed = MarkReadSchema.safeParse(req.body);
90
+ if (!parsed.success) {
91
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
92
+ }
93
+ try {
94
+ repo.markNotificationsRead(parsed.data.ids);
95
+ res.json({ success: true });
96
+ }
97
+ catch (err) {
98
+ res.status(500).json({ error: err.message });
99
+ }
100
+ });
101
+ router.get('/notifications/unread-count', authMiddleware, (req, res) => {
102
+ try {
103
+ const count = repo.countUnread();
104
+ res.json({ count });
105
+ }
106
+ catch (err) {
107
+ res.status(500).json({ error: err.message });
108
+ }
109
+ });
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // WEBHOOK MANAGEMENT ENDPOINTS — all require authMiddleware
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ router.use(authMiddleware);
114
+ // GET /api/webhooks — list all webhooks
115
+ router.get('/', (req, res) => {
116
+ try {
117
+ const webhooks = repo.listWebhooks();
118
+ res.json(webhooks);
119
+ }
120
+ catch (err) {
121
+ res.status(500).json({ error: err.message });
122
+ }
123
+ });
124
+ // POST /api/webhooks — create a webhook
125
+ router.post('/', (req, res) => {
126
+ const parsed = CreateWebhookSchema.safeParse(req.body);
127
+ if (!parsed.success) {
128
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
129
+ }
130
+ try {
131
+ const webhook = repo.createWebhook(parsed.data);
132
+ res.status(201).json(webhook);
133
+ }
134
+ catch (err) {
135
+ // SQLite UNIQUE constraint error
136
+ if (err.message?.includes('UNIQUE constraint failed: webhooks.name')) {
137
+ return res.status(409).json({ error: `A webhook with name "${parsed.data.name}" already exists` });
138
+ }
139
+ res.status(500).json({ error: err.message });
140
+ }
141
+ });
142
+ // GET /api/webhooks/:id — get one webhook
143
+ router.get('/:id', (req, res) => {
144
+ const webhook = repo.getWebhookById(req.params.id);
145
+ if (!webhook)
146
+ return res.status(404).json({ error: 'Webhook not found' });
147
+ res.json(webhook);
148
+ });
149
+ // PUT /api/webhooks/:id — update a webhook
150
+ router.put('/:id', (req, res) => {
151
+ const parsed = UpdateWebhookSchema.safeParse(req.body);
152
+ if (!parsed.success) {
153
+ return res.status(400).json({ error: 'Invalid payload', details: parsed.error.issues });
154
+ }
155
+ try {
156
+ const updated = repo.updateWebhook(req.params.id, parsed.data);
157
+ if (!updated)
158
+ return res.status(404).json({ error: 'Webhook not found' });
159
+ res.json(updated);
160
+ }
161
+ catch (err) {
162
+ if (err.message?.includes('UNIQUE constraint failed: webhooks.name')) {
163
+ return res.status(409).json({ error: `A webhook with that name already exists` });
164
+ }
165
+ res.status(500).json({ error: err.message });
166
+ }
167
+ });
168
+ // DELETE /api/webhooks/:id — delete a webhook
169
+ router.delete('/:id', (req, res) => {
170
+ try {
171
+ const deleted = repo.deleteWebhook(req.params.id);
172
+ if (!deleted)
173
+ return res.status(404).json({ error: 'Webhook not found' });
174
+ res.json({ success: true });
175
+ }
176
+ catch (err) {
177
+ res.status(500).json({ error: err.message });
178
+ }
179
+ });
180
+ return router;
181
+ }