v0.1.0-alpha: interactive demo platform core
- Fastify API: demos/steps CRUD, content-addressed assets, scoped API keys (read/author/publish/admin, draft-only default per D4) - Player: image + sandboxed DOM steps, hotspots, tooltips, keyboard nav, privacy-first beacons (no IP storage, viewer tokens only — R7) - Editor: React SPA — demo list, drag-to-draw hotspots, annotations, publish - MCP server: Streamable HTTP, scope-filtered tools, untrusted-data wrapping on viewer-supplied content (R8); platform never calls a model - Capture extension (MV3): screenshot-flow recording, hotspots pre-placed from real clicks - Docker: single app image + postgres, Traefik-ready (expose only) E2E verified: capture->author->publish->play->beacon->lead->analytics + full MCP tool surface with scope filtering.
This commit is contained in:
commit
9b09f14916
33 changed files with 5555 additions and 0 deletions
116
apps/api/src/routes/demos.ts
Normal file
116
apps/api/src/routes/demos.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { pool } from "../db.js";
|
||||
import { requireScope } from "../auth.js";
|
||||
|
||||
const stepPatch = z.object({
|
||||
hotspot: z
|
||||
.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
annotation: z
|
||||
.object({ title: z.string().max(200), body: z.string().max(4000) })
|
||||
.nullable()
|
||||
.optional(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export async function demoRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/demos", { preHandler: requireScope("read") }, async () => {
|
||||
const r = await pool.query(
|
||||
"SELECT id, name, status, share_token, created_at, updated_at FROM demos ORDER BY updated_at DESC",
|
||||
);
|
||||
return r.rows;
|
||||
});
|
||||
|
||||
app.get("/api/demos/:id", { preHandler: requireScope("read") }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const d = await pool.query("SELECT * FROM demos WHERE id=$1", [id]);
|
||||
if (!d.rowCount) return reply.code(404).send({ error: "not found" });
|
||||
const s = await pool.query(
|
||||
"SELECT id, position, snapshot_type, asset_id, hotspot, annotation FROM steps WHERE demo_id=$1 ORDER BY position",
|
||||
[id],
|
||||
);
|
||||
return { ...d.rows[0], steps: s.rows };
|
||||
});
|
||||
|
||||
app.post("/api/demos", { preHandler: requireScope("author") }, async (req) => {
|
||||
const body = z
|
||||
.object({ name: z.string().min(1).max(200), capture_id: z.string().uuid().optional() })
|
||||
.parse(req.body);
|
||||
const r = await pool.query(
|
||||
"INSERT INTO demos (name, capture_id) VALUES ($1,$2) RETURNING *",
|
||||
[body.name, body.capture_id ?? null],
|
||||
);
|
||||
return r.rows[0];
|
||||
});
|
||||
|
||||
app.post("/api/demos/:id/steps", { preHandler: requireScope("author") }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = z
|
||||
.object({
|
||||
snapshot_type: z.enum(["dom", "image"]),
|
||||
asset_id: z.string().uuid().optional(),
|
||||
dom_html: z.string().optional(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
})
|
||||
.parse(req.body);
|
||||
if (body.snapshot_type === "image" && !body.asset_id)
|
||||
return reply.code(400).send({ error: "image step requires asset_id" });
|
||||
if (body.snapshot_type === "dom" && !body.dom_html)
|
||||
return reply.code(400).send({ error: "dom step requires dom_html" });
|
||||
const pos =
|
||||
body.position ??
|
||||
(await pool.query("SELECT COALESCE(MAX(position)+1,0) AS p FROM steps WHERE demo_id=$1", [id]))
|
||||
.rows[0].p;
|
||||
const r = await pool.query(
|
||||
"INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html) VALUES ($1,$2,$3,$4,$5) RETURNING id, position, snapshot_type, asset_id",
|
||||
[id, pos, body.snapshot_type, body.asset_id ?? null, body.dom_html ?? null],
|
||||
);
|
||||
await pool.query("UPDATE demos SET updated_at=now() WHERE id=$1", [id]);
|
||||
return r.rows[0];
|
||||
});
|
||||
|
||||
app.patch("/api/steps/:stepId", { preHandler: requireScope("author") }, async (req, reply) => {
|
||||
const { stepId } = req.params as { stepId: string };
|
||||
const body = stepPatch.parse(req.body);
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
let i = 1;
|
||||
if (body.hotspot !== undefined) { sets.push(`hotspot=$${i++}`); vals.push(body.hotspot ? JSON.stringify(body.hotspot) : null); }
|
||||
if (body.annotation !== undefined) { sets.push(`annotation=$${i++}`); vals.push(body.annotation ? JSON.stringify(body.annotation) : null); }
|
||||
if (body.position !== undefined) { sets.push(`position=$${i++}`); vals.push(body.position); }
|
||||
if (!sets.length) return reply.code(400).send({ error: "empty patch" });
|
||||
vals.push(stepId);
|
||||
const r = await pool.query(
|
||||
`UPDATE steps SET ${sets.join(",")} WHERE id=$${i} RETURNING id, position, hotspot, annotation`,
|
||||
vals,
|
||||
);
|
||||
if (!r.rowCount) return reply.code(404).send({ error: "not found" });
|
||||
return r.rows[0];
|
||||
});
|
||||
|
||||
app.delete("/api/steps/:stepId", { preHandler: requireScope("author") }, async (req) => {
|
||||
const { stepId } = req.params as { stepId: string };
|
||||
await pool.query("DELETE FROM steps WHERE id=$1", [stepId]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/api/demos/:id/publish", { preHandler: requireScope("publish") }, async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const token = randomBytes(12).toString("hex");
|
||||
const r = await pool.query(
|
||||
"UPDATE demos SET status='published', share_token=COALESCE(share_token,$2), updated_at=now() WHERE id=$1 RETURNING share_token",
|
||||
[id, token],
|
||||
);
|
||||
if (!r.rowCount) return reply.code(404).send({ error: "not found" });
|
||||
return { share_token: r.rows[0].share_token, url: `/p/${r.rows[0].share_token}` };
|
||||
});
|
||||
|
||||
app.post("/api/demos/:id/unpublish", { preHandler: requireScope("publish") }, async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
await pool.query("UPDATE demos SET status='draft', updated_at=now() WHERE id=$1", [id]);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue