@oharness/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ export interface AnalyticsState {
2
+ /** True only when this build carries a vendor and the user has opted in. */
3
+ enabled: boolean;
4
+ /**
5
+ * Whether this build carries either vendor at all — the difference between
6
+ * "off" and "absent". One switch covers both: a person consenting to usage
7
+ * analytics is not being asked to arbitrate between two vendors they did not
8
+ * choose, and a build that carries only one is the normal case.
9
+ */
10
+ available: boolean;
11
+ /** PostHog, present only when enabled and this build carries a key. */
12
+ key?: string;
13
+ host?: string;
14
+ options?: Record<string, unknown>;
15
+ /** DataFast, present only when enabled and this build carries an id. */
16
+ datafast?: {
17
+ id: string;
18
+ domain: string;
19
+ src: string;
20
+ };
21
+ }
22
+ /**
23
+ * What `GET /analytics` answers.
24
+ *
25
+ * `available` is reported separately so Settings can say "this build carries
26
+ * no analytics" rather than showing a switch that would do nothing — a control
27
+ * that silently does nothing is worse than no control.
28
+ */
29
+ export declare function analyticsState(): Promise<AnalyticsState>;
30
+ /**
31
+ * The queue, for the document head.
32
+ *
33
+ * Boot fires events before anything could have decided whether to send them,
34
+ * so they wait here. Nothing in this stub touches the network: it exists only
35
+ * so that `track()` reads the same whether analytics end up on or off, and so
36
+ * that no caller has to remember which branch it is writing in.
37
+ */
38
+ export declare const ANALYTICS_STUB = "<script>\nwindow.__ANALYTICS_QUEUE = [];\nwindow.__TRACK = function (event, props) {\n // Bounded: a queue nothing will ever drain is a leak in a page left open all\n // day, and the events worth having are the early ones anyway.\n if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);\n};\n</script>";
39
+ /**
40
+ * The loader, appended to the client script where the token already exists.
41
+ *
42
+ * One request to `/analytics` answers for both vendors, and one consent
43
+ * decides both — so a person who has not opted in has neither vendor's key in
44
+ * their page source, which is the property that made the key server-served in
45
+ * the first place.
46
+ */
47
+ export declare const ANALYTICS_CLIENT: string;
@@ -0,0 +1,323 @@
1
+ import { readPrefs } from "./prefs.js";
2
+ /**
3
+ * Usage analytics for the browser frontend. Off until the person using it says
4
+ * otherwise, and absent entirely from a build nobody published.
5
+ *
6
+ * This is not the website. The page this feeds runs on the user's own machine,
7
+ * in front of an agent that has a shell there, and its address carries the
8
+ * token that grants that shell. So it is gated twice, and the two gates answer
9
+ * different questions:
10
+ *
11
+ * A key, which only an official build carries. It is injected at packaging
12
+ * time and is not in this repository — deliberately, because a default
13
+ * compiled into the source would mean every fork and every self-hosted
14
+ * install reporting into the publisher's dashboard. Someone who builds this
15
+ * themselves gets a build that cannot report anywhere, and gets it without
16
+ * having to configure anything.
17
+ *
18
+ * Consent, which only the user grants. Stored in prefs, absent by default,
19
+ * and revocable from Settings.
20
+ *
21
+ * Neither implies the other, and the page loads nothing until both are true —
22
+ * which is why the key is served from `/analytics` rather than inlined into
23
+ * the HTML. A user who never opts in never has it in their page source at all.
24
+ *
25
+ * What it sends: named events for actions taken — a panel opened, a prompt
26
+ * submitted, a permission answered, a model switched — and nothing about their
27
+ * content. No prompts, no file paths, no diffs, no transcript. Autocapture is
28
+ * off, so a click is never recorded by the text on the button; session replay
29
+ * is off; and URL properties are stripped, because the URL is where the token
30
+ * is. See `track()` in page.ts for the calls themselves.
31
+ */
32
+ /**
33
+ * The published build's key, or nothing.
34
+ *
35
+ * Read from the environment so that the desktop shell can set it from the file
36
+ * packaging injects, and so that `oharness-web` run from a checkout can be
37
+ * pointed at a project of its own for a day without editing anything.
38
+ */
39
+ const KEY = process.env["OHARNESS_POSTHOG_KEY"] ?? "";
40
+ /** US cloud unless told otherwise. EU is https://eu.i.posthog.com. */
41
+ const HOST = process.env["OHARNESS_POSTHOG_HOST"] ?? "https://us.i.posthog.com";
42
+ /**
43
+ * DataFast, alongside PostHog and under the same two gates.
44
+ *
45
+ * The site runs both for the reason written in the website's own
46
+ * `lib/analytics.ts`: PostHog answers product questions, DataFast answers "how
47
+ * many, from where" without anyone learning a query language. Here it answers
48
+ * one question PostHog also answers — how many launches, from which country —
49
+ * and it is here so that the desktop app and the site show up on the same
50
+ * dashboard rather than in two places that have to be added up by hand.
51
+ *
52
+ * Both shells, and gated on the one thing that actually matters: whether this
53
+ * page's address carries the token.
54
+ *
55
+ * DataFast's script reads `window.location.href` unconditionally and has no
56
+ * hook to filter what it sends — its entire configuration surface is six
57
+ * `data-*` attributes, none of which redacts anything. PostHog has
58
+ * `property_denylist` and `before_send`; DataFast has nothing, so the URL has
59
+ * to be clean before the script is allowed to exist.
60
+ *
61
+ * Today that means the desktop shell and not `oharness-web`, which is where
62
+ * this landed anyway — but by measuring the thing itself rather than by asking
63
+ * which shell it is. `IS_DESKTOP` would be a proxy for the real question and
64
+ * would silently become wrong the day a third shell appears, or the day the
65
+ * desktop window is handed a URL with a query string on it.
66
+ *
67
+ * Taking the token out of the browser's address bar was tried and does not
68
+ * work: the document request on a reload is the one the page cannot influence,
69
+ * so a scrubbed URL means F5 returns 401. The desktop shell gets around that
70
+ * with `onBeforeSendHeaders`; a browser tab has no equivalent. Anyone
71
+ * revisiting this should read that as the constraint it is — browser-mode
72
+ * DataFast needs the credential to stop travelling in the URL, and that is an
73
+ * authentication change, not an analytics one.
74
+ */
75
+ const DATAFAST_ID = process.env["OHARNESS_DATAFAST_ID"] ?? "";
76
+ /**
77
+ * The site this files under. `oharness.ai`, not the page's own hostname —
78
+ * the page's hostname is `127.0.0.1`, which is nobody's site.
79
+ */
80
+ const DATAFAST_DOMAIN = process.env["OHARNESS_DATAFAST_DOMAIN"] ?? "oharness.ai";
81
+ /** Served from DataFast's own origin. There is nothing here to proxy it through. */
82
+ const DATAFAST_SRC = "https://datafa.st/js/script.js";
83
+ /**
84
+ * Every property PostHog would otherwise attach that carries the address of
85
+ * this page.
86
+ *
87
+ * The address is `http://127.0.0.1:<port>/?token=<uuid>`, and that token is
88
+ * the whole of the server's authentication. It must not travel, and "the
89
+ * analytics vendor is trustworthy" is not the argument to rely on — a property
90
+ * that is never collected cannot be leaked by anyone's later mistake.
91
+ *
92
+ * The `$session_entry_` family is easy to miss and was: it is filled in once
93
+ * when a session starts and then rides along on every event afterwards, so
94
+ * denying `$current_url` alone still sent the URL — with the token in it — on
95
+ * all of them. Anything added to this list wants checking against a real
96
+ * payload rather than against the documentation.
97
+ */
98
+ const URL_PROPERTIES = [
99
+ "$current_url",
100
+ "$pathname",
101
+ "$host",
102
+ "$referrer",
103
+ "$referring_domain",
104
+ "$initial_current_url",
105
+ "$initial_pathname",
106
+ "$initial_host",
107
+ "$initial_referrer",
108
+ "$initial_referring_domain",
109
+ "$session_entry_url",
110
+ "$session_entry_host",
111
+ "$session_entry_pathname",
112
+ "$session_entry_referrer",
113
+ "$session_entry_referring_domain",
114
+ ];
115
+ const OPTIONS = {
116
+ api_host: HOST,
117
+ // Autocapture records clicks by the element they landed on, including the
118
+ // text of the button. In this page that text is session titles, file names
119
+ // and model ids the user typed. Named events only.
120
+ autocapture: false,
121
+ capture_pageview: false,
122
+ capture_pageleave: false,
123
+ rageclick: false,
124
+ disable_session_recording: true,
125
+ disable_surveys: true,
126
+ // Nothing here reads a flag or shows a survey, so PostHog never needs to
127
+ // fetch a second script — and with this set it cannot. A page that can be
128
+ // reached with the token is not a page to leave an open script channel in.
129
+ disable_external_dependency_loading: true,
130
+ advanced_disable_feature_flags: true,
131
+ // Counting installs, not people: no profile is created, and the identifier
132
+ // is a random id in this browser's local storage that is never joined to
133
+ // anything. Nothing in the app calls `identify`.
134
+ person_profiles: "never",
135
+ persistence: "localStorage",
136
+ property_denylist: URL_PROPERTIES,
137
+ // Someone who has asked their browser not to be tracked has answered this
138
+ // question already.
139
+ respect_dnt: true,
140
+ };
141
+ /**
142
+ * What `GET /analytics` answers.
143
+ *
144
+ * `available` is reported separately so Settings can say "this build carries
145
+ * no analytics" rather than showing a switch that would do nothing — a control
146
+ * that silently does nothing is worse than no control.
147
+ */
148
+ export async function analyticsState() {
149
+ const available = KEY !== "" || DATAFAST_ID !== "";
150
+ const consented = (await readPrefs()).analytics === true;
151
+ if (!available || !consented)
152
+ return { enabled: false, available };
153
+ // Each vendor is reported only if this build carries it. A build with one
154
+ // and not the other is not a broken build — it is what a fork gets, and what
155
+ // an official build gets if only one id made it into the packaging step.
156
+ const state = { enabled: true, available };
157
+ if (KEY !== "") {
158
+ state.key = KEY;
159
+ state.host = HOST;
160
+ state.options = OPTIONS;
161
+ }
162
+ if (DATAFAST_ID !== "") {
163
+ state.datafast = { id: DATAFAST_ID, domain: DATAFAST_DOMAIN, src: DATAFAST_SRC };
164
+ }
165
+ return state;
166
+ }
167
+ /**
168
+ * The queue, for the document head.
169
+ *
170
+ * Boot fires events before anything could have decided whether to send them,
171
+ * so they wait here. Nothing in this stub touches the network: it exists only
172
+ * so that `track()` reads the same whether analytics end up on or off, and so
173
+ * that no caller has to remember which branch it is writing in.
174
+ */
175
+ export const ANALYTICS_STUB = `<script>
176
+ window.__ANALYTICS_QUEUE = [];
177
+ window.__TRACK = function (event, props) {
178
+ // Bounded: a queue nothing will ever drain is a leak in a page left open all
179
+ // day, and the events worth having are the early ones anyway.
180
+ if (window.__ANALYTICS_QUEUE.length < 100) window.__ANALYTICS_QUEUE.push([event, props]);
181
+ };
182
+ </script>`;
183
+ /**
184
+ * What a build carrying neither vendor ships instead of the loader.
185
+ *
186
+ * Not the loader with a disabled flag: a page that cannot report anywhere
187
+ * should not carry the code for reporting, and `PAGE` should not so much as
188
+ * contain either vendor's name. The suite asserts exactly that, which is how a
189
+ * later change that quietly starts shipping the loader to everyone gets
190
+ * caught. It still drains the queue, because `track()` calls are unconditional
191
+ * and a queue nobody empties grows all day.
192
+ */
193
+ const NO_ANALYTICS_CLIENT = String.raw `
194
+ function startAnalytics() {
195
+ window.__TRACK = function () {};
196
+ window.__ANALYTICS_QUEUE = [];
197
+ }
198
+ `;
199
+ /**
200
+ * The DataFast tag, or a no-op where this build carries no id.
201
+ *
202
+ * Two conditions, and the second is the load-bearing one. `config.datafast`
203
+ * says this build carries an id and the user has consented; the `href` check
204
+ * says this page's address has no token in it. See the comment on
205
+ * `DATAFAST_ID` for why the second cannot be relaxed — the script reads
206
+ * `location.href` and offers nothing to filter it with.
207
+ *
208
+ * Checked against the live `href` rather than against which shell this is, so
209
+ * that a regression in the token scrub turns this off rather than turning it
210
+ * into a leak. It is the last gate before a third party sees the address, and
211
+ * the only one that cannot be wrong about what the address contains.
212
+ *
213
+ * `data-allow-localhost` because the page is served from 127.0.0.1 and the
214
+ * script drops every event from a loopback host unless told not to. That flag
215
+ * is the only reason this reports at all, and it is also the reason the check
216
+ * above has to hold.
217
+ */
218
+ const DATAFAST_CLIENT = DATAFAST_ID === "" ? String.raw `
219
+ function startDataFast() {}
220
+ ` : String.raw `
221
+ function startDataFast(config) {
222
+ if (!config.datafast) return;
223
+ if (location.href.indexOf("token=") !== -1) return;
224
+ try {
225
+ const script = document.createElement("script");
226
+ script.src = config.datafast.src;
227
+ script.defer = true;
228
+ script.setAttribute("data-website-id", config.datafast.id);
229
+ script.setAttribute("data-domain", config.datafast.domain);
230
+ script.setAttribute("data-allow-localhost", "true");
231
+ document.head.appendChild(script);
232
+ } catch (err) {
233
+ // Analytics that fail must never be something a person notices while
234
+ // trying to work. There is nothing to retry and nothing to report.
235
+ }
236
+ }
237
+ `;
238
+ /**
239
+ * PostHog, or a no-op where this build carries no key.
240
+ *
241
+ * `array.js` loaded on its own rather than PostHog's inlined snippet: the
242
+ * snippet is a minified blob whose job is to queue calls made before the
243
+ * library arrives, and this does that job in code that can be read — which is
244
+ * the bar for anything that runs in this page.
245
+ *
246
+ * This is the half that drains the queue in every branch, because `track()` is
247
+ * PostHog's alone — DataFast counts launches and has no named events to send.
248
+ *
249
+ * A failure at any point is silence. Analytics that cannot reach their server
250
+ * must never be something a person notices while trying to work.
251
+ */
252
+ const POSTHOG_CLIENT = KEY === "" ? String.raw `
253
+ function startPostHog() {
254
+ window.__TRACK = function () {};
255
+ window.__ANALYTICS_QUEUE = [];
256
+ }
257
+ ` : String.raw `
258
+ function startPostHog(config) {
259
+ if (!config.key) {
260
+ window.__TRACK = function () {};
261
+ window.__ANALYTICS_QUEUE = [];
262
+ return;
263
+ }
264
+
265
+ const script = document.createElement("script");
266
+ script.src = config.host + "/static/array.js";
267
+ script.async = true;
268
+ script.onload = function () {
269
+ try {
270
+ window.posthog.init(config.key, Object.assign({}, config.options, {
271
+ // The denylist names the properties known to carry this page's address
272
+ // today. This catches the ones a future version of the library adds:
273
+ // whatever the property is called, if its value contains the token
274
+ // then it is the URL, and it does not leave.
275
+ before_send: function (event) {
276
+ const props = event && event.properties;
277
+ for (const key in props) {
278
+ if (typeof props[key] === "string" && props[key].indexOf("token=") !== -1) {
279
+ delete props[key];
280
+ }
281
+ }
282
+ return event;
283
+ },
284
+ }));
285
+ window.__TRACK = function (event, props) { window.posthog.capture(event, props); };
286
+ for (const queued of window.__ANALYTICS_QUEUE) window.__TRACK(queued[0], queued[1]);
287
+ } catch (err) {
288
+ window.__TRACK = function () {};
289
+ }
290
+ window.__ANALYTICS_QUEUE = [];
291
+ };
292
+ script.onerror = function () {
293
+ window.__TRACK = function () {};
294
+ window.__ANALYTICS_QUEUE = [];
295
+ };
296
+ document.head.appendChild(script);
297
+ }
298
+ `;
299
+ /**
300
+ * The loader, appended to the client script where the token already exists.
301
+ *
302
+ * One request to `/analytics` answers for both vendors, and one consent
303
+ * decides both — so a person who has not opted in has neither vendor's key in
304
+ * their page source, which is the property that made the key server-served in
305
+ * the first place.
306
+ */
307
+ export const ANALYTICS_CLIENT = KEY === "" && DATAFAST_ID === "" ? NO_ANALYTICS_CLIENT : String.raw `
308
+ ${DATAFAST_CLIENT}
309
+ ${POSTHOG_CLIENT}
310
+ async function startAnalytics() {
311
+ const config = await getJson("/analytics");
312
+ if (!config || config.error || !config.enabled) {
313
+ // Off, or this build carries nothing. Drop the queue rather than hold it:
314
+ // the answer will not change without a reload.
315
+ window.__TRACK = function () {};
316
+ window.__ANALYTICS_QUEUE = [];
317
+ return;
318
+ }
319
+ startDataFast(config);
320
+ startPostHog(config);
321
+ }
322
+ `;
323
+ //# sourceMappingURL=analytics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import type { Artifact, ArtifactVersion } from "@oharness/engine/artifacts/store.ts";
3
+ /**
4
+ * Serving an artifact's body to a browser that is going to execute it.
5
+ *
6
+ * The rest of this server renders untrusted text by building DOM nodes, never
7
+ * by handing markup to the browser — see markdown.ts, where that is the whole
8
+ * design. An HTML artifact is the one case that cannot work that way: its
9
+ * value *is* the browser executing it. So instead of loosening the renderer,
10
+ * this puts the execution somewhere it can do no harm, and the isolation is
11
+ * built from three independent layers because any one of them alone has a
12
+ * known hole:
13
+ *
14
+ * 1. `sandbox="allow-scripts"` on the iframe, without `allow-same-origin`.
15
+ * Those two together are famously equivalent to no sandbox at all, since
16
+ * the frame can then reach up and remove its own sandbox attribute.
17
+ * Without it the document gets an opaque origin and cannot touch the
18
+ * page, its storage, or its cookies.
19
+ * 2. The CSP below, delivered as a header. `connect-src 'none'` is the load
20
+ * bearing one — an opaque origin still stops nothing from *sending* a
21
+ * request, and this server is a loopback endpoint that drives an agent
22
+ * which runs shell commands. A `fetch(..., {mode:'no-cors'})` reads
23
+ * nothing but arrives all the same.
24
+ * 3. A separate credential (see `artifactToken` in server.ts). The frame can
25
+ * read its own URL, so whatever authorises this route is readable by the
26
+ * content it serves. Making that a different secret from the session
27
+ * token means the worst case is an artifact being readable, not an agent
28
+ * being drivable.
29
+ */
30
+ declare const CSP: string;
31
+ /**
32
+ * The document an iframe loads.
33
+ *
34
+ * `page` artifacts are served as they were written — the model authored a
35
+ * complete document and wrapping it would break any `<!doctype>` or `<head>`
36
+ * it set up. Everything else is wrapped in a minimal shell, because a bare
37
+ * SVG or a bare text file rendered as a top-level document inherits the
38
+ * browser's defaults and looks broken next to the rest of the UI.
39
+ */
40
+ export declare function artifactDocument(artifact: Artifact, revision: ArtifactVersion): {
41
+ body: string;
42
+ contentType: string;
43
+ };
44
+ export declare function sendArtifact(res: ServerResponse, artifact: Artifact, revision: ArtifactVersion): void;
45
+ /**
46
+ * Escaping for the wrapper above.
47
+ *
48
+ * Only ever applied to the non-executable kinds. The `page` kind is
49
+ * deliberately *not* escaped — escaping it would defeat its purpose — which is
50
+ * exactly why that kind is the one the sandbox and the CSP exist for.
51
+ */
52
+ export declare function escapeHtml(text: string): string;
53
+ export { CSP as ARTIFACT_CSP };
@@ -0,0 +1,129 @@
1
+ import { isCanvas } from "@oharness/engine/artifacts/store.ts";
2
+ import { canvasDocument } from "./canvas.js";
3
+ /**
4
+ * Serving an artifact's body to a browser that is going to execute it.
5
+ *
6
+ * The rest of this server renders untrusted text by building DOM nodes, never
7
+ * by handing markup to the browser — see markdown.ts, where that is the whole
8
+ * design. An HTML artifact is the one case that cannot work that way: its
9
+ * value *is* the browser executing it. So instead of loosening the renderer,
10
+ * this puts the execution somewhere it can do no harm, and the isolation is
11
+ * built from three independent layers because any one of them alone has a
12
+ * known hole:
13
+ *
14
+ * 1. `sandbox="allow-scripts"` on the iframe, without `allow-same-origin`.
15
+ * Those two together are famously equivalent to no sandbox at all, since
16
+ * the frame can then reach up and remove its own sandbox attribute.
17
+ * Without it the document gets an opaque origin and cannot touch the
18
+ * page, its storage, or its cookies.
19
+ * 2. The CSP below, delivered as a header. `connect-src 'none'` is the load
20
+ * bearing one — an opaque origin still stops nothing from *sending* a
21
+ * request, and this server is a loopback endpoint that drives an agent
22
+ * which runs shell commands. A `fetch(..., {mode:'no-cors'})` reads
23
+ * nothing but arrives all the same.
24
+ * 3. A separate credential (see `artifactToken` in server.ts). The frame can
25
+ * read its own URL, so whatever authorises this route is readable by the
26
+ * content it serves. Making that a different secret from the session
27
+ * token means the worst case is an artifact being readable, not an agent
28
+ * being drivable.
29
+ */
30
+ const CSP = [
31
+ // Nothing loads unless a directive below says otherwise.
32
+ "default-src 'none'",
33
+ // Inline only: an artifact is one self-contained document by construction,
34
+ // and allowing an origin here would be allowing exfiltration through it.
35
+ "script-src 'unsafe-inline'",
36
+ "style-src 'unsafe-inline'",
37
+ // Data URIs only. A remote image URL is a GET with an attacker-chosen path,
38
+ // which is all exfiltration needs.
39
+ "img-src data:",
40
+ "font-src data:",
41
+ // The three egress channels that are not covered by the source directives.
42
+ "connect-src 'none'",
43
+ "form-action 'none'",
44
+ "base-uri 'none'",
45
+ // An artifact has no business nesting frames of its own, and a frame it
46
+ // controls is a place to render something that looks like the app.
47
+ "frame-src 'none'",
48
+ "child-src 'none'",
49
+ // Only this server's own page may frame an artifact.
50
+ "frame-ancestors 'self'",
51
+ ].join("; ");
52
+ /** Headers for any artifact body, whether it executes or not. */
53
+ function isolationHeaders(contentType) {
54
+ return {
55
+ "content-type": contentType,
56
+ "content-security-policy": CSP,
57
+ // Belt and braces for browsers that honour the older header, and for the
58
+ // case where a proxy strips CSP.
59
+ "x-content-type-options": "nosniff",
60
+ "referrer-policy": "no-referrer",
61
+ // This body is a session scratch surface; caching it would outlive both
62
+ // the session and the credential that authorised reading it.
63
+ "cache-control": "no-store",
64
+ };
65
+ }
66
+ /**
67
+ * The document an iframe loads.
68
+ *
69
+ * `page` artifacts are served as they were written — the model authored a
70
+ * complete document and wrapping it would break any `<!doctype>` or `<head>`
71
+ * it set up. Everything else is wrapped in a minimal shell, because a bare
72
+ * SVG or a bare text file rendered as a top-level document inherits the
73
+ * browser's defaults and looks broken next to the rest of the UI.
74
+ */
75
+ export function artifactDocument(artifact, revision) {
76
+ // Checked before `page`, which a canvas also is: the difference is that the
77
+ // stored content is a fragment of artboards, not a document, and serving it
78
+ // as one would render unstyled boxes with no editor and no explanation.
79
+ if (isCanvas(artifact.mime)) {
80
+ return { body: canvasDocument(artifact, revision), contentType: "text/html; charset=utf-8" };
81
+ }
82
+ if (artifact.kind === "page") {
83
+ return { body: revision.content, contentType: "text/html; charset=utf-8" };
84
+ }
85
+ if (artifact.kind === "vector") {
86
+ // Served as its own content type rather than embedded in HTML: an SVG
87
+ // document scales to the frame on its own, and embedding it would mean
88
+ // deciding on a size here that the artifact panel is better placed to pick.
89
+ return { body: revision.content, contentType: "image/svg+xml; charset=utf-8" };
90
+ }
91
+ // text, markdown, code, image — shown as source in a readable shell. The
92
+ // panel renders these itself in the common case; this route exists so
93
+ // "open in a new tab" works for every kind, not just the executable ones.
94
+ return {
95
+ body: [
96
+ "<!doctype html><meta charset=utf-8>",
97
+ `<title>${escapeHtml(artifact.title)}</title>`,
98
+ "<style>",
99
+ "html{color-scheme:light dark}",
100
+ "body{margin:0;padding:16px;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;",
101
+ "white-space:pre-wrap;word-break:break-word}",
102
+ "</style>",
103
+ `<body>${escapeHtml(revision.content)}</body>`,
104
+ ].join(""),
105
+ contentType: "text/html; charset=utf-8",
106
+ };
107
+ }
108
+ export function sendArtifact(res, artifact, revision) {
109
+ const { body, contentType } = artifactDocument(artifact, revision);
110
+ res.writeHead(200, isolationHeaders(contentType));
111
+ res.end(body);
112
+ }
113
+ /**
114
+ * Escaping for the wrapper above.
115
+ *
116
+ * Only ever applied to the non-executable kinds. The `page` kind is
117
+ * deliberately *not* escaped — escaping it would defeat its purpose — which is
118
+ * exactly why that kind is the one the sandbox and the CSP exist for.
119
+ */
120
+ export function escapeHtml(text) {
121
+ return text
122
+ .replaceAll("&", "&amp;")
123
+ .replaceAll("<", "&lt;")
124
+ .replaceAll(">", "&gt;")
125
+ .replaceAll('"', "&quot;")
126
+ .replaceAll("'", "&#39;");
127
+ }
128
+ export { CSP as ARTIFACT_CSP };
129
+ //# sourceMappingURL=artifacts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.js","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,qCAAqC,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,GAAG,GAAG;IACV,yDAAyD;IACzD,oBAAoB;IACpB,2EAA2E;IAC3E,yEAAyE;IACzE,4BAA4B;IAC5B,2BAA2B;IAC3B,4EAA4E;IAC5E,mCAAmC;IACnC,eAAe;IACf,gBAAgB;IAChB,2EAA2E;IAC3E,oBAAoB;IACpB,oBAAoB;IACpB,iBAAiB;IACjB,wEAAwE;IACxE,mEAAmE;IACnE,kBAAkB;IAClB,kBAAkB;IAClB,qDAAqD;IACrD,wBAAwB;CACzB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,iEAAiE;AACjE,SAAS,gBAAgB,CAAC,WAAmB;IAC3C,OAAO;QACL,cAAc,EAAE,WAAW;QAC3B,yBAAyB,EAAE,GAAG;QAC9B,yEAAyE;QACzE,iCAAiC;QACjC,wBAAwB,EAAE,SAAS;QACnC,iBAAiB,EAAE,aAAa;QAChC,wEAAwE;QACxE,6DAA6D;QAC7D,eAAe,EAAE,UAAU;KAC5B,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,QAAkB,EAAE,QAAyB;IAI5E,4EAA4E;IAC5E,4EAA4E;IAC5E,wEAAwE;IACxE,IAAI,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,cAAc,CAAC,QAAQ,EAAE,QAAQ,CAAC,EAAE,WAAW,EAAE,0BAA0B,EAAE,CAAC;IAC/F,CAAC;IAED,IAAI,QAAQ,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC7B,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,0BAA0B,EAAE,CAAC;IAC7E,CAAC;IAED,IAAI,QAAQ,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC/B,sEAAsE;QACtE,uEAAuE;QACvE,4EAA4E;QAC5E,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,8BAA8B,EAAE,CAAC;IACjF,CAAC;IAED,yEAAyE;IACzE,sEAAsE;IACtE,0EAA0E;IAC1E,OAAO;QACL,IAAI,EAAE;YACJ,qCAAqC;YACrC,UAAU,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU;YAC9C,SAAS;YACT,+BAA+B;YAC/B,uFAAuF;YACvF,6CAA6C;YAC7C,UAAU;YACV,SAAS,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC,SAAS;SAC/C,CAAC,IAAI,CAAC,EAAE,CAAC;QACV,WAAW,EAAE,0BAA0B;KACxC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,YAAY,CAC1B,GAAmB,EACnB,QAAkB,EAClB,QAAyB;IAEzB,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IACnE,GAAG,CAAC,SAAS,CAAC,GAAG,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,IAAI;SACR,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC;SACxB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC;SACzB,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;AAC9B,CAAC;AAED,OAAO,EAAE,GAAG,IAAI,YAAY,EAAE,CAAC"}
@@ -0,0 +1,28 @@
1
+ import type { Artifact, ArtifactVersion } from "@oharness/engine/artifacts/store.ts";
2
+ /**
3
+ * The document a canvas artifact is served as.
4
+ *
5
+ * This is the half of a canvas the model does not write. It authors artboards;
6
+ * everything that makes them a canvas — the pan/zoom stage, selection, the
7
+ * properties panel, undo, and the save protocol — is authored here, once.
8
+ *
9
+ * That split is the whole reason a canvas is worth a mime of its own rather
10
+ * than being "a `page` the model happened to put an editor in":
11
+ *
12
+ * - Size. An editor written into every artifact would be tens of kilobytes
13
+ * of model output on every revision, re-derived and subtly different each
14
+ * time, and counted against MAX_ARTIFACT_BYTES.
15
+ * - Round-tripping. Saving means reading the artboards back out of the DOM.
16
+ * That only works if something knows exactly which markup it put in, which
17
+ * a model improvising an editor per artifact cannot promise.
18
+ * - Trust. The save path writes a new revision of a stored artifact. What
19
+ * decides the bytes that get sent should not itself be model output.
20
+ *
21
+ * The document stays inside the same CSP as any other artifact: everything
22
+ * here is inline, nothing is fetched, and the only channel out is postMessage
23
+ * to the parent frame — which no CSP directive covers, and which carries no
24
+ * credential. A canvas opened with no parent that speaks the protocol (a new
25
+ * tab, a published copy) settles into a read-only board rather than showing a
26
+ * Save button that silently does nothing.
27
+ */
28
+ export declare function canvasDocument(artifact: Artifact, revision: ArtifactVersion): string;