Files
pastilhas 80529dd9e7 six defects, and a typecheck the repo could not pass
Found by reviewing the plugin rather than running it. Nothing was live — the
table does not exist and the plugin is not installed — so all of these were
waiting for the first person to use it.

── The second unnamed design could not be saved ──

`uniqueIndex(userId, title)`, and every design starts life called "Untitled":
`titleOf` returns it for a document with no title and the API defaults to it. So
the first saved, the second collided, and the insert threw under a panel that
could only say "Changes are not being saved." Renaming one design to match
another did the same.

Two designs may share a name, as files and notes and dashboards do. Nothing looks
a design up by title, so uniqueness bought nothing. The index is now ordinary and
on the columns the list query actually uses.

The lesson from `dashboards.id` that the schema comment cites is that a key must
be scoped to its owner. `userId` on the row does that; making the pair unique as
well imported the collision it was citing, one account at a time.

── New did not start a new design ──

It cleared `?doc=`, and no parameter already means "open what I had last",
answered with `/documents/latest`. So New handed back the design on screen and
pointed the next save at its id. There is a `new` sentinel now, in its own module
because both panels read it and neither owns it.

── Two ways the load effect raced the debounced save ──

Changing `?doc=` does not unmount the panel, so the unmount flush never ran for
it: an edit made within the debounce and navigated away from was written after
`latest.current` had been replaced, which is to say never.

And the first save of a new design puts its id in the URL, which lands in the
same effect — re-reading the row overwrote in-memory state with the copy the
server had just been given, discarding whatever was drawn during the round trip.

The effect now flushes what is pending before it reads, and does not re-read a
document it already holds. The editor is keyed on a load generation rather than
on `row?.id`, which could not express "blank again" and left the previous design
on screen with `rowId` cleared underneath it.

── The typecheck ──

354 of the 355 errors were upstream's tree measured against this platform's
settings, which it was never written for. `bunx tsgo` is how this repo is kept
clean and it could not be clean while this plugin existed. `exclude` does not
help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file
unchecked in the same deterministic pass that rewrites its imports. Types still
resolve; only the errors inside those files are silenced, which is what the
adapter already says the seam is for. Whole repo: 0.

── PROVENANCE.md understated what is done to the copy ──

It said "the one edit vendor.sh makes" and described the import rewrite. It also
runs apply-host.py, which places our own lib/host.ts and wires three export
paths. Four files are not upstream and the document whose job is making the copy
trustworthy did not say so. It lists all four now.

Also: web/host.ts imported a path only correct at its destination, so the file
could never typecheck where it is edited — apply-host.py rewrites that line when
placing it, and re-marks the placed copy, which it overwrites after vendor.sh has
marked it. PUT is no longer full-replace: omitting `title` renamed a design to
"Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a
document is bounded at 4 MB, measured in bytes, where the title was capped at 200
and the document at nothing.
2026-09-05 19:52:27 +01:00

232 lines
9.3 KiB
TypeScript

import { createRouter } from '@@/create-router';
import { db } from 'officerdb/db';
import { and, desc, eq } from 'drizzle-orm';
import { m3eCanvasDocuments } from '../db/schema';
/**
* Mounted at `/api/m3e-canvas`.
*
* Documents, and the handoff. Everything is scoped to the calling user by `userId` on every query —
* the multi-user answer being a property of each statement rather than a filter to remember.
*/
export const router = createRouter();
const userIdOf = (ctx: { get: (k: 'user') => { id: number } | undefined }): number | null =>
ctx.get('user')?.id ?? null;
// ── documents ─────────────────────────────────────────────────────────────────────────────────────
router.get('/documents', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const rows = await db
.select({
id: m3eCanvasDocuments.id,
title: m3eCanvasDocuments.title,
updatedAt: m3eCanvasDocuments.updatedAt,
})
.from(m3eCanvasDocuments)
.where(eq(m3eCanvasDocuments.userId, userId))
.orderBy(desc(m3eCanvasDocuments.updatedAt));
// The list never carries the documents themselves. A `Doc` with a few screens of parts is large,
// and a sidebar that ships every design to draw a list of titles gets slower with every design.
return ctx.json(rows);
});
/** What the canvas opens when no `?doc=` is named — the last thing worked on. */
router.get('/documents/latest', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const rows = await db
.select()
.from(m3eCanvasDocuments)
.where(eq(m3eCanvasDocuments.userId, userId))
.orderBy(desc(m3eCanvasDocuments.updatedAt))
.limit(1);
return ctx.json(rows[0] ?? null);
});
router.get('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const rows = await db
.select()
.from(m3eCanvasDocuments)
// The userId is in the WHERE, not checked after the read: another person's design is "not found"
// rather than "forbidden", so the id space cannot be probed.
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.limit(1);
const row = rows[0];
return row ? ctx.json(row) : ctx.json({ error: 'no such design' }, 404);
});
type Body = { title?: string; doc?: unknown; ui?: unknown };
/**
* How large one design may be.
*
* The title was capped at 200 and the document at nothing at all, which is the wrong way round: the
* document is the part that grows without anybody deciding to make it grow, and it is stored per user
* with no quota above it. A real design with several screens of parts measures in the tens of
* kilobytes, so this is far above anything the editor produces and far below anything that would hurt.
*
* Measured on the serialised form rather than guessed at from the object, because that is the thing
* the column actually holds.
*/
const MAX_DOC_BYTES = 4 * 1024 * 1024;
/** Null when it fits, a message when it does not. */
const tooLarge = (doc: unknown, ui: unknown): string | null => {
// Bytes, not string length: `.length` counts UTF-16 units, and a design full of non-Latin labels
// would measure well under what it actually stores.
const size = Buffer.byteLength(JSON.stringify({ doc, ui }), 'utf8');
return size > MAX_DOC_BYTES ? `design is too large (${Math.round(size / 1024)} KB)` : null;
};
router.post('/documents', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const body = (await ctx.req.json().catch(() => null)) as Body | null;
if (!body?.doc) return ctx.json({ error: 'doc is required' }, 400);
const oversize = tooLarge(body.doc, body.ui);
if (oversize) return ctx.json({ error: oversize }, 413);
const [row] = await db
.insert(m3eCanvasDocuments)
.values({
userId,
title: (body.title ?? 'Untitled').slice(0, 200),
doc: body.doc,
ui: body.ui ?? null,
})
.returning();
return ctx.json(row, 201);
});
/**
* A save. Only what is SENT is written.
*
* `title` and `ui` were unconditional — omitting either renamed the design to "Untitled" and nulled
* the editor state. The only caller today sends both every time, so nothing was losing anything; it
* was a trap set for the second caller, and the panel's own comment calls `ui` the cheaper of the two
* things to lose, which is a reason to be careful with it rather than a reason not to be.
*/
router.put('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const body = (await ctx.req.json().catch(() => null)) as Body | null;
if (!body?.doc) return ctx.json({ error: 'doc is required' }, 400);
const oversize = tooLarge(body.doc, body.ui);
if (oversize) return ctx.json({ error: oversize }, 413);
const [row] = await db
.update(m3eCanvasDocuments)
.set({
...(body.title === undefined ? {} : { title: body.title.slice(0, 200) }),
...(body.ui === undefined ? {} : { ui: body.ui }),
doc: body.doc,
updatedAt: new Date(),
})
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.returning();
return row ? ctx.json(row) : ctx.json({ error: 'no such design' }, 404);
});
router.delete('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const rows = await db
.delete(m3eCanvasDocuments)
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.returning({ id: m3eCanvasDocuments.id });
return rows[0] ? ctx.json({ deleted: rows[0].id }) : ctx.json({ error: 'no such design' }, 404);
});
// ── the handoff ───────────────────────────────────────────────────────────────────────────────────
/**
* Where a generated prompt goes instead of the clipboard.
*
* **This is the reason the plugin exists, and it is the one part that is not finished.** What is here
* takes the prompt and holds it; what is missing is the platform hook that starts a chat session with
* it already in the composer.
*
* The reason it is missing rather than merely unwritten: `/api/chat` has **no session-creation route**.
* A session is created by the browser opening `/api/chat/ws` and sending its first message, so there is
* nothing a plugin can POST to. Making this work needs one small platform change — a way for
* `/chat/new` to pick up a prompt by reference — and that is a platform decision rather than a
* plugin's to invent. `docs/handoff.md` states the shape it would need.
*
* Until then this route is the seam: it validates, it holds the prompt in memory against a one-time
* id, and the panel falls back to the clipboard so the feature is degraded rather than absent.
*
* Owner-only by the manifest — starting an agent runs as the owner and writes to the owner's disk,
* which is not a thing to hand out with a drawing tool.
*/
type PendingHandoff = { prompt: string; title: string; userId: number; at: number };
const pending = new Map<string, PendingHandoff>();
const HANDOFF_TTL_MS = 10 * 60 * 1000;
/** Kept small and swept on write: this is a hand-off desk, not storage. */
const sweep = () => {
const cutoff = Date.now() - HANDOFF_TTL_MS;
for (const [id, held] of pending) if (held.at < cutoff) pending.delete(id);
};
router.post('/_handoff', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const body = (await ctx.req.json().catch(() => null)) as { prompt?: string; title?: string } | null;
const prompt = body?.prompt?.trim();
if (!prompt) return ctx.json({ error: 'prompt is required' }, 400);
sweep();
const id = crypto.randomUUID();
pending.set(id, { prompt, title: body?.title ?? 'Untitled', userId, at: Date.now() });
return ctx.json({
id,
// The panel navigates here. Nothing reads the parameter yet — see docs/handoff.md.
chatUrl: `/chat/new?handoff=${id}`,
ready: false,
});
});
/** How the chat side would collect a held prompt, once something calls it. */
router.get('/_handoff/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const held = pending.get(ctx.req.param('id'));
// A handoff belongs to whoever made it; another user asking is told nothing.
if (!held || held.userId !== userId) return ctx.json({ error: 'no such handoff' }, 404);
pending.delete(ctx.req.param('id')); // one-time: collected once, then gone
return ctx.json({ prompt: held.prompt, title: held.title });
});