@worca/ui 0.8.1 → 0.9.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,9 @@
4
4
  */
5
5
 
6
6
  import { spawn } from 'node:child_process';
7
- import { randomBytes } from 'node:crypto';
7
+ import { randomBytes, randomUUID } from 'node:crypto';
8
8
  import {
9
+ appendFileSync,
9
10
  closeSync,
10
11
  existsSync,
11
12
  mkdirSync,
@@ -22,6 +23,19 @@ import { join } from 'node:path';
22
23
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
23
24
  const ARG_INLINE_LIMIT = 128 * 1024;
24
25
 
26
+ const TERMINAL_EVENTS = [
27
+ 'pipeline.run.interrupted',
28
+ 'pipeline.run.failed',
29
+ 'pipeline.run.completed',
30
+ ];
31
+
32
+ function elapsedMsSince(startedAtIso) {
33
+ if (!startedAtIso) return 0;
34
+ const started = Date.parse(startedAtIso);
35
+ if (Number.isNaN(started)) return 0;
36
+ return Math.max(0, Date.now() - started);
37
+ }
38
+
25
39
  /**
26
40
  * Write content to a temp file with restricted permissions (0o600) and return its path.
27
41
  * Used to avoid E2BIG when passing large prompts as CLI arguments.
@@ -163,10 +177,11 @@ export class ProcessManager {
163
177
 
164
178
  if (status.pipeline_status !== 'running') continue;
165
179
 
166
- status.pipeline_status = 'failed';
167
180
  if (!status.stop_reason) {
168
181
  status.stop_reason = 'stale';
169
182
  }
183
+ status.pipeline_status =
184
+ status.stop_reason === 'stale' ? 'interrupted' : 'failed';
170
185
  try {
171
186
  writeFileSync(
172
187
  statusPath,
@@ -177,6 +192,50 @@ export class ProcessManager {
177
192
  } catch {
178
193
  /* ignore */
179
194
  }
195
+
196
+ // Append synthetic interrupted event if no terminal event exists yet
197
+ const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
198
+ let hasTerminalEvent = false;
199
+ if (existsSync(eventsPath)) {
200
+ try {
201
+ const lines = readFileSync(eventsPath, 'utf8')
202
+ .split('\n')
203
+ .filter(Boolean);
204
+ hasTerminalEvent = lines.some((line) => {
205
+ try {
206
+ const evt = JSON.parse(line);
207
+ return TERMINAL_EVENTS.includes(evt.event_type);
208
+ } catch {
209
+ return false;
210
+ }
211
+ });
212
+ } catch {
213
+ /* ignore */
214
+ }
215
+ }
216
+ if (!hasTerminalEvent) {
217
+ try {
218
+ const evt = {
219
+ schema_version: '1',
220
+ event_id: randomUUID(),
221
+ event_type: 'pipeline.run.interrupted',
222
+ timestamp: new Date().toISOString(),
223
+ run_id: status.run_id ?? runId,
224
+ pipeline: {
225
+ branch: status.branch ?? null,
226
+ work_request: status.work_request ?? null,
227
+ },
228
+ payload: {
229
+ interrupted_stage: status.current_stage ?? 'unknown',
230
+ elapsed_ms: elapsedMsSince(status.started_at),
231
+ source: 'reconcile',
232
+ },
233
+ };
234
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ }
180
239
  }
181
240
 
182
241
  return fixed;
@@ -10,7 +10,9 @@ import {
10
10
  unlinkSync,
11
11
  writeFileSync,
12
12
  } from 'node:fs';
13
+ import { readdir } from 'node:fs/promises';
13
14
  import { basename, isAbsolute, join } from 'node:path';
15
+ import { checkWorcaInstalled, readProjectWorcaVersion } from './worca-setup.js';
14
16
 
15
17
  export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
16
18
  const DEFAULT_MAX_PROJECTS = 20;
@@ -24,6 +26,7 @@ export function slugify(name) {
24
26
  .toLowerCase()
25
27
  .replace(/[^a-z0-9_-]/g, '-')
26
28
  .replace(/-{2,}/g, '-')
29
+ .replace(/^-+|-+$/g, '')
27
30
  .slice(0, 64);
28
31
  }
29
32
 
@@ -130,6 +133,40 @@ export function synthesizeDefaultProject(projectRoot) {
130
133
  };
131
134
  }
132
135
 
136
+ const SCAN_MAX_RESULTS = 200;
137
+
138
+ /**
139
+ * Scan a directory for immediate child folders that contain a .git subdirectory.
140
+ * Skips dotfiles (names starting with ".") and "node_modules".
141
+ * Returns entries sorted alphabetically by name, capped at SCAN_MAX_RESULTS.
142
+ *
143
+ * @param {string} dirPath - Absolute path to the parent directory
144
+ * @returns {Promise<{ name: string, path: string }[]>}
145
+ */
146
+ export async function scanDirectory(dirPath) {
147
+ const entries = await readdir(dirPath, { withFileTypes: true });
148
+ const results = [];
149
+ for (const entry of entries) {
150
+ if (!entry.isDirectory()) continue;
151
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
152
+ const childPath = join(dirPath, entry.name);
153
+ if (existsSync(join(childPath, '.git'))) {
154
+ const installed = checkWorcaInstalled(childPath);
155
+ const worcaVersion = installed
156
+ ? readProjectWorcaVersion(childPath)
157
+ : null;
158
+ results.push({
159
+ name: entry.name,
160
+ path: childPath,
161
+ installed,
162
+ worcaVersion,
163
+ });
164
+ if (results.length >= SCAN_MAX_RESULTS) break;
165
+ }
166
+ }
167
+ return results.sort((a, b) => a.name.localeCompare(b.name));
168
+ }
169
+
133
170
  /**
134
171
  * Read max projects from {prefsDir}/config.json. Defaults to 20.
135
172
  */
@@ -20,9 +20,11 @@ import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
22
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
23
+ import { ensureWebhookForUi } from './ensure-webhook.js';
23
24
  import { readPreferences } from './preferences.js';
24
25
  import { ProcessManager } from './process-manager.js';
25
26
  import {
27
+ getMaxProjects,
26
28
  readProjects,
27
29
  removeProject,
28
30
  SLUG_RE,
@@ -36,6 +38,8 @@ import {
36
38
  readMergedSettings,
37
39
  } from './settings-merge.js';
38
40
  import { validateSettingsPayload } from './settings-validator.js';
41
+ import { isVersionBehind } from './version-check.js';
42
+ import { getVersionInfo } from './versions.js';
39
43
  import { discoverRuns } from './watcher.js';
40
44
  import {
41
45
  checkWorcaInstalled,
@@ -135,7 +139,12 @@ export function projectResolver({ prefsDir, projectRoot }) {
135
139
  /**
136
140
  * Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
137
141
  */
138
- export function createProjectRoutes({ prefsDir, projectRoot }) {
142
+ export function createProjectRoutes({
143
+ prefsDir,
144
+ projectRoot,
145
+ serverHost,
146
+ serverPort,
147
+ }) {
139
148
  const router = Router();
140
149
 
141
150
  // GET /api/projects — list all projects (or synthesized default)
@@ -166,6 +175,17 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
166
175
  }
167
176
  try {
168
177
  writeProject(prefsDir, entry);
178
+ // Auto-configure webhook so pipeline events reach this UI server
179
+ if (serverHost && serverPort) {
180
+ try {
181
+ ensureWebhookForUi(entry.path, {
182
+ host: serverHost,
183
+ port: serverPort,
184
+ });
185
+ } catch {
186
+ /* best-effort — don't fail project creation */
187
+ }
188
+ }
169
189
  res.status(201).json({ ok: true, project: entry });
170
190
  } catch (err) {
171
191
  res.status(400).json({ ok: false, error: err.message });
@@ -182,15 +202,128 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
182
202
  res.json({ ok: true, removed: id });
183
203
  });
184
204
 
205
+ // POST /api/projects/batch — register multiple projects atomically
206
+ router.post('/batch', (req, res) => {
207
+ const { projects: batch } = req.body || {};
208
+ if (!Array.isArray(batch) || batch.length === 0) {
209
+ return res
210
+ .status(400)
211
+ .json({ ok: false, error: 'projects must be a non-empty array' });
212
+ }
213
+
214
+ // Validate all entries first (all-or-nothing)
215
+ const failed = [];
216
+ for (const entry of batch) {
217
+ const validation = validateProjectEntry(entry);
218
+ if (!validation.valid) {
219
+ failed.push({ name: entry?.name ?? '', error: validation.error });
220
+ continue;
221
+ }
222
+ if (!existsSync(entry.path)) {
223
+ failed.push({
224
+ name: entry.name,
225
+ error: `directory does not exist: ${entry.path}`,
226
+ });
227
+ }
228
+ }
229
+ if (failed.length > 0) {
230
+ return res.status(400).json({
231
+ ok: false,
232
+ error: `${failed.length} project${failed.length > 1 ? 's' : ''} failed validation`,
233
+ failed,
234
+ });
235
+ }
236
+
237
+ // Check for intra-batch duplicate names
238
+ const batchNames = batch.map((e) => e?.name).filter(Boolean);
239
+ if (new Set(batchNames).size < batchNames.length) {
240
+ return res
241
+ .status(400)
242
+ .json({ ok: false, error: 'Duplicate names within batch' });
243
+ }
244
+
245
+ // Check for intra-batch duplicate paths
246
+ const batchPaths = batch.map((e) => e?.path).filter(Boolean);
247
+ if (new Set(batchPaths).size < batchPaths.length) {
248
+ return res
249
+ .status(400)
250
+ .json({ ok: false, error: 'Duplicate paths within batch' });
251
+ }
252
+
253
+ // Check for duplicate paths against existing projects
254
+ const existing = readProjects(prefsDir);
255
+ const existingPaths = new Set(
256
+ existing.map((p) => p.path.replace(/\/+$/, '')),
257
+ );
258
+ const duplicates = batch.filter((entry) =>
259
+ existingPaths.has(entry.path.replace(/\/+$/, '')),
260
+ );
261
+ if (duplicates.length > 0) {
262
+ return res.status(400).json({
263
+ ok: false,
264
+ error: `${duplicates.length} project${duplicates.length > 1 ? 's' : ''} already registered`,
265
+ failed: duplicates.map((entry) => ({
266
+ name: entry.name,
267
+ error: `path already registered: ${entry.path}`,
268
+ })),
269
+ });
270
+ }
271
+
272
+ // Check max projects limit
273
+ const max = getMaxProjects(prefsDir);
274
+ if (existing.length + batch.length > max) {
275
+ return res.status(400).json({
276
+ ok: false,
277
+ error: `adding ${batch.length} project${batch.length > 1 ? 's' : ''} would exceed the limit of ${max}`,
278
+ });
279
+ }
280
+
281
+ // Write all projects — roll back on partial failure
282
+ const written = [];
283
+ try {
284
+ for (const entry of batch) {
285
+ writeProject(prefsDir, entry);
286
+ written.push(entry.name);
287
+ if (serverHost && serverPort) {
288
+ try {
289
+ ensureWebhookForUi(entry.path, {
290
+ host: serverHost,
291
+ port: serverPort,
292
+ });
293
+ } catch {
294
+ /* best-effort */
295
+ }
296
+ }
297
+ }
298
+ res.status(201).json({ ok: true, projects: batch });
299
+ } catch (err) {
300
+ for (const name of written) {
301
+ try {
302
+ removeProject(prefsDir, name);
303
+ } catch {
304
+ // ignore rollback errors
305
+ }
306
+ }
307
+ res.status(400).json({ ok: false, error: err.message });
308
+ }
309
+ });
310
+
185
311
  return router;
186
312
  }
187
313
 
188
314
  /**
189
315
  * Router for project-scoped sub-routes.
190
316
  * The projectResolver middleware must run before this to set req.project.
317
+ * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
318
+ * worca-cc version lookup for /worca-status' `outdated` flag.
191
319
  */
192
- export function createProjectScopedRoutes() {
320
+ export function createProjectScopedRoutes({
321
+ prefsDir = null,
322
+ serverHost,
323
+ serverPort,
324
+ } = {}) {
193
325
  const router = Router({ mergeParams: true });
326
+ const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
194
327
 
195
328
  // Guard: run-related, cost, and pipeline routes require worcaDir
196
329
  function requireWorcaDir(req, res, next) {
@@ -1213,10 +1346,35 @@ export function createProjectScopedRoutes() {
1213
1346
  res.json({ ok: true, templates });
1214
1347
  });
1215
1348
 
1216
- // GET /api/projects/:projectId/worca-status — check worca installation state
1217
- router.get('/worca-status', (req, res) => {
1218
- const installed = checkWorcaInstalled(req.project.projectRoot);
1219
- res.json({ ok: true, installed });
1349
+ // GET /api/projects/:projectId/worca-status — check worca installation state.
1350
+ // `outdated` is true when the project's installed worca-cc version is
1351
+ // strictly behind the active (dev-path or globally-installed) worca-cc.
1352
+ router.get('/worca-status', async (req, res) => {
1353
+ const { projectRoot } = req.project;
1354
+ const installed = checkWorcaInstalled(projectRoot);
1355
+ if (!installed) {
1356
+ return res.json({
1357
+ ok: true,
1358
+ installed: false,
1359
+ version: null,
1360
+ outdated: false,
1361
+ });
1362
+ }
1363
+ const version = readProjectWorcaVersion(projectRoot);
1364
+ let outdated = false;
1365
+ if (version != null) {
1366
+ try {
1367
+ const versionInfo = await getVersionInfo({
1368
+ prefsPath,
1369
+ worcaVersion: req.app.locals.worcaVersion || null,
1370
+ });
1371
+ outdated = isVersionBehind(version, versionInfo.activeWorcaCc);
1372
+ } catch {
1373
+ // Best-effort — if version lookup fails, treat as not outdated
1374
+ outdated = false;
1375
+ }
1376
+ }
1377
+ res.json({ ok: true, installed: true, version, outdated });
1220
1378
  });
1221
1379
 
1222
1380
  // POST /api/projects/:projectId/worca-setup — install or update worca
@@ -1238,6 +1396,17 @@ export function createProjectScopedRoutes() {
1238
1396
 
1239
1397
  try {
1240
1398
  const { pid } = runWorcaSetup(projectRoot, { source });
1399
+ // Auto-configure webhook so pipeline events reach this UI server
1400
+ if (serverHost && serverPort) {
1401
+ try {
1402
+ ensureWebhookForUi(projectRoot, {
1403
+ host: serverHost,
1404
+ port: serverPort,
1405
+ });
1406
+ } catch {
1407
+ /* best-effort */
1408
+ }
1409
+ }
1241
1410
  res.json({ ok: true, pid });
1242
1411
  } catch (err) {
1243
1412
  res.status(500).json({ ok: false, error: err.message });
@@ -323,8 +323,35 @@ export function validateSettingsPayload(body) {
323
323
  continue;
324
324
  }
325
325
  for (const v of val) {
326
- if (!VALID_AGENTS.includes(v)) {
327
- details.push(`Unknown agent "${v}" in dispatch for "${key}"`);
326
+ if (typeof v !== 'string') {
327
+ details.push(`Dispatch entry for "${key}" must be a string`);
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ if (g.subagent_dispatch !== undefined) {
334
+ if (
335
+ typeof g.subagent_dispatch !== 'object' ||
336
+ g.subagent_dispatch === null ||
337
+ Array.isArray(g.subagent_dispatch)
338
+ ) {
339
+ details.push('governance.subagent_dispatch must be an object');
340
+ } else {
341
+ for (const [key, val] of Object.entries(g.subagent_dispatch)) {
342
+ if (!VALID_AGENTS.includes(key)) {
343
+ details.push(`Unknown subagent_dispatch agent: "${key}"`);
344
+ continue;
345
+ }
346
+ if (!Array.isArray(val)) {
347
+ details.push(`subagent_dispatch for "${key}" must be an array`);
348
+ continue;
349
+ }
350
+ for (const v of val) {
351
+ if (typeof v !== 'string') {
352
+ details.push(
353
+ `subagent_dispatch entry for "${key}" must be a string`,
354
+ );
328
355
  }
329
356
  }
330
357
  }
@@ -505,3 +532,253 @@ export function validateSettingsPayload(body) {
505
532
 
506
533
  return details.length ? { valid: false, details } : { valid: true };
507
534
  }
535
+
536
+ const VALID_WEBHOOK_OUT_FORMATS = [
537
+ 'generic-json',
538
+ 'slack-compatible',
539
+ 'discord-compatible',
540
+ 'teams-card',
541
+ 'ntfy',
542
+ 'plain-text',
543
+ ];
544
+
545
+ export function validateIntegrationsConfig(cfg) {
546
+ const details = [];
547
+
548
+ if (cfg.schema_version === undefined || cfg.schema_version !== 1) {
549
+ details.push('schema_version must be present and equal to 1');
550
+ }
551
+
552
+ if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
553
+ details.push('enabled must be a boolean');
554
+ }
555
+
556
+ if (cfg.webhook_secret_env !== undefined) {
557
+ if (
558
+ typeof cfg.webhook_secret_env !== 'string' ||
559
+ cfg.webhook_secret_env.length === 0
560
+ ) {
561
+ details.push('webhook_secret_env must be a non-empty string');
562
+ }
563
+ }
564
+
565
+ if (cfg.webhook_secrets_env !== undefined) {
566
+ if (
567
+ typeof cfg.webhook_secrets_env !== 'string' ||
568
+ cfg.webhook_secrets_env.length === 0
569
+ ) {
570
+ details.push('webhook_secrets_env must be a non-empty string');
571
+ }
572
+ }
573
+
574
+ if (
575
+ cfg.strict_inbox_verification !== undefined &&
576
+ typeof cfg.strict_inbox_verification !== 'boolean'
577
+ ) {
578
+ details.push('strict_inbox_verification must be a boolean');
579
+ }
580
+
581
+ // telegram
582
+ if (cfg.telegram !== undefined) {
583
+ if (
584
+ typeof cfg.telegram !== 'object' ||
585
+ cfg.telegram === null ||
586
+ Array.isArray(cfg.telegram)
587
+ ) {
588
+ details.push('telegram must be an object');
589
+ } else {
590
+ const tg = cfg.telegram;
591
+ if (tg.enabled !== undefined && typeof tg.enabled !== 'boolean') {
592
+ details.push('telegram.enabled must be a boolean');
593
+ }
594
+ const hasTgToken =
595
+ (typeof tg.bot_token === 'string' && tg.bot_token.length > 0) ||
596
+ (typeof tg.bot_token_env === 'string' && tg.bot_token_env.length > 0);
597
+ if (!hasTgToken) {
598
+ details.push(
599
+ 'telegram requires bot_token or bot_token_env (non-empty string)',
600
+ );
601
+ }
602
+ if (
603
+ tg.chat_id === undefined ||
604
+ (typeof tg.chat_id !== 'string' && typeof tg.chat_id !== 'number')
605
+ ) {
606
+ details.push('telegram.chat_id must be a string or number');
607
+ }
608
+ if (tg.events === undefined || !Array.isArray(tg.events)) {
609
+ details.push('telegram.events must be an array');
610
+ } else {
611
+ for (let i = 0; i < tg.events.length; i++) {
612
+ if (typeof tg.events[i] !== 'string' || tg.events[i].length === 0) {
613
+ details.push(`telegram.events[${i}] must be a non-empty string`);
614
+ }
615
+ }
616
+ }
617
+ if (tg.rate_limit_per_min !== undefined) {
618
+ if (
619
+ !Number.isInteger(tg.rate_limit_per_min) ||
620
+ tg.rate_limit_per_min < 1
621
+ ) {
622
+ details.push(
623
+ 'telegram.rate_limit_per_min must be a positive integer',
624
+ );
625
+ }
626
+ }
627
+ }
628
+ }
629
+
630
+ // discord
631
+ if (cfg.discord !== undefined) {
632
+ if (
633
+ typeof cfg.discord !== 'object' ||
634
+ cfg.discord === null ||
635
+ Array.isArray(cfg.discord)
636
+ ) {
637
+ details.push('discord must be an object');
638
+ } else {
639
+ const dc = cfg.discord;
640
+ if (dc.enabled !== undefined && typeof dc.enabled !== 'boolean') {
641
+ details.push('discord.enabled must be a boolean');
642
+ }
643
+ const hasDcToken =
644
+ (typeof dc.bot_token === 'string' && dc.bot_token.length > 0) ||
645
+ (typeof dc.bot_token_env === 'string' && dc.bot_token_env.length > 0);
646
+ if (!hasDcToken) {
647
+ details.push(
648
+ 'discord requires bot_token or bot_token_env (non-empty string)',
649
+ );
650
+ }
651
+ if (
652
+ dc.channel_id === undefined ||
653
+ typeof dc.channel_id !== 'string' ||
654
+ dc.channel_id.length === 0
655
+ ) {
656
+ details.push('discord.channel_id must be a non-empty string');
657
+ }
658
+ if (dc.events !== undefined && !Array.isArray(dc.events)) {
659
+ details.push('discord.events must be an array');
660
+ } else if (Array.isArray(dc.events)) {
661
+ for (let i = 0; i < dc.events.length; i++) {
662
+ if (typeof dc.events[i] !== 'string' || dc.events[i].length === 0) {
663
+ details.push(`discord.events[${i}] must be a non-empty string`);
664
+ }
665
+ }
666
+ }
667
+ }
668
+ }
669
+
670
+ // slack
671
+ if (cfg.slack !== undefined) {
672
+ if (
673
+ typeof cfg.slack !== 'object' ||
674
+ cfg.slack === null ||
675
+ Array.isArray(cfg.slack)
676
+ ) {
677
+ details.push('slack must be an object');
678
+ } else {
679
+ const sl = cfg.slack;
680
+ if (sl.enabled !== undefined && typeof sl.enabled !== 'boolean') {
681
+ details.push('slack.enabled must be a boolean');
682
+ }
683
+ const hasSlUrl =
684
+ (typeof sl.webhook_url === 'string' && sl.webhook_url.length > 0) ||
685
+ (typeof sl.webhook_url_env === 'string' &&
686
+ sl.webhook_url_env.length > 0);
687
+ if (!hasSlUrl) {
688
+ details.push(
689
+ 'slack requires webhook_url or webhook_url_env (non-empty string)',
690
+ );
691
+ }
692
+ if (sl.events !== undefined && !Array.isArray(sl.events)) {
693
+ details.push('slack.events must be an array');
694
+ } else if (Array.isArray(sl.events)) {
695
+ for (let i = 0; i < sl.events.length; i++) {
696
+ if (typeof sl.events[i] !== 'string' || sl.events[i].length === 0) {
697
+ details.push(`slack.events[${i}] must be a non-empty string`);
698
+ }
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ // webhook_out
705
+ if (cfg.webhook_out !== undefined) {
706
+ if (
707
+ typeof cfg.webhook_out !== 'object' ||
708
+ cfg.webhook_out === null ||
709
+ Array.isArray(cfg.webhook_out)
710
+ ) {
711
+ details.push('webhook_out must be an object');
712
+ } else {
713
+ const wo = cfg.webhook_out;
714
+ if (wo.enabled !== undefined && typeof wo.enabled !== 'boolean') {
715
+ details.push('webhook_out.enabled must be a boolean');
716
+ }
717
+ if (wo.endpoints !== undefined) {
718
+ if (!Array.isArray(wo.endpoints)) {
719
+ details.push('webhook_out.endpoints must be an array');
720
+ } else {
721
+ for (let i = 0; i < wo.endpoints.length; i++) {
722
+ const ep = wo.endpoints[i];
723
+ const pfx = `webhook_out.endpoints[${i}]`;
724
+ if (typeof ep !== 'object' || ep === null || Array.isArray(ep)) {
725
+ details.push(`${pfx} must be an object`);
726
+ continue;
727
+ }
728
+ if (
729
+ ep.url === undefined ||
730
+ typeof ep.url !== 'string' ||
731
+ ep.url.trim().length === 0
732
+ ) {
733
+ details.push(`${pfx}.url must be a non-empty string`);
734
+ } else {
735
+ try {
736
+ const parsed = new URL(ep.url);
737
+ if (
738
+ parsed.protocol !== 'http:' &&
739
+ parsed.protocol !== 'https:'
740
+ ) {
741
+ details.push(`${pfx}.url must use http or https protocol`);
742
+ }
743
+ } catch {
744
+ details.push(`${pfx}.url is not a valid URL`);
745
+ }
746
+ }
747
+ if (ep.format !== undefined) {
748
+ if (!VALID_WEBHOOK_OUT_FORMATS.includes(ep.format)) {
749
+ details.push(
750
+ `${pfx}.format must be one of: ${VALID_WEBHOOK_OUT_FORMATS.join(', ')}`,
751
+ );
752
+ }
753
+ }
754
+ if (ep.headers !== undefined) {
755
+ if (
756
+ typeof ep.headers !== 'object' ||
757
+ ep.headers === null ||
758
+ Array.isArray(ep.headers)
759
+ ) {
760
+ details.push(`${pfx}.headers must be an object`);
761
+ }
762
+ }
763
+ if (ep.events !== undefined && !Array.isArray(ep.events)) {
764
+ details.push(`${pfx}.events must be an array`);
765
+ } else if (Array.isArray(ep.events)) {
766
+ for (let j = 0; j < ep.events.length; j++) {
767
+ if (
768
+ typeof ep.events[j] !== 'string' ||
769
+ ep.events[j].length === 0
770
+ ) {
771
+ details.push(
772
+ `${pfx}.events[${j}] must be a non-empty string`,
773
+ );
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ return details.length ? { valid: false, details } : { valid: true };
784
+ }