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:
Scot Thom 2026-07-18 05:17:45 +00:00
commit 9b09f14916
33 changed files with 5555 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import type { FastifyInstance } from "fastify";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync, createReadStream, existsSync } from "node:fs";
import { join } from "node:path";
import { pool } from "../db.js";
import { requireScope } from "../auth.js";
const DATA_DIR = process.env.DATA_DIR ?? "/data";
export async function assetRoutes(app: FastifyInstance): Promise<void> {
app.post("/api/assets", { preHandler: requireScope("author") }, async (req, reply) => {
const file = await req.file({ limits: { fileSize: 50 * 1024 * 1024 } });
if (!file) return reply.code(400).send({ error: "multipart file required" });
const buf = await file.toBuffer();
const sha = createHash("sha256").update(buf).digest("hex");
// content-addressed dedup
const existing = await pool.query("SELECT id, path, mime FROM assets WHERE sha256=$1", [sha]);
if (existing.rowCount) return existing.rows[0];
const kind = file.mimetype.startsWith("image/") ? "image" : "other";
const dir = join(DATA_DIR, "assets", sha.slice(0, 2));
mkdirSync(dir, { recursive: true });
const path = join(dir, sha);
writeFileSync(path, buf);
const r = await pool.query(
"INSERT INTO assets (kind, path, mime, bytes, sha256) VALUES ($1,$2,$3,$4,$5) RETURNING id, path, mime",
[kind, path, file.mimetype, buf.length, sha],
);
return r.rows[0];
});
// Public asset serving (assets are content-addressed; ids are unguessable UUIDs)
app.get("/assets/:id", async (req, reply) => {
const { id } = req.params as { id: string };
const r = await pool.query("SELECT path, mime FROM assets WHERE id=$1", [id]);
if (!r.rowCount || !existsSync(r.rows[0].path)) return reply.code(404).send({ error: "not found" });
reply.header("Cache-Control", "public, max-age=31536000, immutable");
reply.type(r.rows[0].mime);
return reply.send(createReadStream(r.rows[0].path));
});
}