Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 7c3e20f

Browse files
authored
feat(canvas): enforce runtime capabilities and build controls
Generated-By: PostHog Code Task-Id: 6d725c2e-4a6c-47b9-a41f-31b975c3a0b0
1 parent 68936b7 commit 7c3e20f

17 files changed

Lines changed: 306 additions & 43 deletions

packages/core/src/canvas/canvasBuildSchemas.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function build(
1818
sourceVersionId: `sv-${id}`,
1919
buildStatus,
2020
diagnostics: [],
21+
manifest: null,
2122
artifactUrl:
2223
buildStatus === "ready" ? "https://usercontent.example/index.html" : null,
2324
pinned: false,

packages/core/src/canvas/canvasBuildSchemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
canvasArtifactManifestSchema,
23
canvasBuildStatusSchema,
34
canvasDiagnosticSchema,
45
} from "@posthog/shared";
@@ -12,13 +13,23 @@ export const canvasBuildRecordSchema = z.object({
1213
sourceVersionId: z.string(),
1314
buildStatus: canvasBuildStatusSchema,
1415
diagnostics: z.array(canvasDiagnosticSchema),
16+
manifest: canvasArtifactManifestSchema.nullable().default(null),
1517
artifactUrl: z.string().url().nullable(),
1618
pinned: z.boolean(),
1719
createdAt: z.string(),
1820
finishedAt: z.string().nullable(),
1921
});
2022
export type CanvasBuildRecord = z.infer<typeof canvasBuildRecordSchema>;
2123

24+
export const canvasBuildActionInputSchema = z.object({
25+
id: z.string(),
26+
buildId: z.string(),
27+
action: z.enum(["retry", "pin", "unpin", "cancel"]),
28+
});
29+
export type CanvasBuildActionInput = z.infer<
30+
typeof canvasBuildActionInputSchema
31+
>;
32+
2233
export function publishedCanvasBuild(
2334
lifecycle: CanvasBuildLifecycle,
2435
): CanvasBuildRecord | null {

packages/core/src/canvas/canvasDataService.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,14 @@ describe("CanvasDataService.loadInsight", () => {
103103
makeService().loadInsight({ shortId: "nope" }),
104104
).rejects.toThrow('Insight "nope" not found');
105105
});
106+
107+
it("rejects oversized insight results", async () => {
108+
fetchInsightByShortId.mockResolvedValue(
109+
insight({ results: Array.from({ length: 1_001 }, () => [1]) }),
110+
);
111+
112+
await expect(
113+
makeService().loadInsight({ shortId: "too-large" }),
114+
).rejects.toThrow("result limit");
115+
});
106116
});

packages/core/src/canvas/canvasDataService.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ import {
2323
// Last-resort attribution if we can't resolve the signed-in user (and the
2424
// canvas didn't pass its own distinctId).
2525
const FALLBACK_DISTINCT_ID = "freeform-canvas";
26+
const MAX_CANVAS_RESULT_ROWS = 1_000;
27+
const MAX_CANVAS_RESULT_BYTES = 2 * 1024 * 1024;
28+
29+
function boundedResult(result: CanvasDataResult): CanvasDataResult {
30+
if (
31+
result.results.length > MAX_CANVAS_RESULT_ROWS ||
32+
JSON.stringify(result).length > MAX_CANVAS_RESULT_BYTES
33+
) {
34+
throw new Error("Canvas data result exceeds the result limit");
35+
}
36+
return result;
37+
}
2638

2739
/**
2840
* The host-side data avenue behind a freeform canvas's `ph.query` shim.
@@ -70,15 +82,15 @@ export class CanvasDataService {
7082
const { columns, results } = await runQuery(this.authService, node, {
7183
refresh: "blocking",
7284
});
73-
return {
85+
return boundedResult({
7486
columns,
7587
// HogQL returns rows; normalise a bare scalar row to a 1-cell array.
7688
// Typed nodes return SERIES OBJECTS — pass them through untouched (wrapping
7789
// them in arrays is what made every value read as 0).
7890
results: isTyped
7991
? results
8092
: results.map((r) => (Array.isArray(r) ? r : [r])),
81-
};
93+
});
8294
} catch (err) {
8395
this.log.warn("Canvas query failed", {
8496
error: err instanceof Error ? err.message : String(err),
@@ -102,12 +114,12 @@ export class CanvasDataService {
102114
// OBJECTS, which must pass through untouched (wrapping them reads every value
103115
// as 0).
104116
const isRows = insight.queryKind === "HogQLQuery";
105-
return {
117+
return boundedResult({
106118
columns: insight.columns,
107119
results: isRows
108120
? insight.results.map((r) => (Array.isArray(r) ? r : [r]))
109121
: insight.results,
110-
};
122+
});
111123
} catch (err) {
112124
this.log.warn("Canvas loadInsight failed", {
113125
shortId: input.shortId,

packages/core/src/canvas/dashboardsService.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import type { AuthService } from "@posthog/core/auth/auth";
22
import { AUTH_SERVICE } from "@posthog/core/auth/auth.module";
33
import { inject, injectable } from "inversify";
44
import {
5+
type CanvasBuildActionInput,
56
type CanvasBuildLifecycle,
7+
type CanvasBuildRecord,
68
canvasBuildLifecycleSchema,
9+
canvasBuildRecordSchema,
710
} from "./canvasBuildSchemas";
811
import type {
912
DashboardFileMeta,
@@ -357,6 +360,7 @@ export class DashboardsService {
357360
source_version_id: string;
358361
build_status: string;
359362
diagnostics?: unknown[];
363+
manifest: unknown | null;
360364
artifact_url: string | null;
361365
pinned: boolean;
362366
created_at: string;
@@ -371,6 +375,7 @@ export class DashboardsService {
371375
sourceVersionId: build.source_version_id,
372376
buildStatus: build.build_status,
373377
diagnostics: build.diagnostics ?? [],
378+
manifest: build.manifest ?? null,
374379
artifactUrl: build.artifact_url,
375380
pinned: build.pinned,
376381
createdAt: build.created_at,
@@ -379,6 +384,34 @@ export class DashboardsService {
379384
});
380385
}
381386

387+
async actOnBuild(input: CanvasBuildActionInput): Promise<CanvasBuildRecord> {
388+
const res = await this.fs.fetch(
389+
`${encodeURIComponent(input.id)}/canvas/builds/action/`,
390+
{
391+
method: "POST",
392+
headers: { "Content-Type": "application/json" },
393+
body: JSON.stringify({
394+
action: input.action,
395+
build_id: input.buildId,
396+
}),
397+
},
398+
);
399+
if (!res.ok)
400+
throw new Error(`Failed to update canvas build (${res.status})`);
401+
const build = (await res.json()) as Record<string, unknown>;
402+
return canvasBuildRecordSchema.parse({
403+
id: build.id,
404+
sourceVersionId: build.source_version_id,
405+
buildStatus: build.build_status,
406+
diagnostics: build.diagnostics ?? [],
407+
manifest: build.manifest ?? null,
408+
artifactUrl: build.artifact_url,
409+
pinned: build.pinned,
410+
createdAt: build.created_at,
411+
finishedAt: build.finished_at,
412+
});
413+
}
414+
382415
async delete(id: string): Promise<void> {
383416
const res = await this.fs.fetch(`${encodeURIComponent(id)}/`, {
384417
method: "DELETE",

packages/core/src/canvas/freeformSchemas.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,11 @@ export const canvasDataQueryInput = z
6363
.object({
6464
// A typed query node passed straight to the query runner. Opaque here (the
6565
// node schemas are large + product-owned); validated by the API on execution.
66-
query: z.record(z.string(), z.unknown()).optional(),
66+
query: z.record(z.string().max(256), z.unknown()).optional(),
6767
// Inline HogQL string (the escape hatch). Server wraps it as a HogQLQuery.
68-
hogql: z.string().min(1).optional(),
68+
hogql: z.string().min(1).max(20_000).optional(),
6969
// Reserved for bound parameters (Phase 3 named queries). Edit mode ignores it.
70-
params: z.record(z.string(), z.unknown()).optional(),
70+
params: z.record(z.string().max(128), z.unknown()).optional(),
7171
})
7272
.refine((v) => v.query != null || v.hogql != null, {
7373
message: "ph.query requires a query node or a HogQL string",
@@ -99,7 +99,7 @@ export type CanvasDataResult = z.infer<typeof canvasDataResultSchema>;
9999
// shape as `ph.query`.
100100
// ---------------------------------------------------------------------------
101101
export const canvasLoadInsightInput = z.object({
102-
shortId: z.string().min(1),
102+
shortId: z.string().min(1).max(128),
103103
dateRange: z
104104
.object({ date_from: z.string().nullish(), date_to: z.string().nullish() })
105105
.optional(),
@@ -111,8 +111,8 @@ export type CanvasLoadInsightInput = z.infer<typeof canvasLoadInsightInput>;
111111
// the private read token still never enters the iframe. `distinctId` is who the
112112
// event is attributed to; defaults host-side when omitted.
113113
export const canvasCaptureInput = z.object({
114-
event: z.string().min(1),
115-
distinctId: z.string().min(1).optional(),
114+
event: z.string().min(1).max(200),
115+
distinctId: z.string().min(1).max(200).optional(),
116116
properties: z.record(z.string(), z.unknown()).optional(),
117117
});
118118
export type CanvasCaptureInput = z.infer<typeof canvasCaptureInput>;
@@ -228,8 +228,8 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [
228228
z.object({
229229
channel: z.literal(CANVAS_CHANNEL),
230230
type: z.literal("data-request"),
231-
id: z.string(),
232-
method: z.string(),
231+
id: z.string().min(1).max(128),
232+
method: z.enum(["query", "loadInsight", "capture", "run"]),
233233
payload: z.unknown(),
234234
}),
235235
// A runtime/compile error from inside the iframe, surfaced so the host can
@@ -238,8 +238,8 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [
238238
z.object({
239239
channel: z.literal(CANVAS_CHANNEL),
240240
type: z.literal("error"),
241-
message: z.string(),
242-
stack: z.string().optional(),
241+
message: z.string().max(10_000),
242+
stack: z.string().max(50_000).optional(),
243243
}),
244244
// The canvas rendered successfully (clears any prior error state).
245245
z.object({

packages/core/src/canvas/services.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { CanvasBuildLifecycle } from "./canvasBuildSchemas";
1+
import type {
2+
CanvasBuildActionInput,
3+
CanvasBuildLifecycle,
4+
CanvasBuildRecord,
5+
} from "./canvasBuildSchemas";
26
import type { ChannelTaskRecord } from "./channelTaskSchemas";
37
import type { DashboardRecord, DashboardSummary } from "./dashboardSchemas";
48
import type {
@@ -48,6 +52,7 @@ export interface IDashboardsService {
4852
setPinned(input: { id: string; pinned: boolean }): Promise<DashboardRecord>;
4953
// Read a canvas's build lifecycle (pointers + recent builds).
5054
getBuilds(id: string): Promise<CanvasBuildLifecycle>;
55+
actOnBuild(input: CanvasBuildActionInput): Promise<CanvasBuildRecord>;
5156
rename(input: { id: string; name: string }): Promise<DashboardRecord>;
5257
// Idempotently create + seed a channel's home canvas, returning it.
5358
ensureHomeCanvas(channelId: string): Promise<DashboardRecord>;

packages/host-router/src/routers/dashboards.router.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { canvasBuildLifecycleSchema } from "@posthog/core/canvas/canvasBuildSchemas";
1+
import {
2+
canvasBuildActionInputSchema,
3+
canvasBuildLifecycleSchema,
4+
canvasBuildRecordSchema,
5+
} from "@posthog/core/canvas/canvasBuildSchemas";
26
import {
37
createDashboardInput,
48
dashboardIdInput,
@@ -39,6 +43,14 @@ export const dashboardsRouter = router({
3943
.get<IDashboardsService>(DASHBOARDS_SERVICE)
4044
.getBuilds(input.id),
4145
),
46+
actOnBuild: publicProcedure
47+
.input(canvasBuildActionInputSchema)
48+
.output(canvasBuildRecordSchema)
49+
.mutation(({ ctx, input }) =>
50+
ctx.container
51+
.get<IDashboardsService>(DASHBOARDS_SERVICE)
52+
.actOnBuild(input),
53+
),
4254
create: publicProcedure
4355
.input(createDashboardInput)
4456
.output(dashboardRecordSchema)

packages/shared/src/canvas-build-contract.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,5 +203,9 @@ export function createCanvasStarterProject(): CanvasSourceProject {
203203
]),
204204
),
205205
canvasSdkVersion: "0.1.0",
206+
capabilities: {
207+
posthog: { insights: [], inlineQueries: false, captureEvents: [] },
208+
network: { origins: [] },
209+
},
206210
};
207211
}

packages/shared/src/canvas-build-fixtures.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ function project(
3333
entryHtml: CANVAS_ENTRY_HTML,
3434
dependencies,
3535
canvasSdkVersion: "0.1.0",
36+
capabilities: {
37+
posthog: { insights: [], inlineQueries: false, captureEvents: [] },
38+
network: { origins: [] },
39+
},
3640
};
3741
}
3842

0 commit comments

Comments
 (0)