@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-Bs9QYEN5.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-B7D5FmYL.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsktash/beads-ui",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/rsktash/beads-ui.git"
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
- user: process.env.BEADS_UI_USER || '',
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({ server: http_server, path: ws_path });
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