commit e172b8efeda018252a74864ffaf3f0896896d1e4 equwal <13551856+equwal@users.noreply.github.com> 2026-10-05 00:26:06 -0700 Board: buttons and drags work on the HTTPS page, and a drag from Doing, Done, or Dropped to Do, Maybe, or Skip moves the card
src/serve.js | 17 +++++++++---- tests/serve.test.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 7 deletions(-)
diff --git a/src/serve.js b/src/serve.js index 5e10ad1..8f991ec 100644 --- a/src/serve.js +++ b/src/serve.js @@ -55,15 +55,16 @@ const extraHosts = () => config.get('serve_hosts').split(',').map((h) => h.trim( /** * True when the server may answer a request. The Host header must name this server, which stops a - * DNS rebinding page. A POST must send JSON, and its Origin, if any, must be this server. A page of - * another site cannot send JSON here without a CORS preflight, and this server allows none. + * DNS rebinding page. A POST must send JSON, and its Origin, if any, must be this server, over HTTP + * or over the HTTPS of nginx in front of it. A page of another site cannot send JSON here without a + * CORS preflight, and this server allows none. */ export function allowed({ method, host, origin, type }, port, hosts = []) { const h = String(host ?? '').toLowerCase(); if (h !== `127.0.0.1:${port}` && h !== `localhost:${port}` && !hosts.includes(h)) return false; if (method === 'GET' || method === 'HEAD') return true; if (method !== 'POST' || !/^application\/json\s*(;|$)/i.test(String(type ?? '').trim())) return false; - return origin == null || origin === `http://${h}`; + return origin == null || origin === `http://${h}` || origin === `https://${h}`; } /** @@ -422,8 +423,9 @@ const ACTIONS = { }, /** - * A drag on the board into the Do, Maybe, or Skip lane. Only the verdict changes. The rest of the - * triage (impact, size, model, why, brief) stays as it is. + * A drag on the board into the Do, Maybe, or Skip lane. The verdict changes, and an idea from + * Doing, Done, or Dropped goes back in the queue. The rest of the triage (impact, size, model, + * why, brief) stays as it is. */ async verdict({ id, verdict }) { const db = await archive.fresh(); @@ -433,6 +435,11 @@ const ACTIONS = { if (out.queued) return archive.queuedText(out); const failed = Array.isArray(out) && out[0] && out[0].error; if (failed) throw new Error(failed); + // A verdict alone moves only an idea in the inbox. "reopen" puts a started or closed one in its lane. + if (!['inbox', 'triaged'].includes(idea.status)) { + const reopened = await archive.update(idea.id, { status: 'reopen' }); + if (reopened.queued) return archive.queuedText(reopened); + } return `✓ #${idea.id} ${clip(idea.title, 60)} → ${verdict}`; }, diff --git a/tests/serve.test.js b/tests/serve.test.js index 1d344d6..f0abf3d 100644 --- a/tests/serve.test.js +++ b/tests/serve.test.js @@ -78,10 +78,10 @@ test('only this page may use the server: a property over hosts, origins, and con assert.equal(serve.allowed(post({ host }), port), own); }), ); - const origins = fc.oneof(fc.string(), fc.webUrl(), fc.constantFrom('null', 'http://127.0.0.1:4333', 'https://127.0.0.1:4332', 'http://localhost:4332/')); + const origins = fc.oneof(fc.string(), fc.webUrl(), fc.constantFrom('null', 'http://127.0.0.1:4333', 'https://127.0.0.1:4333', 'http://localhost:4332/')); fc.assert( fc.property(origins, (origin) => { - fc.pre(origin !== 'http://127.0.0.1:4332'); + fc.pre(origin !== 'http://127.0.0.1:4332' && origin !== 'https://127.0.0.1:4332'); assert.equal(serve.allowed(post({ host: '127.0.0.1:4332', origin }), port), false); }), ); @@ -100,6 +100,9 @@ test('only this page may use the server: a property over hosts, origins, and con assert.equal(serve.allowed({ method: 'GET', host: '10.66.0.1' }, port, hosts), true); assert.equal(serve.allowed(post({ host: '10.66.0.1', origin: 'http://10.66.0.1' }), port, hosts), true); assert.equal(serve.allowed(post({ host: '10.66.0.1', origin: 'http://127.0.0.1:4332' }), port, hosts), false); + // nginx serves the page over HTTPS too (for the phone). Its buttons and drags must work there. + assert.equal(serve.allowed(post({ host: 'mem.equwal.com', origin: 'https://mem.equwal.com' }), port, ['mem.equwal.com']), true); + assert.equal(serve.allowed(post({ host: 'mem.equwal.com', origin: 'https://evil.example' }), port, ['mem.equwal.com']), false); fc.assert( fc.property(fc.string(), (host) => { assert.equal(serve.allowed({ method: 'GET', host }, port, hosts), ['127.0.0.1:4332', 'localhost:4332', '10.66.0.1'].includes(host.toLowerCase())); @@ -398,6 +401,68 @@ test('a drag to the Do, Maybe, or Skip lane changes only the verdict', async () assert.equal((await api('verdict', { id: 9, verdict: 'do' })).error, 'no idea #9'); }); +// dropCommand of the page, run against a page state that has no Claude Code (the server role). +const pageSource = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8'); +const dropCommand = new Function('state', `${/function dropCommand[\s\S]*?\n}\n/.exec(pageSource)[0]}; return dropCommand;`)({ data: { live: {} } }); + +/** Put idea `id` in `lane` the way the CLI and Claude do it. `triaged`: it got a verdict first. */ +function seed(id, lane, triaged) { + if (['do', 'maybe', 'skip'].includes(lane)) return store.applyTriage([{ id, verdict: lane }]); + if (triaged) store.applyTriage([{ id, verdict: 'do' }]); + store.updateIdea(id, { status: lane }); +} + +/** A POST from the page at https://mem.equwal.com, the way nginx passes it on. */ +function httpsPagePost(action, body) { + return new Promise((resolve, reject) => { + const host = 'mem.equwal.com'; + const req = http.request(new URL(`api/${action}`, url), { method: 'POST', headers: { host, origin: `https://${host}`, 'content-type': 'application/json' } }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve({ status: res.statusCode, ...JSON.parse(Buffer.concat(chunks).toString()) })); + }); + req.on('error', reject); + req.end(JSON.stringify(body)); + }); +} + +/** Drop idea `id` in `lane` on the board. Resolves to the lane that the idea is in afterwards. */ +async function drop(id, lane, post = api) { + const idea = { ...store.findIdea(store.load(), id) }; + idea.lane = store.lane(idea); + const command = dropCommand(idea, lane); + const out = await post(command.action, command.body); + assert.equal(out.ok, true, out.error); + return store.lane(store.findIdea(store.load(), id)); +} + +test('on the HTTPS page, a drag from Do to Doing starts the idea, and a drag back to Do queues it again', async () => { + process.env.IDEAMINE_SERVE_HOSTS = 'mem.equwal.com'; + try { + await api('add', { text: '- alpha' }); + seed(1, 'do', true); + assert.equal(await drop(1, 'doing', httpsPagePost), 'doing'); + assert.equal(await drop(1, 'do', httpsPagePost), 'do'); + assert.equal(await drop(1, 'done', httpsPagePost), 'done'); + assert.equal(await drop(1, 'maybe', httpsPagePost), 'maybe'); + } finally { + delete process.env.IDEAMINE_SERVE_HOSTS; + } +}); + +test('a drag moves a card from any lane to any other lane: a property over every pair', async () => { + await fc.assert( + fc.asyncProperty(fc.constantFrom(...store.LANES), fc.constantFrom(...store.LANES), fc.boolean(), async (from, to, triaged) => { + fc.pre(from !== to); + const [{ idea }] = store.addIdeas('an idea'); + seed(idea.id, from, triaged); + assert.equal(store.lane(store.findIdea(store.load(), idea.id)), from); + assert.equal(await drop(idea.id, to), to, `${from} → ${to}`); + }), + { numRuns: 200 }, + ); +}); + test('the server keeps the token use of each machine and puts a price on it', async () => { const row = { day: '2026-09-22', project: 'C:\work', model: 'claude-opus-5', messages: 1, input: 0, output: 1000000, read: 0, write5m: 0, write1h: 0 }; assert.equal((await api('usage', { machine: 'm1', host: 'pc1', rows: [row] })).rows, 1);