bmad-kanban 0.2.0 → 0.2.1
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 +13 -0
- package/dist/instance.js +7 -7
- package/dist/server/app.js +10 -5
- package/dist/token.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,19 @@ npm run build # dist/cli.js and dist/web
|
|
|
66
66
|
npm run test:e2e # Playwright smoke test; run the build first
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### Releasing
|
|
70
|
+
|
|
71
|
+
1. `npm version <patch|minor|major>` and `git push --follow-tags`.
|
|
72
|
+
2. Create a GitHub release for the new tag (`gh release create vX.Y.Z`).
|
|
73
|
+
|
|
74
|
+
3. Approve the staged version on npmjs.com (or `npm stage list` then `npm stage approve <id>`); this asks for your 2FA.
|
|
75
|
+
|
|
76
|
+
Publishing the release runs `.github/workflows/release.yml`, which tests the package and *stages* it on npm through trusted publishing, so there is no npm token to manage. A staged version is not public until a maintainer approves it, so nothing reaches npm without the maintainer's 2FA. Don't run `npm publish` locally.
|
|
77
|
+
|
|
78
|
+
## Contributing
|
|
79
|
+
|
|
80
|
+
Contributions are welcome as pull requests. CI must pass, and the maintainer reviews and merges every change; releases to npm are made by the maintainer only.
|
|
81
|
+
|
|
69
82
|
## Acknowledgements
|
|
70
83
|
|
|
71
84
|
Inspired by [vscode-bmad-kanban](https://github.com/RianKhanafi/vscode-bmad-kanban) by RianKhanafi.
|
package/dist/instance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { newToken, proof, sameSecret } from './token.js';
|
|
4
5
|
export function readLock(file) {
|
|
5
6
|
try {
|
|
6
7
|
const v = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -33,7 +34,7 @@ export function loadToken(file) {
|
|
|
33
34
|
catch {
|
|
34
35
|
// missing: create it below
|
|
35
36
|
}
|
|
36
|
-
const token =
|
|
37
|
+
const token = newToken();
|
|
37
38
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
38
39
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
39
40
|
fs.rmSync(tmp, { force: true });
|
|
@@ -61,14 +62,13 @@ function pidAlive(pid) {
|
|
|
61
62
|
return e.code === 'EPERM';
|
|
62
63
|
}
|
|
63
64
|
}
|
|
65
|
+
/** True when the board on the lock's port proves it holds the lock's token; the token itself is not sent. */
|
|
64
66
|
async function answers(lock) {
|
|
67
|
+
const nonce = randomBytes(16).toString('hex');
|
|
65
68
|
try {
|
|
66
|
-
const res = await fetch(`http://127.0.0.1:${lock.port}/api/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
70
|
-
await res.arrayBuffer();
|
|
71
|
-
return res.status === 200;
|
|
69
|
+
const res = await fetch(`http://127.0.0.1:${lock.port}/api/hello?nonce=${nonce}`, { signal: AbortSignal.timeout(1000) });
|
|
70
|
+
const body = (await res.json());
|
|
71
|
+
return res.status === 200 && typeof body.proof === 'string' && sameSecret(body.proof, proof(lock.token, nonce));
|
|
72
72
|
}
|
|
73
73
|
catch {
|
|
74
74
|
return false;
|
package/dist/server/app.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
1
|
import fs from 'node:fs';
|
|
3
2
|
import http from 'node:http';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { getStoryDetail, listSources, readBoard, UnknownSourceError, UnknownStoryError } from '../readers/detect.js';
|
|
6
5
|
import { NotABmadProjectError } from '../registry.js';
|
|
6
|
+
import { newToken, proof, sameSecret } from '../token.js';
|
|
7
7
|
import { watchProject } from './watch.js';
|
|
8
8
|
class HttpError extends Error {
|
|
9
9
|
status;
|
|
@@ -22,8 +22,6 @@ const TYPES = {
|
|
|
22
22
|
'.ico': 'image/x-icon',
|
|
23
23
|
};
|
|
24
24
|
const isMissing = (p) => !fs.existsSync(path.join(p.path, '_bmad'));
|
|
25
|
-
const digest = (s) => createHash('sha256').update(s).digest();
|
|
26
|
-
const sameToken = (a, b) => timingSafeEqual(digest(a), digest(b));
|
|
27
25
|
async function summarize(p, read) {
|
|
28
26
|
const base = { id: p.id, name: p.name, path: p.path, prefs: { ...p.prefs } };
|
|
29
27
|
if (isMissing(p)) {
|
|
@@ -39,7 +37,7 @@ async function summarize(p, read) {
|
|
|
39
37
|
}
|
|
40
38
|
export function startServer(opts) {
|
|
41
39
|
const { registry, webRoot } = opts;
|
|
42
|
-
const token = opts.token ??
|
|
40
|
+
const token = opts.token ?? newToken();
|
|
43
41
|
const read = { scripts: opts.scripts !== false };
|
|
44
42
|
const watchers = new Map();
|
|
45
43
|
const clients = new Set();
|
|
@@ -158,6 +156,13 @@ export function startServer(opts) {
|
|
|
158
156
|
throw new HttpError(405, 'Method not allowed');
|
|
159
157
|
return serveStatic(urlPath, res);
|
|
160
158
|
}
|
|
159
|
+
// needs no token: it proves this board holds one without revealing it
|
|
160
|
+
if (seg[1] === 'hello' && seg.length === 2 && method === 'GET') {
|
|
161
|
+
const nonce = new URLSearchParams(query).get('nonce') ?? '';
|
|
162
|
+
if (!/^[0-9a-f]{32,128}$/.test(nonce))
|
|
163
|
+
throw new HttpError(400, 'nonce must be 32 to 128 hex characters');
|
|
164
|
+
return sendJson(res, 200, { proof: proof(token, nonce) });
|
|
165
|
+
}
|
|
161
166
|
// EventSource cannot send headers, so the event stream also takes ?token=
|
|
162
167
|
const auth = req.headers.authorization;
|
|
163
168
|
const given = auth?.startsWith('Bearer ')
|
|
@@ -165,7 +170,7 @@ export function startServer(opts) {
|
|
|
165
170
|
: seg[1] === 'events'
|
|
166
171
|
? new URLSearchParams(query).get('token')
|
|
167
172
|
: null;
|
|
168
|
-
if (given === null || !
|
|
173
|
+
if (given === null || !sameSecret(given, token)) {
|
|
169
174
|
throw new HttpError(401, 'Missing or wrong access token: open the board with the bmad-kanban command');
|
|
170
175
|
}
|
|
171
176
|
if (seg[1] === 'events' && seg.length === 2 && method === 'GET') {
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export const newToken = () => randomBytes(32).toString('base64url');
|
|
3
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
4
|
+
/** Constant-time string comparison. */
|
|
5
|
+
export const sameSecret = (a, b) => timingSafeEqual(digest(a), digest(b));
|
|
6
|
+
/**
|
|
7
|
+
* What a board holding `token` answers to the challenge `nonce`. The CLI checks it
|
|
8
|
+
* before trusting a port from the lock file, so the token is never sent to whatever
|
|
9
|
+
* else might be listening there.
|
|
10
|
+
*/
|
|
11
|
+
export const proof = (token, nonce) => createHmac('sha256', token).update(`bmad-kanban:${nonce}`).digest('hex');
|