great-cto 2.77.2 → 2.78.0
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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/alerts.mjs +16 -15
- package/board/packages/board/lib/beads.mjs +2 -1
- package/board/packages/board/lib/log.mjs +44 -0
- package/board/packages/board/lib/projects.mjs +33 -10
- package/board/packages/board/lib/routes.mjs +13 -24
- package/board/packages/board/lib/util.mjs +14 -1
- package/board/packages/board/server.mjs +15 -8
- package/dist/telemetry.js +3 -3
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.78.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -16,6 +16,7 @@ import { getTasks } from './beads.mjs';
|
|
|
16
16
|
import { readVerdicts } from './verdicts.mjs';
|
|
17
17
|
import { isFailure } from './fleet.mjs';
|
|
18
18
|
import { getShareState, toggleShare } from './share.mjs';
|
|
19
|
+
import { log } from './log.mjs';
|
|
19
20
|
|
|
20
21
|
// ── Email alerts (Resend) ─────────────────────────────────────────────────
|
|
21
22
|
// Dispatch model: read webhooks.json on every fire (idempotent if disabled
|
|
@@ -91,12 +92,12 @@ async function fireEmailAlert(eventName, dedupeKey, payload) {
|
|
|
91
92
|
});
|
|
92
93
|
if (!res.ok) {
|
|
93
94
|
const txt = await res.text().catch(() => '');
|
|
94
|
-
|
|
95
|
+
log.warn(`fireEmailAlert ${eventName}: relay HTTP ${res.status} ${txt.slice(0, 200)}`);
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
log.info(`fireEmailAlert: sent ${eventName} (${dedupeKey})`);
|
|
98
99
|
} catch (e) {
|
|
99
|
-
|
|
100
|
+
log.warn(`fireEmailAlert ${eventName} failed:`, e.message);
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -119,12 +120,12 @@ async function firePushAlert(eventName, dedupeKey, payload) {
|
|
|
119
120
|
catch (e) {
|
|
120
121
|
// 410 = subscription expired — browser unsubscribed, clean up
|
|
121
122
|
if (e.statusCode === 410) removeSubscription(PUSH_SUBS_FILE, sub.endpoint);
|
|
122
|
-
else
|
|
123
|
+
else log.warn(`firePushAlert send failed for ${sub.endpoint}:`, e.message);
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
fired[pushKey] = new Date().toISOString();
|
|
126
127
|
writeAlertsFired(fired);
|
|
127
|
-
} catch (e) {
|
|
128
|
+
} catch (e) { log.warn('firePushAlert failed:', e.message); }
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// ── Cron: scan gates / cost / weekly digest ───────────────────────────────
|
|
@@ -172,7 +173,7 @@ function startAlertCron() {
|
|
|
172
173
|
firePushAlert('incident.p0', dedupeKey, p0Payload);
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
|
-
} catch (e) {
|
|
176
|
+
} catch (e) { log.warn('cron incident.p0 failed:', e.message); }
|
|
176
177
|
}, FIVE_MIN);
|
|
177
178
|
|
|
178
179
|
// gate.blocked: new BLOCKED verdict from security-officer
|
|
@@ -207,7 +208,7 @@ function startAlertCron() {
|
|
|
207
208
|
addNotification('gate.blocked', blockedPayload);
|
|
208
209
|
firePushAlert('gate.blocked', dedupeKey, blockedPayload);
|
|
209
210
|
}
|
|
210
|
-
} catch (e) {
|
|
211
|
+
} catch (e) { log.warn('cron gate.blocked failed:', e.message); }
|
|
211
212
|
}, FIVE_MIN);
|
|
212
213
|
|
|
213
214
|
// gate.stale: gate task open between 2h and 7 days.
|
|
@@ -238,7 +239,7 @@ function startAlertCron() {
|
|
|
238
239
|
firePushAlert('gate.stale', dedupeKey, stalePayload);
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
|
-
} catch (e) {
|
|
242
|
+
} catch (e) { log.warn('cron gate.stale failed:', e.message); }
|
|
242
243
|
}, FIVE_MIN);
|
|
243
244
|
|
|
244
245
|
// cost.threshold: monthly LLM spend at 80% / 100% of budget
|
|
@@ -277,7 +278,7 @@ function startAlertCron() {
|
|
|
277
278
|
firePushAlert('cost.threshold', dedupeKey, costPayload);
|
|
278
279
|
}
|
|
279
280
|
}
|
|
280
|
-
} catch (e) {
|
|
281
|
+
} catch (e) { log.warn('cron cost.threshold failed:', e.message); }
|
|
281
282
|
}, ONE_HOUR);
|
|
282
283
|
|
|
283
284
|
// digest.weekly: Friday 09:00 UTC ± server tick
|
|
@@ -317,7 +318,7 @@ function startAlertCron() {
|
|
|
317
318
|
addNotification('digest.weekly', weeklyPayload);
|
|
318
319
|
await firePushAlert('digest.weekly', dedupeKey, weeklyPayload);
|
|
319
320
|
}
|
|
320
|
-
} catch (e) {
|
|
321
|
+
} catch (e) { log.warn('cron digest.weekly failed:', e.message); }
|
|
321
322
|
}, FIVE_MIN);
|
|
322
323
|
|
|
323
324
|
// digest.daily: morning summary (Mon–Fri, 08:00 UTC).
|
|
@@ -384,7 +385,7 @@ function startAlertCron() {
|
|
|
384
385
|
addNotification('digest.daily', dailyPayload);
|
|
385
386
|
await firePushAlert('digest.daily', dedupeKey, dailyPayload);
|
|
386
387
|
}
|
|
387
|
-
} catch (e) {
|
|
388
|
+
} catch (e) { log.warn('cron digest.daily failed:', e.message); }
|
|
388
389
|
}, FIVE_MIN);
|
|
389
390
|
|
|
390
391
|
// report.daily: republish share reports every day at 09:00 UTC
|
|
@@ -401,13 +402,13 @@ function startAlertCron() {
|
|
|
401
402
|
if (_reportRepublishDedupeSet.has(dedupeKey)) continue;
|
|
402
403
|
_reportRepublishDedupeSet.add(dedupeKey);
|
|
403
404
|
toggleShare(true, proj.path, true)
|
|
404
|
-
.then(() =>
|
|
405
|
-
.catch(e =>
|
|
405
|
+
.then(() => log.info(`report.daily: republished ${proj.slug}`))
|
|
406
|
+
.catch(e => log.warn(`report.daily: ${proj.slug} failed: ${e.message}`));
|
|
406
407
|
}
|
|
407
|
-
} catch (e) {
|
|
408
|
+
} catch (e) { log.warn('cron report.daily failed:', e.message); }
|
|
408
409
|
}, FIVE_MIN);
|
|
409
410
|
|
|
410
|
-
|
|
411
|
+
log.info('Alert cron started: gate.stale (5min), sla.escalate (5min), connector.health (5min), cost.threshold (1h), digest.daily (Mon–Fri 08:00), digest.weekly (Fri 09:00), report.daily (09:00)');
|
|
411
412
|
}
|
|
412
413
|
|
|
413
414
|
export { readAlertsFired, writeAlertsFired, fireEmailAlert, firePushAlert, startAlertCron };
|
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
5
|
import { bdCache } from './state.mjs';
|
|
6
|
+
import { log } from './log.mjs';
|
|
6
7
|
|
|
7
8
|
// ── Beads data ─────────────────────────────────────────────────────────────────
|
|
8
9
|
// Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
|
|
@@ -85,7 +86,7 @@ function checkBeadsAvailable(cwd) {
|
|
|
85
86
|
let _bdWriteChain = Promise.resolve();
|
|
86
87
|
function bdWriteSerialised(fn) {
|
|
87
88
|
const next = _bdWriteChain.then(() => fn()).catch((e) => {
|
|
88
|
-
|
|
89
|
+
log.error('[bd-write-serialised]', e?.message || e);
|
|
89
90
|
return null;
|
|
90
91
|
});
|
|
91
92
|
_bdWriteChain = next.then(() => undefined).catch(() => undefined);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Zero-dep structured logger for the great_cto board server.
|
|
2
|
+
//
|
|
3
|
+
// Design:
|
|
4
|
+
// - Levels: debug < info < warn < error. Threshold read once from
|
|
5
|
+
// GREAT_CTO_LOG_LEVEL (default 'info'); unrecognized values fall back to 'info'.
|
|
6
|
+
// - Output: single-line `<ISO timestamp> <LEVEL> <message>` per call.
|
|
7
|
+
// - Routing: error → stderr; debug/info/warn → stdout. This matches Unix
|
|
8
|
+
// convention (stdout = normal operation, stderr = attention-worthy) and
|
|
9
|
+
// keeps the board's stdout free of interleaved error noise.
|
|
10
|
+
// - No external deps, ESM only, node: prefix for built-ins.
|
|
11
|
+
|
|
12
|
+
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
13
|
+
|
|
14
|
+
function currentThreshold() {
|
|
15
|
+
const raw = (process.env.GREAT_CTO_LOG_LEVEL || 'info').toLowerCase();
|
|
16
|
+
return LEVELS[raw] ?? LEVELS.info;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function format(level, args) {
|
|
20
|
+
const ts = new Date().toISOString();
|
|
21
|
+
const msg = args
|
|
22
|
+
.map(a => (typeof a === 'string' ? a : a instanceof Error ? (a.stack || a.message) : JSON.stringify(a)))
|
|
23
|
+
.join(' ');
|
|
24
|
+
return `${ts} ${level.toUpperCase()} ${msg}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function emit(level, args) {
|
|
28
|
+
if (LEVELS[level] < currentThreshold()) return;
|
|
29
|
+
const line = format(level, args);
|
|
30
|
+
if (level === 'error') {
|
|
31
|
+
process.stderr.write(line + '\n');
|
|
32
|
+
} else {
|
|
33
|
+
process.stdout.write(line + '\n');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const log = {
|
|
38
|
+
debug: (...args) => emit('debug', args),
|
|
39
|
+
info: (...args) => emit('info', args),
|
|
40
|
+
warn: (...args) => emit('warn', args),
|
|
41
|
+
error: (...args) => emit('error', args),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default log;
|
|
@@ -4,6 +4,20 @@ import os from 'os';
|
|
|
4
4
|
import { spawnSync } from 'child_process';
|
|
5
5
|
import { planGates } from '../../../scripts/lib/gate-plan.mjs';
|
|
6
6
|
import { GREAT_CTO_DIR, PROJECTS_FILE } from './config.mjs';
|
|
7
|
+
import { isInsideDir } from './util.mjs';
|
|
8
|
+
|
|
9
|
+
// Same HOME-boundary policy /api/projects/register enforces (lib/routes.mjs):
|
|
10
|
+
// a raw absolute/tilde path must resolve inside the operator's home directory,
|
|
11
|
+
// no exceptions. Without this, a request like ?project=/etc or ?project=/tmp
|
|
12
|
+
// makes the board read/write project data (.beads, verdicts/, PROJECT.md)
|
|
13
|
+
// at an arbitrary filesystem location the operator never registered. Returns
|
|
14
|
+
// null (not process.cwd()) so callers can distinguish "not a raw path" from
|
|
15
|
+
// "raw path rejected" and fall back explicitly.
|
|
16
|
+
function resolveRawPathWithinHome(slugOrPath) {
|
|
17
|
+
const raw = slugOrPath.startsWith('~') ? slugOrPath.replace(/^~/, os.homedir()) : slugOrPath;
|
|
18
|
+
const resolved = path.resolve(raw);
|
|
19
|
+
return isInsideDir(os.homedir(), resolved) ? resolved : null;
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
// ── Project registry ───────────────────────────────────────────────────────────
|
|
9
23
|
function readProjectsRegistry() {
|
|
@@ -251,12 +265,19 @@ function listProjects() {
|
|
|
251
265
|
}
|
|
252
266
|
function resolveProjectCwd(slugOrPath) {
|
|
253
267
|
if (!slugOrPath) return process.cwd();
|
|
254
|
-
// If absolute path or starts with ~,
|
|
255
|
-
|
|
256
|
-
|
|
268
|
+
// If absolute path or starts with ~, it's raw user input — enforce the same
|
|
269
|
+
// HOME-boundary policy /api/projects/register uses. A path outside HOME
|
|
270
|
+
// falls back to process.cwd() (same fallback already used below for an
|
|
271
|
+
// unknown slug) rather than being honored verbatim.
|
|
272
|
+
if (slugOrPath.startsWith('/') || slugOrPath.startsWith('~')) {
|
|
273
|
+
const withinHome = resolveRawPathWithinHome(slugOrPath);
|
|
274
|
+
return withinHome || process.cwd();
|
|
275
|
+
}
|
|
257
276
|
// Else look up in registry by slug — among duplicate slugs (great_cto-7xfc),
|
|
258
277
|
// prefer the entry whose path exists on disk; among several existing (or
|
|
259
|
-
// several missing), prefer the most recently added_at.
|
|
278
|
+
// several missing), prefer the most recently added_at. Registry entries were
|
|
279
|
+
// already validated at registration time (register enforces the HOME
|
|
280
|
+
// boundary before writing), so slug resolution is untouched.
|
|
260
281
|
const reg = readProjectsRegistry();
|
|
261
282
|
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
262
283
|
const found = pickBestBySlug(matches);
|
|
@@ -271,16 +292,18 @@ function resolveProjectCwd(slugOrPath) {
|
|
|
271
292
|
*
|
|
272
293
|
* resolved values:
|
|
273
294
|
* 'cwd' — no project param passed; using server cwd as documented
|
|
274
|
-
* 'path' — absolute / tilde path passed
|
|
295
|
+
* 'path' — absolute / tilde path passed, resolved inside HOME, used directly
|
|
275
296
|
* 'slug' — slug found in registry
|
|
276
|
-
* 'fallback' — slug requested but NOT in registry
|
|
277
|
-
*
|
|
297
|
+
* 'fallback' — slug requested but NOT in registry, OR a raw path was requested
|
|
298
|
+
* that resolves outside HOME (great_cto-qvg9); using cwd as
|
|
299
|
+
* fallback. Caller should warn the user (header + log).
|
|
278
300
|
*/
|
|
279
301
|
function resolveProjectInfo(slugOrPath) {
|
|
280
302
|
if (!slugOrPath) return { cwd: process.cwd(), resolved: 'cwd' };
|
|
281
|
-
if (slugOrPath.startsWith('/')
|
|
282
|
-
|
|
283
|
-
return { cwd:
|
|
303
|
+
if (slugOrPath.startsWith('/') || slugOrPath.startsWith('~')) {
|
|
304
|
+
const withinHome = resolveRawPathWithinHome(slugOrPath);
|
|
305
|
+
if (withinHome) return { cwd: withinHome, resolved: 'path' };
|
|
306
|
+
return { cwd: process.cwd(), resolved: 'fallback', requested: slugOrPath };
|
|
284
307
|
}
|
|
285
308
|
const reg = readProjectsRegistry();
|
|
286
309
|
const matches = reg.projects.filter(p => p.slug === slugOrPath);
|
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
addSubscription,
|
|
7
7
|
removeSubscription,
|
|
8
8
|
} from '../push-adapter.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import { eventSurface, readFileSafe } from './util.mjs';
|
|
9
|
+
import { GREAT_CTO_DIR, VAPID_KEYS_FILE, PUSH_SUBS_FILE, BUILD_VERSION } from './config.mjs';
|
|
10
|
+
import { eventSurface, readFileSafe, originAllowed } from './util.mjs';
|
|
11
11
|
import { sseClients, notifHistory } from './state.mjs';
|
|
12
12
|
import { autoRegisterProject, listProjects, resolveProjectCwd, getChangeTier } from './projects.mjs';
|
|
13
13
|
import { broadcastTasks } from './sse.mjs';
|
|
14
14
|
import { saveNotifHistory } from './notifications.mjs';
|
|
15
15
|
import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
|
|
16
|
+
import { log } from './log.mjs';
|
|
16
17
|
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks } from './beads.mjs';
|
|
17
18
|
import { getMetrics } from './metrics.mjs';
|
|
18
19
|
import { readVerdicts } from './verdicts.mjs';
|
|
@@ -54,18 +55,12 @@ async function dispatch(req, res, url, cwd) {
|
|
|
54
55
|
// it MUST reject cross-origin requests. The board listens on 127.0.0.1
|
|
55
56
|
// but a malicious page the user visits can still issue text/plain POSTs
|
|
56
57
|
// (simple CORS request — no preflight) to localhost. Two gates:
|
|
57
|
-
// 1) Origin / Referer must
|
|
58
|
+
// 1) Origin / Referer must be same-origin (originAllowed() in lib/util.mjs —
|
|
59
|
+
// also enforced as the top-level CSRF guard in server.mjs before dispatch()
|
|
60
|
+
// is ever called, so this is defense-in-depth, not the only check).
|
|
58
61
|
// 2) Resolved target path must live inside HOME — no /tmp, no /etc.
|
|
59
62
|
if (pathname === '/api/projects/register' && req.method === 'POST') {
|
|
60
|
-
|
|
61
|
-
const expectedOrigin = `http://localhost:${PORT}`;
|
|
62
|
-
const expectedOrigin2 = `http://127.0.0.1:${PORT}`;
|
|
63
|
-
const originOk = !origin
|
|
64
|
-
|| origin === expectedOrigin
|
|
65
|
-
|| origin === expectedOrigin2
|
|
66
|
-
|| origin.startsWith(expectedOrigin + '/')
|
|
67
|
-
|| origin.startsWith(expectedOrigin2 + '/');
|
|
68
|
-
if (!originOk) {
|
|
63
|
+
if (!originAllowed(req)) {
|
|
69
64
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
70
65
|
res.end(JSON.stringify({ error: 'origin not allowed' }));
|
|
71
66
|
return true;
|
|
@@ -384,8 +379,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
384
379
|
const shareState = getShareState(gateCwd);
|
|
385
380
|
if (shareState.enabled) {
|
|
386
381
|
toggleShare(true, gateCwd, true)
|
|
387
|
-
.then(() =>
|
|
388
|
-
.catch(e =>
|
|
382
|
+
.then(() => log.info(`report: auto-republished after gate approve (${id})`))
|
|
383
|
+
.catch(e => log.warn(`report: republish after gate failed: ${e.message}`));
|
|
389
384
|
}
|
|
390
385
|
}
|
|
391
386
|
});
|
|
@@ -850,16 +845,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
850
845
|
// (DESIGN-agents-fleet-view §9 Top-2 #2: reversible sidecar chosen over
|
|
851
846
|
// filesystem move; founder may revise.)
|
|
852
847
|
if (pathname.startsWith('/api/agents/') && req.method === 'POST') {
|
|
853
|
-
// Cross-origin guard — same
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const originOk = !origin
|
|
858
|
-
|| origin === expectedOrigin
|
|
859
|
-
|| origin === expectedOrigin2
|
|
860
|
-
|| origin.startsWith(expectedOrigin + '/')
|
|
861
|
-
|| origin.startsWith(expectedOrigin2 + '/');
|
|
862
|
-
if (!originOk) {
|
|
848
|
+
// Cross-origin guard — same helper as BH-23 on /api/projects/register
|
|
849
|
+
// (originAllowed() in lib/util.mjs; also enforced as the top-level CSRF
|
|
850
|
+
// guard in server.mjs before dispatch() is ever called).
|
|
851
|
+
if (!originAllowed(req)) {
|
|
863
852
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
864
853
|
res.end(JSON.stringify({ error: 'origin_not_allowed' }));
|
|
865
854
|
return true;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { PORT } from './config.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -33,4 +34,16 @@ function readFileSafe(p) {
|
|
|
33
34
|
try { return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null; } catch { return null; }
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
// Path-containment check used by any handler that joins user-controlled input
|
|
38
|
+
// onto a base directory (static file serving, doc reads, etc.). Resolves both
|
|
39
|
+
// sides and requires `target` to be exactly `base`, or a real descendant of
|
|
40
|
+
// it (base + path.sep prefix) — this is what actually blocks ".." escapes,
|
|
41
|
+
// since a naive startsWith(base) would wrongly allow a sibling directory
|
|
42
|
+
// like "/public-evil" when base is "/public".
|
|
43
|
+
function isInsideDir(base, target) {
|
|
44
|
+
const resolvedBase = path.resolve(base);
|
|
45
|
+
const resolvedTarget = path.resolve(target);
|
|
46
|
+
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { csvCell, originAllowed, eventSurface, readFileSafe, isInsideDir };
|
|
@@ -13,11 +13,12 @@ import fs from 'fs';
|
|
|
13
13
|
import path from 'path';
|
|
14
14
|
import { spawnSync } from 'child_process';
|
|
15
15
|
import { PORT, PUBLIC, HOST } from './lib/config.mjs';
|
|
16
|
-
import { originAllowed } from './lib/util.mjs';
|
|
16
|
+
import { originAllowed, isInsideDir } from './lib/util.mjs';
|
|
17
17
|
import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
|
|
18
18
|
import { startAlertCron } from './lib/alerts.mjs';
|
|
19
19
|
import { watchBeads, watchVerdicts } from './lib/watchers.mjs';
|
|
20
20
|
import { dispatch } from './lib/routes.mjs';
|
|
21
|
+
import { log } from './lib/log.mjs';
|
|
21
22
|
|
|
22
23
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -77,7 +78,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
77
78
|
// Static files
|
|
78
79
|
let filePath = pathname === '/' ? '/index.html' : pathname;
|
|
79
80
|
const fullPath = path.join(PUBLIC, filePath);
|
|
80
|
-
|
|
81
|
+
// Path-traversal guard (mirrors the /api/doc containment check in lib/routes.mjs):
|
|
82
|
+
// the joined path must resolve to somewhere inside PUBLIC. Without this, a decoded
|
|
83
|
+
// pathname like /..%2F..%2Fetc%2Fpasswd can escape the public/ directory via
|
|
84
|
+
// path.join's ".." collapsing. Reject with 404 (same as the generic static 404
|
|
85
|
+
// below) so a traversal attempt is indistinguishable from a missing file — no
|
|
86
|
+
// signal to a prober about what exists outside PUBLIC.
|
|
87
|
+
if (isInsideDir(PUBLIC, fullPath) && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
81
88
|
const ext = path.extname(fullPath);
|
|
82
89
|
const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
|
83
90
|
// HTML must never cache — board UI is iterated daily and stale layouts
|
|
@@ -102,15 +109,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
102
109
|
|
|
103
110
|
// ── Start ──────────────────────────────────────────────────────────────────────
|
|
104
111
|
server.listen(PORT, HOST, () => {
|
|
105
|
-
|
|
112
|
+
log.info(`great_cto board → http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
|
|
106
113
|
if (HOST !== '127.0.0.1' && HOST !== 'localhost') {
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
|
|
115
|
+
log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
|
|
109
116
|
}
|
|
110
117
|
// Discover all great_cto projects on disk asynchronously — don't block
|
|
111
118
|
// the listening event so /api/tasks is available immediately.
|
|
112
119
|
discoverProjects().then(n => {
|
|
113
|
-
if (n > 0)
|
|
120
|
+
if (n > 0) log.info(` → discovered ${n} project${n === 1 ? '' : 's'} with .great_cto/PROJECT.md`);
|
|
114
121
|
}).catch(() => {}); // non-fatal
|
|
115
122
|
watchBeads();
|
|
116
123
|
startAlertCron();
|
|
@@ -125,9 +132,9 @@ server.listen(PORT, HOST, () => {
|
|
|
125
132
|
|
|
126
133
|
server.on('error', e => {
|
|
127
134
|
if (e.code === 'EADDRINUSE') {
|
|
128
|
-
|
|
135
|
+
log.info(`Port ${PORT} in use — board already running at http://localhost:${PORT}`);
|
|
129
136
|
} else {
|
|
130
|
-
|
|
137
|
+
log.error(e);
|
|
131
138
|
}
|
|
132
139
|
process.exit(0);
|
|
133
140
|
});
|
package/dist/telemetry.js
CHANGED
|
@@ -27,9 +27,9 @@ import * as path from "node:path";
|
|
|
27
27
|
import * as os from "node:os";
|
|
28
28
|
import * as crypto from "node:crypto";
|
|
29
29
|
const TELEMETRY_ENDPOINT = process.env.GREAT_CTO_TELEMETRY_ENDPOINT
|
|
30
|
-
|| "https://
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
|| "https://telemetry.greatcto.systems/v1/event";
|
|
31
|
+
// Override anytime with GREAT_CTO_TELEMETRY_ENDPOINT (e.g. to point at a
|
|
32
|
+
// workers.dev URL during local worker development).
|
|
33
33
|
const TELEMETRY_TIMEOUT_MS = 1000;
|
|
34
34
|
// Allowlist — anything else is dropped client-side and server-side.
|
|
35
35
|
const ALLOWED_COMMANDS = new Set([
|
package/package.json
CHANGED