Skip to content

Commit 5815c04

Browse files
committed
refactor(canvas): move freeform authoring contract to @posthog/shared
Relocate the freeform canvas authoring contract — the system-prompt builder (freeformSystemPromptFor), the import whitelist, the starter scaffold, and the freeform template id — from @posthog/core into @posthog/shared, so @posthog/agent can consume it (for the canvas_checkout tool) without a core↔agent package cycle. Pure move: @posthog/core/canvas/{canvasTemplates,freeformSchemas,freeformWhitelist, freeformStarter} now re-export from @posthog/shared, so existing consumers are unchanged. Generated-By: PostHog Code Task-Id: 2ed89933-091b-446c-af91-5cc72d939b3b
1 parent a0f9a42 commit 5815c04

9 files changed

Lines changed: 547 additions & 511 deletions

packages/core/src/canvas/canvasTemplates.ts

Lines changed: 10 additions & 158 deletions
Large diffs are not rendered by default.

packages/core/src/canvas/freeformSchemas.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { z } from "zod";
22

33
// The template id for freeform-React canvases. Stored on a canvas's meta so the
44
// generation path can resolve the right system prompt.
5-
export const FREEFORM_TEMPLATE_ID = "freeform";
5+
export { FREEFORM_TEMPLATE_ID } from "@posthog/shared/canvas-freeform-prompt";
66

77
// A single point in a freeform canvas's edit history. Every agent turn appends
8-
// one full-file snapshot (Q7: full-file rewrite); the user can revert to any of
8+
// one full-file snapshot (the agent edits a local scratch copy incrementally,
9+
// then publishes the finished file wholesale); the user can revert to any of
910
// them and the `currentVersionId` pointer is what publishes. We keep whole-file
1011
// snapshots rather than diffs because canvases are small and a snapshot can
1112
// never fail to reconstruct.
Lines changed: 2 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,186 +1,2 @@
1-
// A known-good STARTER scaffold for a freeform (React) canvas. Instead of
2-
// authoring the whole single-file app from scratch every time, the generation
3-
// path seeds this working baseline as the agent's starting point (on by default;
4-
// opt out via the generate bar toggle). It already wires the pieces that are
5-
// easy to get wrong — the date picker (self-sizing, no `compact`), theme-aware
6-
// tokens, per-card loading skeletons, and reading a TYPED-NODE result correctly
7-
// — so the agent edits a compiling app instead of re-deriving boilerplate.
8-
//
9-
// Stored as a string (like the prompt contracts) — it is injected into the
10-
// generation prompt, not compiled here. It imports ONLY whitelisted packages
11-
// (see freeformWhitelist) and uses the runtime `ph` global for data. The sample
12-
// metric is "all events" (math:total, event:null) so it renders on ANY project;
13-
// the agent replaces it with the user's real metrics.
14-
export const FREEFORM_STARTER_CODE = `import React, { useEffect, useState } from "react";
15-
import {
16-
Button,
17-
Card,
18-
CardContent,
19-
CardHeader,
20-
CardTitle,
21-
DateTimePicker,
22-
Heading,
23-
Popover,
24-
PopoverContent,
25-
PopoverTrigger,
26-
quickRanges,
27-
SkeletonText,
28-
} from "@posthog/quill";
29-
import { RefreshCw } from "lucide-react";
30-
import {
31-
CartesianGrid,
32-
Line,
33-
LineChart,
34-
ResponsiveContainer,
35-
Tooltip,
36-
XAxis,
37-
YAxis,
38-
} from "recharts";
39-
40-
// Starter scaffold. Replace the sample "total events" metric and the layout
41-
// with what the user asked for — but KEEP the wiring below (date picker, theme
42-
// tokens, skeletons, typed-node result reading), it is already correct.
43-
export default function Canvas() {
44-
const def =
45-
quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0];
46-
const [win, setWin] = useState({
47-
start: def.rangeSetter(new Date()),
48-
end: new Date(),
49-
range: def,
50-
});
51-
const [open, setOpen] = useState(false);
52-
53-
const [loading, setLoading] = useState(true);
54-
const [total, setTotal] = useState(0);
55-
const [series, setSeries] = useState([]);
56-
// Refresh plumbing: bump this nonce to re-run the data effect on demand. The
57-
// effect already re-runs when the date window changes; the Refresh button just
58-
// forces a re-run with the same window.
59-
const [nonce, setNonce] = useState(0);
60-
61-
useEffect(() => {
62-
let cancelled = false;
63-
setLoading(true);
64-
// PREFERRED data path: a TYPED query node, computed by PostHog's own runner
65-
// so the numbers match the UI exactly. \`event: null\` = all events (works on
66-
// any project). Swap in the real metric — ideally a SAVED insight loaded
67-
// with \`ph.loadInsight(shortId, { dateRange })\`.
68-
ph.query({
69-
kind: "TrendsQuery",
70-
series: [
71-
{ kind: "EventsNode", event: null, name: "All events", math: "total" },
72-
],
73-
dateRange: {
74-
date_from: win.start.toISOString(),
75-
date_to: win.end.toISOString(),
76-
},
77-
})
78-
.then((res) => {
79-
if (cancelled) return;
80-
// Typed-node result: \`results\` is an array of SERIES OBJECTS, not rows.
81-
const s = res.results[0] ?? {};
82-
setTotal(s.count ?? 0);
83-
setSeries(
84-
(s.days ?? []).map((day, i) => ({ day, value: s.data?.[i] ?? 0 })),
85-
);
86-
setLoading(false);
87-
})
88-
.catch(() => {
89-
if (!cancelled) setLoading(false);
90-
});
91-
return () => {
92-
cancelled = true;
93-
};
94-
}, [win, nonce]);
95-
96-
return (
97-
<div className="flex flex-col gap-4 p-6">
98-
<div className="flex items-center justify-between">
99-
<Heading size="xl" className="mb-4">Canvas</Heading>
100-
<div className="flex items-center gap-2">
101-
<Popover open={open} onOpenChange={setOpen}>
102-
<PopoverTrigger
103-
render={<Button variant="outline">{win.range.name}</Button>}
104-
/>
105-
{/* PopoverContent needs w-auto p-0 so its default fixed width +
106-
padding don't squeeze the self-sizing picker (which clips the
107-
quick-range tabs). No other styles on it or the picker. */}
108-
<PopoverContent className="w-auto p-0">
109-
<DateTimePicker
110-
value={win}
111-
onApply={(v) => {
112-
setWin(v);
113-
setOpen(false);
114-
}}
115-
onCancel={() => setOpen(false)}
116-
/>
117-
</PopoverContent>
118-
</Popover>
119-
<Button
120-
variant="outline"
121-
disabled={loading}
122-
onClick={() => setNonce((n) => n + 1)}
123-
>
124-
<RefreshCw size={14} className={loading ? "animate-spin" : undefined} />
125-
Refresh
126-
</Button>
127-
</div>
128-
</div>
129-
130-
<div className="grid gap-4 md:grid-cols-3">
131-
<Card size="sm">
132-
<CardHeader>
133-
<CardTitle>Total events</CardTitle>
134-
</CardHeader>
135-
<CardContent>
136-
{loading ? (
137-
<SkeletonText lines={1} className="text-3xl" />
138-
) : (
139-
<Heading size="2xl">{total.toLocaleString()}</Heading>
140-
)}
141-
</CardContent>
142-
</Card>
143-
</div>
144-
145-
<Card size="sm">
146-
<CardHeader>
147-
<CardTitle>Events over time</CardTitle>
148-
</CardHeader>
149-
<CardContent>
150-
{loading ? (
151-
<SkeletonText lines={6} />
152-
) : (
153-
<div className="h-[280px] w-full">
154-
<ResponsiveContainer>
155-
<LineChart data={series}>
156-
<CartesianGrid
157-
stroke="var(--border)"
158-
strokeDasharray="3 3"
159-
/>
160-
<XAxis
161-
dataKey="day"
162-
stroke="var(--muted-foreground)"
163-
tick={{ fontSize: 12 }}
164-
/>
165-
<YAxis
166-
stroke="var(--muted-foreground)"
167-
tick={{ fontSize: 12 }}
168-
/>
169-
<Tooltip />
170-
<Line
171-
type="monotone"
172-
dataKey="value"
173-
stroke="var(--primary)"
174-
dot={false}
175-
strokeWidth={2}
176-
/>
177-
</LineChart>
178-
</ResponsiveContainer>
179-
</div>
180-
)}
181-
</CardContent>
182-
</Card>
183-
</div>
184-
);
185-
}
186-
`;
1+
// Moved to @posthog/shared/canvas-freeform-starter (see freeformWhitelist).
2+
export * from "@posthog/shared/canvas-freeform-starter";
Lines changed: 4 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,165 +1,4 @@
1-
// The package whitelist for freeform-React canvases (Q16: curated, PostHog-
2-
// anchored). Every entry is a package the agent may import; anything else is
3-
// rejected by the static check below. Keep this list SMALL — each entry is
4-
// hosting surface (in public mode), bundle weight, and attack surface. Expand
5-
// only on observed demand.
6-
//
7-
// `esm` is the render-time module URL used in EDIT mode (Q2/Q3: in-browser Babel
8-
// + esm.sh CDN). In published/view mode these resolve to self-hosted, frozen
9-
// copies instead (Phase 2: the publish/bundle step rewrites the import map); the
10-
// names stay the same so canvas code is identical across tiers.
11-
export interface WhitelistEntry {
12-
/** The bare import specifier the agent writes, e.g. "recharts". */
13-
name: string;
14-
/** Pinned version. Frozen so a canvas can't drift onto a new major. */
15-
version: string;
16-
/** esm.sh URL for edit-mode render (CDN). */
17-
esm: string;
18-
}
19-
20-
const ESM = "https://esm.sh";
21-
22-
// One source of truth for the Quill version — used by both the import-map entry
23-
// (the JS module) and the stylesheet URLs the iframe links (see below).
24-
const QUILL_VERSION = "0.3.0-beta.18";
25-
26-
// `?external=react,react-dom` keeps every dependent bound to the ONE react the
27-
// import map provides, instead of esm.sh bundling its own copy (which breaks
28-
// hooks across module boundaries — "invalid hook call").
29-
export const FREEFORM_WHITELIST: WhitelistEntry[] = [
30-
{ name: "react", version: "19.0.0", esm: `${ESM}/react@19.0.0` },
31-
{
32-
name: "react-dom",
33-
version: "19.0.0",
34-
esm: `${ESM}/react-dom@19.0.0?external=react`,
35-
},
36-
{
37-
name: "react-dom/client",
38-
version: "19.0.0",
39-
esm: `${ESM}/react-dom@19.0.0/client?external=react`,
40-
},
41-
// PostHog's own design system — already built + self-hosted, so it's the
42-
// cheapest dependency and keeps shared canvases visually on-brand.
43-
{
44-
name: "@posthog/quill",
45-
version: QUILL_VERSION,
46-
esm: `${ESM}/@posthog/quill@${QUILL_VERSION}?external=react,react-dom`,
47-
},
48-
// One charting library (the conventional React pick).
49-
{
50-
name: "recharts",
51-
version: "2.15.0",
52-
esm: `${ESM}/recharts@2.15.0?external=react,react-dom`,
53-
},
54-
// The icon set (named exports, e.g. `import { Calendar, RefreshCw } from "lucide-react"`).
55-
{
56-
name: "lucide-react",
57-
version: "1.21.0",
58-
esm: `${ESM}/lucide-react@1.21.0?external=react`,
59-
},
60-
// One formatting/date util.
61-
{ name: "dayjs", version: "1.11.13", esm: `${ESM}/dayjs@1.11.13` },
62-
];
63-
64-
// The CDN host the edit-mode import map (and Babel) load from. The iframe CSP
65-
// must allow this in edit mode; view/published mode self-hosts and forbids it.
66-
export const FREEFORM_ESM_HOST = ESM;
67-
68-
// Quill stylesheets the iframe must <link> for its components to render styled.
69-
// Quill ships a self-contained compiled sheet (BEM `.quill-*` classes — NO
70-
// Tailwind build needed) plus its design tokens; the sandbox has no build step,
71-
// so without these every Quill component renders unstyled (which is what forced
72-
// agents to inline raw hex). Order matters: tokens + colors define the CSS vars,
73-
// then the component styles consume them. CSP `style-src` already allows the CDN.
74-
export const FREEFORM_QUILL_CSS_URLS = [
75-
`${ESM}/@posthog/quill@${QUILL_VERSION}/tokens.css`,
76-
`${ESM}/@posthog/quill@${QUILL_VERSION}/color-system.css`,
77-
`${ESM}/@posthog/quill@${QUILL_VERSION}/base.css`,
78-
`${ESM}/@posthog/quill@${QUILL_VERSION}/primitives.css`,
79-
];
80-
81-
// The in-browser transpiler (Q2), imported as ESM so egress stays on one host.
82-
export const FREEFORM_BABEL_URL = `${ESM}/@babel/standalone@7.26.4`;
83-
84-
// posthog-js, booted by the runtime (not the agent) to power in-iframe analytics
85-
// + session replay. Edit mode loads it from the CDN; the published tier will
86-
// self-host it in the bundle. Pinned so a canvas can't drift onto a new major.
87-
export const FREEFORM_POSTHOG_JS_URL = `${ESM}/posthog-js@1.205.0`;
88-
89-
// Names the agent is allowed to import. Subpath imports (e.g. "dayjs/plugin/x")
90-
// are allowed when their package root is whitelisted AND the exact subpath is
91-
// listed; we keep it strict (exact-match only) so a subpath can't smuggle in an
92-
// unreviewed entry point.
93-
const ALLOWED_SPECIFIERS = new Set(FREEFORM_WHITELIST.map((e) => e.name));
94-
95-
// The import map handed to the iframe so bare specifiers resolve to the pinned
96-
// modules. Edit mode -> esm.sh; view mode (Phase 2) will pass self-hosted URLs.
97-
export function buildImportMap(): { imports: Record<string, string> } {
98-
const imports: Record<string, string> = {};
99-
for (const entry of FREEFORM_WHITELIST) imports[entry.name] = entry.esm;
100-
// The automatic JSX runtime compiles `<div/>` to imports of these; canvases
101-
// never write them by hand, so they're not in the whitelist, but they must
102-
// resolve for any JSX to run.
103-
imports["react/jsx-runtime"] = `${ESM}/react@19.0.0/jsx-runtime`;
104-
imports["react/jsx-dev-runtime"] = `${ESM}/react@19.0.0/jsx-dev-runtime`;
105-
return { imports };
106-
}
107-
108-
export interface ImportCheckResult {
109-
ok: boolean;
110-
/** Human-readable reasons the code was rejected (empty when ok). */
111-
violations: string[];
112-
}
113-
114-
// Matches static module specifiers, which appear either as `from "spec"`
115-
// (import-with-bindings and export-from) or a bare side-effect `import "spec"`.
116-
// Anchoring on `from`/`import` (rather than "any quoted string following an
117-
// import/export keyword") avoids flagging ordinary string literals such as
118-
// `export default function App() { const s = "hi"; }`. Captures the specifier in
119-
// group 1 or 2. Note: being regex-based it can still be fooled by the literal
120-
// text `from "x"` inside a string/JSX — a fully correct check would parse the
121-
// AST (deferred; this check isn't wired into save/publish yet).
122-
const STATIC_IMPORT_RE =
123-
/\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']/g;
124-
125-
// Patterns we reject outright regardless of specifier (Q9): dynamic import()
126-
// dodges static analysis; require()/importScripts pull arbitrary modules; inline
127-
// <script> and javascript: URLs are out-of-band code the import check can't see.
128-
const FORBIDDEN_PATTERNS: { re: RegExp; reason: string }[] = [
129-
{ re: /\bimport\s*\(/, reason: "dynamic import() is not allowed" },
130-
{ re: /\brequire\s*\(/, reason: "require() is not allowed" },
131-
{ re: /\bimportScripts\s*\(/, reason: "importScripts() is not allowed" },
132-
{ re: /<script\b/i, reason: "inline <script> is not allowed" },
133-
];
134-
135-
/**
136-
* Statically verify that freeform canvas code imports only whitelisted packages
137-
* and uses no out-of-band code-loading. Intended as the enforcement point (Q9)
138-
* at save AND publish — but NOT yet wired into the save path (the autosave in
139-
* freeformChatStore persists code without calling this). For now it is exercised
140-
* only by tests; wiring it in is a follow-up (and should land with the regex's
141-
* string/JSX false-positive limitation addressed, ideally via AST parsing).
142-
* Deliberately conservative — when in doubt it rejects. A relative import (./x)
143-
* is rejected because a canvas is a single file with no sibling modules.
144-
*/
145-
export function checkFreeformImports(code: string): ImportCheckResult {
146-
const violations: string[] = [];
147-
148-
for (const { re, reason } of FORBIDDEN_PATTERNS) {
149-
if (re.test(code)) violations.push(reason);
150-
}
151-
152-
for (const match of code.matchAll(STATIC_IMPORT_RE)) {
153-
const specifier = match[1] ?? match[2];
154-
if (!specifier) continue;
155-
if (!isAllowedSpecifier(specifier)) {
156-
violations.push(`import of non-whitelisted module "${specifier}"`);
157-
}
158-
}
159-
160-
return { ok: violations.length === 0, violations };
161-
}
162-
163-
function isAllowedSpecifier(specifier: string): boolean {
164-
return ALLOWED_SPECIFIERS.has(specifier);
165-
}
1+
// Moved to @posthog/shared/canvas-freeform-whitelist so @posthog/agent can read
2+
// the whitelist without a package cycle. Re-exported here for existing
3+
// `@posthog/core/canvas/freeformWhitelist` consumers.
4+
export * from "@posthog/shared/canvas-freeform-whitelist";

packages/shared/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@
88
"types": "./dist/index.d.ts",
99
"import": "./dist/index.js"
1010
},
11+
"./canvas-freeform-prompt": {
12+
"types": "./dist/canvas-freeform-prompt.d.ts",
13+
"import": "./dist/canvas-freeform-prompt.js"
14+
},
15+
"./canvas-freeform-whitelist": {
16+
"types": "./dist/canvas-freeform-whitelist.d.ts",
17+
"import": "./dist/canvas-freeform-whitelist.js"
18+
},
19+
"./canvas-freeform-starter": {
20+
"types": "./dist/canvas-freeform-starter.d.ts",
21+
"import": "./dist/canvas-freeform-starter.js"
22+
},
1123
"./analytics-events": {
1224
"types": "./dist/analytics-events.d.ts",
1325
"import": "./dist/analytics-events.js"

0 commit comments

Comments
 (0)