syndes 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/adapters/claude-code.mjs +59 -0
- package/adapters/codex.mjs +256 -0
- package/adapters/index.mjs +92 -0
- package/analytics/index.mjs +189 -0
- package/analytics/metrics/context.mjs +95 -0
- package/analytics/metrics/cost.mjs +83 -0
- package/analytics/metrics/friction.mjs +86 -0
- package/analytics/metrics/prompts.mjs +93 -0
- package/analytics/metrics/rework.mjs +113 -0
- package/analytics/metrics/time.mjs +104 -0
- package/analytics/metrics/tokens.mjs +88 -0
- package/analytics/metrics/tools.mjs +118 -0
- package/analytics/metrics/volume.mjs +98 -0
- package/analytics/ranges.mjs +98 -0
- package/analytics/rollup.mjs +151 -0
- package/analytics/score.mjs +194 -0
- package/bin/cli.mjs +596 -0
- package/bin/postinstall.mjs +44 -0
- package/collect/classify.mjs +226 -0
- package/collect/git.mjs +78 -0
- package/collect/projects.mjs +82 -0
- package/collect/redact.mjs +85 -0
- package/collect/sessions.mjs +119 -0
- package/collect/tail.mjs +126 -0
- package/collect/tools.mjs +121 -0
- package/collect/transcript.mjs +128 -0
- package/dashboard/api/index.mjs +296 -0
- package/dashboard/auth.mjs +235 -0
- package/dashboard/router.mjs +55 -0
- package/dashboard/security.mjs +95 -0
- package/dashboard/server.mjs +156 -0
- package/dashboard/static.mjs +47 -0
- package/dashboard/web/SynDes.icns +0 -0
- package/dashboard/web/api.js +80 -0
- package/dashboard/web/app.css +532 -0
- package/dashboard/web/app.js +261 -0
- package/dashboard/web/charts.js +273 -0
- package/dashboard/web/index.html +23 -0
- package/dashboard/web/logo.png +0 -0
- package/dashboard/web/ui.js +434 -0
- package/dashboard/web/views/habits.js +166 -0
- package/dashboard/web/views/ledger.js +164 -0
- package/dashboard/web/views/overview.js +214 -0
- package/dashboard/web/views/sessions.js +133 -0
- package/dashboard/web/views/settings.js +180 -0
- package/ledger/append.mjs +126 -0
- package/ledger/chain.mjs +53 -0
- package/ledger/keys.mjs +72 -0
- package/ledger/read.mjs +77 -0
- package/ledger/retention.mjs +104 -0
- package/ledger/schema.mjs +96 -0
- package/ledger/segments.mjs +109 -0
- package/ledger/verify.mjs +174 -0
- package/notify/index.mjs +67 -0
- package/notify/linux.mjs +41 -0
- package/notify/mac.mjs +44 -0
- package/notify/terminal.mjs +15 -0
- package/notify/windows.mjs +61 -0
- package/package.json +66 -0
- package/practices/budget.mjs +97 -0
- package/practices/catalog.mjs +64 -0
- package/practices/deliver.mjs +101 -0
- package/practices/engine.mjs +107 -0
- package/practices/rules/batch-tool-calls.mjs +15 -0
- package/practices/rules/context-hygiene.mjs +17 -0
- package/practices/rules/delegate-wide-search.mjs +15 -0
- package/practices/rules/index.mjs +28 -0
- package/practices/rules/permission-friction.mjs +16 -0
- package/practices/rules/project-memory.mjs +27 -0
- package/practices/rules/prompt-specificity.mjs +15 -0
- package/practices/rules/read-before-edit.mjs +16 -0
- package/practices/rules/retry-storm.mjs +22 -0
- package/practices/rules/session-sprawl.mjs +15 -0
- package/practices/rules/verify-after-change.mjs +16 -0
- package/runtime/config.mjs +116 -0
- package/runtime/hook.mjs +154 -0
- package/runtime/jsonl.mjs +104 -0
- package/runtime/lock.mjs +98 -0
- package/runtime/log.mjs +37 -0
- package/runtime/paths.mjs +116 -0
- package/runtime/platform.mjs +74 -0
- package/runtime/spool.mjs +92 -0
- package/runtime/worker.mjs +275 -0
- package/src/briefing.mjs +94 -0
- package/src/doctor.mjs +153 -0
- package/src/export.mjs +68 -0
- package/src/install.mjs +95 -0
- package/src/open.mjs +23 -0
- package/src/report.mjs +120 -0
- package/src/settings.mjs +173 -0
- package/src/status.mjs +61 -0
- package/src/systemauth.mjs +179 -0
- package/src/term.mjs +272 -0
- package/src/uninstall.mjs +43 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may open the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Three modes, in order of how much they ask of the user:
|
|
5
|
+
*
|
|
6
|
+
* open (default) Nothing to remember. `syndes dashboard` mints a
|
|
7
|
+
* launch token, opens the browser with it, and the token is exchanged
|
|
8
|
+
* for a session cookie on first load. Anyone who did not run the
|
|
9
|
+
* command does not have the token — the same model Jupyter uses.
|
|
10
|
+
* system The OS asks. Touch ID on a Mac, falling back to the login password.
|
|
11
|
+
* pin A short PIN the user sets, for machines with no biometric.
|
|
12
|
+
*
|
|
13
|
+
* A password nobody chose is not security, it is friction, so there is no
|
|
14
|
+
* password mode at all. What actually keeps this port private is that it binds
|
|
15
|
+
* loopback and refuses any request whose Host is not a literal loopback address
|
|
16
|
+
* — see security.mjs. Auth is the second lock, for other users on a shared machine.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
import { authFile } from '../runtime/paths.mjs';
|
|
23
|
+
import * as systemAuth from '../src/systemauth.mjs';
|
|
24
|
+
|
|
25
|
+
const PARAMS = { N: 16384, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 };
|
|
26
|
+
const SESSION_MS = 12 * 60 * 60 * 1000;
|
|
27
|
+
const FREE_ATTEMPTS = 5;
|
|
28
|
+
const MAX_LOCKOUT_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
export const MODES = ['open', 'system', 'pin'];
|
|
31
|
+
|
|
32
|
+
/** Lives only in this process: a launch token dies with the server that made it. */
|
|
33
|
+
let launchToken = null;
|
|
34
|
+
|
|
35
|
+
// ── Stored state ───────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export function loadAuth() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(authFile, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveAuth(auth) {
|
|
46
|
+
mkdirSync(dirname(authFile), { recursive: true });
|
|
47
|
+
const staging = `${authFile}.tmp`;
|
|
48
|
+
writeFileSync(staging, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
renameSync(staging, authFile);
|
|
50
|
+
return auth;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every install has a cookie secret, whatever the mode. */
|
|
54
|
+
function ensureAuth() {
|
|
55
|
+
const existing = loadAuth();
|
|
56
|
+
if (existing?.secret) return existing;
|
|
57
|
+
return saveAuth({
|
|
58
|
+
mode: existing?.mode ?? 'open',
|
|
59
|
+
secret: randomBytes(32).toString('hex'),
|
|
60
|
+
sessionEpoch: (existing?.sessionEpoch ?? 0) + 1,
|
|
61
|
+
attempts: { count: 0, until: 0 },
|
|
62
|
+
...(existing?.pin ? { pin: existing.pin } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mode() {
|
|
67
|
+
return loadAuth()?.mode ?? 'open';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {'open'|'system'|'pin'} next
|
|
72
|
+
* @param {{pin?: string}} options
|
|
73
|
+
*/
|
|
74
|
+
export function setMode(next, { pin } = {}) {
|
|
75
|
+
if (!MODES.includes(next)) throw new Error(`unknown mode: ${next}`);
|
|
76
|
+
|
|
77
|
+
const auth = ensureAuth();
|
|
78
|
+
|
|
79
|
+
if (next === 'system') {
|
|
80
|
+
// Build now, not at first unlock: a ninety-second compile in front of the
|
|
81
|
+
// unlock screen is indistinguishable from a hang.
|
|
82
|
+
const ready = systemAuth.prepare();
|
|
83
|
+
if (!ready.ok) throw new Error(ready.reason);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (next === 'pin') {
|
|
87
|
+
if (!/^\d{4,12}$/.test(String(pin ?? ''))) throw new Error('a PIN must be 4 to 12 digits');
|
|
88
|
+
const salt = randomBytes(16);
|
|
89
|
+
auth.pin = {
|
|
90
|
+
salt: salt.toString('hex'),
|
|
91
|
+
hash: scryptSync(String(pin), salt, PARAMS.keylen, PARAMS).toString('hex'),
|
|
92
|
+
params: { N: PARAMS.N, r: PARAMS.r, p: PARAMS.p, keylen: PARAMS.keylen },
|
|
93
|
+
};
|
|
94
|
+
} else {
|
|
95
|
+
delete auth.pin;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
auth.mode = next;
|
|
99
|
+
// Changing how the door locks signs out everyone already inside.
|
|
100
|
+
auth.sessionEpoch = (auth.sessionEpoch ?? 0) + 1;
|
|
101
|
+
auth.secret = randomBytes(32).toString('hex');
|
|
102
|
+
auth.attempts = { count: 0, until: 0 };
|
|
103
|
+
saveAuth(auth);
|
|
104
|
+
return auth.mode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Launch token ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Minted per server start. Printed by the CLI and put in the opened URL. */
|
|
110
|
+
export function mintLaunchToken() {
|
|
111
|
+
ensureAuth();
|
|
112
|
+
launchToken = randomBytes(24).toString('base64url');
|
|
113
|
+
return launchToken;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Exchange the launch token for a session.
|
|
118
|
+
*
|
|
119
|
+
* Only valid in `open` mode: in system or pin mode the user asked to be asked,
|
|
120
|
+
* and a URL that walks straight past that would quietly undo their choice.
|
|
121
|
+
*/
|
|
122
|
+
export function consumeLaunchToken(candidate) {
|
|
123
|
+
if (mode() !== 'open') return false;
|
|
124
|
+
if (!launchToken || typeof candidate !== 'string') return false;
|
|
125
|
+
if (candidate.length !== launchToken.length) return false;
|
|
126
|
+
return timingSafeEqual(Buffer.from(candidate), Buffer.from(launchToken));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Unlocking ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** @returns {{ok: boolean, reason: string|null, retryAfterMs: number}} */
|
|
132
|
+
export function verifyPin(candidate) {
|
|
133
|
+
const auth = loadAuth();
|
|
134
|
+
if (!auth?.pin) return { ok: false, reason: 'no PIN set', retryAfterMs: 0 };
|
|
135
|
+
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const until = auth.attempts?.until ?? 0;
|
|
138
|
+
if (now < until) return { ok: false, reason: 'too many attempts', retryAfterMs: until - now };
|
|
139
|
+
|
|
140
|
+
const params = { ...PARAMS, ...(auth.pin.params ?? {}) };
|
|
141
|
+
let matches = false;
|
|
142
|
+
try {
|
|
143
|
+
const attempt = scryptSync(String(candidate ?? ''), Buffer.from(auth.pin.salt, 'hex'), params.keylen, params);
|
|
144
|
+
const stored = Buffer.from(auth.pin.hash, 'hex');
|
|
145
|
+
matches = attempt.length === stored.length && timingSafeEqual(attempt, stored);
|
|
146
|
+
} catch {
|
|
147
|
+
matches = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (matches) {
|
|
151
|
+
auth.attempts = { count: 0, until: 0 };
|
|
152
|
+
saveAuth(auth);
|
|
153
|
+
return { ok: true, reason: null, retryAfterMs: 0 };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const count = (auth.attempts?.count ?? 0) + 1;
|
|
157
|
+
const over = Math.max(0, count - FREE_ATTEMPTS);
|
|
158
|
+
const lockout = over ? Math.min(MAX_LOCKOUT_MS, 1000 * 2 ** over) : 0;
|
|
159
|
+
auth.attempts = { count, until: lockout ? now + lockout : 0 };
|
|
160
|
+
saveAuth(auth);
|
|
161
|
+
return { ok: false, reason: 'wrong PIN', retryAfterMs: lockout };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function verifySystem() {
|
|
165
|
+
return systemAuth.authenticate('open your dashboard');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function systemAvailable() {
|
|
169
|
+
return systemAuth.available();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Session cookie ─────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
export function issueToken(now = Date.now()) {
|
|
175
|
+
const auth = ensureAuth();
|
|
176
|
+
const payload = { exp: now + SESSION_MS, epoch: auth.sessionEpoch ?? 0, nonce: randomBytes(9).toString('base64url') };
|
|
177
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
178
|
+
return `${body}.${sign(body, auth.secret)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function verifyToken(token) {
|
|
182
|
+
const auth = loadAuth();
|
|
183
|
+
if (!auth?.secret) return { ok: false, reason: 'not initialised' };
|
|
184
|
+
if (typeof token !== 'string' || !token.includes('.')) return { ok: false, reason: 'malformed' };
|
|
185
|
+
|
|
186
|
+
const [body, signature] = token.split('.');
|
|
187
|
+
if (!safeEqual(signature, sign(body, auth.secret))) return { ok: false, reason: 'bad signature' };
|
|
188
|
+
|
|
189
|
+
let payload;
|
|
190
|
+
try {
|
|
191
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
192
|
+
} catch {
|
|
193
|
+
return { ok: false, reason: 'malformed' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (Date.now() > (payload.exp ?? 0)) return { ok: false, reason: 'expired' };
|
|
197
|
+
if ((payload.epoch ?? -1) !== (auth.sessionEpoch ?? 0)) return { ok: false, reason: 'revoked' };
|
|
198
|
+
return { ok: true, reason: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function revokeSessions() {
|
|
202
|
+
const auth = ensureAuth();
|
|
203
|
+
auth.sessionEpoch = (auth.sessionEpoch ?? 0) + 1;
|
|
204
|
+
saveAuth(auth);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function lockoutState() {
|
|
209
|
+
const auth = loadAuth();
|
|
210
|
+
const until = auth?.attempts?.until ?? 0;
|
|
211
|
+
return { attempts: auth?.attempts?.count ?? 0, lockedFor: Math.max(0, until - Date.now()) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What the unlock screen needs to know before it renders anything. */
|
|
215
|
+
export function describe() {
|
|
216
|
+
const current = mode();
|
|
217
|
+
const probe = systemAuth.available();
|
|
218
|
+
return {
|
|
219
|
+
mode: current,
|
|
220
|
+
system: { available: probe.available, method: probe.method, reason: probe.reason },
|
|
221
|
+
locked: current !== 'open',
|
|
222
|
+
lockout: lockoutState(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sign(body, secret) {
|
|
227
|
+
return createHmac('sha256', Buffer.from(secret, 'hex')).update(body).digest('base64url');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function safeEqual(a, b) {
|
|
231
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
|
|
232
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { SESSION_MS, ensureAuth };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Method + path → handler.
|
|
3
|
+
*
|
|
4
|
+
* The default is DENY: a route is authenticated unless it explicitly opts out.
|
|
5
|
+
* The alternative — listing what to protect — means every new endpoint is public
|
|
6
|
+
* until someone remembers, and someone eventually does not.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { verifyToken } from './auth.mjs';
|
|
10
|
+
import { parseCookies, COOKIE_NAME } from './security.mjs';
|
|
11
|
+
import * as api from './api/index.mjs';
|
|
12
|
+
|
|
13
|
+
/** The only routes reachable without a session. */
|
|
14
|
+
const PUBLIC = new Set([
|
|
15
|
+
'GET /api/status', // the unlock screen needs to know which lock it is
|
|
16
|
+
'GET /api/unlock', // what unlock methods exist
|
|
17
|
+
'POST /api/unlock', // perform the unlock
|
|
18
|
+
'POST /api/logout',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const ROUTES = {
|
|
22
|
+
'GET /api/unlock': api.unlockOptions,
|
|
23
|
+
'POST /api/unlock': api.unlock,
|
|
24
|
+
'POST /api/logout': api.logout,
|
|
25
|
+
'GET /api/status': api.status,
|
|
26
|
+
'GET /api/overview': api.overview,
|
|
27
|
+
'GET /api/sessions': api.sessions,
|
|
28
|
+
'GET /api/session': api.sessionDetail,
|
|
29
|
+
'GET /api/tools': api.tools,
|
|
30
|
+
'GET /api/projects': api.projects,
|
|
31
|
+
'GET /api/timeline': api.timeline,
|
|
32
|
+
'GET /api/practices': api.practices,
|
|
33
|
+
'POST /api/practices': api.practiceAction,
|
|
34
|
+
'GET /api/ledger': api.ledger,
|
|
35
|
+
'GET /api/verify': api.verifyChain,
|
|
36
|
+
'GET /api/config': api.readConfig,
|
|
37
|
+
'POST /api/config': api.writeConfig,
|
|
38
|
+
'POST /api/auth': api.setAuthMode,
|
|
39
|
+
'GET /api/export': api.exportData,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function isPublic(key) {
|
|
43
|
+
return PUBLIC.has(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function authenticate(req) {
|
|
47
|
+
const token = parseCookies(req)[COOKIE_NAME];
|
|
48
|
+
if (!token) return { ok: false, reason: 'no session' };
|
|
49
|
+
return verifyToken(token);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveRoute(method, pathname) {
|
|
53
|
+
const key = `${method} ${pathname}`;
|
|
54
|
+
return { key, handler: ROUTES[key] ?? null };
|
|
55
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checks that run before the router sees a request.
|
|
3
|
+
*
|
|
4
|
+
* The one that matters most is the Host check. A page on any website can make
|
|
5
|
+
* requests to 127.0.0.1 — and with a DNS rebinding trick, can do it with its own
|
|
6
|
+
* origin attached. Binding to loopback is not, by itself, a security boundary.
|
|
7
|
+
* Requiring the Host header to be a literal loopback address closes it, because
|
|
8
|
+
* a rebound name arrives with the attacker's hostname in Host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
12
|
+
|
|
13
|
+
export function hostAllowed(req, port) {
|
|
14
|
+
const host = req.headers.host;
|
|
15
|
+
if (!host) return false;
|
|
16
|
+
|
|
17
|
+
const index = host.lastIndexOf(':');
|
|
18
|
+
const name = index > 0 && !host.endsWith(']') ? host.slice(0, index) : host;
|
|
19
|
+
const declared = index > 0 && !host.endsWith(']') ? host.slice(index + 1) : '';
|
|
20
|
+
|
|
21
|
+
if (!LOOPBACK.has(name)) return false;
|
|
22
|
+
return declared === '' || declared === String(port);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cross-origin requests are refused outright.
|
|
27
|
+
*
|
|
28
|
+
* Nothing legitimate reaches this server from another origin: the UI is served
|
|
29
|
+
* from the same port and there is no public API to embed.
|
|
30
|
+
*/
|
|
31
|
+
export function originAllowed(req, port) {
|
|
32
|
+
const origin = req.headers.origin;
|
|
33
|
+
if (!origin) return true; // same-origin navigations and curl send none
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(origin);
|
|
37
|
+
return LOOPBACK.has(url.hostname) && (url.port === String(port) || url.port === '');
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Applied to every response, including errors. */
|
|
44
|
+
export function securityHeaders(extra = {}) {
|
|
45
|
+
return {
|
|
46
|
+
'Content-Security-Policy': [
|
|
47
|
+
"default-src 'self'",
|
|
48
|
+
"script-src 'self'",
|
|
49
|
+
"style-src 'self' 'unsafe-inline'",
|
|
50
|
+
"img-src 'self' data:",
|
|
51
|
+
"font-src 'self'",
|
|
52
|
+
"connect-src 'self'",
|
|
53
|
+
"frame-ancestors 'none'",
|
|
54
|
+
"base-uri 'none'",
|
|
55
|
+
"form-action 'self'",
|
|
56
|
+
].join('; '),
|
|
57
|
+
'X-Content-Type-Options': 'nosniff',
|
|
58
|
+
'X-Frame-Options': 'DENY',
|
|
59
|
+
'Referrer-Policy': 'no-referrer',
|
|
60
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
61
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
62
|
+
// No caching of metrics: a payload cached past a logout is a leak, and this
|
|
63
|
+
// is a loopback server where caching buys nothing anyway.
|
|
64
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
65
|
+
...extra,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCookies(req) {
|
|
70
|
+
const header = req.headers.cookie;
|
|
71
|
+
if (!header) return {};
|
|
72
|
+
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const part of header.split(';')) {
|
|
75
|
+
const index = part.indexOf('=');
|
|
76
|
+
if (index === -1) continue;
|
|
77
|
+
out[part.slice(0, index).trim()] = decodeURIComponent(part.slice(index + 1).trim());
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The session cookie.
|
|
84
|
+
*
|
|
85
|
+
* No `Secure`: this is http on loopback, and setting it would make the cookie
|
|
86
|
+
* silently never sent, which is a worse failure than the one it guards against.
|
|
87
|
+
* SameSite=Strict is what actually protects it here.
|
|
88
|
+
*/
|
|
89
|
+
export function sessionCookie(token, { clear = false } = {}) {
|
|
90
|
+
const attributes = ['Path=/', 'HttpOnly', 'SameSite=Strict'];
|
|
91
|
+
if (clear) attributes.push('Max-Age=0');
|
|
92
|
+
return `syndes_session=${clear ? '' : encodeURIComponent(token)}; ${attributes.join('; ')}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const COOKIE_NAME = 'syndes_session';
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `syndes dashboard` — node:http on 127.0.0.1, zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Binds the literal 127.0.0.1, never the string "localhost": on some Windows
|
|
5
|
+
* setups that resolves to ::1 first, and the listener then silently misses every
|
|
6
|
+
* request while appearing to have started fine.
|
|
7
|
+
*
|
|
8
|
+
* The server is READ-ONLY over the ledger. It can write config and coach state.
|
|
9
|
+
* It can never append a record — one writer, forever, and that writer is the
|
|
10
|
+
* worker. See CLAUDE.md rule 3.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { loadConfig } from '../runtime/config.mjs';
|
|
15
|
+
import { mintLaunchToken, consumeLaunchToken, issueToken, mode as authMode } from './auth.mjs';
|
|
16
|
+
import { hostAllowed, originAllowed, securityHeaders, sessionCookie } from './security.mjs';
|
|
17
|
+
import { resolveRoute, isPublic, authenticate } from './router.mjs';
|
|
18
|
+
import { serveStatic } from './static.mjs';
|
|
19
|
+
import { debug } from '../runtime/log.mjs';
|
|
20
|
+
|
|
21
|
+
const HOST = '127.0.0.1';
|
|
22
|
+
/** A request body larger than this is not a request we serve. */
|
|
23
|
+
const MAX_BODY = 256 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {Promise<{url, port, close, server}>}
|
|
27
|
+
*/
|
|
28
|
+
export async function startServer({ port, idleTimeoutMinutes } = {}) {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
const chosen = port ?? config.dashboard.port;
|
|
31
|
+
const idleMs = (idleTimeoutMinutes ?? config.dashboard.idleTimeoutMinutes) * 60_000;
|
|
32
|
+
|
|
33
|
+
// The port the handler validates against must be the port actually BOUND, not
|
|
34
|
+
// the one requested. They differ whenever 0 is passed to get an ephemeral
|
|
35
|
+
// port, and using the requested one makes every Host check fail with a 403
|
|
36
|
+
// that looks exactly like an attack being blocked.
|
|
37
|
+
let boundPort = chosen;
|
|
38
|
+
|
|
39
|
+
const server = createServer((req, res) => { handle(req, res, boundPort).catch((error) => {
|
|
40
|
+
debug('request failed', error?.stack ?? String(error));
|
|
41
|
+
send(res, 500, { error: 'internal error' });
|
|
42
|
+
}); });
|
|
43
|
+
|
|
44
|
+
let idleTimer = null;
|
|
45
|
+
const touch = () => {
|
|
46
|
+
if (!idleMs) return;
|
|
47
|
+
clearTimeout(idleTimer);
|
|
48
|
+
// A forgotten dashboard should not be left listening for a week.
|
|
49
|
+
idleTimer = setTimeout(() => { server.close(); }, idleMs);
|
|
50
|
+
idleTimer.unref();
|
|
51
|
+
};
|
|
52
|
+
server.on('request', touch);
|
|
53
|
+
|
|
54
|
+
await new Promise((resolve, reject) => {
|
|
55
|
+
server.once('error', reject);
|
|
56
|
+
server.listen(chosen, HOST, resolve);
|
|
57
|
+
});
|
|
58
|
+
boundPort = server.address().port;
|
|
59
|
+
touch();
|
|
60
|
+
|
|
61
|
+
const token = mintLaunchToken();
|
|
62
|
+
const base = `http://${HOST}:${server.address().port}`;
|
|
63
|
+
const url = authMode() === 'open' ? `${base}/?t=${token}` : base;
|
|
64
|
+
return {
|
|
65
|
+
url,
|
|
66
|
+
base,
|
|
67
|
+
port: server.address().port,
|
|
68
|
+
server,
|
|
69
|
+
close: () => new Promise((resolve) => { clearTimeout(idleTimer); server.close(resolve); }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function handle(req, res, port) {
|
|
74
|
+
// Rebinding defence first, before anything reads the path or a cookie.
|
|
75
|
+
if (!hostAllowed(req, port)) return send(res, 403, { error: 'host not allowed' });
|
|
76
|
+
if (!originAllowed(req, port)) return send(res, 403, { error: 'cross-origin request refused' });
|
|
77
|
+
|
|
78
|
+
const url = new URL(req.url, `http://${HOST}:${port}`);
|
|
79
|
+
|
|
80
|
+
// `?t=` is the launch token the CLI put in the URL it opened. Exchange it for
|
|
81
|
+
// a cookie and redirect, so the token never survives in the address bar, in
|
|
82
|
+
// history, or in a screenshot of the browser.
|
|
83
|
+
const launch = url.searchParams.get('t');
|
|
84
|
+
if (launch && consumeLaunchToken(launch)) {
|
|
85
|
+
res.writeHead(302, {
|
|
86
|
+
...securityHeaders(),
|
|
87
|
+
'Set-Cookie': sessionCookie(issueToken()),
|
|
88
|
+
Location: url.pathname + url.hash,
|
|
89
|
+
});
|
|
90
|
+
res.end();
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { key, handler } = resolveRoute(req.method, url.pathname);
|
|
95
|
+
|
|
96
|
+
if (handler) {
|
|
97
|
+
if (!isPublic(key)) {
|
|
98
|
+
const session = authenticate(req);
|
|
99
|
+
if (!session.ok) return send(res, 401, { error: 'not signed in', reason: session.reason });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body = req.method === 'POST' ? await readBody(req) : null;
|
|
103
|
+
if (body === undefined) return send(res, 413, { error: 'body too large' });
|
|
104
|
+
|
|
105
|
+
const result = await handler({ req, res, url, body, port });
|
|
106
|
+
if (res.writableEnded) return undefined;
|
|
107
|
+
return send(res, result?.status ?? 200, result?.data ?? result, result?.headers);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (url.pathname.startsWith('/api/')) return send(res, 404, { error: 'no such endpoint' });
|
|
111
|
+
|
|
112
|
+
// Everything else is the UI. An unknown path falls back to the shell so the
|
|
113
|
+
// client-side router owns its own history.
|
|
114
|
+
const file = (await serveStatic(url.pathname)) ?? (await serveStatic('/index.html'));
|
|
115
|
+
if (!file) {
|
|
116
|
+
return send(res, 503, { error: 'the dashboard UI is missing', hint: 'reinstall with: syndes install' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
res.writeHead(200, { ...securityHeaders(), 'Content-Type': file.type });
|
|
120
|
+
res.end(file.body);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readBody(req) {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
req.on('data', (chunk) => {
|
|
129
|
+
size += chunk.length;
|
|
130
|
+
if (size > MAX_BODY) { resolve(undefined); req.destroy(); return; }
|
|
131
|
+
chunks.push(chunk);
|
|
132
|
+
});
|
|
133
|
+
req.on('end', () => {
|
|
134
|
+
if (!chunks.length) return resolve({});
|
|
135
|
+
try {
|
|
136
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
137
|
+
} catch {
|
|
138
|
+
resolve({});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
req.on('error', () => resolve({}));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function send(res, status, data, extra = {}) {
|
|
146
|
+
if (res.writableEnded) return;
|
|
147
|
+
const body = JSON.stringify(data ?? {});
|
|
148
|
+
res.writeHead(status, {
|
|
149
|
+
...securityHeaders(extra),
|
|
150
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
151
|
+
'Content-Length': Buffer.byteLength(body),
|
|
152
|
+
});
|
|
153
|
+
res.end(body);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { HOST };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves dashboard/web from disk.
|
|
3
|
+
*
|
|
4
|
+
* The path check resolves first and confirms containment before opening
|
|
5
|
+
* anything: a URL like /../../../.claude/ledger/keys/chain.key must not resolve
|
|
6
|
+
* to a file, and checking the raw string for ".." is not sufficient because
|
|
7
|
+
* encodings and symlinks get around it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
11
|
+
import { join, resolve, extname, sep } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const WEB_ROOT = resolve(join(dirname(fileURLToPath(import.meta.url)), 'web'));
|
|
16
|
+
|
|
17
|
+
const TYPES = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
20
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.woff2': 'font/woff2',
|
|
26
|
+
'.ico': 'image/x-icon',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** @returns {Promise<{body: Buffer, type: string}|null>} */
|
|
30
|
+
export async function serveStatic(pathname) {
|
|
31
|
+
const relative = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
|
|
32
|
+
const target = resolve(join(WEB_ROOT, relative));
|
|
33
|
+
|
|
34
|
+
// Containment: the resolved path must sit inside WEB_ROOT, not merely start
|
|
35
|
+
// with a string that looks like it (WEB_ROOT + separator, not WEB_ROOT).
|
|
36
|
+
if (target !== WEB_ROOT && !target.startsWith(WEB_ROOT + sep)) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const info = await stat(target);
|
|
40
|
+
if (!info.isFile()) return null;
|
|
41
|
+
return { body: await readFile(target), type: TYPES[extname(target)] ?? 'application/octet-stream' };
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { WEB_ROOT };
|
|
Binary file
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fetch wrapper. One place for auth handling, errors and the in-flight flag.
|
|
3
|
+
*
|
|
4
|
+
* `credentials: 'same-origin'` is explicit rather than assumed: the session
|
|
5
|
+
* cookie is the entire authentication story, and a default that changes between
|
|
6
|
+
* browsers would silently log everyone out.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
let inFlight = 0;
|
|
11
|
+
|
|
12
|
+
export function onBusy(callback) {
|
|
13
|
+
listeners.add(callback);
|
|
14
|
+
return () => listeners.delete(callback);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function setBusy(delta) {
|
|
18
|
+
inFlight = Math.max(0, inFlight + delta);
|
|
19
|
+
for (const listener of listeners) listener(inFlight > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thrown for any non-2xx, carrying the status so callers can act on 401. */
|
|
23
|
+
export class ApiError extends Error {
|
|
24
|
+
constructor(status, body) {
|
|
25
|
+
super(body?.error ?? `request failed (${status})`);
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.body = body ?? {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function request(path, options = {}) {
|
|
32
|
+
setBusy(1);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(path, {
|
|
35
|
+
credentials: 'same-origin',
|
|
36
|
+
headers: options.body ? { 'content-type': 'application/json' } : undefined,
|
|
37
|
+
...options,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
let body = null;
|
|
42
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = { raw: text }; }
|
|
43
|
+
|
|
44
|
+
if (!response.ok) throw new ApiError(response.status, body);
|
|
45
|
+
return body;
|
|
46
|
+
} finally {
|
|
47
|
+
setBusy(-1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const get = (path) => request(path);
|
|
52
|
+
export const post = (path, data) => request(path, { method: 'POST', body: JSON.stringify(data ?? {}) });
|
|
53
|
+
|
|
54
|
+
// ── Endpoints, named so views never build URLs by hand ────────────────────
|
|
55
|
+
|
|
56
|
+
export const api = {
|
|
57
|
+
status: () => get('/api/status'),
|
|
58
|
+
unlockOptions: () => get('/api/unlock'),
|
|
59
|
+
unlock: (pin) => post('/api/unlock', pin ? { pin } : {}),
|
|
60
|
+
logout: () => post('/api/logout'),
|
|
61
|
+
setAuth: (mode, pin) => post('/api/auth', { mode, pin }),
|
|
62
|
+
|
|
63
|
+
overview: (range) => get(`/api/overview?range=${encodeURIComponent(range)}`),
|
|
64
|
+
sessions: (range) => get(`/api/sessions?range=${encodeURIComponent(range)}`),
|
|
65
|
+
session: (id) => get(`/api/session?id=${encodeURIComponent(id)}`),
|
|
66
|
+
tools: (range) => get(`/api/tools?range=${encodeURIComponent(range)}`),
|
|
67
|
+
projects: (range) => get(`/api/projects?range=${encodeURIComponent(range)}`),
|
|
68
|
+
timeline: (range) => get(`/api/timeline?range=${encodeURIComponent(range)}`),
|
|
69
|
+
|
|
70
|
+
practices: () => get('/api/practices'),
|
|
71
|
+
practiceAction: (action, rule, ms) => post('/api/practices', { action, rule, ms }),
|
|
72
|
+
|
|
73
|
+
ledger: (params = {}) => get(`/api/ledger?${new URLSearchParams(params)}`),
|
|
74
|
+
verify: (full) => get(`/api/verify${full ? '?full=1' : ''}`),
|
|
75
|
+
|
|
76
|
+
config: () => get('/api/config'),
|
|
77
|
+
saveConfig: (updates) => post('/api/config', { updates }),
|
|
78
|
+
|
|
79
|
+
exportUrl: (format, range) => `/api/export?format=${format}&range=${encodeURIComponent(range)}`,
|
|
80
|
+
};
|