Files
m3e-canvas/api/router.ts
T
pastilhasandClaude Fable 5 a0349cc7a3 the officer shell: designs per user, and the handoff desk
manifest, schema, router and two panels. the editor is hosted and
saving; the last step is not finished and the readme says so.

designs are rows scoped per user, with userId in every WHERE rather
than checked after the read — another person's design is 'not found',
so the id space cannot be probed. the Doc is stored whole as jsonb
because upstream adds fields to it constantly and none of them should
be a migration here.

the handoff is the reason the plugin exists and it is the part that
does not work: /api/chat has NO session-creation route, a session is
constructed by the browser opening a websocket, so a plugin has nothing
to POST to. onExport returns false and the clipboard still runs, which
is upstream's own behaviour — degraded, not broken. docs/handoff.md
names the one platform change that would finish it, and argues it
should be generic rather than ours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:16:35 +01:00

197 lines
7.6 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 };
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 [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);
});
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 [row] = await db
.update(m3eCanvasDocuments)
.set({
title: (body.title ?? 'Untitled').slice(0, 200),
doc: body.doc,
ui: body.ui ?? null,
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 });
});