Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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);