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 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 = randomBytes(32).toString('base64url');
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/state`, {
67
- headers: { Authorization: `Bearer ${lock.token}` },
68
- signal: AbortSignal.timeout(1000),
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;
@@ -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 ?? randomBytes(32).toString('base64url');
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 || !sameToken(given, token)) {
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-kanban",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Read-only local Kanban board for BMAD Method projects (v6 and v7 layouts)",
5
5
  "type": "module",
6
6
  "license": "MIT",