changeledger 0.3.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.
Files changed (44) hide show
  1. package/AGENTS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/bin/changeledger.mjs +375 -0
  5. package/package.json +72 -0
  6. package/src/atomic-write.mjs +134 -0
  7. package/src/change.mjs +102 -0
  8. package/src/check.mjs +536 -0
  9. package/src/commands/agent.mjs +256 -0
  10. package/src/commands/check.mjs +48 -0
  11. package/src/commands/graduate.mjs +105 -0
  12. package/src/commands/init.mjs +43 -0
  13. package/src/commands/new.mjs +164 -0
  14. package/src/commands/register.mjs +28 -0
  15. package/src/commands/release.mjs +138 -0
  16. package/src/commands/view.mjs +52 -0
  17. package/src/config.mjs +76 -0
  18. package/src/contract.mjs +100 -0
  19. package/src/git.mjs +73 -0
  20. package/src/lifecycle.mjs +57 -0
  21. package/src/metrics.mjs +143 -0
  22. package/src/paths.mjs +12 -0
  23. package/src/registry.mjs +55 -0
  24. package/src/release.mjs +71 -0
  25. package/src/repo.mjs +122 -0
  26. package/src/slug.mjs +12 -0
  27. package/src/spec.mjs +12 -0
  28. package/src/viewer/domain.mjs +133 -0
  29. package/src/viewer/public/api.js +25 -0
  30. package/src/viewer/public/app-state.js +87 -0
  31. package/src/viewer/public/app.js +717 -0
  32. package/src/viewer/public/index.html +64 -0
  33. package/src/viewer/public/security.js +65 -0
  34. package/src/viewer/public/state.js +31 -0
  35. package/src/viewer/public/styles.css +1062 -0
  36. package/src/viewer/public/templates.js +9 -0
  37. package/src/viewer/public/view-parts.js +162 -0
  38. package/src/viewer/public/view-renderers.js +191 -0
  39. package/src/viewer/server/router.mjs +200 -0
  40. package/src/viewer/server/security.mjs +27 -0
  41. package/src/writer.mjs +151 -0
  42. package/src/yaml.mjs +75 -0
  43. package/templates/AGENTS.md +540 -0
  44. package/templates/config.yml +55 -0
@@ -0,0 +1,9 @@
1
+ import { html, nothing, render, svg } from 'lit-html';
2
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
+ import { safeHtml } from './security.js';
4
+
5
+ export { html, nothing, render, svg };
6
+
7
+ export function markdownHtml(markdown) {
8
+ return unsafeHTML(safeHtml(markdown));
9
+ }
@@ -0,0 +1,162 @@
1
+ import { cssIdent } from './security.js';
2
+ import { html, markdownHtml, nothing } from './templates.js';
3
+
4
+ const MARK = { done: '✓', todo: '○', blocked: '✕' };
5
+
6
+ export const humanizeStatus = (status) => {
7
+ const text = String(status ?? '').replaceAll('-', ' ');
8
+ return text ? text[0].toUpperCase() + text.slice(1) : '';
9
+ };
10
+
11
+ export function statusSummary(statuses) {
12
+ if (!statuses.size) return 'All statuses';
13
+ if (statuses.size === 1) return humanizeStatus([...statuses][0]);
14
+ return `${statuses.size} statuses`;
15
+ }
16
+
17
+ export function statusTag(status) {
18
+ return html`<span
19
+ class="status-tag"
20
+ style=${`--status-color: var(--status-${cssIdent(status)}, var(--status-muted))`}
21
+ ><i aria-hidden="true"></i>${humanizeStatus(status)}</span>`;
22
+ }
23
+
24
+ export function sortIndicator(direction) {
25
+ const path = direction > 0 ? 'M2.5 6.5 5 4l2.5 2.5' : 'M2.5 3.5 5 6l2.5-2.5';
26
+ return html`<svg class="sort-indicator" viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
27
+ <path d=${path}></path>
28
+ </svg>`;
29
+ }
30
+
31
+ export function closeButton(label = 'Close detail', extraClass = '') {
32
+ return html`<button type="button" class=${`icon-button close ${extraClass}`.trim()} aria-label=${label} title=${label}>
33
+ <svg viewBox="0 0 16 16" aria-hidden="true">
34
+ <path d="M3.75 3.75 12.25 12.25M12.25 3.75 3.75 12.25"></path>
35
+ </svg>
36
+ </button>`;
37
+ }
38
+
39
+ export function validationPanel() {
40
+ return html`<section class="validation-actions" aria-labelledby="validation-title">
41
+ <div class="validation-copy">
42
+ <span class="validation-kicker">Human checkpoint</span>
43
+ <h2 id="validation-title">Ready for your verdict</h2>
44
+ <p>Test the complete change, then accept it or send it back with a precise reason.</p>
45
+ </div>
46
+ <div class="validation-controls">
47
+ <button type="button" class="button button-primary" data-validation="pass">Accept change</button>
48
+ <div class="rejection-field">
49
+ <label for="validation-reason">Reason for rejection</label>
50
+ <input id="validation-reason" data-validation-reason type="text" placeholder="What still needs work?" />
51
+ <p class="validation-error" role="alert" hidden></p>
52
+ </div>
53
+ <button type="button" class="button button-danger" data-validation="fail">Reject with reason</button>
54
+ </div>
55
+ </section>`;
56
+ }
57
+
58
+ export function splitGraduationHistory(body) {
59
+ const lines = String(body ?? '').split('\n');
60
+ let cursor = 0;
61
+ while (cursor < lines.length && !lines[cursor].trim()) cursor += 1;
62
+ if (/^#\s+/.test(lines[cursor] ?? '')) {
63
+ cursor += 1;
64
+ while (cursor < lines.length && !lines[cursor].trim()) cursor += 1;
65
+ }
66
+ const start = cursor;
67
+ const entries = [];
68
+ while (/^>\s+Graduado del change\s+/.test(lines[cursor] ?? '')) {
69
+ entries.push(lines[cursor].replace(/^>\s+/, ''));
70
+ cursor += 1;
71
+ }
72
+ if (!entries.length) return { before: '', entries: [], after: String(body ?? '') };
73
+ while (cursor < lines.length && !lines[cursor].trim()) cursor += 1;
74
+ return {
75
+ before: lines.slice(0, start).join('\n').trim(),
76
+ entries,
77
+ after: lines.slice(cursor).join('\n'),
78
+ };
79
+ }
80
+
81
+ export function specBody(body) {
82
+ const { before, entries, after } = splitGraduationHistory(body);
83
+ if (!entries.length) return html`<div class="stage-content">${markdownHtml(after)}</div>`;
84
+ return html`<div class="stage-content spec-content">
85
+ ${before ? markdownHtml(before) : nothing}
86
+ <details class="graduation-history">
87
+ <summary>
88
+ <span class="history-icon" aria-hidden="true">↳</span>
89
+ <span>Graduation history</span>
90
+ <span class="history-count">${entries.length}</span>
91
+ </summary>
92
+ <ol>${entries.map((entry) => html`<li>${entry}</li>`)}</ol>
93
+ </details>
94
+ ${markdownHtml(after)}
95
+ </div>`;
96
+ }
97
+
98
+ export function card(c) {
99
+ const pct = c.progress.total ? Math.round((c.progress.done / c.progress.total) * 100) : 0;
100
+ const blocked = c.progress.blocked
101
+ ? html`<span class="flag-blocked">● ${c.progress.blocked} blocked</span>`
102
+ : nothing;
103
+ return html`
104
+ <div
105
+ class=${`card ${c.archived ? 'archived' : ''}`}
106
+ data-id=${c.id}
107
+ style=${`--type-color: var(--${cssIdent(c.type)})`}
108
+ >
109
+ <div class="card-top">
110
+ <span class="card-id">#${c.id}</span>
111
+ <span class="type-tag">${c.type}</span>
112
+ </div>
113
+ <div class="card-title">${c.title}</div>
114
+ ${c.progress.total ? html`<div class="progress"><i style=${`width:${pct}%`}></i></div>` : nothing}
115
+ <div class="card-meta">
116
+ ${c.progress.total ? html`<span>${c.progress.done}/${c.progress.total} tasks</span>` : nothing}
117
+ ${c.owner ? html`<span class="owner">@${c.owner}</span>` : nothing}
118
+ ${blocked}
119
+ </div>
120
+ </div>`;
121
+ }
122
+
123
+ export function stageBlock(c, s) {
124
+ const content = s.key === 'plan' && c.tasks.length ? taskList(c.tasks) : markdownHtml(s.body);
125
+ return html`
126
+ <div class="stage" id=${`stage-${s.key}`}>
127
+ <h2>${s.heading}</h2>
128
+ <div class="stage-content">${content}</div>
129
+ </div>`;
130
+ }
131
+
132
+ export function taskList(tasks) {
133
+ return html`<ul class="tasks">
134
+ ${tasks.map((t) => {
135
+ const cr = (t.criteria || []).map((x) => html`<span class="cr">${x}</span>`);
136
+ const when = t.resolvedAt ? html`<span class="when">${t.resolvedAt}</span>` : nothing;
137
+ const reason = t.reason ? html`<span class="reason">— ${t.reason}</span>` : nothing;
138
+ return html`<li class=${`task ${t.state}`}>
139
+ <span class="mark">${MARK[t.state]}</span>
140
+ <span class="text">${t.text} ${cr} ${reason}</span>
141
+ ${when}
142
+ </li>`;
143
+ })}
144
+ </ul>`;
145
+ }
146
+
147
+ export function tableRow(c) {
148
+ const pct = c.progress.total ? Math.round((c.progress.done / c.progress.total) * 100) : 0;
149
+ const prog = c.progress.total
150
+ ? `${c.progress.done}/${c.progress.total}${c.progress.blocked ? ` · ${c.progress.blocked}!` : ''} (${pct}%)`
151
+ : '—';
152
+ return html`<tr data-id=${c.id}>
153
+ <td class="mono cell-id cell-nowrap">#${c.id}</td>
154
+ <td class="cell-title cell-nowrap">${c.title}</td>
155
+ <td class="cell-type cell-nowrap">
156
+ <span class="type-tag" style=${`--type-color: var(--${cssIdent(c.type)})`}>${c.type}</span>
157
+ </td>
158
+ <td class="cell-status cell-nowrap">${statusTag(c.status)}</td>
159
+ <td class="mono cell-progress cell-nowrap">${prog}</td>
160
+ <td class="mono cell-deps">${(c.depends_on || []).join(', ') || '—'}</td>
161
+ </tr>`;
162
+ }
@@ -0,0 +1,191 @@
1
+ import { cssIdent } from './security.js';
2
+ import { html, nothing, svg } from './templates.js';
3
+
4
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
5
+
6
+ export function graphSvg(changes) {
7
+ if (!changes.length) {
8
+ return html`<p class="empty">No changes match the current filters.</p>`;
9
+ }
10
+
11
+ const byId = new Map(changes.map((c) => [String(c.id), c]));
12
+ const depthCache = new Map();
13
+ const depth = (id, seen = new Set()) => {
14
+ if (depthCache.has(id)) return depthCache.get(id);
15
+ if (seen.has(id)) return 0;
16
+ const nextSeen = new Set(seen);
17
+ nextSeen.add(id);
18
+ const c = byId.get(String(id));
19
+ const deps = (c?.depends_on || []).filter((d) => byId.has(String(d)));
20
+ const d = deps.length ? 1 + Math.max(...deps.map((x) => depth(String(x), nextSeen))) : 0;
21
+ depthCache.set(id, d);
22
+ return d;
23
+ };
24
+
25
+ const layers = {};
26
+ for (const c of changes) {
27
+ const d = depth(String(c.id));
28
+ layers[d] ||= [];
29
+ layers[d].push(c);
30
+ }
31
+
32
+ const COL = 230;
33
+ const ROW = 78;
34
+ const W = 180;
35
+ const H = 52;
36
+ const pos = new Map();
37
+ for (const [d, items] of Object.entries(layers)) {
38
+ items.forEach((c, i) => {
39
+ pos.set(String(c.id), { x: +d * COL + 30, y: i * ROW + 30 });
40
+ });
41
+ }
42
+
43
+ const width = (Math.max(...Object.keys(layers).map(Number)) + 1) * COL + 60;
44
+ const height = Math.max(...Object.values(layers).map((l) => l.length)) * ROW + 60;
45
+
46
+ const edges = changes
47
+ .flatMap((c) =>
48
+ (c.depends_on || [])
49
+ .filter((d) => pos.has(String(d)))
50
+ .map((d) => ({ from: pos.get(String(d)), to: pos.get(String(c.id)) })),
51
+ )
52
+ .map((e) => {
53
+ const x1 = e.from.x + W;
54
+ const y1 = e.from.y + H / 2;
55
+ const x2 = e.to.x;
56
+ const y2 = e.to.y + H / 2;
57
+ const mx = (x1 + x2) / 2;
58
+ return svg`<path class="edge" d=${`M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`} />`;
59
+ });
60
+
61
+ const nodes = changes.map((c) => {
62
+ const p = pos.get(String(c.id));
63
+ return svg`<g class="node" data-id=${c.id} transform=${`translate(${p.x},${p.y})`}>
64
+ <rect width=${W} height=${H} stroke=${`var(--${cssIdent(c.type)})`}></rect>
65
+ <text class="nid" x="10" y="18">#${c.id} · ${c.status}</text>
66
+ <text x="10" y="36">${clip(c.title, 24)}</text>
67
+ </g>`;
68
+ });
69
+
70
+ return html`
71
+ <svg viewBox=${`0 0 ${width} ${height}`} height=${height}>
72
+ <defs>
73
+ <marker id="arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
74
+ <path d="M0,0 L7,3 L0,6 Z" fill="var(--muted)"></path>
75
+ </marker>
76
+ </defs>
77
+ ${edges}${nodes}
78
+ </svg>`;
79
+ }
80
+
81
+ export function specsListHtml(specs, fmtDateTime) {
82
+ return specs.length
83
+ ? specs.map(
84
+ (s, i) => html`<div class="spec-card" data-i=${i}>
85
+ <div class="spec-title">${s.title}</div>
86
+ <div class="card-meta">
87
+ <span title=${s.updated || ''}>${fmtDateTime(s.updated)}</span>
88
+ ${(s.tags || []).map((t) => html`<span class="pill">${t}</span>`)}
89
+ </div>
90
+ </div>`,
91
+ )
92
+ : html`<p class="empty">No specs yet. Truth graduates here as changes complete.</p>`;
93
+ }
94
+
95
+ export function fmtDuration(ms) {
96
+ if (!ms || ms < 0) return '—';
97
+ const h = ms / 3600000;
98
+ if (h < 48) return `${h.toFixed(1)} h`;
99
+ return `${(h / 24).toFixed(1)} d`;
100
+ }
101
+
102
+ function barRows(items, label, value, fmt = (v) => v) {
103
+ const max = Math.max(1, ...items.map(value));
104
+ return items.map(
105
+ (it) =>
106
+ html`<div class="bar-row">
107
+ <span class="bar-date">${label(it)}</span>
108
+ <span class="bar" style=${`width:${(value(it) / max) * 100}%`}></span>
109
+ <span class="mono">${fmt(value(it))}</span>
110
+ </div>`,
111
+ );
112
+ }
113
+
114
+ export function metricsHtml(metrics = {}) {
115
+ const wip = metrics.wip || {};
116
+ const wipTotal = Object.values(wip).reduce((a, b) => a + b, 0);
117
+ const cards = [
118
+ ['Closed', metrics.count ?? 0],
119
+ ['Avg cycle', fmtDuration(metrics.avgCycleMs)],
120
+ ['Median cycle', fmtDuration(metrics.medianCycleMs)],
121
+ ['WIP', wipTotal],
122
+ ['Blocked time', fmtDuration(metrics.blockedMs)],
123
+ ].map(
124
+ ([label, val]) =>
125
+ html`<div class="metric-card">
126
+ <div class="metric-val">${val}</div>
127
+ <div class="metric-label">${label}</div>
128
+ </div>`,
129
+ );
130
+
131
+ const wipChips = Object.entries(wip).map(([s, n]) => html`<span class="pill">${s}: ${n}</span>`);
132
+
133
+ const lead = (metrics.timeInStatus || []).filter((t) => t.avgMs > 0);
134
+ const leadBars = lead.length
135
+ ? barRows(
136
+ lead,
137
+ (t) => t.state,
138
+ (t) => t.avgMs,
139
+ fmtDuration,
140
+ )
141
+ : html`<p class="empty">No data yet.</p>`;
142
+
143
+ const tp = metrics.throughput || [];
144
+ const tpBars = tp.length
145
+ ? barRows(
146
+ tp,
147
+ (t) => html`<span class="mono">${t.date}</span>`,
148
+ (t) => t.count,
149
+ )
150
+ : html`<p class="empty">No closed changes yet.</p>`;
151
+
152
+ const aging = metrics.aging || [];
153
+ const agingRows = aging.length
154
+ ? html`<ul class="git-commits">
155
+ ${aging.map(
156
+ (a) =>
157
+ html`<li><span class="mono">#${a.id}</span> <span class="when">${fmtDuration(a.ms)}</span></li>`,
158
+ )}
159
+ </ul>`
160
+ : html`<p class="empty">Nothing in progress.</p>`;
161
+
162
+ const byType = metrics.byType || [];
163
+ const typeRows = byType.length
164
+ ? html`<table class="grid">
165
+ <thead>
166
+ <tr><th>Type</th><th>Closed</th><th>Avg cycle</th></tr>
167
+ </thead>
168
+ <tbody>
169
+ ${byType.map(
170
+ (t) => html`<tr>
171
+ <td><span class="type-tag" style=${`--type-color: var(--${cssIdent(t.type)})`}>${t.type}</span></td>
172
+ <td class="mono">${t.closed}</td>
173
+ <td class="mono">${fmtDuration(t.avgCycleMs)}</td>
174
+ </tr>`,
175
+ )}
176
+ </tbody>
177
+ </table>`
178
+ : html`<p class="empty">No closed changes yet.</p>`;
179
+
180
+ return html`
181
+ <div class="metrics-cards">${cards}</div>
182
+ ${wipChips.length ? html`<div class="detail-meta">${wipChips}</div>` : nothing}
183
+ <h3 class="metrics-h">Avg time in status (lead time per stage)</h3>
184
+ <div>${leadBars}</div>
185
+ <h3 class="metrics-h">Throughput (closed per day)</h3>
186
+ <div>${tpBars}</div>
187
+ <h3 class="metrics-h">Aging — in progress</h3>
188
+ ${agingRows}
189
+ <h3 class="metrics-h">By type</h3>
190
+ ${typeRows}`;
191
+ }
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { gitRefs } from '../../git.mjs';
5
+ import { publicDir } from '../../paths.mjs';
6
+ import { loadRepoAsync } from '../../repo.mjs';
7
+ import { changeStatus, resolveProjects, searchProjects, serialize } from '../domain.mjs';
8
+ import { isAuthorizedWrite, isLocalHost } from './security.mjs';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ // Browser builds of the markdown/diagram libs, resolved from node_modules
13
+ // (installed as dependencies) and served under /vendor/*.
14
+ function vendorFile(route) {
15
+ try {
16
+ if (route === '/vendor/marked.min.js') {
17
+ return path.join(path.dirname(require.resolve('marked/package.json')), 'lib/marked.umd.js');
18
+ }
19
+ if (route === '/vendor/mermaid.min.js') {
20
+ return require.resolve('mermaid/dist/mermaid.min.js');
21
+ }
22
+ if (route === '/vendor/purify.min.js') {
23
+ return require.resolve('dompurify/dist/purify.min.js');
24
+ }
25
+ if (route.startsWith('/vendor/lit-html/')) {
26
+ const subpath = route.slice('/vendor/lit-html/'.length);
27
+ if (subpath === 'lit-html.js') return require.resolve('lit-html');
28
+ return require.resolve(`lit-html/${subpath}`);
29
+ }
30
+ } catch {
31
+ return null;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ const MIME = {
37
+ '.html': 'text/html; charset=utf-8',
38
+ '.js': 'text/javascript; charset=utf-8',
39
+ '.css': 'text/css; charset=utf-8',
40
+ '.json': 'application/json; charset=utf-8',
41
+ };
42
+
43
+ // Defensive headers for a local-only UI: never sniff types, never cache, and
44
+ // forbid embedding in a frame (clickjacking).
45
+ const SECURITY_HEADERS = {
46
+ 'X-Content-Type-Options': 'nosniff',
47
+ 'X-Frame-Options': 'DENY',
48
+ 'Cache-Control': 'no-store',
49
+ };
50
+
51
+ const MAX_BODY = 64 * 1024; // a status payload is tiny; cap to stay defensive
52
+
53
+ function send(res, code, type, body) {
54
+ res.writeHead(code, { 'Content-Type': type, ...SECURITY_HEADERS });
55
+ res.end(body);
56
+ }
57
+
58
+ // Injects the per-process write token into the served page so same-origin JS can
59
+ // read it; cross-origin pages cannot, by the same-origin policy.
60
+ function serveIndex(res, token) {
61
+ const safeToken = JSON.stringify(token).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
62
+ const html = fs
63
+ .readFileSync(path.join(publicDir, 'index.html'), 'utf8')
64
+ .replace("'__CHANGELEDGER_TOKEN_VALUE__'", safeToken);
65
+ send(res, 200, MIME['.html'], html);
66
+ }
67
+
68
+ // Builds the HTTP request listener. Exposed (with an injectable token) so the
69
+ // security boundary is testable over real HTTP without opening a browser.
70
+ export function createRequestListener(cwd, localOnly, token) {
71
+ return async (req, res) => {
72
+ try {
73
+ if (!isLocalHost(req)) {
74
+ send(res, 403, MIME['.json'], JSON.stringify({ error: 'non-local host rejected' }));
75
+ return;
76
+ }
77
+ const url = new URL(req.url, 'http://127.0.0.1');
78
+ const route = url.pathname;
79
+ const params = url.searchParams;
80
+
81
+ if (req.method === 'POST' && route === '/api/status') {
82
+ if (!isAuthorizedWrite(req, token)) {
83
+ send(res, 403, MIME['.json'], JSON.stringify({ error: 'unauthorized write' }));
84
+ req.destroy();
85
+ return;
86
+ }
87
+ let raw = '';
88
+ let aborted = false;
89
+ req.on('data', (chunk) => {
90
+ if (aborted) return;
91
+ raw += chunk;
92
+ if (raw.length > MAX_BODY) {
93
+ aborted = true;
94
+ send(res, 413, MIME['.json'], JSON.stringify({ error: 'body too large' }));
95
+ req.destroy();
96
+ }
97
+ });
98
+ req.on('end', () => {
99
+ if (aborted) return;
100
+ let payload;
101
+ try {
102
+ payload = JSON.parse(raw || '{}');
103
+ } catch {
104
+ send(res, 400, MIME['.json'], JSON.stringify({ error: 'invalid JSON body' }));
105
+ return;
106
+ }
107
+ const { projects } = resolveProjects(cwd, localOnly);
108
+ const { code, body } = changeStatus(projects, payload);
109
+ send(res, code, MIME['.json'], JSON.stringify(body));
110
+ });
111
+ return;
112
+ }
113
+
114
+ if (route === '/api/projects') {
115
+ send(res, 200, MIME['.json'], JSON.stringify(resolveProjects(cwd, localOnly)));
116
+ return;
117
+ }
118
+ if (route === '/api/git') {
119
+ const rawId = params.get('id');
120
+ if (rawId && !/^[0-9]{8}-[0-9]{6}$/.test(rawId)) {
121
+ send(res, 400, MIME['.json'], JSON.stringify({ error: 'invalid id' }));
122
+ return;
123
+ }
124
+ const { projects } = resolveProjects(cwd, localOnly);
125
+ const proj = projects.find((p) => p.id === params.get('project'));
126
+ if (!proj?.alive) {
127
+ send(res, 200, MIME['.json'], JSON.stringify({ commits: [], branches: [] }));
128
+ return;
129
+ }
130
+ send(res, 200, MIME['.json'], JSON.stringify(gitRefs(proj.path, rawId)));
131
+ return;
132
+ }
133
+ if (route === '/api/search') {
134
+ const { projects } = resolveProjects(cwd, localOnly);
135
+ send(res, 200, MIME['.json'], JSON.stringify(searchProjects(projects, params.get('q'))));
136
+ return;
137
+ }
138
+ if (route === '/api/repo') {
139
+ const { projects } = resolveProjects(cwd, localOnly);
140
+ const proj = projects.find((p) => p.id === params.get('project'));
141
+ if (!proj) {
142
+ send(res, 404, MIME['.json'], JSON.stringify({ error: 'no project' }));
143
+ return;
144
+ }
145
+ if (!proj.alive) {
146
+ send(res, 410, MIME['.json'], JSON.stringify({ error: 'project path is gone' }));
147
+ return;
148
+ }
149
+ send(res, 200, MIME['.json'], JSON.stringify(serialize(await loadRepoAsync(proj.path))));
150
+ return;
151
+ }
152
+
153
+ const vendor = vendorFile(route);
154
+ if (vendor) {
155
+ if (fs.existsSync(vendor)) send(res, 200, MIME['.js'], fs.readFileSync(vendor));
156
+ else send(res, 404, 'text/plain', 'vendor lib not installed');
157
+ return;
158
+ }
159
+
160
+ if (route === '/' || route === '/index.html') {
161
+ serveIndex(res, token);
162
+ return;
163
+ }
164
+ const file = staticFile(route);
165
+ if (file) {
166
+ send(res, 200, MIME[path.extname(file)] ?? 'text/plain', fs.readFileSync(file));
167
+ } else {
168
+ send(res, 404, 'text/plain', 'Not found');
169
+ }
170
+ } catch (e) {
171
+ process.stderr.write(`[changeledger-viewer] ${e.message}\n`);
172
+ send(res, 500, MIME['.json'], JSON.stringify({ error: 'Internal server error' }));
173
+ }
174
+ };
175
+ }
176
+
177
+ export function staticFile(route) {
178
+ let decoded;
179
+ try {
180
+ decoded = decodeURIComponent(route);
181
+ } catch {
182
+ return null;
183
+ }
184
+ if (decoded.includes('\0')) return null;
185
+
186
+ const rel = decoded.replace(/^\/+/, '');
187
+ const file = path.resolve(publicDir, rel);
188
+ if (!isInsidePath(publicDir, file)) return null;
189
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return null;
190
+
191
+ const realPublic = fs.realpathSync(publicDir);
192
+ const realFile = fs.realpathSync(file);
193
+ if (!isInsidePath(realPublic, realFile)) return null;
194
+ return file;
195
+ }
196
+
197
+ function isInsidePath(root, target) {
198
+ const rel = path.relative(path.resolve(root), path.resolve(target));
199
+ return rel === '' || (rel && !rel.startsWith('..') && !path.isAbsolute(rel));
200
+ }
@@ -0,0 +1,27 @@
1
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
2
+
3
+ // Bare hostname from a Host/Origin authority, dropping the port and IPv6
4
+ // brackets. '' when absent.
5
+ export function hostnameOf(authority) {
6
+ if (!authority) return '';
7
+ const m = String(authority).match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
8
+ const host = m ? m[1] : authority;
9
+ return host.replace(/^\[|\]$/g, '');
10
+ }
11
+
12
+ // The request must be addressed to the loopback host. This (with the loopback
13
+ // bind) defends against DNS-rebinding: a rebinding attack reaches the socket but
14
+ // carries the attacker's domain in Host.
15
+ export function isLocalHost(req) {
16
+ return LOOPBACK.has(hostnameOf(req.headers.host));
17
+ }
18
+
19
+ // A write must come from the viewer's own page: a same-origin Origin (when the
20
+ // browser sends one) and the per-process token the page was given. A
21
+ // cross-origin attacker cannot read the token, so it cannot forge the header.
22
+ export function isAuthorizedWrite(req, token) {
23
+ if (req.headers['x-changeledger-token'] !== token) return false;
24
+ const origin = req.headers.origin;
25
+ if (origin && !LOOPBACK.has(hostnameOf(new URL(origin).host))) return false;
26
+ return true;
27
+ }