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