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
40
apps/api/src/routes/assets.ts
Normal file
40
apps/api/src/routes/assets.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue