Kanban — the todo board, extracted, over as many projects as you like

was `/todos` in the platform: a board, an API and a tree reader pointed at
`resolve(process.cwd(), 'todo')`. that is not a setting — it is the belief that
there is one project and it is the one the server happens to have been started in.

THE READER CAME ACROSS UNCHANGED except for that one line. the frontmatter parser
that never rewrites a body, the ordering, the index writer — all of it as it was,
and its 8 tests came too and still pass. everything that changed is that the
directory is now a parameter.

`writeIndex` no longer writes to `process.cwd()` either. that was the same
assumption wearing a different hat, and with two projects it would have had every
board overwriting one file. the map goes beside the folder it describes.

A PROJECT IS A NAME AND A PATH, and paths are confined to the owner's home by the
same three checks av-club uses — absolute, realpath BEFORE the containment test,
and `root` or `root + sep` rather than a bare startsWith. spelled out again rather
than cross-referenced, because the next person to add a path-taking route reads the
file they are in.

it stores NO ITEMS. one table, a list of folders. uninstalling with "delete its
data" ticked loses the list and never a line of anybody's work — which is the
property the markdown format was chosen for.

`/projects` is owner-only: pointing this at a directory is a statement about the
filesystem. moving a card is not, so a member granted `kanban` can work any board
they can see.

removing a project removes the ROW. the route says so in its response and the
button says so in its tooltip, because "delete" beside a folder full of somebody's
work should not be ambiguous.

README records the seam honestly: the platform keeps its own todo/ and its CLI, so
there are two copies of the reader and they will drift. the alternative was making
the platform's own backlog depend on a plugin being installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 03:27:56 +01:00
co-authored by Claude Opus 5
commit 6e54f223e6
12 changed files with 1179 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import * as errors from '@@/custom-errors';
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './tree';
import { ProjectPathError, addProject, getProject, listProjects, removeProject, safeProjectPath } from './projects';
/**
* Mounted at `/api/kanban`.
*
* ── Every item route takes a project ──
*
* That is the extraction, expressed in the URL. The platform's version had no such parameter because it
* had no such question: there was one folder and it was found from `process.cwd()`.
*
* The project is resolved to a row FIRST, scoped by the caller's own user id, and the path used for the
* filesystem work comes from that row — never from the request. So a caller cannot name a directory,
* only choose among ones already vetted by `safeProjectPath` when they were added.
*
* ── Who may do what ──
*
* The manifest makes `/projects` owner-only: pointing this plugin at a folder is a statement about the
* filesystem. Moving a card is not, so a member granted `kanban` can work the board of any project they
* can see. Nothing in this file checks again — two places deciding the same thing is how they come to
* disagree.
*/
export const router = createRouter();
// ── Projects ──
router.get('/projects', async (ctx) => {
const user = ctx.get('user');
return ctx.json({ projects: await listProjects(user.id as number) });
});
router.post('/projects', async (ctx) => {
const user = ctx.get('user');
const { name, path } = (ctx.get('body') ?? {}) as { name?: string; path?: string };
if (!name?.trim()) throw errors.BAD_REQUEST('name is required');
try {
const resolved = await safeProjectPath(path, user.email as string);
return ctx.json(await addProject(user.id as number, name.trim(), resolved), 201);
} catch (error) {
// A bad path is the caller's words being unusable, not a fault. Anything else is a real failure and
// is left to the platform's error handler rather than flattened into a 400.
if (error instanceof ProjectPathError) throw errors.BAD_REQUEST(error.message);
throw error;
}
});
router.delete('/projects/:id', async (ctx) => {
const user = ctx.get('user');
const removed = await removeProject(user.id as number, Number(ctx.req.param('id')));
if (!removed) throw errors.NOT_FOUND('no such project');
// Said plainly, because "delete" next to a folder full of somebody's work deserves to be unambiguous.
return ctx.json({ ok: true, note: 'The folder and its files were not touched.' });
});
// ── The board ──
/**
* Resolve `?project=<id>` to a directory this caller owns.
*
* Every item route starts here, so there is one place where a request turns into a path on disk.
*/
async function rootFor(ctx: Context<{ Variables: HonoVariables }>): Promise<string> {
const user = ctx.get('user');
const id = Number(ctx.req.query('project'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('project is required');
const project = await getProject(user.id as number, id);
if (!project) throw errors.NOT_FOUND('no such project');
return project.path;
}
/** `<theme>/<slug>`, both kebab-case — also the traversal guard, since it admits no dot or slash pair. */
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
const fileForId = (root: string, id: string): string => {
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
const [theme, slug] = id.split('/');
// Joined onto the ROW's path, which was vetted when the project was added — never onto anything from
// this request beyond the id, which the shape above has already constrained.
const path = resolve(root, join(theme!, `${slug}.md`));
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
return path;
};
/** GET /api/kanban?project=<id> — the whole tree, no bodies. What the board renders. */
router.get('/', async (ctx) => ctx.json(readTree(await rootFor(ctx))));
/** GET /api/kanban/item?project=<id>&id=<theme>/<slug> — one item, body included. */
router.get('/item', async (ctx) => {
const text = readFileSync(fileForId(await rootFor(ctx), ctx.req.query('id') ?? ''), 'utf8');
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
});
/**
* PATCH /api/kanban/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
*
* **The body is untouchable through this API, on purpose.** The prose is the owner's; only the machine's
* half of the file is writable here, and `patchFrontmatter` splices keys without touching a byte past
* the closing fence.
*/
router.patch('/item', async (ctx) => {
const root = await rootFor(ctx);
const { id, status, priority, due, order } = (ctx.get('body') ?? {}) as {
id: string;
status?: string;
priority?: string;
due?: string;
order?: number;
};
const path = fileForId(root, id);
const updates: Record<string, string> = {};
if (status !== undefined) {
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
updates.status = status;
}
// `urgent` or nothing — the format deliberately has no scale.
if (priority !== undefined) {
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
updates.priority = priority;
}
if (due !== undefined) {
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
updates.due = due;
}
if (order !== undefined) {
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
updates.order = String(order);
}
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
// The generated map, regenerated on every write so it cannot drift from the tree it describes.
writeIndex(root);
return ctx.json({ ok: true });
});