@rsktash/beads-ui 0.1.23 → 0.1.25
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/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Beads UI</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Cw2Wd_3N.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cgg9X2NI.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body style="background: #FDFBF7; color: #1A1A1A;">
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import express from 'express';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
|
+
import { authMiddleware, isAuthEnabled, loadUsers, login } from './auth.js';
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const pkg = require('../package.json');
|
|
@@ -38,6 +39,38 @@ export function createApp(config) {
|
|
|
38
39
|
// Enable JSON body parsing for API endpoints
|
|
39
40
|
app.use(express.json());
|
|
40
41
|
|
|
42
|
+
// Load users and apply auth middleware
|
|
43
|
+
loadUsers();
|
|
44
|
+
app.use(authMiddleware);
|
|
45
|
+
|
|
46
|
+
// Auth endpoints
|
|
47
|
+
app.post('/api/auth/login', (req, res) => {
|
|
48
|
+
const { username, password } = req.body || {};
|
|
49
|
+
if (!username || !password) {
|
|
50
|
+
res.status(400).json({ ok: false, error: 'Username and password required' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const result = login(username, password);
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
res.status(401).json(result);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.json(result);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
app.get('/api/auth/me', (req, res) => {
|
|
62
|
+
if (!isAuthEnabled()) {
|
|
63
|
+
res.json({ ok: true, authEnabled: false });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const user = /** @type {any} */ (req).user;
|
|
67
|
+
if (!user) {
|
|
68
|
+
res.status(401).json({ ok: false, error: 'Unauthorized' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
res.json({ ok: true, authEnabled: true, user });
|
|
72
|
+
});
|
|
73
|
+
|
|
41
74
|
// Register workspace endpoint - allows CLI to register workspaces dynamically
|
|
42
75
|
// when the server is already running
|
|
43
76
|
/**
|
|
@@ -69,8 +102,7 @@ export function createApp(config) {
|
|
|
69
102
|
res.status(200).json({
|
|
70
103
|
ok: true,
|
|
71
104
|
version: pkg.version,
|
|
72
|
-
|
|
73
|
-
role: process.env.BEADS_UI_ROLE || '',
|
|
105
|
+
authEnabled: isAuthEnabled(),
|
|
74
106
|
fileAttachmentBaseUrl: (process.env.FILE_ATTACHMENT_BASE_URL || '').replace(/\/$/, '')
|
|
75
107
|
});
|
|
76
108
|
});
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional authentication module.
|
|
3
|
+
* When a users config file exists and contains users, auth is enforced.
|
|
4
|
+
* When no users are configured, auth is bypassed entirely.
|
|
5
|
+
*/
|
|
6
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { debug } from './logging.js';
|
|
9
|
+
|
|
10
|
+
const log = debug('auth');
|
|
11
|
+
|
|
12
|
+
/** @type {{ username: string, password: string, role?: string }[]} */
|
|
13
|
+
let users = [];
|
|
14
|
+
|
|
15
|
+
/** JWT secret — generated per server start when no env override */
|
|
16
|
+
const JWT_SECRET = process.env.BEADS_UI_JWT_SECRET || randomBytes(32).toString('hex');
|
|
17
|
+
const TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load users from config file.
|
|
21
|
+
* File path: BEADS_UI_AUTH_FILE env var, or ./beads-ui-users.json in cwd.
|
|
22
|
+
*/
|
|
23
|
+
export function loadUsers() {
|
|
24
|
+
const filePath = process.env.BEADS_UI_AUTH_FILE || '';
|
|
25
|
+
if (!filePath) {
|
|
26
|
+
users = [];
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
users = Array.isArray(parsed.users) ? parsed.users : [];
|
|
33
|
+
log('loaded %d users from %s', users.length, filePath);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
log('no users file at %s — auth disabled', filePath);
|
|
36
|
+
users = [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns {boolean} Whether auth is enabled (users configured) */
|
|
41
|
+
export function isAuthEnabled() {
|
|
42
|
+
return users.length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a JWT-like token (HMAC-SHA256 signed).
|
|
48
|
+
* @param {{ username: string, role: string }} payload
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
function createToken(payload) {
|
|
52
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
53
|
+
const body = Buffer.from(JSON.stringify({
|
|
54
|
+
...payload,
|
|
55
|
+
iat: Date.now(),
|
|
56
|
+
exp: Date.now() + TOKEN_EXPIRY_MS
|
|
57
|
+
})).toString('base64url');
|
|
58
|
+
const sig = createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
|
59
|
+
return `${header}.${body}.${sig}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Verify and decode a token.
|
|
64
|
+
* @param {string} token
|
|
65
|
+
* @returns {{ username: string, role: string } | null}
|
|
66
|
+
*/
|
|
67
|
+
export function verifyToken(token) {
|
|
68
|
+
try {
|
|
69
|
+
const parts = token.split('.');
|
|
70
|
+
if (parts.length !== 3) return null;
|
|
71
|
+
const [header, body, sig] = parts;
|
|
72
|
+
const expected = createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
|
73
|
+
if (sig !== expected) return null;
|
|
74
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
75
|
+
if (payload.exp && payload.exp < Date.now()) return null;
|
|
76
|
+
return { username: payload.username, role: payload.role };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Attempt login.
|
|
84
|
+
* @param {string} username
|
|
85
|
+
* @param {string} password
|
|
86
|
+
* @returns {{ ok: true, token: string, user: { username: string, role: string } } | { ok: false, error: string }}
|
|
87
|
+
*/
|
|
88
|
+
export function login(username, password) {
|
|
89
|
+
const user = users.find(u => u.username === username);
|
|
90
|
+
if (!user) return { ok: false, error: 'Invalid credentials' };
|
|
91
|
+
if (password !== user.password) return { ok: false, error: 'Invalid credentials' };
|
|
92
|
+
const role = user.role || '';
|
|
93
|
+
const token = createToken({ username, role });
|
|
94
|
+
return { ok: true, token, user: { username, role } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Express middleware — skips auth if no users configured.
|
|
99
|
+
* Protects all routes except /api/auth/* and static assets.
|
|
100
|
+
* @param {import('express').Request} req
|
|
101
|
+
* @param {import('express').Response} res
|
|
102
|
+
* @param {import('express').NextFunction} next
|
|
103
|
+
*/
|
|
104
|
+
export function authMiddleware(req, res, next) {
|
|
105
|
+
if (!isAuthEnabled()) return next();
|
|
106
|
+
|
|
107
|
+
// Allow auth endpoints and health check
|
|
108
|
+
if (req.path.startsWith('/api/auth') || req.path === '/healthz' || req.path === '/api/config') return next();
|
|
109
|
+
|
|
110
|
+
// Allow static assets and SPA routes (auth enforced client-side)
|
|
111
|
+
const ext = req.path.split('.').pop();
|
|
112
|
+
if (ext && ['js', 'css', 'html', 'svg', 'png', 'ico', 'woff', 'woff2', 'ttf'].includes(ext)) return next();
|
|
113
|
+
if (!req.path.startsWith('/api/')) return next();
|
|
114
|
+
|
|
115
|
+
// Check Authorization header
|
|
116
|
+
const authHeader = req.headers.authorization;
|
|
117
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
118
|
+
if (!token) {
|
|
119
|
+
res.status(401).json({ ok: false, error: 'Unauthorized' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const decoded = verifyToken(token);
|
|
123
|
+
if (!decoded) {
|
|
124
|
+
res.status(401).json({ ok: false, error: 'Invalid token' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
/** @type {any} */ (req).user = decoded;
|
|
128
|
+
next();
|
|
129
|
+
}
|
package/server/ws.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { WebSocketServer } from 'ws';
|
|
8
8
|
import { isRequest, makeError, makeOk } from '../app/protocol.js';
|
|
9
|
+
import { isAuthEnabled, verifyToken } from './auth.js';
|
|
9
10
|
import { getGitUserName, runBd, runBdJson } from './bd.js';
|
|
10
11
|
import { resolveWorkspaceDatabase } from './db.js';
|
|
11
12
|
import {
|
|
@@ -509,7 +510,17 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
509
510
|
}
|
|
510
511
|
}
|
|
511
512
|
|
|
512
|
-
const wss = new WebSocketServer({
|
|
513
|
+
const wss = new WebSocketServer({
|
|
514
|
+
server: http_server,
|
|
515
|
+
path: ws_path,
|
|
516
|
+
verifyClient: (info, cb) => {
|
|
517
|
+
if (!isAuthEnabled()) return cb(true);
|
|
518
|
+
const url = new URL(info.req.url, 'http://localhost');
|
|
519
|
+
const token = url.searchParams.get('token');
|
|
520
|
+
if (token && verifyToken(token)) return cb(true);
|
|
521
|
+
cb(false, 401, 'Unauthorized');
|
|
522
|
+
}
|
|
523
|
+
});
|
|
513
524
|
CURRENT_WSS = wss;
|
|
514
525
|
|
|
515
526
|
// Heartbeat: track if client answered the last ping
|