@wolpertingerlabs/drawlatch 1.0.0-alpha.19 → 1.0.0-alpha.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,6 +137,86 @@ drawlatch start
137
137
  drawlatch doctor # Validate full setup
138
138
  ```
139
139
 
140
+ ## Admin Dashboard
141
+
142
+ `drawlatch start` serves a built-in web dashboard — a React single-page app — that **fully manages** your running daemon: enable/disable connections per caller, set and clear secrets, create and delete callers, configure and control event listeners (start/stop/restart, multi-instance management), and watch the live event/log feed — all from the browser, no config-file editing required. Every change is applied with a **live reload** (the daemon re-resolves routes and ingestors in place), so there is no "restart to apply" step. drawlatch owns 100% of its own state through this password-gated surface; nothing external writes its config.
143
+
144
+ ### Architecture
145
+
146
+ There is no separate UI service to run. The React app, the `/api/admin/*` API, and the MCP protocol endpoints (`/handshake`, `/request`, `/events`, `/webhooks`, …) are all served by the **same Express process on the same port** as the daemon (default `http://127.0.0.1:9999/`):
147
+
148
+ ```
149
+ ┌────────────────────────────── drawlatch daemon (one process, port 9999) ──────────────────────────────┐
150
+ │ │
151
+ │ GET / → React SPA (served from frontend/dist in production) │
152
+ │ /api/admin/* → read + mutating JSON API ──┐ │
153
+ │ POST /api/auth/* → login / logout / check ├─ password-gated (session cookie) │
154
+ │ /handshake /request … → MCP protocol (E2EE) ─┘ ← unaffected by dashboard auth │
155
+ │ │
156
+ └────────────────────────────────────────────────────────────────────────────────────────────────────┘
157
+ ```
158
+
159
+ Any unmatched non-API GET falls back to `index.html` so client-side routing works. The MCP protocol endpoints are independent of the dashboard — they keep serving agents even when the dashboard is locked (see below).
160
+
161
+ ### Setup
162
+
163
+ **1. Set a password** (required — the dashboard is locked until one is set):
164
+
165
+ ```bash
166
+ drawlatch set-password # prompts on a TTY, or reads a password piped on stdin
167
+ # echo 'my-strong-password' | drawlatch set-password # non-interactive
168
+ ```
169
+
170
+ The password is hashed with **scrypt** (random 16-byte salt, verified with a constant-time compare); the hash + salt are written to `~/.drawlatch/.env` (`AUTH_PASSWORD_HASH` / `AUTH_PASSWORD_SALT`, mode `0600`). Your plaintext password is never stored. Use `drawlatch change-password` (an alias of the same command) to rotate it later — rotating signs out every other session.
171
+
172
+ **2. Open the dashboard** at `http://127.0.0.1:9999/` and log in. `drawlatch status` prints the dashboard URL and whether a password is configured.
173
+
174
+ ### Pages
175
+
176
+ | Page | Route | What it shows | Refresh |
177
+ |------|-------|---------------|---------|
178
+ | **Overview** | `/` | Daemon health at a glance: status, PID, port, version, uptime, active session count, ingestor state breakdown, and secrets-configured progress. | on load |
179
+ | **Connections** | `/connections` | Full management: connections grouped by category with search + a stable/beta/dev filter; a caller selector with create/delete-caller; per-connection enable toggle, secrets modal, test connection/listener, listener config panel (all field types + multi-instance), and quick start/stop/restart — with live ingestor state dots and secret-status badges. | live (5s) |
180
+ | **Logs** | `/logs` | Live event/log feed per caller — ingestor status cards, source filter pills, and expandable event rows (eventType, ids, timestamps, pretty-printed JSON payload). | every 5s |
181
+ | **Callers** | `/callers` | Registered MCP callers — alias, name, connection count, key fingerprint, and whether their keys directory exists. Click through for a caller's connections and secret status. | on load |
182
+ | **Ingestors** | `/ingestors` | Live table of every running ingestor (WebSocket / webhook / poll) — state, buffered event count, total events received, last activity, and any error. | every 2s |
183
+ | **Sessions** | `/sessions` | Active MCP proxy sessions — caller alias, created/last-active times, request count, and current per-window request rate. | every 5s |
184
+ | **Secrets** | `/secrets` | A (caller × connection × secret) matrix showing required/optional and present/missing — with a "only missing" filter. **Never shows secret values**, only whether each is set. | every 10s |
185
+
186
+ ### The `/api/admin/*` API
187
+
188
+ The pages are views over the `/api/admin/*` JSON API. **Read** endpoints (`/meta`, `/health`, `/connections`, `/callers`, `/callers/:alias/connection-status`, `/callers/:alias/connections`, `/callers/:alias/ingestors`, `/callers/:alias/events`, `/ingestors`, `/sessions`, `/secrets`) **never return a secret value** — caller `env` maps are reduced to key *names*, secret state is reported as booleans, and session crypto material is never serialized.
189
+
190
+ **Mutating** endpoints (all behind the password gate) let the dashboard own management end-to-end:
191
+
192
+ | Method + path | Action |
193
+ |---|---|
194
+ | `POST /callers` | Create a caller **with a fresh keypair** (no interactive sync) |
195
+ | `DELETE /callers/:alias` | Delete a caller (its keys + prefixed env vars); `default` is protected |
196
+ | `POST /callers/:alias/connections/:connection` `{enabled}` | Enable/disable a connection |
197
+ | `PUT /callers/:alias/connections/:connection/secrets` `{secrets}` | Set/clear secrets (empty string = delete) — **write-only**, read back as booleans |
198
+ | `POST /callers/:alias/connections/:connection/test` · `/test-ingestor` | Run a connection / listener test |
199
+ | `POST /callers/:alias/connections/:connection/listener/control` `{action,instance_id?}` | Start/stop/restart a listener |
200
+ | `GET/PUT /…/listener/params`, `GET/POST/DELETE /…/listener/instances[/:id]`, `POST /…/listener/resolve-options` | Listener params + multi-instance management |
201
+
202
+ Secrets are **write-only** through this API: you `PUT` values, and every read path reports only booleans. After any mutation the daemon live-reloads routes/ingestors for the affected caller. The same logic powers the encrypted MCP tools and the admin API through a single shared `tool-dispatch` module, so the two surfaces can never drift.
203
+
204
+ A loopback-only `POST /sync/auto-enroll` lets a **co-located** client (one that shares drawlatch's filesystem) provision a caller with zero interaction by presenting the one-time token drawlatch writes to `~/.drawlatch/enroll.token` at startup.
205
+
206
+ ### Security model
207
+
208
+ The **password is the trust boundary** for the dashboard and `/api/admin/*` — not loopback. That lets you expose the dashboard to a LAN by binding a non-loopback host:
209
+
210
+ ```bash
211
+ DRAWLATCH_HOST=0.0.0.0 drawlatch start # or: drawlatch start --host 0.0.0.0
212
+ ```
213
+
214
+ Auth uses a `drawlatch_session` cookie that is `httpOnly` and `sameSite=strict`, with a **7-day rolling expiry** (every authenticated request extends it). Login, password-change, and auth-check endpoints are rate-limited per IP (5/min for login & change-password, 20/min for checks). If **no** password is configured, the daemon still starts and serves MCP normally — only the dashboard is locked: `/api/auth/*` and `/api/admin/*` return `503` and the SPA shows a locked state prompting `drawlatch set-password`. The daemon never exits just because the dashboard is unconfigured.
215
+
216
+ > **Cookies run over plain HTTP** on loopback/LAN (no `secure` flag). Put the daemon behind a TLS-terminating reverse proxy if you expose it beyond a trusted network.
217
+
218
+ > **Migrating from `drawlatch-ui`?** The standalone `drawlatch-ui` service and its `~/.drawlatch-ui/` config directory are abandoned — its dashboard, auth gate, and password now live inside drawlatch. There is no automatic migration: just run `drawlatch set-password` once to set the password in `~/.drawlatch/.env`.
219
+
140
220
  ## MCP Tools
141
221
 
142
222
  Once connected, agents get these tools:
@@ -274,7 +354,7 @@ Key paths are derived automatically — no configuration needed:
274
354
 
275
355
  ### Advanced Configuration
276
356
 
277
- #### `MCP_CONFIG_DIR`
357
+ #### `MCP_CONFIG_DIR` — the config-dir contract
278
358
 
279
359
  By default, all config and key files live in `~/.drawlatch/`. Override with:
280
360
 
@@ -282,7 +362,28 @@ By default, all config and key files live in `~/.drawlatch/`. Override with:
282
362
  export MCP_CONFIG_DIR=/custom/path/to/config
283
363
  ```
284
364
 
285
- Useful for CI environments or running multiple independent setups on the same machine.
365
+ Useful for CI environments or running multiple independent setups on the same machine. drawlatch **owns this layout as a stable contract** (and migrates legacy key layouts into it automatically on startup):
366
+
367
+ ```
368
+ $MCP_CONFIG_DIR/ (default: ~/.drawlatch)
369
+ remote.config.json — RemoteServerConfig (callers, connectors, port, tunnel flag)
370
+ proxy.config.json — ProxyConfig (local MCP proxy → remote URL)
371
+ .env — secret values, prefixed per caller (mode 0600)
372
+ enroll.token — one-time loopback auto-enroll token (mode 0600)
373
+ keys/
374
+ server/ — the daemon's own Ed25519 + X25519 keypair
375
+ callers/<alias>/ — one keypair per caller alias
376
+ ```
377
+
378
+ Legacy `keys/local`, `keys/remote`, and `keys/peers/*` directories are migrated to `keys/callers` / `keys/server` on first start — idempotent and safe to re-run.
379
+
380
+ #### Self-managed tunnel
381
+
382
+ Set `"tunnel": true` in `remote.config.json` (or `DRAWLATCH_TUNNEL=1` / `drawlatch start --tunnel`) and drawlatch brings up and supervises its own Cloudflare quick tunnel on startup: it learns the public URL, injects it into callback-dependent connection configs (e.g. `TRELLO_CALLBACK_URL`) **before** secret resolution and ingestor start, and surfaces it in `drawlatch status`, the Overview page, and `/api/admin/meta`. It is a config flag, not a runtime control surface.
383
+
384
+ #### Daemon lifecycle
385
+
386
+ `drawlatch start` runs the **whole** daemon in one process (MCP protocol + admin API + dashboard UI + optional tunnel). It is daemon-first and cleanly supervisable: a PID file, a deterministic `start` / `stop` / `restart` / `status`, an unauthenticated `/health` endpoint, and `drawlatch start --foreground` for running under a process manager. Local and remote deployments differ only by host binding (`DRAWLATCH_HOST`) and the dashboard password.
286
387
 
287
388
  ## Connections
288
389
 
@@ -382,10 +483,11 @@ Commands:
382
483
  start Start the remote server (background daemon)
383
484
  stop Stop the remote server
384
485
  restart Restart the remote server
385
- status Show server status (PID, port, uptime, health, sessions)
486
+ status Show server status (PID, port, uptime, health, sessions, dashboard URL)
386
487
  logs View server logs
387
488
  config Show effective configuration and secret status
388
489
  doctor Validate setup and diagnose issues
490
+ set-password Set/change the dashboard password (alias: change-password)
389
491
  generate-keys Generate Ed25519 + X25519 keypairs
390
492
  sync Exchange keys with a callboard instance
391
493
 
@@ -487,9 +589,11 @@ npm run dev:mcp # MCP proxy with hot reload
487
589
  src/
488
590
  ├── cli/ # Key generation CLI
489
591
  ├── connections/ # 23 pre-built route templates (JSON)
592
+ ├── auth/ # Dashboard auth (scrypt password, session cookies)
490
593
  ├── mcp/server.ts # Local MCP proxy (stdio transport)
491
594
  ├── remote/
492
- │ ├── server.ts # Remote secure server (Express)
595
+ │ ├── server.ts # Remote secure server (Express) — also serves the dashboard
596
+ │ ├── admin.ts # Read-only /api/admin/* API
493
597
  │ └── ingestors/ # Event ingestion system
494
598
  │ ├── discord/ # Discord Gateway WebSocket
495
599
  │ ├── slack/ # Slack Socket Mode WebSocket
@@ -501,6 +605,9 @@ src/
501
605
  ├── env-utils.ts # Environment variable utilities
502
606
  ├── crypto/ # Ed25519/X25519 keys, AES-256-GCM channel
503
607
  └── protocol/ # Handshake, message types
608
+
609
+ frontend/ # React + Vite dashboard SPA (built to frontend/dist)
610
+ └── src/pages/ # Overview, Connections, Callers, Ingestors, Sessions, Secrets
504
611
  ```
505
612
 
506
613
  ## License
package/bin/drawlatch.js CHANGED
@@ -186,6 +186,14 @@ switch (subcommand) {
186
186
  await cmdSync();
187
187
  }
188
188
  break;
189
+ case "set-password":
190
+ case "change-password":
191
+ if (values.help) {
192
+ printSetPasswordHelp(subcommand);
193
+ } else {
194
+ await cmdSetPassword();
195
+ }
196
+ break;
189
197
  case "watch":
190
198
  if (values.help) {
191
199
  printWatchHelp();
@@ -464,6 +472,10 @@ async function cmdStatus() {
464
472
  console.log("Drawlatch remote server is running.");
465
473
  console.log(` PID: ${pid}`);
466
474
  console.log(` Listening: ${host}:${port}`);
475
+ console.log(` Dashboard: http://${connectHost(host)}:${port}/`);
476
+ console.log(
477
+ ` Password: ${isPasswordConfigured() ? "configured" : "not set (run: drawlatch set-password)"}`,
478
+ );
467
479
  console.log(` Uptime: ${uptime}`);
468
480
  console.log(
469
481
  ` Health: ${healthData ? "healthy" : "unhealthy (not responding)"}`,
@@ -978,6 +990,131 @@ async function promptInput(prompt) {
978
990
  });
979
991
  }
980
992
 
993
+ // ── set-password / change-password ────────────────────────────────
994
+ //
995
+ // Sets the dashboard/admin password. The scrypt hash + salt are written to
996
+ // ~/.drawlatch/.env (AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT) using the same
997
+ // env-writer the daemon's change-password handler uses, so both stay in sync.
998
+ async function cmdSetPassword() {
999
+ ensureConfigDir();
1000
+
1001
+ const { hashPassword, generateSalt } = await import(
1002
+ join(PKG_ROOT, "dist/auth/password.js")
1003
+ );
1004
+ const { updateEnvFile } = await import(
1005
+ join(PKG_ROOT, "dist/auth/env-writer.js")
1006
+ );
1007
+
1008
+ let password, confirm;
1009
+ if (process.stdin.isTTY) {
1010
+ password = await promptPasswordTTY("Enter new password: ");
1011
+ if (!password) {
1012
+ console.error("Error: password cannot be empty.");
1013
+ process.exit(1);
1014
+ }
1015
+ if (password.length < 8) {
1016
+ console.error("Error: password must be at least 8 characters.");
1017
+ process.exit(1);
1018
+ }
1019
+ confirm = await promptPasswordTTY("Confirm new password: ");
1020
+ } else {
1021
+ // Piped input — read both lines from a single readline interface so
1022
+ // closing it doesn't break the second prompt.
1023
+ const { createInterface } = await import("node:readline");
1024
+ const rl = createInterface({ input: process.stdin });
1025
+ [password, confirm] = await readTwoLines(rl);
1026
+ rl.close();
1027
+ if (!password) {
1028
+ console.error("Error: password cannot be empty.");
1029
+ process.exit(1);
1030
+ }
1031
+ if (password.length < 8) {
1032
+ console.error("Error: password must be at least 8 characters.");
1033
+ process.exit(1);
1034
+ }
1035
+ }
1036
+
1037
+ if (password !== confirm) {
1038
+ console.error("Error: passwords do not match.");
1039
+ process.exit(1);
1040
+ }
1041
+
1042
+ const salt = generateSalt();
1043
+ const hash = await hashPassword(password, salt);
1044
+
1045
+ updateEnvFile(
1046
+ { AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt },
1047
+ ["AUTH_PASSWORD"], // strip any plaintext leftover, just in case
1048
+ );
1049
+
1050
+ console.log("\nPassword set successfully.");
1051
+ console.log(` Stored in: ${ENV_FILE}`);
1052
+
1053
+ const pid = readPid();
1054
+ if (pid) {
1055
+ console.log(" Restart the server to apply: drawlatch restart");
1056
+ }
1057
+ }
1058
+
1059
+ function readTwoLines(rl) {
1060
+ return new Promise((resolveP) => {
1061
+ const lines = [];
1062
+ const onLine = (line) => {
1063
+ lines.push(line);
1064
+ if (lines.length >= 2) {
1065
+ rl.removeListener("line", onLine);
1066
+ resolveP(lines);
1067
+ }
1068
+ };
1069
+ rl.on("line", onLine);
1070
+ rl.once("close", () => {
1071
+ while (lines.length < 2) lines.push("");
1072
+ resolveP(lines);
1073
+ });
1074
+ });
1075
+ }
1076
+
1077
+ function promptPasswordTTY(prompt) {
1078
+ return new Promise((resolveP) => {
1079
+ process.stdout.write(prompt);
1080
+ const stdin = process.stdin;
1081
+ stdin.setRawMode(true);
1082
+ stdin.resume();
1083
+ stdin.setEncoding("utf8");
1084
+ let password = "";
1085
+ const onData = (ch) => {
1086
+ ch = ch.toString();
1087
+ if (ch === "\n" || ch === "\r" || ch === "\u0004") {
1088
+ stdin.setRawMode(false);
1089
+ stdin.pause();
1090
+ stdin.removeListener("data", onData);
1091
+ process.stdout.write("\n");
1092
+ resolveP(password);
1093
+ } else if (ch === "\u0003") {
1094
+ // Ctrl+C
1095
+ stdin.setRawMode(false);
1096
+ process.stdout.write("\n");
1097
+ process.exit(0);
1098
+ } else if (ch === "\u007F" || ch === "\b") {
1099
+ if (password.length > 0) password = password.slice(0, -1);
1100
+ } else {
1101
+ password += ch;
1102
+ }
1103
+ };
1104
+ stdin.on("data", onData);
1105
+ });
1106
+ }
1107
+
1108
+ /** Read ~/.drawlatch/.env and report whether a dashboard password hash is set. */
1109
+ function isPasswordConfigured() {
1110
+ try {
1111
+ const envVars = loadEnvFileVars();
1112
+ return !!envVars.AUTH_PASSWORD_HASH;
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
1117
+
981
1118
  // ── PID utilities ─────────────────────────────────────────────────
982
1119
 
983
1120
  function isProcessAlive(pid) {
@@ -1253,6 +1390,7 @@ Commands:
1253
1390
  doctor Validate setup and diagnose issues
1254
1391
  generate-keys Generate Ed25519 + X25519 keypairs
1255
1392
  sync Exchange keys with a callboard instance
1393
+ set-password Set/change the dashboard password (alias: change-password)
1256
1394
 
1257
1395
  Options:
1258
1396
  -h, --help Show this help message
@@ -1457,6 +1595,26 @@ The server must be running (drawlatch start) before using this command.
1457
1595
  `);
1458
1596
  }
1459
1597
 
1598
+ function printSetPasswordHelp(cmd = "set-password") {
1599
+ console.log(`
1600
+ drawlatch ${cmd}
1601
+
1602
+ Set or change the password that gates the admin dashboard and /api/admin/*.
1603
+
1604
+ Usage: drawlatch ${cmd} [options]
1605
+
1606
+ Options:
1607
+ -h, --help Show this help message
1608
+
1609
+ Prompts for a new password (hidden in a TTY; reads two lines when piped),
1610
+ hashes it with scrypt, and stores AUTH_PASSWORD_HASH / AUTH_PASSWORD_SALT in
1611
+ ${ENV_FILE}.
1612
+
1613
+ The password must be at least 8 characters. 'change-password' is an alias.
1614
+ Restart the server afterwards (drawlatch restart) to apply the new password.
1615
+ `);
1616
+ }
1617
+
1460
1618
  function printGenerateKeysHelp() {
1461
1619
  console.log(`
1462
1620
  drawlatch generate-keys
@@ -0,0 +1,10 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ export declare const SESSION_COOKIE_NAME: string;
3
+ export declare const SESSION_TTL_MS: number;
4
+ export declare function isPasswordConfigured(): boolean;
5
+ export declare function loginHandler(req: Request, res: Response): Promise<void>;
6
+ export declare function logoutHandler(req: Request, res: Response): void;
7
+ export declare function checkAuthHandler(req: Request, res: Response): void;
8
+ export declare function changePasswordHandler(req: Request, res: Response): Promise<void>;
9
+ export declare function requireAuth(req: Request, res: Response, next: NextFunction): void;
10
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1,146 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { getSession, createSession, deleteSession, extendSession, cleanupExpiredSessions, deleteAllSessionsExcept, } from './sessions.js';
3
+ import { verifyPassword, hashPassword, generateSalt } from './password.js';
4
+ import { updateEnvFile } from './env-writer.js';
5
+ export const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME ?? 'drawlatch_session';
6
+ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
7
+ const NO_PASSWORD_MESSAGE = 'Server misconfigured: no password set. Run: drawlatch set-password';
8
+ // ── Password helpers ────────────────────────────────────────────────
9
+ export function isPasswordConfigured() {
10
+ return !!process.env.AUTH_PASSWORD_HASH;
11
+ }
12
+ async function verifyConfiguredPassword(password) {
13
+ const storedHash = process.env.AUTH_PASSWORD_HASH;
14
+ if (!storedHash)
15
+ return false;
16
+ const salt = process.env.AUTH_PASSWORD_SALT ?? '';
17
+ return verifyPassword(password, storedHash, salt);
18
+ }
19
+ // ── Session helpers ─────────────────────────────────────────────────
20
+ function setSessionCookie(res, token) {
21
+ res.cookie(SESSION_COOKIE_NAME, token, {
22
+ httpOnly: true,
23
+ sameSite: 'strict',
24
+ // No `secure` flag — the daemon serves the dashboard over HTTP on
25
+ // loopback/LAN. Setting `secure: true` would prevent the cookie from
26
+ // being sent.
27
+ maxAge: SESSION_TTL_MS,
28
+ path: '/',
29
+ });
30
+ }
31
+ /** Roll the session: extend server-side expiry and refresh the cookie. */
32
+ function rollSession(token, res) {
33
+ extendSession(token, Date.now() + SESSION_TTL_MS);
34
+ setSessionCookie(res, token);
35
+ }
36
+ // Best-effort startup cleanup. Skipped when no password is configured —
37
+ // the dashboard is locked in that case anyway, but the import is still
38
+ // performed by tests, where an empty data dir is fine.
39
+ try {
40
+ cleanupExpiredSessions();
41
+ }
42
+ catch {
43
+ // sessions file may not exist yet on first run; cleanup is best-effort.
44
+ }
45
+ // ── Handlers ────────────────────────────────────────────────────────
46
+ export async function loginHandler(req, res) {
47
+ if (!isPasswordConfigured()) {
48
+ res.status(503).json({ error: NO_PASSWORD_MESSAGE });
49
+ return;
50
+ }
51
+ const { password } = req.body ?? {};
52
+ if (typeof password !== 'string' || password.length === 0) {
53
+ res.status(400).json({ error: 'Password is required' });
54
+ return;
55
+ }
56
+ const valid = await verifyConfiguredPassword(password);
57
+ if (!valid) {
58
+ res.status(401).json({ error: 'Invalid password' });
59
+ return;
60
+ }
61
+ const token = randomBytes(32).toString('hex');
62
+ const forwarded = req.headers['x-forwarded-for'];
63
+ const firstForwarded = typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined;
64
+ const ip = firstForwarded ?? req.ip;
65
+ createSession(token, Date.now() + SESSION_TTL_MS, ip);
66
+ setSessionCookie(res, token);
67
+ res.json({ ok: true });
68
+ }
69
+ export function logoutHandler(req, res) {
70
+ const token = req.cookies[SESSION_COOKIE_NAME];
71
+ if (token)
72
+ deleteSession(token);
73
+ res.clearCookie(SESSION_COOKIE_NAME, { path: '/' });
74
+ res.json({ ok: true });
75
+ }
76
+ export function checkAuthHandler(req, res) {
77
+ if (!isPasswordConfigured()) {
78
+ res.status(503).json({
79
+ authenticated: false,
80
+ error: NO_PASSWORD_MESSAGE,
81
+ });
82
+ return;
83
+ }
84
+ const token = req.cookies[SESSION_COOKIE_NAME];
85
+ if (!token) {
86
+ res.status(401).json({ authenticated: false });
87
+ return;
88
+ }
89
+ const entry = getSession(token);
90
+ if (!entry || Date.now() > entry.expires_at) {
91
+ if (entry)
92
+ deleteSession(token);
93
+ res.status(401).json({ authenticated: false });
94
+ return;
95
+ }
96
+ rollSession(token, res);
97
+ res.json({ authenticated: true });
98
+ }
99
+ export async function changePasswordHandler(req, res) {
100
+ const { currentPassword, newPassword } = req.body ?? {};
101
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
102
+ res.status(400).json({ error: 'Both currentPassword and newPassword are required.' });
103
+ return;
104
+ }
105
+ if (newPassword.length < 8) {
106
+ res.status(400).json({ error: 'New password must be at least 8 characters.' });
107
+ return;
108
+ }
109
+ const valid = await verifyConfiguredPassword(currentPassword);
110
+ if (!valid) {
111
+ res.status(401).json({ error: 'Current password is incorrect.' });
112
+ return;
113
+ }
114
+ const salt = generateSalt();
115
+ const hash = await hashPassword(newPassword, salt);
116
+ updateEnvFile({ AUTH_PASSWORD_HASH: hash, AUTH_PASSWORD_SALT: salt });
117
+ process.env.AUTH_PASSWORD_HASH = hash;
118
+ process.env.AUTH_PASSWORD_SALT = salt;
119
+ // Invalidate every session except the caller's, so other browsers/devices
120
+ // are forced to re-authenticate with the new password.
121
+ const currentToken = req.cookies[SESSION_COOKIE_NAME];
122
+ deleteAllSessionsExcept(currentToken);
123
+ res.json({ ok: true });
124
+ }
125
+ // ── Middleware ───────────────────────────────────────────────────────
126
+ export function requireAuth(req, res, next) {
127
+ if (!isPasswordConfigured()) {
128
+ res.status(503).json({ error: NO_PASSWORD_MESSAGE });
129
+ return;
130
+ }
131
+ const token = req.cookies[SESSION_COOKIE_NAME];
132
+ if (!token) {
133
+ res.status(401).json({ error: 'Not authenticated' });
134
+ return;
135
+ }
136
+ const entry = getSession(token);
137
+ if (!entry || Date.now() > entry.expires_at) {
138
+ if (entry)
139
+ deleteSession(token);
140
+ res.status(401).json({ error: 'Session expired' });
141
+ return;
142
+ }
143
+ rollSession(token, res);
144
+ next();
145
+ }
146
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Read the current .env file, update or add the specified key/value pairs,
3
+ * and write back with mode 0o600. Keys in `keysToRemove` are deleted entirely.
4
+ */
5
+ export declare function updateEnvFile(updates: Record<string, string>, keysToRemove?: string[]): void;
6
+ //# sourceMappingURL=env-writer.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { getConfigDir, getEnvFilePath } from '../shared/config.js';
3
+ /** Ensure the daemon's config dir and .env file exist before reading/writing. */
4
+ function ensureEnvFile() {
5
+ mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
6
+ const envFile = getEnvFilePath();
7
+ if (!existsSync(envFile)) {
8
+ writeFileSync(envFile, '', { mode: 0o600 });
9
+ }
10
+ }
11
+ /**
12
+ * Read the current .env file, update or add the specified key/value pairs,
13
+ * and write back with mode 0o600. Keys in `keysToRemove` are deleted entirely.
14
+ */
15
+ export function updateEnvFile(updates, keysToRemove = []) {
16
+ ensureEnvFile();
17
+ const envFile = getEnvFilePath();
18
+ const content = readFileSync(envFile, 'utf-8');
19
+ const lines = content.split('\n');
20
+ const updatedKeys = new Set();
21
+ const result = [];
22
+ for (const line of lines) {
23
+ const trimmed = line.trim();
24
+ const shouldRemove = keysToRemove.some((key) => {
25
+ return trimmed.startsWith(`${key}=`) || trimmed === `${key}=`;
26
+ });
27
+ if (shouldRemove)
28
+ continue;
29
+ let replaced = false;
30
+ for (const [key, value] of Object.entries(updates)) {
31
+ if (trimmed.startsWith(`${key}=`) || trimmed === `${key}=`) {
32
+ result.push(`${key}=${value}`);
33
+ updatedKeys.add(key);
34
+ replaced = true;
35
+ break;
36
+ }
37
+ }
38
+ if (!replaced)
39
+ result.push(line);
40
+ }
41
+ for (const [key, value] of Object.entries(updates)) {
42
+ if (!updatedKeys.has(key)) {
43
+ let insertIdx = result.length;
44
+ while (insertIdx > 0 && result[insertIdx - 1].trim() === '') {
45
+ insertIdx--;
46
+ }
47
+ result.splice(insertIdx, 0, `${key}=${value}`);
48
+ }
49
+ }
50
+ writeFileSync(envFile, result.join('\n'), { mode: 0o600 });
51
+ }
52
+ //# sourceMappingURL=env-writer.js.map
@@ -0,0 +1,8 @@
1
+ export declare function generateSalt(): string;
2
+ export declare function hashPassword(password: string, salt: string): Promise<string>;
3
+ /**
4
+ * Verify a password against a stored hash and salt.
5
+ * Uses timing-safe comparison to prevent timing attacks.
6
+ */
7
+ export declare function verifyPassword(password: string, storedHash: string, salt: string): Promise<boolean>;
8
+ //# sourceMappingURL=password.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ const KEY_LENGTH = 64;
3
+ const SALT_LENGTH = 16;
4
+ export function generateSalt() {
5
+ return randomBytes(SALT_LENGTH).toString('hex');
6
+ }
7
+ export function hashPassword(password, salt) {
8
+ return new Promise((resolve, reject) => {
9
+ scrypt(password, salt, KEY_LENGTH, (err, derivedKey) => {
10
+ if (err)
11
+ return reject(err);
12
+ resolve(derivedKey.toString('hex'));
13
+ });
14
+ });
15
+ }
16
+ /**
17
+ * Verify a password against a stored hash and salt.
18
+ * Uses timing-safe comparison to prevent timing attacks.
19
+ */
20
+ export async function verifyPassword(password, storedHash, salt) {
21
+ const derivedKey = await hashPassword(password, salt);
22
+ const hashBuffer = Buffer.from(storedHash, 'hex');
23
+ const derivedBuffer = Buffer.from(derivedKey, 'hex');
24
+ if (hashBuffer.length !== derivedBuffer.length)
25
+ return false;
26
+ return timingSafeEqual(hashBuffer, derivedBuffer);
27
+ }
28
+ //# sourceMappingURL=password.js.map
@@ -0,0 +1,13 @@
1
+ export interface SessionData {
2
+ expires_at: number;
3
+ created_at: number;
4
+ ip?: string;
5
+ }
6
+ export declare function getSession(token: string): SessionData | undefined;
7
+ export declare function createSession(token: string, expiresAt: number, ip?: string): void;
8
+ export declare function extendSession(token: string, newExpiresAt: number): void;
9
+ export declare function deleteSession(token: string): void;
10
+ /** Used after change-password to log out every other session. */
11
+ export declare function deleteAllSessionsExcept(exceptToken?: string): void;
12
+ export declare function cleanupExpiredSessions(): number;
13
+ //# sourceMappingURL=sessions.d.ts.map